Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 327dd44992 | |||
| 0e11d4812a | |||
| 676a3d175e | |||
| 34358ab33d | |||
| 5ccaf40ad0 | |||
| 51abe5d1ff | |||
| 2dea824cc0 | |||
| 055888e181 | |||
| 67ba17cc8a | |||
| e1ef14dbcc | |||
| 685d54f2cb | |||
| 490f36099f | |||
| f9ff2b22f2 | |||
| 0ac4c7b8a9 | |||
| 47f6f0f239 | |||
| c27314f896 | |||
| 1b68c55763 | |||
| ed313db742 | |||
| f435ae32ce | |||
| 548d3677f0 | |||
| b0e0bd23c9 | |||
| 2bd5e981bc | |||
| 5de728612e | |||
| 64019ee28d | |||
| 6cad77328b | |||
| 26ac35ad40 | |||
| 31514d1943 | |||
| 52ea452010 | |||
| c469e0acc3 | |||
| f05f9ca735 | |||
| 1d76f93304 | |||
| 05e904e613 | |||
| 095f6fde47 | |||
| 1130bbc090 | |||
| 76f984ce7c | |||
| 6535ad597f | |||
| 40f81b4d2b | |||
| 54ca601e8f | |||
| de549cac05 | |||
| 83d474b13d | |||
| 9480ef06ab | |||
| c099e8eb27 | |||
| 1325f05432 | |||
| cfd19ae232 | |||
| cb6ff8738b | |||
| 81ebb8b6c0 | |||
| 9bdd00ea5a | |||
| 6f35e543ca | |||
| ee61270e1b | |||
| e902b74267 | |||
| 3eb6d9ea8e | |||
| 98d87d7573 | |||
| f7fea1f779 | |||
| 1f834863a7 | |||
| e4846a2046 | |||
| 0dda593c45 | |||
| 1bfb473c19 | |||
| 3f01baebfe | |||
| 46b61857ab | |||
| 0b214a59a1 | |||
| 77579669f2 | |||
| 2c5c99c0c7 | |||
| a3f9e7f638 | |||
| 4e67393d27 | |||
| cb0933a908 | |||
| af95461a70 | |||
| 79b3a61ac8 | |||
| a9e3b659d3 | |||
| af9584a174 | |||
| 63827c9dcb | |||
| 809175612c | |||
| 06d9ef24f1 | |||
| 22683f6910 | |||
| 7ffa1b06ee | |||
| 418e38ee46 | |||
| 6ac5507e4e | |||
| 9bcef1774d | |||
| 8dd4d15b1f | |||
| 2a83e530e9 | |||
| e5d763860c | |||
| 9e4b401f9b | |||
| 2c304eafad | |||
| 70d6ac9daf | |||
| 5b3804ac08 |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.0.0",
|
||||
"version": "10.1.0",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "9.0.6",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
},
|
||||
"repository": "https://github.com/thedotmack/claude-mem",
|
||||
"license": "AGPL-3.0",
|
||||
"keywords": [
|
||||
"memory",
|
||||
"context",
|
||||
"persistence",
|
||||
"hooks",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
# Plan: Fix 81 Test Failures from Incomplete Logger Mocks
|
||||
|
||||
## Problem Summary
|
||||
|
||||
**Root Cause**: NOT circular dependency (which is handled gracefully), but **incomplete logger mocks** that pollute across test files when Bun runs tests in alphabetical order.
|
||||
|
||||
When `tests/context/` runs before `tests/utils/`, the incomplete mocks replace the real logger module globally, causing subsequent tests to fail with `TypeError: logger.formatTool is not a function`.
|
||||
|
||||
## Phase 0: Documentation Discovery (COMPLETED)
|
||||
|
||||
### Sources Consulted
|
||||
- `src/utils/logger.ts` - Full logger interface (lines 136, 289-373)
|
||||
- `tests/context/context-builder.test.ts` - Mock pattern (lines 22-29)
|
||||
- `tests/context/observation-compiler.test.ts` - Mock pattern (lines 4-10)
|
||||
- `tests/server/server.test.ts` - Mock pattern (lines 4-11)
|
||||
- `tests/server/error-handler.test.ts` - Mock pattern (lines 5-12)
|
||||
- `tests/worker/agents/response-processor.test.ts` - Mock pattern (lines 32-39)
|
||||
|
||||
### Logger Methods (Complete List)
|
||||
All 11 methods that must be in any logger mock:
|
||||
1. `formatTool(toolName: string, toolInput?: any): string` (line 136)
|
||||
2. `debug(component, message, context?, data?): void` (line 289)
|
||||
3. `info(component, message, context?, data?): void` (line 293)
|
||||
4. `warn(component, message, context?, data?): void` (line 297)
|
||||
5. `error(component, message, context?, data?): void` (line 301)
|
||||
6. `dataIn(component, message, context?, data?): void` (line 308)
|
||||
7. `dataOut(component, message, context?, data?): void` (line 315)
|
||||
8. `success(component, message, context?, data?): void` (line 322)
|
||||
9. `failure(component, message, context?, data?): void` (line 329)
|
||||
10. `timing(component, message, durationMs, context?): void` (line 336)
|
||||
11. `happyPathError<T>(message, context?): T` (line 362)
|
||||
|
||||
### Files Requiring Updates
|
||||
1. `tests/context/observation-compiler.test.ts` (lines 4-10)
|
||||
2. `tests/context/context-builder.test.ts` (lines 22-29)
|
||||
3. `tests/server/server.test.ts` (lines 4-11)
|
||||
4. `tests/server/error-handler.test.ts` (lines 5-12)
|
||||
5. `tests/worker/agents/response-processor.test.ts` (lines 32-39)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Create Shared Logger Mock Utility
|
||||
|
||||
### Objective
|
||||
Create a reusable complete logger mock to avoid duplication and ensure consistency.
|
||||
|
||||
### Implementation
|
||||
|
||||
**Create new file**: `tests/test-utils/mock-logger.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Complete logger mock for tests.
|
||||
* Includes ALL logger methods to prevent mock pollution across test files.
|
||||
*/
|
||||
import { mock } from 'bun:test';
|
||||
|
||||
export function createMockLogger() {
|
||||
return {
|
||||
logger: {
|
||||
// Core logging methods
|
||||
debug: mock(() => {}),
|
||||
info: mock(() => {}),
|
||||
warn: mock(() => {}),
|
||||
error: mock(() => {}),
|
||||
|
||||
// Data flow logging
|
||||
dataIn: mock(() => {}),
|
||||
dataOut: mock(() => {}),
|
||||
|
||||
// Status logging
|
||||
success: mock(() => {}),
|
||||
failure: mock(() => {}),
|
||||
|
||||
// Performance logging
|
||||
timing: mock(() => {}),
|
||||
|
||||
// Tool formatting - returns string
|
||||
formatTool: mock((toolName: string, _toolInput?: any) => toolName),
|
||||
|
||||
// Error helper - returns the message
|
||||
happyPathError: mock((message: string, _context?: any) => message),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] File created at `tests/test-utils/mock-logger.ts`
|
||||
- [ ] All 11 logger methods included
|
||||
- [ ] `formatTool` returns string (not void)
|
||||
- [ ] `happyPathError` returns the message (not void)
|
||||
- [ ] File compiles without errors: `bunx tsc --noEmit tests/test-utils/mock-logger.ts`
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- ❌ Don't forget `formatTool` - it returns a string, not void
|
||||
- ❌ Don't forget `happyPathError` - it's generic and returns the message
|
||||
- ❌ Don't use `() => {}` for methods that return values
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Update Affected Test Files
|
||||
|
||||
### Objective
|
||||
Replace incomplete logger mocks with the complete shared mock.
|
||||
|
||||
### Files to Update (5 total)
|
||||
|
||||
#### 2.1 `tests/context/observation-compiler.test.ts`
|
||||
|
||||
**Current (lines 4-10)**:
|
||||
```typescript
|
||||
mock.module('../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
debug: mock(() => {}),
|
||||
failure: mock(() => {}),
|
||||
error: mock(() => {}),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Replace with**:
|
||||
```typescript
|
||||
import { createMockLogger } from '../test-utils/mock-logger.js';
|
||||
|
||||
mock.module('../../src/utils/logger.js', () => createMockLogger());
|
||||
```
|
||||
|
||||
#### 2.2 `tests/context/context-builder.test.ts`
|
||||
|
||||
**Current (lines 22-29)**:
|
||||
```typescript
|
||||
mock.module('../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
debug: mock(() => {}),
|
||||
failure: mock(() => {}),
|
||||
error: mock(() => {}),
|
||||
info: mock(() => {}),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Replace with**:
|
||||
```typescript
|
||||
import { createMockLogger } from '../test-utils/mock-logger.js';
|
||||
|
||||
mock.module('../../src/utils/logger.js', () => createMockLogger());
|
||||
```
|
||||
|
||||
#### 2.3 `tests/server/server.test.ts`
|
||||
|
||||
**Current (lines 4-11)**:
|
||||
```typescript
|
||||
mock.module('../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Replace with**:
|
||||
```typescript
|
||||
import { createMockLogger } from '../test-utils/mock-logger.js';
|
||||
|
||||
mock.module('../../src/utils/logger.js', () => createMockLogger());
|
||||
```
|
||||
|
||||
#### 2.4 `tests/server/error-handler.test.ts`
|
||||
|
||||
**Current (lines 5-12)**:
|
||||
```typescript
|
||||
mock.module('../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Replace with**:
|
||||
```typescript
|
||||
import { createMockLogger } from '../test-utils/mock-logger.js';
|
||||
|
||||
mock.module('../../src/utils/logger.js', () => createMockLogger());
|
||||
```
|
||||
|
||||
#### 2.5 `tests/worker/agents/response-processor.test.ts`
|
||||
|
||||
**Current (lines 32-39)**:
|
||||
```typescript
|
||||
mock.module('../../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Replace with**:
|
||||
```typescript
|
||||
import { createMockLogger } from '../../test-utils/mock-logger.js';
|
||||
|
||||
mock.module('../../../src/utils/logger.js', () => createMockLogger());
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] All 5 files updated with import statement
|
||||
- [ ] All 5 files use `createMockLogger()` instead of inline mock
|
||||
- [ ] Import paths are correct (relative to each file's location)
|
||||
- [ ] Each file still has `mock.module` BEFORE the module imports it mocks
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- ❌ Don't place import AFTER the mock.module call
|
||||
- ❌ Don't use wrong relative path (../test-utils vs ../../test-utils)
|
||||
- ❌ Don't forget the .js extension in imports
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Verification
|
||||
|
||||
### Objective
|
||||
Confirm all 81 failures are fixed.
|
||||
|
||||
### Test Commands
|
||||
|
||||
```bash
|
||||
# 1. Run individual test groups first
|
||||
bun test tests/context/
|
||||
bun test tests/server/
|
||||
bun test tests/utils/
|
||||
bun test tests/shared/
|
||||
bun test tests/worker/
|
||||
|
||||
# 2. Run full suite
|
||||
bun test
|
||||
|
||||
# 3. Verify specific test counts
|
||||
# Expected: 733+ tests pass (was 652 before)
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] `bun test tests/context/` - all pass
|
||||
- [ ] `bun test tests/server/` - all pass
|
||||
- [ ] `bun test tests/utils/` - all pass (including 56 formatTool tests)
|
||||
- [ ] `bun test tests/shared/` - all pass (including 27 settings tests)
|
||||
- [ ] `bun test` - 730+ tests pass, 0 failures
|
||||
- [ ] No `TypeError: logger.formatTool is not a function` errors
|
||||
|
||||
### Anti-Pattern Grep Checks
|
||||
|
||||
```bash
|
||||
# Check no incomplete logger mocks remain
|
||||
grep -r "logger: {" tests/ --include="*.ts" | grep -v mock-logger
|
||||
|
||||
# Verify all test files use createMockLogger
|
||||
grep -r "createMockLogger" tests/ --include="*.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Commit
|
||||
|
||||
### Commit Message
|
||||
|
||||
```
|
||||
fix(tests): complete logger mocks to prevent cross-test pollution
|
||||
|
||||
The 81 test failures were caused by incomplete logger mocks that
|
||||
polluted the module cache when tests ran in alphabetical order.
|
||||
|
||||
Changes:
|
||||
- Create shared mock-logger.ts with all 11 logger methods
|
||||
- Update 5 test files to use complete mock
|
||||
- Fix TypeError: logger.formatTool is not a function
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Files Changed | Purpose |
|
||||
|-------|--------------|---------|
|
||||
| 1 | 1 new file | Create shared mock utility |
|
||||
| 2 | 5 files | Update to use shared mock |
|
||||
| 3 | 0 files | Verification only |
|
||||
| 4 | 0 files | Commit |
|
||||
|
||||
**Total**: 6 files changed, fixing all 81 test failures.
|
||||
@@ -1,176 +0,0 @@
|
||||
# Bugfix Plan: Observer Sessions Authentication Failure
|
||||
|
||||
## Problem Summary
|
||||
|
||||
Observer sessions fail with "Invalid API key · Please run /login" because the `CLAUDE_CONFIG_DIR` environment variable is being set to an isolated directory (`~/.claude-mem/observer-config/`) that lacks authentication credentials.
|
||||
|
||||
## Root Cause
|
||||
|
||||
**File:** `src/services/worker/ProcessRegistry.ts` (lines 207-211)
|
||||
|
||||
```typescript
|
||||
const isolatedEnv = {
|
||||
...spawnOptions.env,
|
||||
CLAUDE_CONFIG_DIR: OBSERVER_CONFIG_DIR // <-- This isolates auth credentials!
|
||||
};
|
||||
```
|
||||
|
||||
This was added in Issue #832 to prevent observer sessions from polluting the `claude --resume` list. However, it also isolates the authentication credentials, breaking the SDK's ability to authenticate with the Anthropic API.
|
||||
|
||||
## Evidence
|
||||
|
||||
1. Running Claude with alternate config dir reproduces the error:
|
||||
```bash
|
||||
CLAUDE_CONFIG_DIR=/tmp/test-claude claude --print "hello"
|
||||
# Output: Invalid API key · Please run /login
|
||||
```
|
||||
|
||||
2. The observer config directory exists but only has cached feature flags, no authentication:
|
||||
- `~/.claude-mem/observer-config/.claude.json` - feature flags only
|
||||
- No credentials copied from main `~/.claude/` directory
|
||||
|
||||
## Solution
|
||||
|
||||
The fix must allow authentication while still isolating session history. Claude Code stores different data types in `CLAUDE_CONFIG_DIR`:
|
||||
- Authentication credentials (needed)
|
||||
- Session history/resume list (should be isolated)
|
||||
- Feature flags and settings (can be shared or isolated)
|
||||
|
||||
**Approach:** Do NOT override `CLAUDE_CONFIG_DIR`. Instead, find an alternative solution for Issue #832.
|
||||
|
||||
### Alternative Approaches for Session Isolation
|
||||
|
||||
1. **Use `--no-resume` flag** (if SDK supports it) - Prevent observer sessions from being resumable
|
||||
2. **Accept pollution** - Observer sessions in resume list may be acceptable tradeoff
|
||||
3. **Post-hoc cleanup** - Clean up observer session entries from history after completion
|
||||
4. **SDK parameter** - Check if SDK has a session isolation option that doesn't affect auth
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery
|
||||
|
||||
### Objective
|
||||
Understand SDK options for session isolation without breaking authentication.
|
||||
|
||||
### Tasks
|
||||
1. Read SDK documentation/source for:
|
||||
- Available `query()` options
|
||||
- Session isolation mechanisms
|
||||
- Authentication handling
|
||||
|
||||
2. Read Issue #832 context:
|
||||
- What was the original problem?
|
||||
- How bad was the pollution?
|
||||
- Are there alternative solutions mentioned?
|
||||
|
||||
### Verification
|
||||
- [ ] List all `query()` options available
|
||||
- [ ] Identify if `--no-resume` or equivalent exists
|
||||
- [ ] Document the tradeoffs
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Fix Authentication
|
||||
|
||||
### Objective
|
||||
Remove the `CLAUDE_CONFIG_DIR` override to restore authentication.
|
||||
|
||||
### File to Modify
|
||||
`src/services/worker/ProcessRegistry.ts`
|
||||
|
||||
### Change
|
||||
Remove lines 207-211 that override `CLAUDE_CONFIG_DIR`:
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
const isolatedEnv = {
|
||||
...spawnOptions.env,
|
||||
CLAUDE_CONFIG_DIR: OBSERVER_CONFIG_DIR
|
||||
};
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const isolatedEnv = {
|
||||
...spawnOptions.env
|
||||
// CLAUDE_CONFIG_DIR removed - observer sessions need access to auth credentials
|
||||
// Session isolation addressed via [alternative approach]
|
||||
};
|
||||
```
|
||||
|
||||
### Verification
|
||||
- [ ] Build succeeds: `npm run build`
|
||||
- [ ] Observer sessions authenticate successfully
|
||||
- [ ] Observations are saved to database
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Address Session Isolation (Issue #832)
|
||||
|
||||
### Objective
|
||||
Find alternative solution to prevent observer sessions from polluting `claude --resume` list.
|
||||
|
||||
### Options to Evaluate
|
||||
|
||||
1. **Option A: Accept the tradeoff**
|
||||
- Observer sessions appear in resume list but users can ignore them
|
||||
- No code changes needed beyond Phase 1
|
||||
|
||||
2. **Option B: Use isSynthetic flag**
|
||||
- If SDK has a flag to mark sessions as non-resumable, use it
|
||||
- Requires SDK documentation review
|
||||
|
||||
3. **Option C: Post-processing cleanup**
|
||||
- After session ends, remove observer entries from history
|
||||
- More complex, may have race conditions
|
||||
|
||||
### Decision Point
|
||||
After Phase 0 documentation review, choose the appropriate option.
|
||||
|
||||
### Verification
|
||||
- [ ] Chosen approach documented
|
||||
- [ ] If code changes made, tests pass
|
||||
- [ ] Observer sessions either isolated OR tradeoff accepted
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Testing
|
||||
|
||||
### Manual Tests
|
||||
1. Start a new Claude Code session with the plugin
|
||||
2. Verify observations are being saved (check logs)
|
||||
3. Check that no "Invalid API key" errors appear
|
||||
4. Verify `claude --resume` behavior (acceptable level of observer entries)
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] `npm run build` succeeds
|
||||
- [ ] Worker service starts without errors
|
||||
- [ ] Observations save to database
|
||||
- [ ] No authentication errors in logs
|
||||
- [ ] Issue #832 regression acceptable or addressed
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
1. **DO NOT** add `ANTHROPIC_API_KEY` to environment - authentication is handled by Claude Code's built-in credential management
|
||||
2. **DO NOT** copy credential files to observer config dir - credentials may be in keychain or other secure storage
|
||||
3. **DO NOT** try to "fix" authentication by adding API key loading - that creates Issue #588 (unexpected API charges)
|
||||
|
||||
---
|
||||
|
||||
## Files Involved
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/worker/ProcessRegistry.ts` | Contains the problematic `CLAUDE_CONFIG_DIR` override |
|
||||
| `src/shared/paths.ts` | Defines `OBSERVER_CONFIG_DIR` constant |
|
||||
| `src/services/worker/SDKAgent.ts` | Uses `createPidCapturingSpawn` which sets the env |
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Low Risk:** Removing the `CLAUDE_CONFIG_DIR` override is a simple, targeted change.
|
||||
|
||||
**Regression Risk (Issue #832):** Observer sessions may appear in `claude --resume` list again. This is a cosmetic issue vs. complete authentication failure, so the tradeoff favors removing the override.
|
||||
@@ -1,262 +0,0 @@
|
||||
# CLAUDE.md Path Validation Bug Fix
|
||||
|
||||
## Problem Summary
|
||||
|
||||
Claude-Mem 9.0's distributed CLAUDE.md feature has a **critical path validation bug** that creates invalid directories when Claude SDK agent outputs non-path strings in file tracking XML tags (`<files_read>`, `<files_modified>`).
|
||||
|
||||
### Root Cause
|
||||
|
||||
In `src/utils/claude-md-utils.ts:234-239`:
|
||||
```typescript
|
||||
if (projectRoot && !path.isAbsolute(filePath)) {
|
||||
absoluteFilePath = path.join(projectRoot, filePath);
|
||||
}
|
||||
```
|
||||
|
||||
- `path.isAbsolute('~/.claude-mem/logs')` returns `false` (Node.js doesn't recognize `~`)
|
||||
- Code joins: `path.join(projectRoot, '~/.claude-mem/logs')` → `/project/~/.claude-mem/logs`
|
||||
- `mkdirSync` creates literal directories
|
||||
|
||||
### Invalid Directories Currently in Repo
|
||||
|
||||
```
|
||||
./~/ ← literal tilde directory
|
||||
./PR #610 on thedotmack/ ← GitHub PR reference
|
||||
./git diff for src/ ← git command text
|
||||
./https:/code.claude.com/docs/en/ ← URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Add Path Validation Function
|
||||
|
||||
**File:** `src/utils/claude-md-utils.ts`
|
||||
|
||||
Add new validation function after the imports (around line 16):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Validate that a file path is safe for CLAUDE.md generation.
|
||||
* Rejects tilde paths, URLs, command-like strings, and paths with invalid chars.
|
||||
*
|
||||
* @param filePath - The file path to validate
|
||||
* @param projectRoot - Optional project root for boundary checking
|
||||
* @returns true if path is valid for CLAUDE.md processing
|
||||
*/
|
||||
function isValidPathForClaudeMd(filePath: string, projectRoot?: string): boolean {
|
||||
// Reject empty or whitespace-only
|
||||
if (!filePath || !filePath.trim()) return false;
|
||||
|
||||
// Reject tilde paths (Node.js doesn't expand ~)
|
||||
if (filePath.startsWith('~')) return false;
|
||||
|
||||
// Reject URLs
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return false;
|
||||
|
||||
// Reject paths with spaces (likely command text or PR references)
|
||||
if (filePath.includes(' ')) return false;
|
||||
|
||||
// Reject paths with # (GitHub issue/PR references)
|
||||
if (filePath.includes('#')) return false;
|
||||
|
||||
// If projectRoot provided, ensure resolved path stays within project
|
||||
if (projectRoot) {
|
||||
const resolved = path.resolve(projectRoot, filePath);
|
||||
const normalizedRoot = path.resolve(projectRoot);
|
||||
if (!resolved.startsWith(normalizedRoot + path.sep) && resolved !== normalizedRoot) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Integrate Validation in updateFolderClaudeMdFiles
|
||||
|
||||
**File:** `src/utils/claude-md-utils.ts`
|
||||
|
||||
Modify the file path loop in `updateFolderClaudeMdFiles` (around line 232):
|
||||
|
||||
```typescript
|
||||
for (const filePath of filePaths) {
|
||||
if (!filePath || filePath === '') continue;
|
||||
|
||||
// VALIDATE PATH BEFORE PROCESSING
|
||||
if (!isValidPathForClaudeMd(filePath, projectRoot)) {
|
||||
logger.debug('FOLDER_INDEX', 'Skipping invalid file path', {
|
||||
filePath,
|
||||
reason: 'Failed path validation'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// ... rest of existing logic unchanged
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Add Unit Tests
|
||||
|
||||
**File:** `tests/utils/claude-md-utils.test.ts`
|
||||
|
||||
Add new test block after existing tests:
|
||||
|
||||
```typescript
|
||||
describe('path validation in updateFolderClaudeMdFiles', () => {
|
||||
it('should reject tilde paths', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['~/.claude-mem/logs/worker.log'],
|
||||
'test-project',
|
||||
37777,
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject URLs', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['https://example.com/file.ts'],
|
||||
'test-project',
|
||||
37777,
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject paths with spaces', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['PR #610 on thedotmack/CLAUDE.md'],
|
||||
'test-project',
|
||||
37777,
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject paths with hash symbols', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['issue#123/file.ts'],
|
||||
'test-project',
|
||||
37777,
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path traversal outside project', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['../../../etc/passwd'],
|
||||
'test-project',
|
||||
37777,
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept valid relative paths', async () => {
|
||||
const apiResponse = {
|
||||
content: [{ text: '| #123 | 4:30 PM | 🔵 | Test | ~100 |' }]
|
||||
};
|
||||
const fetchMock = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(apiResponse)
|
||||
} as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['src/utils/logger.ts'],
|
||||
'test-project',
|
||||
37777,
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 4: Update .gitignore
|
||||
|
||||
**File:** `.gitignore`
|
||||
|
||||
Add at end of file:
|
||||
|
||||
```gitignore
|
||||
# Prevent literal tilde directories (path validation bug artifacts)
|
||||
~*/
|
||||
|
||||
# Prevent other malformed path directories
|
||||
http*/
|
||||
https*/
|
||||
```
|
||||
|
||||
### Phase 5: Clean Up Invalid Directories
|
||||
|
||||
**Command sequence:**
|
||||
```bash
|
||||
rm -rf "~/."
|
||||
rm -rf "PR #610 on thedotmack"
|
||||
rm -rf "git diff for src"
|
||||
rm -rf "https:"
|
||||
```
|
||||
|
||||
### Phase 6: Verify and Commit
|
||||
|
||||
1. Run test suite: `npm test`
|
||||
2. Run build: `npm run build`
|
||||
3. Verify no invalid directories remain
|
||||
4. Commit with message: `fix: Add path validation to CLAUDE.md distribution to prevent invalid directory creation`
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/utils/claude-md-utils.ts` | Add `isValidPathForClaudeMd()` function + integrate in loop |
|
||||
| `tests/utils/claude-md-utils.test.ts` | Add 6 new path validation tests |
|
||||
| `.gitignore` | Add `~*/`, `http*/`, `https*/` patterns |
|
||||
|
||||
## Files Deleted
|
||||
|
||||
| Path | Reason |
|
||||
|------|--------|
|
||||
| `~/` (directory tree) | Invalid literal tilde directory |
|
||||
| `PR #610 on thedotmack/` | Invalid PR reference directory |
|
||||
| `git diff for src/` | Invalid git command directory |
|
||||
| `https:/` | Invalid URL directory |
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Low Risk:**
|
||||
- Validation is additive (only skips invalid paths, doesn't change valid path handling)
|
||||
- Existing tests remain unchanged
|
||||
- Fire-and-forget design means failures are logged but don't break hooks
|
||||
|
||||
**Testing Coverage:**
|
||||
- 6 new unit tests covering all rejection cases
|
||||
- Existing 27 tests verify valid path behavior unchanged
|
||||
@@ -1,314 +0,0 @@
|
||||
# Plan: Cleanup worker-service.ts Unjustified Logic
|
||||
|
||||
**Created:** 2026-01-13
|
||||
**Source:** `docs/reports/nonsense-logic.md`
|
||||
**Target:** `src/services/worker-service.ts` (813 lines)
|
||||
**Goal:** Address 23 identified issues, prioritizing safe deletions first
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery (COMPLETED)
|
||||
|
||||
### Evidence Gathered
|
||||
|
||||
**Exit Code Strategy (CLAUDE.md:44-54):**
|
||||
```
|
||||
- Exit 0: Success or graceful shutdown (Windows Terminal closes tabs)
|
||||
- Exit 1: Non-blocking error
|
||||
- Exit 2: Blocking error
|
||||
Philosophy: Exit 0 prevents Windows Terminal tab accumulation
|
||||
```
|
||||
|
||||
**Signal Handler Pattern (ProcessManager.ts:294-317):**
|
||||
- Uses mutable reference object `isShuttingDownRef`
|
||||
- Factory function `createSignalHandler()` returns handler with embedded state
|
||||
- Current implementation has 3-hop indirection
|
||||
|
||||
**MCP Client Pattern (worker-service.ts:157-160, ChromaSync.ts:124-136):**
|
||||
```typescript
|
||||
this.mcpClient = new Client({
|
||||
name: 'worker-search-proxy',
|
||||
version: '1.0.0'
|
||||
}, { capabilities: {} });
|
||||
```
|
||||
|
||||
**Verification Results:**
|
||||
- `runInteractiveSetup` (lines 439-639): **NEVER CALLED** - grep shows only definition
|
||||
- `import * as fs from 'fs'` (line 13): **UNUSED** - no `fs.` usage found
|
||||
- `import { spawn } from 'child_process'` (line 14): **UNUSED** - no `spawn(` calls
|
||||
- `homedir` (line 15): Only used in `runInteractiveSetup` (dead code)
|
||||
- `processPendingQueues` default `= 10`: Never used, all callers pass explicit args
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Safe Deletions (Dead Code & Unused Imports)
|
||||
|
||||
### 1.1 Delete `runInteractiveSetup` Function
|
||||
|
||||
**What:** Delete lines 435-639 (~201 lines)
|
||||
**Why:** Function is defined but never called. Setup happens via `handleCursorCommand()`.
|
||||
**Evidence:** `grep -n "runInteractiveSetup" src/services/worker-service.ts` returns only definition
|
||||
|
||||
**Pattern to follow:** N/A - straight deletion
|
||||
|
||||
**Steps:**
|
||||
1. Read worker-service.ts lines 435-650
|
||||
2. Delete the entire function including section comment (lines 435-639)
|
||||
3. Run `npm run build` to verify no compile errors
|
||||
|
||||
**Verification:**
|
||||
- `grep "runInteractiveSetup" src/` returns nothing
|
||||
- Build succeeds
|
||||
|
||||
### 1.2 Remove Unused Imports
|
||||
|
||||
**What:** Delete lines 13, 14, 17
|
||||
|
||||
**Current (delete these):**
|
||||
```typescript
|
||||
import * as fs from 'fs'; // Line 13 - UNUSED
|
||||
import { spawn } from 'child_process'; // Line 14 - UNUSED
|
||||
import * as readline from 'readline'; // Line 17 - Only in dead code
|
||||
```
|
||||
|
||||
**Keep:**
|
||||
```typescript
|
||||
import { homedir } from 'os'; // Line 15 - DELETE (only in dead code)
|
||||
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; // Line 16 - CHECK USAGE
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. After deleting `runInteractiveSetup`, grep for remaining usages:
|
||||
- `grep "homedir" src/services/worker-service.ts`
|
||||
- `grep "readline" src/services/worker-service.ts`
|
||||
- `grep "detectClaudeCode\|findCursorHooksDir\|installCursorHooks\|configureCursorMcp" src/services/worker-service.ts`
|
||||
2. Delete imports with zero usages
|
||||
3. Run `npm run build`
|
||||
|
||||
**Verification:**
|
||||
- No TypeScript unused import warnings
|
||||
- Build succeeds
|
||||
|
||||
### 1.3 Clean Up Cursor Integration Imports
|
||||
|
||||
After deleting `runInteractiveSetup`, some CursorHooksInstaller imports become unused:
|
||||
- `detectClaudeCode` - only in runInteractiveSetup
|
||||
- `findCursorHooksDir` - only in runInteractiveSetup
|
||||
- `installCursorHooks` - only in runInteractiveSetup
|
||||
- `configureCursorMcp` - only in runInteractiveSetup
|
||||
|
||||
**Steps:**
|
||||
1. Grep each import after dead code removal
|
||||
2. Remove any that are now unused
|
||||
3. Keep `updateCursorContextForProject` (re-exported) and `handleCursorCommand` (used in main)
|
||||
|
||||
**Verification:**
|
||||
- `grep "detectClaudeCode\|findCursorHooksDir\|installCursorHooks\|configureCursorMcp" src/services/worker-service.ts` returns nothing
|
||||
- Build succeeds
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Low-Risk Simplifications
|
||||
|
||||
### 2.1 Remove Unused Default Parameter
|
||||
|
||||
**What:** Line 350 - `async processPendingQueues(sessionLimit: number = 10)`
|
||||
**Why:** Default never used. All callers pass explicit args (50 in startup, dynamic in HTTP)
|
||||
|
||||
**Change from:**
|
||||
```typescript
|
||||
async processPendingQueues(sessionLimit: number = 10): Promise<{...}>
|
||||
```
|
||||
|
||||
**Change to:**
|
||||
```typescript
|
||||
async processPendingQueues(sessionLimit: number): Promise<{...}>
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- Build succeeds
|
||||
- All call sites provide explicit values
|
||||
|
||||
### 2.2 Simplify onRestart Callback
|
||||
|
||||
**Location:** Lines 395-396 (approximate, find exact)
|
||||
**Issue:** `onShutdown` and `onRestart` both call `this.shutdown()`
|
||||
|
||||
**Find pattern:**
|
||||
```typescript
|
||||
onShutdown: () => this.shutdown(),
|
||||
onRestart: () => this.shutdown()
|
||||
```
|
||||
|
||||
**Options:**
|
||||
1. **Keep as-is** if restart semantically differs from shutdown (future-proofing)
|
||||
2. **Add comment** explaining intentional parity
|
||||
3. **Remove onRestart** if never used differently
|
||||
|
||||
**Investigation needed:** Grep for `onRestart` usage in Server.ts to understand contract
|
||||
|
||||
**Steps:**
|
||||
1. Grep `onRestart` in `src/services/server/`
|
||||
2. If Server.ts treats them identically, add clarifying comment
|
||||
3. If different, document why both map to shutdown
|
||||
|
||||
### 2.3 Fix Over-Commented Lines (Sample Only)
|
||||
|
||||
**Strategy:** Do NOT strip all comments. Only remove comments that describe obvious code.
|
||||
|
||||
**Anti-pattern (remove):**
|
||||
```typescript
|
||||
// WHAT: Imports centralized logging utility with structured output
|
||||
// WHY: All worker logs go through this for consistent formatting
|
||||
import { logger } from '../utils/logger.js';
|
||||
```
|
||||
|
||||
**Pattern to follow:** Remove WHAT/WHY on simple imports. Keep architectural comments.
|
||||
|
||||
**Scope:** Sample 5-10 obvious comment removals to demonstrate approach, not exhaustive
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Medium-Risk Improvements
|
||||
|
||||
### 3.1 Simplify Signal Handler Pattern
|
||||
|
||||
**Current (worker-service.ts:180-192 + ProcessManager.ts:294-317):**
|
||||
```typescript
|
||||
// 3-hop indirection with mutable reference
|
||||
const shutdownRef = { value: this.isShuttingDown };
|
||||
const handler = createSignalHandler(() => this.shutdown(), shutdownRef);
|
||||
process.on('SIGTERM', () => {
|
||||
this.isShuttingDown = shutdownRef.value; // Sync back
|
||||
handler('SIGTERM');
|
||||
});
|
||||
```
|
||||
|
||||
**Simplified approach:**
|
||||
```typescript
|
||||
private registerSignalHandlers(): void {
|
||||
const handler = async (signal: string) => {
|
||||
if (this.isShuttingDown) {
|
||||
logger.warn('SYSTEM', `Received ${signal} but shutdown already in progress`);
|
||||
return;
|
||||
}
|
||||
this.isShuttingDown = true;
|
||||
logger.info('SYSTEM', `Received ${signal}, shutting down...`);
|
||||
try {
|
||||
await this.shutdown();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('SYSTEM', 'Error during shutdown', {}, error as Error);
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => handler('SIGTERM'));
|
||||
process.on('SIGINT', () => handler('SIGINT'));
|
||||
}
|
||||
```
|
||||
|
||||
**Decision needed:** Does `createSignalHandler` serve other callers? If yes, keep factory but simplify worker usage.
|
||||
|
||||
**Steps:**
|
||||
1. Grep `createSignalHandler` usage across codebase
|
||||
2. If only worker-service uses it, inline and simplify
|
||||
3. If shared, simplify worker's usage while keeping factory
|
||||
|
||||
### 3.2 Unify Dual Initialization Tracking
|
||||
|
||||
**Current (lines 111, 129-130):**
|
||||
```typescript
|
||||
private initializationCompleteFlag: boolean = false;
|
||||
private initializationComplete: Promise<void>;
|
||||
```
|
||||
|
||||
**Recommendation:** Keep both but add clarifying comments:
|
||||
- Promise: For async waiters (HTTP handlers)
|
||||
- Flag: For sync checks (status endpoints)
|
||||
|
||||
**Alternative:** Use Promise with inspection pattern:
|
||||
```typescript
|
||||
private initializationComplete = false;
|
||||
private initializationPromise: Promise<void>;
|
||||
// Flag derived from promise state via finally() callback
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Add documentation comment explaining dual tracking purpose
|
||||
2. Consider if flag can be derived from promise state instead
|
||||
|
||||
### 3.3 Reduce 5-Minute Timeout
|
||||
|
||||
**Location:** Lines 464-478 (approximate)
|
||||
**Current:** `const timeoutMs = 300000; // 5 minutes`
|
||||
**Recommendation:** Reduce to 30-60 seconds for HTTP handler, keep 5min for MCP init
|
||||
|
||||
**Caution:** MCP initialization can legitimately be slow (ChromaDB, model loading). May need different timeouts per use case.
|
||||
|
||||
**Steps:**
|
||||
1. Find exact line for context inject timeout
|
||||
2. Verify this is separate from MCP init timeout
|
||||
3. Reduce HTTP handler timeout to 30-60 seconds
|
||||
4. Keep MCP init timeout at 5 minutes
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Deferred / Low Priority
|
||||
|
||||
These items are noted but NOT part of this cleanup:
|
||||
|
||||
| Issue | Reason to Defer |
|
||||
|-------|-----------------|
|
||||
| Exit code 0 always | Documented Windows Terminal workaround - intentional |
|
||||
| Re-export for circular import | Works correctly, architectural fix is separate work |
|
||||
| Fallback agent verification | Behavioral change, needs feature design |
|
||||
| MCP version hardcoding | Low impact, separate version management issue |
|
||||
| Empty capabilities | Documentation issue only |
|
||||
| Unsafe `as Error` casts | Common TS pattern, low risk |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Verification
|
||||
|
||||
### 5.1 Build Verification
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: No errors
|
||||
|
||||
### 5.2 Test Suite
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
Expected: All tests pass
|
||||
|
||||
### 5.3 Grep for Anti-patterns
|
||||
```bash
|
||||
# Verify dead code removed
|
||||
grep -r "runInteractiveSetup" src/
|
||||
|
||||
# Verify unused imports removed
|
||||
grep "import \* as fs from 'fs'" src/services/worker-service.ts
|
||||
grep "import { spawn }" src/services/worker-service.ts
|
||||
```
|
||||
Expected: No matches
|
||||
|
||||
### 5.4 Runtime Check
|
||||
```bash
|
||||
npm run build-and-sync
|
||||
# Start worker and verify basic operation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Items | Estimated Reduction |
|
||||
|-------|-------|---------------------|
|
||||
| Phase 1 | Dead code + unused imports | ~210 lines |
|
||||
| Phase 2 | Low-risk simplifications | ~5 lines + clarity |
|
||||
| Phase 3 | Medium-risk improvements | ~30 lines |
|
||||
| Total | | ~245 lines (~30% reduction) |
|
||||
|
||||
**Execution Order:** Phase 1 → Phase 2 → Phase 3 → Phase 5 (verification after each)
|
||||
@@ -1,516 +0,0 @@
|
||||
# Fix CLAUDE.md Worktree Bug - Implementation Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
CLAUDE.md files are being written to the wrong directory when using git worktrees. The worker service writes files relative to its own `process.cwd()` instead of the project's working directory (`cwd`) from the observation.
|
||||
|
||||
**Reproduction scenario:**
|
||||
1. Start Claude Code in `budapest` worktree → worker starts with `cwd=budapest`
|
||||
2. Run Claude Code in `~/Scripts/claude-mem/` (main repo)
|
||||
3. Observations created with relative file paths (e.g., `src/utils/foo.ts`)
|
||||
4. `updateFolderClaudeMdFiles` writes to `budapest/src/utils/CLAUDE.md` instead of main repo
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The `cwd` (project root path) IS captured and stored:
|
||||
- `SessionRoutes.ts:309,403` - receives `cwd` from hooks
|
||||
- `PendingMessageStore.ts:70` - stores `cwd` in database
|
||||
- `SDKAgent.ts:295` - passes `cwd` to prompt builder
|
||||
|
||||
But `cwd` is NOT passed to file writing:
|
||||
- `ResponseProcessor.ts:222-225` - calls `updateFolderClaudeMdFiles(allFilePaths, session.project, port)` without `cwd`
|
||||
- `claude-md-utils.ts:219` - uses `path.dirname(filePath)` which produces relative paths
|
||||
- Relative paths resolve against worker's `process.cwd()`, not project root
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation & API Inventory
|
||||
|
||||
### Allowed APIs (from codebase analysis)
|
||||
|
||||
**File: `src/utils/claude-md-utils.ts`**
|
||||
```typescript
|
||||
export async function updateFolderClaudeMdFiles(
|
||||
filePaths: string[],
|
||||
project: string,
|
||||
port: number
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**File: `src/sdk/parser.ts`**
|
||||
```typescript
|
||||
export interface ParsedObservation {
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
facts: string[];
|
||||
narrative: string | null;
|
||||
concepts: string[];
|
||||
files_read: string[];
|
||||
files_modified: string[];
|
||||
// NOTE: Does NOT include cwd
|
||||
}
|
||||
```
|
||||
|
||||
**File: `src/services/worker-types.ts`**
|
||||
```typescript
|
||||
export interface PendingMessage {
|
||||
type: 'observation' | 'summarize';
|
||||
tool_name?: string;
|
||||
tool_input?: unknown;
|
||||
tool_response?: unknown;
|
||||
prompt_number?: number;
|
||||
cwd?: string; // <-- Source of project root
|
||||
last_assistant_message?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**File: `src/shared/paths.ts`** - Path utilities
|
||||
```typescript
|
||||
import path from 'path';
|
||||
// Standard pattern: path.join(baseDir, relativePath)
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
1. **DO NOT** add `cwd` to `ParsedObservation` - it comes from message, not agent response
|
||||
2. **DO NOT** use `process.cwd()` for project-specific paths
|
||||
3. **DO NOT** assume file paths are absolute - they are relative from agent response
|
||||
4. **DO NOT** modify the parser - file paths come from agent XML output
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Add `projectRoot` Parameter to `updateFolderClaudeMdFiles`
|
||||
|
||||
### What to implement
|
||||
|
||||
Modify the function signature to accept an optional `projectRoot` parameter for resolving relative paths to absolute paths.
|
||||
|
||||
### Files to modify
|
||||
|
||||
**File: `src/utils/claude-md-utils.ts`**
|
||||
|
||||
**Location: Lines 206-210 (function signature)**
|
||||
|
||||
Current:
|
||||
```typescript
|
||||
export async function updateFolderClaudeMdFiles(
|
||||
filePaths: string[],
|
||||
project: string,
|
||||
port: number
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
New:
|
||||
```typescript
|
||||
export async function updateFolderClaudeMdFiles(
|
||||
filePaths: string[],
|
||||
project: string,
|
||||
port: number,
|
||||
projectRoot?: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**Location: Lines 215-228 (folder extraction logic)**
|
||||
|
||||
Current:
|
||||
```typescript
|
||||
const folderPaths = new Set<string>();
|
||||
for (const filePath of filePaths) {
|
||||
if (!filePath || filePath === '') continue;
|
||||
const folderPath = path.dirname(filePath);
|
||||
if (folderPath && folderPath !== '.' && folderPath !== '/') {
|
||||
if (isProjectRoot(folderPath)) {
|
||||
logger.debug('FOLDER_INDEX', 'Skipping project root CLAUDE.md', { folderPath });
|
||||
continue;
|
||||
}
|
||||
folderPaths.add(folderPath);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
New:
|
||||
```typescript
|
||||
const folderPaths = new Set<string>();
|
||||
for (const filePath of filePaths) {
|
||||
if (!filePath || filePath === '') continue;
|
||||
|
||||
// Resolve relative paths to absolute using projectRoot
|
||||
let absoluteFilePath = filePath;
|
||||
if (projectRoot && !path.isAbsolute(filePath)) {
|
||||
absoluteFilePath = path.join(projectRoot, filePath);
|
||||
}
|
||||
|
||||
const folderPath = path.dirname(absoluteFilePath);
|
||||
if (folderPath && folderPath !== '.' && folderPath !== '/') {
|
||||
if (isProjectRoot(folderPath)) {
|
||||
logger.debug('FOLDER_INDEX', 'Skipping project root CLAUDE.md', { folderPath });
|
||||
continue;
|
||||
}
|
||||
folderPaths.add(folderPath);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Documentation references
|
||||
|
||||
- Pattern for `path.isAbsolute()`: Standard Node.js path module
|
||||
- Pattern for `path.join(base, relative)`: Used throughout `src/shared/paths.ts`
|
||||
|
||||
### Verification checklist
|
||||
|
||||
1. [ ] `grep -n "updateFolderClaudeMdFiles" src/utils/claude-md-utils.ts` shows new signature
|
||||
2. [ ] `grep -n "path.isAbsolute" src/utils/claude-md-utils.ts` confirms new check added
|
||||
3. [ ] `grep -n "projectRoot" src/utils/claude-md-utils.ts` shows parameter usage
|
||||
4. [ ] Existing callers still compile (optional param is backward compatible)
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- **DO NOT** make `projectRoot` required - breaks existing callers
|
||||
- **DO NOT** use `process.cwd()` as default - defeats purpose of fix
|
||||
- **DO NOT** modify the API endpoint format - path resolution is caller's responsibility
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Pass `cwd` from Message to `updateFolderClaudeMdFiles`
|
||||
|
||||
### What to implement
|
||||
|
||||
Extract `cwd` from the original messages being processed and pass it to `updateFolderClaudeMdFiles`.
|
||||
|
||||
### Challenge
|
||||
|
||||
The `syncAndBroadcastObservations` function receives `ParsedObservation[]` which does NOT include `cwd`. The `cwd` is in the original `PendingMessage` but is consumed during prompt generation.
|
||||
|
||||
### Solution
|
||||
|
||||
Add `projectRoot` parameter to `syncAndBroadcastObservations` and `processAgentResponse`, sourced from `session` or passed through from message processing.
|
||||
|
||||
### Files to modify
|
||||
|
||||
**File: `src/services/worker/agents/ResponseProcessor.ts`**
|
||||
|
||||
**Step 1: Update `processAgentResponse` signature (lines 46-55)**
|
||||
|
||||
Current:
|
||||
```typescript
|
||||
export async function processAgentResponse(
|
||||
text: string,
|
||||
session: ActiveSession,
|
||||
dbManager: DatabaseManager,
|
||||
sessionManager: SessionManager,
|
||||
worker: WorkerRef | undefined,
|
||||
discoveryTokens: number,
|
||||
originalTimestamp: number | null,
|
||||
agentName: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
New:
|
||||
```typescript
|
||||
export async function processAgentResponse(
|
||||
text: string,
|
||||
session: ActiveSession,
|
||||
dbManager: DatabaseManager,
|
||||
sessionManager: SessionManager,
|
||||
worker: WorkerRef | undefined,
|
||||
discoveryTokens: number,
|
||||
originalTimestamp: number | null,
|
||||
agentName: string,
|
||||
projectRoot?: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**Step 2: Pass `projectRoot` to `syncAndBroadcastObservations` (line 101-109)**
|
||||
|
||||
Current:
|
||||
```typescript
|
||||
await syncAndBroadcastObservations(
|
||||
observations,
|
||||
result,
|
||||
session,
|
||||
dbManager,
|
||||
worker,
|
||||
discoveryTokens,
|
||||
agentName
|
||||
);
|
||||
```
|
||||
|
||||
New:
|
||||
```typescript
|
||||
await syncAndBroadcastObservations(
|
||||
observations,
|
||||
result,
|
||||
session,
|
||||
dbManager,
|
||||
worker,
|
||||
discoveryTokens,
|
||||
agentName,
|
||||
projectRoot
|
||||
);
|
||||
```
|
||||
|
||||
**Step 3: Update `syncAndBroadcastObservations` signature (lines 153-161)**
|
||||
|
||||
Current:
|
||||
```typescript
|
||||
async function syncAndBroadcastObservations(
|
||||
observations: ParsedObservation[],
|
||||
result: StorageResult,
|
||||
session: ActiveSession,
|
||||
dbManager: DatabaseManager,
|
||||
worker: WorkerRef | undefined,
|
||||
discoveryTokens: number,
|
||||
agentName: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
New:
|
||||
```typescript
|
||||
async function syncAndBroadcastObservations(
|
||||
observations: ParsedObservation[],
|
||||
result: StorageResult,
|
||||
session: ActiveSession,
|
||||
dbManager: DatabaseManager,
|
||||
worker: WorkerRef | undefined,
|
||||
discoveryTokens: number,
|
||||
agentName: string,
|
||||
projectRoot?: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**Step 4: Update `updateFolderClaudeMdFiles` call (lines 222-229)**
|
||||
|
||||
Current:
|
||||
```typescript
|
||||
if (allFilePaths.length > 0) {
|
||||
updateFolderClaudeMdFiles(
|
||||
allFilePaths,
|
||||
session.project,
|
||||
getWorkerPort()
|
||||
).catch(error => {
|
||||
logger.warn('FOLDER_INDEX', 'CLAUDE.md update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
New:
|
||||
```typescript
|
||||
if (allFilePaths.length > 0) {
|
||||
updateFolderClaudeMdFiles(
|
||||
allFilePaths,
|
||||
session.project,
|
||||
getWorkerPort(),
|
||||
projectRoot
|
||||
).catch(error => {
|
||||
logger.warn('FOLDER_INDEX', 'CLAUDE.md update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Verification checklist
|
||||
|
||||
1. [ ] `grep -n "projectRoot" src/services/worker/agents/ResponseProcessor.ts` shows parameter throughout
|
||||
2. [ ] `grep -n "processAgentResponse" src/services/worker/*.ts` to find all callers
|
||||
3. [ ] TypeScript compiles without errors
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- **DO NOT** extract `cwd` from `ParsedObservation` - it doesn't have one
|
||||
- **DO NOT** store `cwd` on session globally - messages may come from different cwds (edge case)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Update Agent Callers to Pass `cwd`
|
||||
|
||||
### What to implement
|
||||
|
||||
Update SDKAgent, GeminiAgent, and OpenRouterAgent to pass `message.cwd` to `processAgentResponse`.
|
||||
|
||||
### Files to modify
|
||||
|
||||
**File: `src/services/worker/SDKAgent.ts`**
|
||||
|
||||
Find the `processAgentResponse` call and add the `projectRoot` parameter from `message.cwd`.
|
||||
|
||||
**Pattern to follow (from SDKAgent.ts:289-296):**
|
||||
```typescript
|
||||
const obsPrompt = buildObservationPrompt({
|
||||
id: 0,
|
||||
tool_name: message.tool_name!,
|
||||
tool_input: JSON.stringify(message.tool_input),
|
||||
tool_output: JSON.stringify(message.tool_response),
|
||||
created_at_epoch: Date.now(),
|
||||
cwd: message.cwd // <-- This is available
|
||||
});
|
||||
```
|
||||
|
||||
**Challenge:** `processAgentResponse` is called after the SDK response, not in the message loop. Need to track `lastCwd` from messages.
|
||||
|
||||
**Solution:** Store `lastCwd` from messages being processed and pass to `processAgentResponse`.
|
||||
|
||||
**File: `src/services/worker/GeminiAgent.ts`** - Same pattern
|
||||
**File: `src/services/worker/OpenRouterAgent.ts`** - Same pattern
|
||||
|
||||
### Implementation pattern for each agent
|
||||
|
||||
Add tracking variable:
|
||||
```typescript
|
||||
let lastCwd: string | undefined;
|
||||
```
|
||||
|
||||
In message loop, capture cwd:
|
||||
```typescript
|
||||
if (message.cwd) {
|
||||
lastCwd = message.cwd;
|
||||
}
|
||||
```
|
||||
|
||||
In `processAgentResponse` call:
|
||||
```typescript
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
this.dbManager,
|
||||
this.sessionManager,
|
||||
worker,
|
||||
discoveryTokens,
|
||||
originalTimestamp,
|
||||
'SDK', // or 'Gemini' or 'OpenRouter'
|
||||
lastCwd
|
||||
);
|
||||
```
|
||||
|
||||
### Verification checklist
|
||||
|
||||
1. [ ] `grep -n "lastCwd" src/services/worker/SDKAgent.ts` shows tracking
|
||||
2. [ ] `grep -n "lastCwd" src/services/worker/GeminiAgent.ts` shows tracking
|
||||
3. [ ] `grep -n "lastCwd" src/services/worker/OpenRouterAgent.ts` shows tracking
|
||||
4. [ ] `grep -n "processAgentResponse.*lastCwd" src/services/worker/` shows all calls updated
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- **DO NOT** use `session.cwd` - sessions can have messages from multiple cwds
|
||||
- **DO NOT** default to `process.cwd()` - defeats the fix
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Update Tests
|
||||
|
||||
### What to implement
|
||||
|
||||
Update existing tests and add new tests for the `projectRoot` functionality.
|
||||
|
||||
### Files to modify
|
||||
|
||||
**File: `tests/utils/claude-md-utils.test.ts`**
|
||||
|
||||
Add test cases for:
|
||||
1. Relative paths with `projectRoot` resolve correctly
|
||||
2. Absolute paths ignore `projectRoot`
|
||||
3. Missing `projectRoot` maintains backward compatibility
|
||||
|
||||
### Test pattern to copy
|
||||
|
||||
From `tests/utils/claude-md-utils.test.ts:245-266` (folder deduplication test):
|
||||
```typescript
|
||||
it('should deduplicate folders from multiple files', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ content: [{ text: mockApiResponse }] })
|
||||
});
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['/project/src/utils/file1.ts', '/project/src/utils/file2.ts'],
|
||||
'test-project',
|
||||
37777
|
||||
);
|
||||
|
||||
// Should only call API once for the deduplicated folder
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
### New test to add
|
||||
|
||||
```typescript
|
||||
it('should resolve relative paths using projectRoot', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ content: [{ text: mockApiResponse }] })
|
||||
});
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
['src/utils/file.ts'], // relative path
|
||||
'test-project',
|
||||
37777,
|
||||
'/home/user/my-project' // projectRoot
|
||||
);
|
||||
|
||||
// Should write to absolute path /home/user/my-project/src/utils/CLAUDE.md
|
||||
expect(mockWriteClaudeMd).toHaveBeenCalledWith(
|
||||
'/home/user/my-project/src/utils',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Verification checklist
|
||||
|
||||
1. [ ] `bun test tests/utils/claude-md-utils.test.ts` passes
|
||||
2. [ ] New test case for `projectRoot` exists and passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Final Verification
|
||||
|
||||
### Verification commands
|
||||
|
||||
```bash
|
||||
# 1. Confirm new parameter exists
|
||||
grep -n "projectRoot" src/utils/claude-md-utils.ts
|
||||
grep -n "projectRoot" src/services/worker/agents/ResponseProcessor.ts
|
||||
grep -n "lastCwd" src/services/worker/SDKAgent.ts
|
||||
|
||||
# 2. Confirm path.isAbsolute check added
|
||||
grep -n "path.isAbsolute" src/utils/claude-md-utils.ts
|
||||
|
||||
# 3. Confirm all agents updated
|
||||
grep -n "processAgentResponse.*lastCwd" src/services/worker/*.ts
|
||||
|
||||
# 4. Run tests
|
||||
bun test tests/utils/claude-md-utils.test.ts
|
||||
|
||||
# 5. Build and verify no TypeScript errors
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Anti-pattern grep checks
|
||||
|
||||
```bash
|
||||
# Should NOT find process.cwd() in updateFolderClaudeMdFiles path logic
|
||||
grep -n "process.cwd" src/utils/claude-md-utils.ts
|
||||
|
||||
# Should NOT find cwd in ParsedObservation interface
|
||||
grep -A 10 "interface ParsedObservation" src/sdk/parser.ts | grep cwd
|
||||
```
|
||||
|
||||
### Manual testing
|
||||
|
||||
1. Start worker in one directory
|
||||
2. Run Claude Code in a different directory (worktree)
|
||||
3. Make a code change that creates an observation
|
||||
4. Verify CLAUDE.md is written to the correct project directory
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/utils/claude-md-utils.ts` | Add `projectRoot` param, resolve relative paths |
|
||||
| `src/services/worker/agents/ResponseProcessor.ts` | Pass `projectRoot` through call chain |
|
||||
| `src/services/worker/SDKAgent.ts` | Track `lastCwd`, pass to `processAgentResponse` |
|
||||
| `src/services/worker/GeminiAgent.ts` | Track `lastCwd`, pass to `processAgentResponse` |
|
||||
| `src/services/worker/OpenRouterAgent.ts` | Track `lastCwd`, pass to `processAgentResponse` |
|
||||
| `tests/utils/claude-md-utils.test.ts` | Add tests for `projectRoot` behavior |
|
||||
@@ -1,266 +0,0 @@
|
||||
# Plan: Fix Empty CLAUDE.md File Generation
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently the CLAUDE.md generator creates files with wasteful content:
|
||||
1. **Empty files with "No recent activity"** - Files are created even when there are zero observations for a folder
|
||||
2. **Redundant HTML comment** - "<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->" is unnecessary since the `<claude-mem-context>` tag already conveys this information
|
||||
|
||||
These issues create noisy, wasteful context that loads automatically and provides no value.
|
||||
|
||||
## Phase 0: Documentation Discovery
|
||||
|
||||
### Allowed APIs (from code analysis)
|
||||
- `formatTimelineForClaudeMd(timelineText: string): string` - src/utils/claude-md-utils.ts:139
|
||||
- `formatObservationsForClaudeMd(observations, folderPath): string` - scripts/regenerate-claude-md.ts:238
|
||||
- `writeClaudeMdToFolder(folderPath, newContent): void` - src/utils/claude-md-utils.ts:94
|
||||
- `updateFolderClaudeMdFiles(filePaths, project, port, projectRoot): Promise<void>` - src/utils/claude-md-utils.ts:257
|
||||
- `replaceTaggedContent(existingContent, newContent): string` - src/utils/claude-md-utils.ts:64
|
||||
|
||||
### Key Locations
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `src/utils/claude-md-utils.ts` | 139-235 | Main formatting function |
|
||||
| `src/utils/claude-md-utils.ts` | 143 | HTML comment generation |
|
||||
| `src/utils/claude-md-utils.ts` | 209-211 | "No recent activity" handling |
|
||||
| `src/utils/claude-md-utils.ts` | 322-323 | Write decision point |
|
||||
| `scripts/regenerate-claude-md.ts` | 238-286 | Regeneration script formatting |
|
||||
| `scripts/regenerate-claude-md.ts` | 242 | HTML comment generation (duplicate) |
|
||||
| `scripts/regenerate-claude-md.ts` | 245-247 | "No recent activity" handling |
|
||||
| `scripts/regenerate-claude-md.ts` | 452-453 | Write decision point |
|
||||
| `tests/utils/claude-md-utils.test.ts` | 96-109 | Tests for "No recent activity" behavior |
|
||||
|
||||
### Anti-patterns to avoid
|
||||
- Do NOT add new configuration options for this behavior - just fix it
|
||||
- Do NOT add logging for skipped files (unnecessary noise)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Modify formatTimelineForClaudeMd to Return Empty on No Observations
|
||||
|
||||
### Task 1.1: Update formatTimelineForClaudeMd return behavior
|
||||
**File:** `src/utils/claude-md-utils.ts`
|
||||
**Lines:** 139-235
|
||||
|
||||
**Changes:**
|
||||
1. Remove HTML comment line at line 143
|
||||
2. Change the empty observations case (lines 209-211) to return an empty string instead of "No recent activity"
|
||||
|
||||
**Before (lines 141-144):**
|
||||
```typescript
|
||||
lines.push('# Recent Activity');
|
||||
lines.push('');
|
||||
lines.push('<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->');
|
||||
lines.push('');
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
lines.push('# Recent Activity');
|
||||
lines.push('');
|
||||
```
|
||||
|
||||
**Before (lines 209-212):**
|
||||
```typescript
|
||||
if (observations.length === 0) {
|
||||
lines.push('*No recent activity*');
|
||||
return lines.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
if (observations.length === 0) {
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Run `bun test tests/utils/claude-md-utils.test.ts`
|
||||
- Tests at lines 96-109 will FAIL (expected - they test for "No recent activity")
|
||||
- Update tests to expect empty string for empty input
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Update updateFolderClaudeMdFiles to Skip Empty Content
|
||||
|
||||
### Task 2.1: Add empty content check before writing
|
||||
**File:** `src/utils/claude-md-utils.ts`
|
||||
**Lines:** 322-323
|
||||
|
||||
**Changes:**
|
||||
After formatting, check if result is empty and skip writing if so.
|
||||
|
||||
**Before (lines 321-325):**
|
||||
```typescript
|
||||
const formatted = formatTimelineForClaudeMd(result.content[0].text);
|
||||
writeClaudeMdToFolder(folderPath, formatted);
|
||||
|
||||
logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath });
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const formatted = formatTimelineForClaudeMd(result.content[0].text);
|
||||
if (!formatted) {
|
||||
logger.debug('FOLDER_INDEX', 'No observations for folder, skipping', { folderPath });
|
||||
continue;
|
||||
}
|
||||
writeClaudeMdToFolder(folderPath, formatted);
|
||||
|
||||
logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath });
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Grep for files containing "No recent activity": should find none after running
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Update Regeneration Script
|
||||
|
||||
### Task 3.1: Remove HTML comment from formatObservationsForClaudeMd
|
||||
**File:** `scripts/regenerate-claude-md.ts`
|
||||
**Lines:** 238-286
|
||||
|
||||
**Changes:**
|
||||
1. Remove HTML comment line at line 242
|
||||
2. Change empty observations case (lines 245-247) to return empty string
|
||||
|
||||
**Before (lines 240-244):**
|
||||
```typescript
|
||||
lines.push('# Recent Activity');
|
||||
lines.push('');
|
||||
lines.push('<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->');
|
||||
lines.push('');
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
lines.push('# Recent Activity');
|
||||
lines.push('');
|
||||
```
|
||||
|
||||
**Before (lines 245-248):**
|
||||
```typescript
|
||||
if (observations.length === 0) {
|
||||
lines.push('*No recent activity*');
|
||||
return lines.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
if (observations.length === 0) {
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3.2: Update regenerateFolder to handle empty formatted content
|
||||
**File:** `scripts/regenerate-claude-md.ts`
|
||||
**Lines:** 432-459
|
||||
|
||||
The script already skips folders with no observations (lines 443-444), so this change is already compatible. The `formatObservationsForClaudeMd` returning empty string doesn't change behavior since observations are checked before calling it.
|
||||
|
||||
### Verification
|
||||
- Run `bun scripts/regenerate-claude-md.ts --dry-run` in the project
|
||||
- Should NOT show any folders with 0 observations
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Update Tests
|
||||
|
||||
### Task 4.1: Update tests for new empty behavior
|
||||
**File:** `tests/utils/claude-md-utils.test.ts`
|
||||
**Lines:** 96-109
|
||||
|
||||
**Changes:**
|
||||
Update the two tests that expect "No recent activity" to expect empty string instead.
|
||||
|
||||
**Before (lines 96-101):**
|
||||
```typescript
|
||||
it('should return "No recent activity" for empty input', () => {
|
||||
const result = formatTimelineForClaudeMd('');
|
||||
|
||||
expect(result).toContain('# Recent Activity');
|
||||
expect(result).toContain('*No recent activity*');
|
||||
});
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
it('should return empty string for empty input', () => {
|
||||
const result = formatTimelineForClaudeMd('');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
```
|
||||
|
||||
**Before (lines 103-109):**
|
||||
```typescript
|
||||
it('should return "No recent activity" when no table rows exist', () => {
|
||||
const input = 'Just some plain text without table rows';
|
||||
|
||||
const result = formatTimelineForClaudeMd(input);
|
||||
|
||||
expect(result).toContain('*No recent activity*');
|
||||
});
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
it('should return empty string when no table rows exist', () => {
|
||||
const input = 'Just some plain text without table rows';
|
||||
|
||||
const result = formatTimelineForClaudeMd(input);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
```
|
||||
|
||||
### Task 4.2: Remove HTML comment assertions from any other tests
|
||||
Search for tests that assert on "auto-generated" comment and update accordingly.
|
||||
|
||||
### Verification
|
||||
- Run full test suite: `bun test`
|
||||
- All tests should pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Cleanup Existing Empty Files
|
||||
|
||||
### Task 5.1: Run cleanup to remove existing empty CLAUDE.md files
|
||||
**Command:**
|
||||
```bash
|
||||
bun scripts/regenerate-claude-md.ts --clean
|
||||
```
|
||||
|
||||
This will:
|
||||
- Find all CLAUDE.md files with `<claude-mem-context>` tags
|
||||
- Strip the tagged section
|
||||
- Delete files that become empty after stripping
|
||||
- Preserve files that have user content outside the tags
|
||||
|
||||
### Verification
|
||||
- `grep -r "No recent activity" . --include="CLAUDE.md"` should return no results
|
||||
- `grep -r "auto-generated by claude-mem" . --include="CLAUDE.md"` should return no results
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/utils/claude-md-utils.ts:143` | Remove HTML comment line |
|
||||
| `src/utils/claude-md-utils.ts:209-211` | Return empty string instead of "No recent activity" |
|
||||
| `src/utils/claude-md-utils.ts:322` | Skip writing if formatted content is empty |
|
||||
| `scripts/regenerate-claude-md.ts:242` | Remove HTML comment line |
|
||||
| `scripts/regenerate-claude-md.ts:245-247` | Return empty string instead of "No recent activity" |
|
||||
| `tests/utils/claude-md-utils.test.ts:96-109` | Update tests to expect empty string |
|
||||
|
||||
## Final Verification Checklist
|
||||
- [ ] `bun test` passes
|
||||
- [ ] No "No recent activity" CLAUDE.md files exist
|
||||
- [ ] No "auto-generated" comments in CLAUDE.md files
|
||||
- [ ] Build succeeds: `npm run build-and-sync`
|
||||
- [ ] New observations correctly generate CLAUDE.md files with content
|
||||
- [ ] Folders without observations get no CLAUDE.md file created
|
||||
@@ -1,252 +0,0 @@
|
||||
# Plan: Fix Stale Session Resume Crash
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The worker crashes repeatedly with "Claude Code process exited with code 1" when attempting to resume into a stale/non-existent SDK session.
|
||||
|
||||
**Root Cause:** In `SDKAgent.ts:94`, the resume parameter is passed whenever `memorySessionId` exists in the database, regardless of whether this is an INIT prompt or CONTINUATION prompt. When a worker restarts or re-initializes a session, it loads a stale `memorySessionId` from a previous SDK session and tries to resume into a session that no longer exists in Claude's context.
|
||||
|
||||
**Evidence from logs:**
|
||||
```
|
||||
[17:30:21.773] Starting SDK query {
|
||||
hasRealMemorySessionId=true, ← DB has old memorySessionId
|
||||
resume_parameter=5439891b-..., ← Trying to resume with it
|
||||
lastPromptNumber=1 ← But this is a NEW SDK session!
|
||||
}
|
||||
[17:30:24.450] Generator failed {error=Claude Code process exited with code 1}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery (COMPLETED)
|
||||
|
||||
### Allowed APIs (from subagent research)
|
||||
|
||||
**V1 SDK API (currently used):**
|
||||
```typescript
|
||||
// From @anthropic-ai/claude-agent-sdk
|
||||
function query(options: {
|
||||
prompt: string | AsyncIterable<SDKUserMessage>;
|
||||
options: {
|
||||
model: string;
|
||||
resume?: string; // SESSION ID - only use for CONTINUATION
|
||||
disallowedTools?: string[];
|
||||
abortController?: AbortController;
|
||||
pathToClaudeCodeExecutable?: string;
|
||||
}
|
||||
}): AsyncIterable<SDKMessage>
|
||||
```
|
||||
|
||||
**Resume Parameter Rules (from docs/context/agent-sdk-v2-preview.md and SESSION_ID_ARCHITECTURE.md):**
|
||||
- `resume` should only be used when continuing an existing SDK conversation
|
||||
- For INIT prompts (first prompt in a fresh SDK session), no resume parameter should be passed
|
||||
- Session ID is captured from first SDK message and stored for subsequent prompts
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- Passing `resume` parameter with INIT prompts (causes crash)
|
||||
- Using `contentSessionId` for resume (contaminates user session)
|
||||
- Assuming memorySessionId validity without checking prompt context
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Fix the Resume Parameter Logic
|
||||
|
||||
### What to Implement
|
||||
|
||||
Modify `src/services/worker/SDKAgent.ts` line 94 to check BOTH conditions:
|
||||
1. `hasRealMemorySessionId` - memorySessionId exists and is non-null
|
||||
2. `session.lastPromptNumber > 1` - this is a CONTINUATION, not an INIT prompt
|
||||
|
||||
### Current Code (line 89-99):
|
||||
```typescript
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
model: modelId,
|
||||
// Resume with captured memorySessionId (null on first prompt, real ID on subsequent)
|
||||
...(hasRealMemorySessionId && { resume: session.memorySessionId }),
|
||||
disallowedTools,
|
||||
abortController: session.abortController,
|
||||
pathToClaudeCodeExecutable: claudePath
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Fixed Code:
|
||||
```typescript
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
model: modelId,
|
||||
// Only resume if BOTH: (1) we have a memorySessionId AND (2) this isn't the first prompt
|
||||
// On worker restart, memorySessionId may exist from a previous SDK session but we
|
||||
// need to start fresh since the SDK context was lost
|
||||
...(hasRealMemorySessionId && session.lastPromptNumber > 1 && { resume: session.memorySessionId }),
|
||||
disallowedTools,
|
||||
abortController: session.abortController,
|
||||
pathToClaudeCodeExecutable: claudePath
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Also Update the Comment at Line 66-68:
|
||||
```typescript
|
||||
// CRITICAL: Only resume if:
|
||||
// 1. memorySessionId exists (was captured from a previous SDK response)
|
||||
// 2. lastPromptNumber > 1 (this is a continuation within the same SDK session)
|
||||
// On worker restart or crash recovery, memorySessionId may exist from a previous
|
||||
// SDK session but we must NOT resume because the SDK context was lost.
|
||||
// NEVER use contentSessionId for resume - that would inject messages into the user's transcript!
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] `grep "hasRealMemorySessionId && session.lastPromptNumber > 1" src/services/worker/SDKAgent.ts` returns the fix
|
||||
- [ ] Build succeeds: `npm run build`
|
||||
- [ ] No TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Add Logging for Debugging
|
||||
|
||||
### What to Implement
|
||||
|
||||
Enhance the alignment log at line 81-85 to clearly indicate when resume is skipped due to INIT prompt:
|
||||
|
||||
```typescript
|
||||
// Debug-level alignment logs for detailed tracing
|
||||
if (session.lastPromptNumber > 1) {
|
||||
const willResume = hasRealMemorySessionId;
|
||||
logger.debug('SDK', `[ALIGNMENT] Resume Decision | contentSessionId=${session.contentSessionId} | memorySessionId=${session.memorySessionId} | prompt#=${session.lastPromptNumber} | hasRealMemorySessionId=${hasRealMemorySessionId} | willResume=${willResume} | resumeWith=${willResume ? session.memorySessionId : 'NONE'}`);
|
||||
} else {
|
||||
// INIT prompt - never resume even if memorySessionId exists (stale from previous session)
|
||||
const hasStaleMemoryId = hasRealMemorySessionId;
|
||||
logger.debug('SDK', `[ALIGNMENT] First Prompt (INIT) | contentSessionId=${session.contentSessionId} | prompt#=${session.lastPromptNumber} | hasStaleMemoryId=${hasStaleMemoryId} | action=START_FRESH | Will capture new memorySessionId from SDK response`);
|
||||
if (hasStaleMemoryId) {
|
||||
logger.warn('SDK', `Skipping resume for INIT prompt despite existing memorySessionId=${session.memorySessionId} - SDK context was lost (worker restart or crash recovery)`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] Build succeeds: `npm run build`
|
||||
- [ ] Log message appears when running with stale session scenario
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Add Unit Tests
|
||||
|
||||
### What to Implement
|
||||
|
||||
Create tests in `tests/sdk-agent-resume.test.ts` following patterns from `tests/session_id_usage_validation.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
|
||||
describe('SDKAgent Resume Parameter Logic', () => {
|
||||
describe('hasRealMemorySessionId check', () => {
|
||||
it('should NOT pass resume parameter when lastPromptNumber === 1 even if memorySessionId exists', () => {
|
||||
// Scenario: Worker restart with stale memorySessionId
|
||||
const session = {
|
||||
memorySessionId: 'stale-session-id-from-previous-run',
|
||||
lastPromptNumber: 1, // INIT prompt
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = hasRealMemorySessionId && session.lastPromptNumber > 1;
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(true); // memorySessionId exists
|
||||
expect(shouldResume).toBe(false); // but should NOT resume
|
||||
});
|
||||
|
||||
it('should pass resume parameter when lastPromptNumber > 1 AND memorySessionId exists', () => {
|
||||
// Scenario: Normal continuation within same SDK session
|
||||
const session = {
|
||||
memorySessionId: 'valid-session-id',
|
||||
lastPromptNumber: 2, // CONTINUATION prompt
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = hasRealMemorySessionId && session.lastPromptNumber > 1;
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(true);
|
||||
expect(shouldResume).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT pass resume parameter when memorySessionId is null', () => {
|
||||
// Scenario: Fresh session, no captured ID yet
|
||||
const session = {
|
||||
memorySessionId: null,
|
||||
lastPromptNumber: 1,
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = hasRealMemorySessionId && session.lastPromptNumber > 1;
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(false);
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Documentation Reference
|
||||
- Pattern: `tests/session_id_usage_validation.test.ts` lines 1-50 for test structure
|
||||
- Mock pattern: `tests/worker/agents/response-processor.test.ts` for session mocking
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] Tests pass: `bun test tests/sdk-agent-resume.test.ts`
|
||||
- [ ] Test file follows project conventions
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Build and Deploy
|
||||
|
||||
### What to Implement
|
||||
|
||||
1. Build the plugin: `npm run build-and-sync`
|
||||
2. Verify worker restarts with fix applied
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] `npm run build-and-sync` succeeds
|
||||
- [ ] Worker health check passes: `curl http://localhost:37777/api/health`
|
||||
- [ ] No "Claude Code process exited with code 1" errors in logs after restart
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Final Verification
|
||||
|
||||
### Verification Commands
|
||||
|
||||
```bash
|
||||
# 1. Verify fix is in place
|
||||
grep -n "hasRealMemorySessionId && session.lastPromptNumber > 1" src/services/worker/SDKAgent.ts
|
||||
|
||||
# 2. Verify no crashes in recent logs
|
||||
tail -100 ~/.claude-mem/logs/claude-mem-$(date +%Y-%m-%d).log | grep -c "exited with code 1"
|
||||
|
||||
# 3. Run tests
|
||||
bun test tests/sdk-agent-resume.test.ts
|
||||
|
||||
# 4. Check for anti-patterns (should return 0 results)
|
||||
grep -n "hasRealMemorySessionId && { resume" src/services/worker/SDKAgent.ts
|
||||
```
|
||||
|
||||
### Success Criteria
|
||||
- [ ] Fix in place at SDKAgent.ts:94
|
||||
- [ ] Zero "exited with code 1" errors related to stale resume
|
||||
- [ ] All tests pass
|
||||
- [ ] Worker stable for 10+ minutes without crash loop
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `src/services/worker/SDKAgent.ts` - Fix resume logic (Phase 1 & 2)
|
||||
2. `tests/sdk-agent-resume.test.ts` - New test file (Phase 3)
|
||||
|
||||
## Estimated Complexity
|
||||
|
||||
- **Phase 1**: Low - Single line change with updated condition
|
||||
- **Phase 2**: Low - Enhanced logging
|
||||
- **Phase 3**: Medium - New test file following existing patterns
|
||||
- **Phase 4-5**: Low - Standard build/verify process
|
||||
@@ -1,298 +0,0 @@
|
||||
# Folder CLAUDE.md Generator
|
||||
|
||||
## CORE DIRECTIVE (NON-NEGOTIABLE)
|
||||
|
||||
**EXTEND THE EXISTING CURSOR RULES TIMELINE GENERATION SYSTEM TO ALSO WRITE CLAUDE.MD FILES**
|
||||
|
||||
- DO NOT create new services
|
||||
- DO NOT create new orchestrators
|
||||
- DO NOT create new HTTP routes
|
||||
- DO NOT create new database query functions
|
||||
- EXTEND existing functions to add folder-level output
|
||||
|
||||
---
|
||||
|
||||
## Approved Directives (From Planning Conversation)
|
||||
|
||||
### Trigger Mechanism
|
||||
- Observation save triggers folder CLAUDE.md regeneration **INLINE**
|
||||
- NO batching
|
||||
- NO debouncing
|
||||
- NO Set-based queuing
|
||||
- NO session-end hook
|
||||
- Synchronous: `observation.save()` → update folder CLAUDE.md files → done
|
||||
|
||||
### Tag Strategy
|
||||
- Wrap ONLY auto-generated content with `<claude-mem-context>` tags
|
||||
- Everything outside tags is untouched (user's manual content preserved)
|
||||
- If tags are deleted, just regenerate them
|
||||
- NO backup system
|
||||
- NO manual content markers
|
||||
|
||||
### Git Behavior
|
||||
- CLAUDE.md files SHOULD be committed (intentional)
|
||||
- `<claude-mem-context>` tag is searchable fingerprint for GitHub analytics
|
||||
- NO .gitignore for these files
|
||||
|
||||
### Phasing
|
||||
- **Phase 1**: CLAUDE.md generation only (THIS PLAN)
|
||||
- **Phase 2**: IDE symlinks (FUTURE)
|
||||
|
||||
### REJECTED
|
||||
- Cross-folder linking — NO
|
||||
- Semantic grouping — deferred enhancement only
|
||||
- Team sync — future phase
|
||||
|
||||
### DEFERRED
|
||||
- Priority weighting by observation type
|
||||
- IDE-specific template refinements
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery (COMPLETED)
|
||||
|
||||
### Existing APIs to USE (Not Rebuild)
|
||||
|
||||
| Function | Location | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `findByFile(filePath, options)` | `src/services/sqlite/SessionSearch.ts:342` | Query observations by folder prefix (already supports LIKE wildcards) |
|
||||
| `updateCursorContextForProject()` | `src/services/integrations/CursorHooksInstaller.ts:98` | Write context files after observation save |
|
||||
| `writeContextFile()` | `src/utils/cursor-utils.ts:97` | Atomic file write with temp file + rename |
|
||||
| `extractFirstFile()` | `src/shared/timeline-formatting.ts` | Extract file paths from JSON arrays |
|
||||
| `groupByDate()` | `src/shared/timeline-formatting.ts` | Group items chronologically |
|
||||
| `formatTime()`, `formatDate()` | `src/shared/timeline-formatting.ts` | Time formatting |
|
||||
|
||||
### Existing Integration Points
|
||||
|
||||
| Location | What Happens | Extension Point |
|
||||
|----------|--------------|-----------------|
|
||||
| `ResponseProcessor.ts:266` | Calls `updateCursorContextForProject()` after summary save | Add folder CLAUDE.md update here |
|
||||
| `CursorHooksInstaller.ts:98` | `updateCursorContextForProject()` fetches context and writes file | Add sibling function for folder updates |
|
||||
|
||||
### Anti-Patterns to AVOID
|
||||
- Creating `FolderIndexOrchestrator.ts` — NO
|
||||
- Creating `FolderTimelineCompiler.ts` — NO
|
||||
- Creating `FolderDiscovery.ts` — NO
|
||||
- Creating `ClaudeMdGenerator.ts` — NO
|
||||
- Creating `FolderIndexRoutes.ts` — NO
|
||||
- Adding new HTTP endpoints — NO
|
||||
- Adding new settings in `SettingsDefaultsManager.ts` — NO (use sensible defaults inline)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Extend CursorHooksInstaller
|
||||
|
||||
### What to Implement
|
||||
|
||||
Add ONE new function to `src/services/integrations/CursorHooksInstaller.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Update CLAUDE.md files for folders touched by an observation.
|
||||
* Called inline after observation save, similar to updateCursorContextForProject.
|
||||
*/
|
||||
export async function updateFolderClaudeMd(
|
||||
workspacePath: string,
|
||||
filesModified: string[],
|
||||
filesRead: string[],
|
||||
project: string,
|
||||
port: number
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
### Implementation Pattern (Copy From)
|
||||
|
||||
Follow the EXACT pattern of `updateCursorContextForProject()` at line 98:
|
||||
1. Extract unique folder paths from filesModified and filesRead
|
||||
2. For each folder, fetch timeline via existing `/api/search/file?files=<folderPath>` endpoint
|
||||
3. Format as simple timeline (reuse existing formatters)
|
||||
4. Write to `<folder>/CLAUDE.md` preserving content outside `<claude-mem-context>` tags
|
||||
|
||||
### Tag Preservation Logic
|
||||
|
||||
```typescript
|
||||
function replaceTaggedContent(existingContent: string, newContent: string): string {
|
||||
const startTag = '<claude-mem-context>';
|
||||
const endTag = '</claude-mem-context>';
|
||||
|
||||
// If no existing content, wrap new content in tags
|
||||
if (!existingContent) {
|
||||
return `${startTag}\n${newContent}\n${endTag}`;
|
||||
}
|
||||
|
||||
// If existing has tags, replace only tagged section
|
||||
const startIdx = existingContent.indexOf(startTag);
|
||||
const endIdx = existingContent.indexOf(endTag);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
return existingContent.substring(0, startIdx) +
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
}
|
||||
|
||||
// If no tags exist, append tagged content at end
|
||||
return existingContent + `\n\n${startTag}\n${newContent}\n${endTag}`;
|
||||
}
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] Function added to CursorHooksInstaller.ts
|
||||
- [ ] Uses existing `findByFile` endpoint (no new database queries)
|
||||
- [ ] Preserves content outside `<claude-mem-context>` tags
|
||||
- [ ] Atomic writes (temp file + rename)
|
||||
- [ ] Build passes: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Hook Into ResponseProcessor
|
||||
|
||||
### What to Implement
|
||||
|
||||
Add call to `updateFolderClaudeMd()` in `src/services/worker/agents/ResponseProcessor.ts`, right after the existing `updateCursorContextForProject()` call at line 266.
|
||||
|
||||
### Code Location
|
||||
|
||||
In `syncAndBroadcastSummary()` function, after line 269:
|
||||
|
||||
```typescript
|
||||
// EXISTING: Update Cursor context file for registered projects (fire-and-forget)
|
||||
updateCursorContextForProject(session.project, getWorkerPort()).catch(error => {
|
||||
logger.warn('CURSOR', 'Context update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
|
||||
// NEW: Update folder CLAUDE.md files for touched folders (fire-and-forget)
|
||||
// Extract file paths from the saved observations
|
||||
updateFolderClaudeMd(
|
||||
workspacePath, // From registry lookup
|
||||
filesModified, // From observations
|
||||
filesRead, // From observations
|
||||
session.project,
|
||||
getWorkerPort()
|
||||
).catch(error => {
|
||||
logger.warn('FOLDER_INDEX', 'CLAUDE.md update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
1. `processAgentResponse()` saves observations → gets back `observationIds`
|
||||
2. Fetch observation records to get `files_read` and `files_modified`
|
||||
3. Pass to `updateFolderClaudeMd()`
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] Call added to ResponseProcessor.ts
|
||||
- [ ] Fire-and-forget pattern (non-blocking, errors logged)
|
||||
- [ ] Uses existing observation data (no new queries)
|
||||
- [ ] Build passes: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Timeline Formatting
|
||||
|
||||
### What to Implement
|
||||
|
||||
Create a minimal timeline formatter for CLAUDE.md output. This can be:
|
||||
1. A simple function in CursorHooksInstaller.ts, OR
|
||||
2. Reuse existing `ResultFormatter.formatSearchResults()` from `src/services/worker/search/ResultFormatter.ts`
|
||||
|
||||
### Output Format
|
||||
|
||||
```markdown
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
<claude-mem-context>
|
||||
|
||||
### 2026-01-04
|
||||
|
||||
| Time | Type | Title |
|
||||
|------|------|-------|
|
||||
| 4:30pm | feature | Added folder index support |
|
||||
| 3:15pm | bugfix | Fixed file path handling |
|
||||
|
||||
### 2026-01-03
|
||||
|
||||
| Time | Type | Title |
|
||||
|------|------|-------|
|
||||
| 11:00am | refactor | Cleaned up cursor utils |
|
||||
|
||||
</claude-mem-context>
|
||||
```
|
||||
|
||||
### Key Points
|
||||
- Compact format (time, type emoji, title only)
|
||||
- Grouped by date
|
||||
- Limited to last N days or observations (sensible default: 10)
|
||||
- NO token counts
|
||||
- NO file columns (redundant - we're IN the folder)
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] Formatter produces clean markdown
|
||||
- [ ] Output is concise (not verbose)
|
||||
- [ ] Grouped by date
|
||||
- [ ] Build passes: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Verification
|
||||
|
||||
### Functional Tests
|
||||
|
||||
1. **Manual Test**:
|
||||
- Start worker: `npm run dev`
|
||||
- Create a test observation touching `src/services/sqlite/`
|
||||
- Verify `src/services/sqlite/CLAUDE.md` is created/updated
|
||||
- Verify `<claude-mem-context>` tags are present
|
||||
- Verify manual content outside tags is preserved
|
||||
|
||||
2. **Build Check**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. **Grep for Anti-Patterns**:
|
||||
```bash
|
||||
# Should find NOTHING
|
||||
grep -r "FolderIndexOrchestrator" src/
|
||||
grep -r "FolderTimelineCompiler" src/
|
||||
grep -r "FolderDiscovery" src/
|
||||
grep -r "ClaudeMdGenerator" src/
|
||||
grep -r "FolderIndexRoutes" src/
|
||||
```
|
||||
|
||||
4. **Grep for Correct Implementation**:
|
||||
```bash
|
||||
# Should find the new function
|
||||
grep -r "updateFolderClaudeMd" src/
|
||||
```
|
||||
|
||||
### Tag Preservation Test
|
||||
|
||||
1. Create `src/test-folder/CLAUDE.md` with manual content:
|
||||
```markdown
|
||||
# My Notes
|
||||
This is manual content I wrote.
|
||||
```
|
||||
|
||||
2. Trigger observation save touching files in `src/test-folder/`
|
||||
|
||||
3. Verify result:
|
||||
```markdown
|
||||
# My Notes
|
||||
This is manual content I wrote.
|
||||
|
||||
<claude-mem-context>
|
||||
### 2026-01-04
|
||||
| Time | Type | Title |
|
||||
...
|
||||
</claude-mem-context>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This is a **~100 line change** spread across 2 files:
|
||||
1. `CursorHooksInstaller.ts` — Add `updateFolderClaudeMd()` function (~60 lines)
|
||||
2. `ResponseProcessor.ts` — Add call to the new function (~10 lines)
|
||||
|
||||
NO new files. NO new services. NO new routes. Just extending existing patterns.
|
||||
@@ -1,378 +0,0 @@
|
||||
# Folder CLAUDE.md Refactor - Extract to Shared Utils
|
||||
|
||||
## CORE DIRECTIVE
|
||||
|
||||
**DECOUPLE FOLDER CLAUDE.MD WRITING FROM CURSOR INTEGRATION**
|
||||
|
||||
The current implementation incorrectly couples folder-level CLAUDE.md generation to Cursor-specific registry lookups. The file paths from observations are already absolute - no workspace registry lookup is needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery (COMPLETED)
|
||||
|
||||
### Current Implementation Location
|
||||
|
||||
| Function | Location | Lines | Purpose |
|
||||
|----------|----------|-------|---------|
|
||||
| `updateFolderClaudeMd` | CursorHooksInstaller.ts | 128-199 | Orchestrates folder CLAUDE.md updates |
|
||||
| `formatTimelineForClaudeMd` | CursorHooksInstaller.ts | 221-295 | Parses API response to markdown |
|
||||
| `replaceTaggedContent` | CursorHooksInstaller.ts | 300-321 | Preserves user content outside tags |
|
||||
| `writeFolderClaudeMd` | CursorHooksInstaller.ts | 326-353 | Atomic file write |
|
||||
|
||||
### Integration Point
|
||||
|
||||
**File:** `src/services/worker/agents/ResponseProcessor.ts:274-298`
|
||||
|
||||
Current (problematic) code:
|
||||
```typescript
|
||||
const registry = readCursorRegistry();
|
||||
const registryEntry = registry[session.project];
|
||||
|
||||
if (registryEntry && (filesModified.length > 0 || filesRead.length > 0)) {
|
||||
updateFolderClaudeMd(
|
||||
registryEntry.workspacePath, // <-- PROBLEM: Needs Cursor registry
|
||||
filesModified,
|
||||
filesRead,
|
||||
session.project,
|
||||
getWorkerPort()
|
||||
).catch(error => { ... });
|
||||
}
|
||||
```
|
||||
|
||||
### The Problem
|
||||
|
||||
1. `filesModified` and `filesRead` already contain **absolute paths**
|
||||
2. We don't need `workspacePath` - just extract folder from file path directly
|
||||
3. Cursor registry is only populated when Cursor hooks are installed
|
||||
4. This makes folder CLAUDE.md a Cursor-only feature (unintended)
|
||||
|
||||
### Project Utils Pattern
|
||||
|
||||
**From `src/utils/cursor-utils.ts:97-122`:**
|
||||
- Pure functions with paths as parameters
|
||||
- Atomic write pattern: temp file + rename
|
||||
- `mkdirSync(dir, { recursive: true })` for directory creation
|
||||
|
||||
### Related Utils
|
||||
|
||||
**`src/utils/tag-stripping.ts`** - Handles *stripping* tags (input filtering)
|
||||
- `stripMemoryTagsFromJson()` - removes `<claude-mem-context>` content
|
||||
- `stripMemoryTagsFromPrompt()` - removes `<private>` content
|
||||
|
||||
Our `replaceTaggedContent` handles *preserving/replacing* (output writing) - complementary, not duplicative.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Create Shared Utils File
|
||||
|
||||
### What to Implement
|
||||
|
||||
Create `src/utils/claude-md-utils.ts` with extracted and simplified functions.
|
||||
|
||||
### File Structure
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* CLAUDE.md File Utilities
|
||||
*
|
||||
* Shared utilities for writing folder-level CLAUDE.md files with
|
||||
* auto-generated context sections. Preserves user content outside
|
||||
* <claude-mem-context> tags.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Replace tagged content in existing file, preserving content outside tags.
|
||||
*
|
||||
* Handles three cases:
|
||||
* 1. No existing content → wraps new content in tags
|
||||
* 2. Has existing tags → replaces only tagged section
|
||||
* 3. No tags in existing content → appends tagged content at end
|
||||
*/
|
||||
export function replaceTaggedContent(existingContent: string, newContent: string): string {
|
||||
// Copy from CursorHooksInstaller.ts:300-321
|
||||
}
|
||||
|
||||
/**
|
||||
* Write CLAUDE.md file to folder with atomic writes.
|
||||
* Creates directory structure if needed.
|
||||
*
|
||||
* @param folderPath - Absolute path to the folder
|
||||
* @param newContent - Content to write inside tags
|
||||
*/
|
||||
export function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
||||
// Simplified from writeFolderClaudeMd - no workspacePath needed
|
||||
// Copy atomic write pattern from CursorHooksInstaller.ts:326-353
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timeline text from API response to compact CLAUDE.md format.
|
||||
*
|
||||
* @param timelineText - Raw API response text
|
||||
* @returns Formatted markdown with date headers and compact table
|
||||
*/
|
||||
export function formatTimelineForClaudeMd(timelineText: string): string {
|
||||
// Copy from CursorHooksInstaller.ts:221-295
|
||||
}
|
||||
```
|
||||
|
||||
### Key Simplification
|
||||
|
||||
**OLD `writeFolderClaudeMd` signature:**
|
||||
```typescript
|
||||
async function writeFolderClaudeMd(
|
||||
workspacePath: string, // <-- REMOVE
|
||||
folderPath: string,
|
||||
newContent: string
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**NEW `writeClaudeMdToFolder` signature:**
|
||||
```typescript
|
||||
export function writeClaudeMdToFolder(
|
||||
folderPath: string, // Must be absolute path
|
||||
newContent: string
|
||||
): void // Sync is fine, atomic anyway
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] File created at `src/utils/claude-md-utils.ts`
|
||||
- [ ] `replaceTaggedContent` exported and handles all 3 cases
|
||||
- [ ] `writeClaudeMdToFolder` exported with atomic writes
|
||||
- [ ] `formatTimelineForClaudeMd` exported
|
||||
- [ ] Build passes: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create Folder Index Service Function
|
||||
|
||||
### What to Implement
|
||||
|
||||
Create a new orchestrating function that replaces `updateFolderClaudeMd`. This should NOT be in CursorHooksInstaller - it's a general feature.
|
||||
|
||||
**Option A:** Add to `src/utils/claude-md-utils.ts` (keeps it simple)
|
||||
**Option B:** Create `src/services/folder-index-service.ts` (follows service pattern)
|
||||
|
||||
Recommend **Option A** for simplicity - it's just one function.
|
||||
|
||||
### New Function
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Update CLAUDE.md files for folders containing the given files.
|
||||
* Fetches timeline from worker API and writes formatted content.
|
||||
*
|
||||
* @param filePaths - Array of absolute file paths (modified or read)
|
||||
* @param project - Project identifier for API query
|
||||
* @param port - Worker API port
|
||||
*/
|
||||
export async function updateFolderClaudeMdFiles(
|
||||
filePaths: string[],
|
||||
project: string,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
// Extract unique folder paths from file paths
|
||||
const folderPaths = new Set<string>();
|
||||
for (const filePath of filePaths) {
|
||||
if (!filePath || filePath === '') continue;
|
||||
const folderPath = path.dirname(filePath);
|
||||
if (folderPath && folderPath !== '.' && folderPath !== '/') {
|
||||
folderPaths.add(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderPaths.size === 0) return;
|
||||
|
||||
logger.debug('FOLDER_INDEX', 'Updating CLAUDE.md files', {
|
||||
project,
|
||||
folderCount: folderPaths.size
|
||||
});
|
||||
|
||||
// Process each folder
|
||||
for (const folderPath of folderPaths) {
|
||||
try {
|
||||
// Fetch timeline via existing API
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/search/by-file?filePath=${encodeURIComponent(folderPath)}&limit=10&project=${encodeURIComponent(project)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('FOLDER_INDEX', 'Failed to fetch timeline', { folderPath, status: response.status });
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.content?.[0]?.text) {
|
||||
logger.debug('FOLDER_INDEX', 'No content for folder', { folderPath });
|
||||
continue;
|
||||
}
|
||||
|
||||
const formatted = formatTimelineForClaudeMd(result.content[0].text);
|
||||
writeClaudeMdToFolder(folderPath, formatted);
|
||||
|
||||
logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath });
|
||||
} catch (error) {
|
||||
logger.warn('FOLDER_INDEX', 'Failed to update CLAUDE.md', { folderPath }, error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] `updateFolderClaudeMdFiles` function added
|
||||
- [ ] Takes only `filePaths`, `project`, `port` (no workspacePath)
|
||||
- [ ] Extracts folder paths from absolute file paths
|
||||
- [ ] Uses `writeClaudeMdToFolder` for atomic writes
|
||||
- [ ] Build passes: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Update ResponseProcessor Integration
|
||||
|
||||
### What to Implement
|
||||
|
||||
Simplify the call site in `src/services/worker/agents/ResponseProcessor.ts`.
|
||||
|
||||
### Current Code (lines 274-298)
|
||||
```typescript
|
||||
// Update folder CLAUDE.md files for touched folders (fire-and-forget)
|
||||
const filesModified: string[] = [];
|
||||
const filesRead: string[] = [];
|
||||
|
||||
for (const obs of observations) {
|
||||
filesModified.push(...(obs.files_modified || []));
|
||||
filesRead.push(...(obs.files_read || []));
|
||||
}
|
||||
|
||||
// Get workspace path from project registry
|
||||
const registry = readCursorRegistry();
|
||||
const registryEntry = registry[session.project];
|
||||
|
||||
if (registryEntry && (filesModified.length > 0 || filesRead.length > 0)) {
|
||||
updateFolderClaudeMd(
|
||||
registryEntry.workspacePath,
|
||||
filesModified,
|
||||
filesRead,
|
||||
session.project,
|
||||
getWorkerPort()
|
||||
).catch(error => {
|
||||
logger.warn('FOLDER_INDEX', 'CLAUDE.md update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### New Code
|
||||
```typescript
|
||||
// Update folder CLAUDE.md files for touched folders (fire-and-forget)
|
||||
const allFilePaths: string[] = [];
|
||||
for (const obs of observations) {
|
||||
allFilePaths.push(...(obs.files_modified || []));
|
||||
allFilePaths.push(...(obs.files_read || []));
|
||||
}
|
||||
|
||||
if (allFilePaths.length > 0) {
|
||||
updateFolderClaudeMdFiles(
|
||||
allFilePaths,
|
||||
session.project,
|
||||
getWorkerPort()
|
||||
).catch(error => {
|
||||
logger.warn('FOLDER_INDEX', 'CLAUDE.md update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Import Changes
|
||||
|
||||
**Remove:**
|
||||
```typescript
|
||||
import { updateFolderClaudeMd, readCursorRegistry } from '../../integrations/CursorHooksInstaller.js';
|
||||
```
|
||||
|
||||
**Add:**
|
||||
```typescript
|
||||
import { updateFolderClaudeMdFiles } from '../../../utils/claude-md-utils.js';
|
||||
```
|
||||
|
||||
**Keep (if still needed for Cursor context):**
|
||||
```typescript
|
||||
import { updateCursorContextForProject } from '../../worker-service.js';
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] Import updated to use `claude-md-utils.ts`
|
||||
- [ ] `readCursorRegistry` import removed (if no longer needed)
|
||||
- [ ] Call site simplified - no registry lookup
|
||||
- [ ] Fire-and-forget pattern preserved
|
||||
- [ ] Build passes: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Clean Up CursorHooksInstaller
|
||||
|
||||
### What to Implement
|
||||
|
||||
Remove the extracted functions from `src/services/integrations/CursorHooksInstaller.ts`.
|
||||
|
||||
### Functions to Remove
|
||||
- `updateFolderClaudeMd` (lines 128-199)
|
||||
- `formatTimelineForClaudeMd` (lines 221-295)
|
||||
- `replaceTaggedContent` (lines 300-321)
|
||||
- `writeFolderClaudeMd` (lines 326-353)
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] All 4 functions removed from CursorHooksInstaller.ts
|
||||
- [ ] No dangling references to removed functions
|
||||
- [ ] CursorHooksInstaller still exports what it needs for Cursor integration
|
||||
- [ ] Build passes: `npm run build`
|
||||
- [ ] Grep shows no references to old function locations
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Verification
|
||||
|
||||
### Build Check
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Anti-Pattern Grep (should find NOTHING in CursorHooksInstaller)
|
||||
```bash
|
||||
grep -n "updateFolderClaudeMd\|formatTimelineForClaudeMd\|replaceTaggedContent\|writeFolderClaudeMd" src/services/integrations/CursorHooksInstaller.ts
|
||||
```
|
||||
|
||||
### Correct Location Grep (should find in claude-md-utils)
|
||||
```bash
|
||||
grep -rn "updateFolderClaudeMdFiles\|writeClaudeMdToFolder\|formatTimelineForClaudeMd" src/utils/
|
||||
```
|
||||
|
||||
### Integration Check
|
||||
```bash
|
||||
grep -n "updateFolderClaudeMdFiles" src/services/worker/agents/ResponseProcessor.ts
|
||||
```
|
||||
|
||||
### No Cursor Registry Dependency
|
||||
```bash
|
||||
grep -n "readCursorRegistry" src/services/worker/agents/ResponseProcessor.ts
|
||||
# Should return nothing (or only for Cursor context, not folder index)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**~150 lines moved** from CursorHooksInstaller.ts to claude-md-utils.ts with simplification:
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| 4 functions in CursorHooksInstaller | 4 functions in claude-md-utils |
|
||||
| Requires Cursor registry lookup | Works with absolute paths directly |
|
||||
| `updateFolderClaudeMd(workspacePath, ...)` | `updateFolderClaudeMdFiles(filePaths, ...)` |
|
||||
| Coupled to Cursor integration | Independent utility |
|
||||
|
||||
**Files Changed:**
|
||||
1. `src/utils/claude-md-utils.ts` - NEW (create)
|
||||
2. `src/services/worker/agents/ResponseProcessor.ts` - UPDATE (simplify call site)
|
||||
3. `src/services/integrations/CursorHooksInstaller.ts` - UPDATE (remove extracted functions)
|
||||
@@ -1,186 +0,0 @@
|
||||
# Plan: Change Folder CLAUDE.md to Timeline Format
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the simple table format in folder-level CLAUDE.md files with the timeline format used by search results.
|
||||
|
||||
## Current vs Target Format
|
||||
|
||||
### Current Format (Simple)
|
||||
```markdown
|
||||
# Recent Activity
|
||||
|
||||
### Recent
|
||||
|
||||
| Time | Type | Title |
|
||||
|------|------|-------|
|
||||
| 6:33pm | feature | Multiple CLAUDE.md files generated |
|
||||
| 6:32pm | feature | CLAUDE.md file successfully generated |
|
||||
```
|
||||
|
||||
### Target Format (Timeline)
|
||||
```markdown
|
||||
# Recent Activity
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**src/services/worker/agents/ResponseProcessor.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37110 | 6:35 PM | 🔴 | Folder CLAUDE.md updates moved from summary | ~85 |
|
||||
| #37109 | " | ✅ | ResponseProcessor.ts modified | ~92 |
|
||||
|
||||
**General**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37108 | 6:33 PM | 🟣 | Multiple CLAUDE.md files generated | ~78 |
|
||||
```
|
||||
|
||||
## Key Changes
|
||||
|
||||
1. **Group by date** - Use `### Jan 4, 2026` instead of `### Recent`
|
||||
2. **Group by file within each date** - Add `**filename**` headers
|
||||
3. **Expand columns** - Add ID and Read columns: `| ID | Time | T | Title | Read |`
|
||||
4. **Use type emojis** - Use `🔴` `🟣` `✅` etc. instead of text
|
||||
5. **Show ditto marks** - Use `"` for repeated times
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Refactor formatTimelineForClaudeMd
|
||||
|
||||
**File:** `src/utils/claude-md-utils.ts`
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Add imports from shared utilities:
|
||||
```typescript
|
||||
import { formatDate, formatTime, extractFirstFile, estimateTokens, groupByDate } from '../shared/timeline-formatting.js';
|
||||
import { ModeManager } from '../services/domain/ModeManager.js';
|
||||
```
|
||||
|
||||
2. Replace `formatTimelineForClaudeMd()` (lines 78-151) with new implementation that:
|
||||
- Parses API response to extract full observation data (id, time, type emoji, title, files)
|
||||
- Groups observations by date using `groupByDate()`
|
||||
- Within each date, groups by file using a Map
|
||||
- Renders file sections with `**filename**` headers
|
||||
- Uses search table format: `| ID | Time | T | Title | Read |`
|
||||
- Uses ditto marks for repeated times
|
||||
|
||||
**Pattern to Copy From:** `src/services/worker/search/ResultFormatter.ts` lines 56-108
|
||||
|
||||
**Key APIs:**
|
||||
- `groupByDate(items, getDate)` - from `src/shared/timeline-formatting.ts:104-127`
|
||||
- `formatTime(epoch)` - from `src/shared/timeline-formatting.ts:46-53`
|
||||
- `formatDate(epoch)` - from `src/shared/timeline-formatting.ts:59-66`
|
||||
- `extractFirstFile(filesModified, cwd)` - from `src/shared/timeline-formatting.ts:81-84`
|
||||
- `estimateTokens(text)` - from `src/shared/timeline-formatting.ts:89-92`
|
||||
- `ModeManager.getInstance().getTypeIcon(type)` - from `src/services/domain/ModeManager.ts`
|
||||
|
||||
**Verification:**
|
||||
1. Run `npm run build` - no errors
|
||||
2. Restart worker: `npm run worker:restart`
|
||||
3. Make a test edit to trigger observation
|
||||
4. Check generated CLAUDE.md files for new format
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Parse Full Observation Data from API
|
||||
|
||||
**Context:** The current regex parsing extracts only time, type emoji, and title. Need to also extract:
|
||||
- Observation ID (for `#123` column)
|
||||
- File path (from files_modified in API response, for grouping)
|
||||
- Token estimate (for `Read` column)
|
||||
|
||||
**Challenge:** The current API returns formatted text, not structured data. We need to:
|
||||
1. Parse the existing text format more thoroughly, OR
|
||||
2. Use a different API endpoint that returns JSON
|
||||
|
||||
**Decision Point:** Check what data the `/api/search/by-file` endpoint returns. If it returns structured JSON with observations, use that. Otherwise, enhance parsing.
|
||||
|
||||
**Investigation Required:**
|
||||
- Read `src/services/worker/http/routes/SearchRoutes.ts` to see by-file response format
|
||||
- Determine if we can access raw observation data or just formatted text
|
||||
|
||||
**Verification:**
|
||||
- Confirm API response structure
|
||||
- Update parsing to extract all needed fields
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Integrate File-Based Grouping
|
||||
|
||||
**File:** `src/utils/claude-md-utils.ts`
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Create helper to group by file:
|
||||
```typescript
|
||||
function groupByFile(observations: ParsedObservation[]): Map<string, ParsedObservation[]> {
|
||||
const byFile = new Map<string, ParsedObservation[]>();
|
||||
for (const obs of observations) {
|
||||
const file = obs.file || 'General';
|
||||
if (!byFile.has(file)) byFile.set(file, []);
|
||||
byFile.get(file)!.push(obs);
|
||||
}
|
||||
return byFile;
|
||||
}
|
||||
```
|
||||
|
||||
2. Render with file sections:
|
||||
```typescript
|
||||
for (const [file, fileObs] of resultsByFile) {
|
||||
lines.push(`**${file}**`);
|
||||
lines.push(`| ID | Time | T | Title | Read |`);
|
||||
lines.push(`|----|------|---|-------|------|`);
|
||||
// render rows with ditto marks
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern to Copy From:** `ResultFormatter.formatSearchResults()` lines 60-108
|
||||
|
||||
**Verification:**
|
||||
- Generated CLAUDE.md shows file grouping
|
||||
- Files are displayed as relative paths when possible
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Final Verification
|
||||
|
||||
**Checklist:**
|
||||
|
||||
1. **Build passes:** `npm run build`
|
||||
2. **Worker restarts cleanly:** `npm run worker:restart`
|
||||
3. **Format matches target:**
|
||||
- Date headers: `### Jan 4, 2026`
|
||||
- File sections: `**filename**`
|
||||
- Table columns: `| ID | Time | T | Title | Read |`
|
||||
- Type emojis: `🔴` `🟣` `✅` not text
|
||||
- Ditto marks: `"` for repeated times
|
||||
4. **Anti-pattern checks:**
|
||||
- No hardcoded type maps (use ModeManager)
|
||||
- No invented APIs
|
||||
- Reuses existing formatters from shared utils
|
||||
5. **Graceful degradation:** Empty results still show `*No recent activity*`
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/utils/claude-md-utils.ts` | Replace `formatTimelineForClaudeMd()` with timeline format |
|
||||
|
||||
## Files to Read (Patterns to Copy)
|
||||
|
||||
| File | Pattern |
|
||||
|------|---------|
|
||||
| `src/services/worker/search/ResultFormatter.ts:56-108` | Date/file grouping logic |
|
||||
| `src/shared/timeline-formatting.ts` | All formatting utilities |
|
||||
| `src/services/domain/ModeManager.ts` | Type icon lookup |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- ❌ Creating new hardcoded type→emoji maps (use ModeManager)
|
||||
- ❌ Parsing dates manually (use shared formatters)
|
||||
- ❌ Skipping the existing groupByDate utility
|
||||
- ❌ Not handling ditto marks for repeated times
|
||||
@@ -1,356 +0,0 @@
|
||||
# Execution Plan: Intentional Patterns Validation Actions
|
||||
|
||||
**Created:** 2026-01-13
|
||||
**Source:** `docs/reports/intentional-patterns-validation.md`
|
||||
**Target:** `src/services/worker-service.ts` and related files
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery (COMPLETED)
|
||||
|
||||
### Evidence Gathered
|
||||
|
||||
**Files Analyzed:**
|
||||
- `docs/reports/intentional-patterns-validation.md` - Pattern verdicts and recommendations
|
||||
- `docs/reports/nonsense-logic.md` - Original 23 issues identified
|
||||
- `.claude/plans/cleanup-worker-service-nonsense-logic.md` - Existing cleanup plan
|
||||
- `src/services/worker-service.ts` (813 lines) - Current state
|
||||
|
||||
**Current State:**
|
||||
- File has been reduced from 1445 lines to 813 lines in prior refactoring
|
||||
- `runInteractiveSetup` still exists at line 439 (~200 lines of dead code)
|
||||
- Re-export at line 78: `export { updateCursorContextForProject };`
|
||||
- MCP version hardcoded "1.0.0" at line 159
|
||||
- Fallback agents set at lines 144-146 without verification
|
||||
- Unused imports: `fs`, `spawn`, `homedir`, `readline` at lines 13-17
|
||||
|
||||
**Allowed APIs (from validation report):**
|
||||
- Exit code 0 pattern: **KEEP** (documented Windows Terminal workaround)
|
||||
- `as Error` casts: **KEEP** (documented project policy)
|
||||
- Dual init tracking: **KEEP** (serves async + sync callers)
|
||||
- Signal handler ref pattern: **KEEP** (standard JS mutable state sharing)
|
||||
- Empty MCP capabilities: **KEEP** (correct per MCP spec)
|
||||
|
||||
**Actions Required:**
|
||||
| Pattern | Action | Priority |
|
||||
|---------|--------|----------|
|
||||
| Re-export for circular import | Remove (no actual circular dep) | LOW |
|
||||
| Fallback agent without check | Add availability verification | HIGH |
|
||||
| MCP version hardcoded | Update to use package.json | LOW |
|
||||
| Dead code `runInteractiveSetup` | Delete (~200 lines) | HIGH |
|
||||
| Unused imports | Delete | LOW |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Delete Dead Code (HIGH PRIORITY)
|
||||
|
||||
### 1.1 Delete `runInteractiveSetup` Function
|
||||
|
||||
**What:** Delete lines 435-639 (approximately 200 lines)
|
||||
**File:** `src/services/worker-service.ts`
|
||||
|
||||
**Location confirmed:** Line 439 starts `async function runInteractiveSetup(): Promise<number>`
|
||||
|
||||
**Steps:**
|
||||
1. Read worker-service.ts lines 435-650 to find exact boundaries
|
||||
2. Delete the section comment and entire function
|
||||
3. Run build to verify no compile errors
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
grep -n "runInteractiveSetup" src/services/worker-service.ts
|
||||
# Expected: No output (function deleted)
|
||||
npm run build
|
||||
# Expected: No errors
|
||||
```
|
||||
|
||||
### 1.2 Remove Unused Imports
|
||||
|
||||
**What:** Delete imports only used by dead code
|
||||
**Lines to delete:** 13-17 (check each)
|
||||
|
||||
**Current imports to remove:**
|
||||
```typescript
|
||||
import * as fs from 'fs'; // Line 13 - UNUSED (namespace never accessed)
|
||||
import { spawn } from 'child_process'; // Line 14 - UNUSED (MCP uses StdioClientTransport)
|
||||
import { homedir } from 'os'; // Line 15 - Only in dead code
|
||||
import * as readline from 'readline'; // Line 17 - Only in dead code
|
||||
```
|
||||
|
||||
**Keep:**
|
||||
```typescript
|
||||
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; // Line 16 - CHECK
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. After deleting `runInteractiveSetup`, grep each import
|
||||
2. Delete any with zero usages
|
||||
3. Run build to verify
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
grep -n "^import \* as fs" src/services/worker-service.ts
|
||||
grep -n "import { spawn }" src/services/worker-service.ts
|
||||
# Expected: No output
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 1.3 Remove Unused CursorHooksInstaller Imports
|
||||
|
||||
**After deleting dead code, check:**
|
||||
```typescript
|
||||
import {
|
||||
updateCursorContextForProject, // KEEP (re-exported)
|
||||
handleCursorCommand, // KEEP (used in main)
|
||||
detectClaudeCode, // DELETE (only in dead code)
|
||||
findCursorHooksDir, // DELETE (only in dead code)
|
||||
installCursorHooks, // DELETE (only in dead code)
|
||||
configureCursorMcp // DELETE (only in dead code)
|
||||
} from './integrations/CursorHooksInstaller.js';
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
grep "detectClaudeCode\|findCursorHooksDir\|installCursorHooks\|configureCursorMcp" src/services/worker-service.ts
|
||||
# Expected: Only import line (which gets trimmed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Fix Fallback Agent Oversight (HIGH PRIORITY)
|
||||
|
||||
### 2.1 Add SDKAgent Availability Check
|
||||
|
||||
**Problem:** Lines 144-146 set Claude SDK as fallback without verifying it's configured
|
||||
```typescript
|
||||
this.geminiAgent.setFallbackAgent(this.sdkAgent);
|
||||
this.openRouterAgent.setFallbackAgent(this.sdkAgent);
|
||||
```
|
||||
|
||||
**Risk:** User chooses Gemini because they lack Claude credentials → transient Gemini error → fallback to Claude SDK → cascading failure
|
||||
|
||||
**Solution Options:**
|
||||
|
||||
**Option A: Add isConfigured() method to SDKAgent**
|
||||
1. Add method to SDKAgent that checks for valid Claude SDK credentials
|
||||
2. Only set fallback if `sdkAgent.isConfigured()` returns true
|
||||
3. Log warning when fallback unavailable
|
||||
|
||||
**Pattern to follow (from SDKAgent.ts constructor):**
|
||||
```typescript
|
||||
// Check if Claude SDK can be initialized
|
||||
public isConfigured(): boolean {
|
||||
// Claude SDK uses subprocess, check if claude command exists
|
||||
try {
|
||||
// Check for ANTHROPIC_API_KEY or claude CLI availability
|
||||
return !!process.env.ANTHROPIC_API_KEY || this.checkClaudeCliAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Document limitation (minimal fix)**
|
||||
Add comment explaining the risk:
|
||||
```typescript
|
||||
// NOTE: Fallback to Claude SDK may fail if user lacks Claude credentials
|
||||
// Consider adding availability check in future (Issue #XXX)
|
||||
this.geminiAgent.setFallbackAgent(this.sdkAgent);
|
||||
```
|
||||
|
||||
**Recommended: Option A**
|
||||
|
||||
**Steps:**
|
||||
1. Read SDKAgent.ts to understand initialization pattern
|
||||
2. Add `isConfigured()` method that checks Claude CLI/credentials
|
||||
3. Update worker-service.ts to conditionally set fallback
|
||||
4. Add warning log when fallback unavailable
|
||||
5. Run tests
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
grep -n "isConfigured" src/services/worker/SDKAgent.ts
|
||||
# Expected: Method definition
|
||||
grep -n "setFallbackAgent" src/services/worker-service.ts
|
||||
# Expected: Conditional calls with isConfigured check
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Remove Unnecessary Re-Export (LOW PRIORITY)
|
||||
|
||||
### 3.1 Fix Misleading Re-Export
|
||||
|
||||
**Current (worker-service.ts:77-78):**
|
||||
```typescript
|
||||
// Re-export updateCursorContextForProject for SDK agents
|
||||
export { updateCursorContextForProject };
|
||||
```
|
||||
|
||||
**Issue:** Comment implies avoiding circular import, but investigation found NO circular dependency exists.
|
||||
|
||||
**Import chain:**
|
||||
```
|
||||
CursorHooksInstaller.ts (defines) → worker-service.ts (imports, re-exports) → ResponseProcessor.ts (imports)
|
||||
```
|
||||
|
||||
**ResponseProcessor.ts could import directly from CursorHooksInstaller.ts**
|
||||
|
||||
**Options:**
|
||||
1. **Remove re-export entirely** - Update ResponseProcessor.ts to import from CursorHooksInstaller directly
|
||||
2. **Fix comment** - Update to reflect actual reason (API surface simplification)
|
||||
|
||||
**Recommended: Option 1 (cleaner)**
|
||||
|
||||
**Steps:**
|
||||
1. Update `src/services/worker/agents/ResponseProcessor.ts`:
|
||||
- Change: `import { updateCursorContextForProject } from '../../worker-service.js';`
|
||||
- To: `import { updateCursorContextForProject } from '../../integrations/CursorHooksInstaller.js';`
|
||||
2. Delete re-export from worker-service.ts (lines 77-78)
|
||||
3. Run build to verify
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
grep -n "export { updateCursorContextForProject" src/services/worker-service.ts
|
||||
# Expected: No output
|
||||
grep -n "updateCursorContextForProject" src/services/worker/agents/ResponseProcessor.ts
|
||||
# Expected: Import from CursorHooksInstaller
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Update MCP Version (LOW PRIORITY)
|
||||
|
||||
### 4.1 Use Package Version for MCP Client
|
||||
|
||||
**Current (worker-service.ts:157-160):**
|
||||
```typescript
|
||||
this.mcpClient = new Client({
|
||||
name: 'worker-search-proxy',
|
||||
version: '1.0.0' // Hardcoded, should match package.json (9.0.4)
|
||||
}, { capabilities: {} });
|
||||
```
|
||||
|
||||
**Also affects (from report):**
|
||||
- `src/services/sync/ChromaSync.ts:126-131`
|
||||
- MCP server (separate file)
|
||||
|
||||
**Pattern to follow:**
|
||||
```typescript
|
||||
import { version } from '../../package.json' assert { type: 'json' };
|
||||
|
||||
this.mcpClient = new Client({
|
||||
name: 'worker-search-proxy',
|
||||
version: version
|
||||
}, { capabilities: {} });
|
||||
```
|
||||
|
||||
**Alternative (if JSON import not supported):**
|
||||
```typescript
|
||||
import { readFileSync } from 'fs';
|
||||
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'));
|
||||
|
||||
this.mcpClient = new Client({
|
||||
name: 'worker-search-proxy',
|
||||
version: pkg.version
|
||||
}, { capabilities: {} });
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Check if JSON import assertion works in project
|
||||
2. Update worker-service.ts MCP client initialization
|
||||
3. Update ChromaSync.ts similarly
|
||||
4. Run build to verify
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
grep -n "version: '1.0.0'" src/services/worker-service.ts src/services/sync/ChromaSync.ts
|
||||
# Expected: No output
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 4.2 Add MCP Capabilities Comment
|
||||
|
||||
**Current:**
|
||||
```typescript
|
||||
}, { capabilities: {} });
|
||||
```
|
||||
|
||||
**Add clarifying comment:**
|
||||
```typescript
|
||||
}, {
|
||||
// MCP spec: Clients accept all server capabilities; no declaration needed
|
||||
capabilities: {}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Verification
|
||||
|
||||
### 5.1 Build Check
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
**Expected:** No TypeScript errors
|
||||
|
||||
### 5.2 Test Suite
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
**Expected:** All tests pass
|
||||
|
||||
### 5.3 Grep for Anti-Patterns
|
||||
```bash
|
||||
# Verify dead code removed
|
||||
grep -r "runInteractiveSetup" src/
|
||||
# Expected: No matches
|
||||
|
||||
# Verify unused imports removed
|
||||
grep "import \* as fs from 'fs'" src/services/worker-service.ts
|
||||
# Expected: No match
|
||||
|
||||
# Verify re-export removed
|
||||
grep "export { updateCursorContextForProject" src/services/worker-service.ts
|
||||
# Expected: No match
|
||||
|
||||
# Verify fallback has check
|
||||
grep -A2 "setFallbackAgent" src/services/worker-service.ts
|
||||
# Expected: Conditional with isConfigured check
|
||||
```
|
||||
|
||||
### 5.4 Runtime Check
|
||||
```bash
|
||||
npm run build-and-sync
|
||||
# Manually verify worker starts and basic operations work
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Description | Lines Changed | Priority |
|
||||
|-------|-------------|---------------|----------|
|
||||
| Phase 1 | Delete dead code + imports | ~200 deleted | HIGH |
|
||||
| Phase 2 | Add fallback verification | ~10 added | HIGH |
|
||||
| Phase 3 | Remove re-export | ~5 changed | LOW |
|
||||
| Phase 4 | Update MCP version | ~3 changed | LOW |
|
||||
| Phase 5 | Verification | N/A | N/A |
|
||||
|
||||
**Execution Order:** Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5
|
||||
|
||||
**Note:** Each phase should be followed by verification (build + test) before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Patterns Confirmed KEEP (No Action)
|
||||
|
||||
These patterns were validated as intentional:
|
||||
|
||||
1. **Exit code 0 always** - Windows Terminal tab accumulation workaround (commit 222a73da)
|
||||
2. **`as Error` casts** - Documented project policy with anti-pattern detection
|
||||
3. **Dual init tracking** - Promise for async, flag for sync callers
|
||||
4. **Signal handler ref pattern** - Standard JS mutable state sharing
|
||||
5. **Empty MCP capabilities** - Correct per MCP client spec
|
||||
@@ -1,144 +0,0 @@
|
||||
# Plan: Address PR #610 Review Issues
|
||||
|
||||
## Overview
|
||||
This plan addresses the issues identified in the PR review for PR #610 "fix: Update hooks for Claude Code 2.1.0/1 - SessionStart no longer shows user messages".
|
||||
|
||||
## Phase 0: Verification and Discovery
|
||||
|
||||
### 0.1 Verify Test Failure
|
||||
- **File**: `tests/hook-constants.test.ts`
|
||||
- **Issue**: Lines 61-63 test for `HOOK_EXIT_CODES.USER_MESSAGE_ONLY` which was removed
|
||||
- **Verification**: Run `bun test tests/hook-constants.test.ts` to confirm failure
|
||||
|
||||
### 0.2 Verify No Code References USER_MESSAGE_ONLY
|
||||
- **Finding**: Grep found references only in:
|
||||
- `tests/hook-constants.test.ts` (test file - needs fix)
|
||||
- `src/services/CLAUDE.md` (memory context - auto-generated, not code)
|
||||
- `plugin/scripts/CLAUDE.md` (memory context - auto-generated, not code)
|
||||
- **Conclusion**: Only the test file needs updating; CLAUDE.md files are memory records
|
||||
|
||||
### 0.3 Verify CLAUDE.md Files Are Legitimate
|
||||
- **Clarification**: The PR reviewer mentioned "user-specific CLAUDE.md files starting with ~/"
|
||||
- **Finding**: All CLAUDE.md files in the commit are within the repository (`docs/`, `src/`, `plugin/`)
|
||||
- **Conclusion**: These are legitimate in-repo context files, not user-specific paths
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Fix Test File (REQUIRED)
|
||||
|
||||
### Task 1.1: Remove USER_MESSAGE_ONLY Test
|
||||
**File**: `tests/hook-constants.test.ts`
|
||||
**Action**: Delete lines 61-63 that test for the removed constant
|
||||
|
||||
```typescript
|
||||
// DELETE THESE LINES:
|
||||
it('should define USER_MESSAGE_ONLY exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.USER_MESSAGE_ONLY).toBe(3);
|
||||
});
|
||||
```
|
||||
|
||||
### Task 1.2: Add Test for BLOCKING_ERROR
|
||||
**File**: `tests/hook-constants.test.ts`
|
||||
**Action**: Add test for the new `BLOCKING_ERROR` constant (exit code 2) that replaced it
|
||||
|
||||
```typescript
|
||||
// ADD THIS TEST:
|
||||
it('should define BLOCKING_ERROR exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.BLOCKING_ERROR).toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Run `bun test tests/hook-constants.test.ts`
|
||||
- Expect: All tests pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Documentation Consistency (NICE TO HAVE)
|
||||
|
||||
### Issue
|
||||
Three similar notes about Claude Code 2.1.0 have slightly different wording:
|
||||
|
||||
1. `docs/public/architecture/hooks.mdx:254`:
|
||||
> "SessionStart hooks no longer display any user-visible messages. Context is still injected via `hookSpecificOutput.additionalContext` but users don't see startup output in the UI."
|
||||
|
||||
2. `docs/public/hooks-architecture.mdx:31`:
|
||||
> "SessionStart hooks no longer display any user-visible messages. Context is silently injected via `hookSpecificOutput.additionalContext`."
|
||||
|
||||
3. `docs/public/hooks-architecture.mdx:441`:
|
||||
> "SessionStart hooks output is never displayed to users. Context is injected silently via `hookSpecificOutput.additionalContext`."
|
||||
|
||||
### Task 2.1: Standardize Note Wording
|
||||
**Action**: Use consistent wording across all three locations
|
||||
|
||||
**Standard text**:
|
||||
```
|
||||
As of Claude Code 2.1.0 (ultrathink update), SessionStart hooks no longer display user-visible messages. Context is silently injected via `hookSpecificOutput.additionalContext`.
|
||||
```
|
||||
|
||||
### Files to Update
|
||||
1. `docs/public/architecture/hooks.mdx:253-255` - Update Note block
|
||||
2. `docs/public/hooks-architecture.mdx:30-32` - Update Note block
|
||||
3. `docs/public/hooks-architecture.mdx:440-442` - Update Note block
|
||||
|
||||
### Verification
|
||||
- Grep for the standard text in all three files
|
||||
- Visual review of documentation
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Code Quality Improvements (OPTIONAL)
|
||||
|
||||
### Issue 3.1: Hardcoded Promotional Message
|
||||
**File**: `src/hooks/context-hook.ts:66-68`
|
||||
**Current code**:
|
||||
```typescript
|
||||
const enhancedContext = `${text}
|
||||
|
||||
Access 300k tokens of past research & decisions for just 19,008t. Use MCP search tools to access memories by ID.`;
|
||||
```
|
||||
|
||||
### Options
|
||||
1. **Leave as-is**: The token count is a rough estimate and doesn't need to be exact
|
||||
2. **Make configurable**: Add to settings (over-engineering for this use case)
|
||||
3. **Remove hardcoded numbers**: Use relative language instead
|
||||
|
||||
### Recommendation
|
||||
Leave as-is for now. The token counts are marketing copy, not critical functionality. Creating a PR just for this adds unnecessary complexity.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Final Verification
|
||||
|
||||
### 4.1 Run Full Test Suite
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
### 4.2 Build Verification
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 4.3 Grep Verification
|
||||
```bash
|
||||
grep -r "USER_MESSAGE_ONLY" src/ --include="*.ts" --include="*.js"
|
||||
```
|
||||
Expected: No results (CLAUDE.md files excluded as they're memory records)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Priority | Effort | Description |
|
||||
|-------|----------|--------|-------------|
|
||||
| 1 | REQUIRED | 5 min | Fix test file - remove USER_MESSAGE_ONLY test, add BLOCKING_ERROR test |
|
||||
| 2 | Nice to have | 10 min | Standardize documentation note wording |
|
||||
| 3 | Skip | - | Hardcoded token counts are fine as-is |
|
||||
| 4 | REQUIRED | 5 min | Run tests and build to verify |
|
||||
|
||||
## Expected Outcome
|
||||
- All tests pass
|
||||
- Build succeeds
|
||||
- No code references to removed USER_MESSAGE_ONLY constant
|
||||
- Documentation uses consistent wording (if Phase 2 is done)
|
||||
@@ -1,223 +0,0 @@
|
||||
# Plan: PR #628 Polish Items
|
||||
|
||||
**PR**: #628 - Windows Terminal Tab Accumulation & Windows 11 Compatibility
|
||||
**Status**: APPROVED by 3 reviewers with minor suggestions
|
||||
**Branch**: `feature/no-more-hook-files`
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery (Completed by Orchestrator)
|
||||
|
||||
### Allowed APIs and Patterns
|
||||
|
||||
**Exit Code Constants** - `src/shared/hook-constants.ts:18-23`:
|
||||
```typescript
|
||||
export const HOOK_EXIT_CODES = {
|
||||
SUCCESS: 0,
|
||||
FAILURE: 1,
|
||||
BLOCKING_ERROR: 2,
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Timeout Constants** - `src/shared/hook-constants.ts:1-8`:
|
||||
```typescript
|
||||
export const HOOK_TIMEOUTS = {
|
||||
DEFAULT: 300000,
|
||||
HEALTH_CHECK: 30000,
|
||||
WORKER_STARTUP_WAIT: 1000,
|
||||
WORKER_STARTUP_RETRIES: 300,
|
||||
PRE_RESTART_SETTLE_DELAY: 2000,
|
||||
WINDOWS_MULTIPLIER: 1.5
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Platform Timeout Function** - `src/services/infrastructure/ProcessManager.ts:70-73`:
|
||||
```typescript
|
||||
export function getPlatformTimeout(baseMs: number): number {
|
||||
const WINDOWS_MULTIPLIER = 2.0;
|
||||
return process.platform === 'win32' ? Math.round(baseMs * WINDOWS_MULTIPLIER) : baseMs;
|
||||
}
|
||||
```
|
||||
|
||||
**Migration Guide Pattern** - `docs/public/architecture/pm2-to-bun-migration.mdx`:
|
||||
- Uses MDX format with frontmatter
|
||||
- Starts with `<Note>` for historical context
|
||||
- Uses `<AccordionGroup>` for before/after comparisons
|
||||
- Includes executive summary, key benefits, migration impact sections
|
||||
|
||||
**Exit Code Documentation** - `private/context/claude-code/exit-codes.md`:
|
||||
- Defines exit code 0, 2, and other behaviors
|
||||
- Per-hook event behavior table
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Change | Lines |
|
||||
|------|--------|-------|
|
||||
| `src/services/infrastructure/ProcessManager.ts` | Add POWERSHELL_TIMEOUT constant, reduce from 60000 to 10000 | 93, 123, 175, 241 |
|
||||
| `src/shared/hook-constants.ts` | Add POWERSHELL_TIMEOUT constant | After line 8 |
|
||||
| `CLAUDE.md` | Document exit code strategy | Architecture section |
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- DO NOT invent new exit code values (only 0, 1, 2 exist)
|
||||
- DO NOT change Windows multiplier (1.5x in hooks, 2.0x in ProcessManager - they serve different purposes)
|
||||
- DO NOT add upper bound PID validation (not in existing pattern, reviewers marked as "nice to have")
|
||||
- DO NOT create migration guide for Cursor (shell scripts still exist in cursor-hooks/, not removed)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Extract PowerShell Timeout Constant
|
||||
|
||||
### What to Implement
|
||||
|
||||
Add a `POWERSHELL_TIMEOUT` constant to centralize the magic number `60000` and reduce to `10000` (10 seconds) as recommended by reviewers.
|
||||
|
||||
### Documentation References
|
||||
|
||||
1. Copy constant pattern from `src/shared/hook-constants.ts:1-8`
|
||||
2. Copy usage pattern from `src/services/infrastructure/ProcessManager.ts:93`
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Add constant to hook-constants.ts** after line 8:
|
||||
```typescript
|
||||
POWERSHELL_COMMAND: 10000, // PowerShell process enumeration (10s - typically completes in <1s)
|
||||
```
|
||||
|
||||
2. **Import and use in ProcessManager.ts**:
|
||||
- Import `HOOK_TIMEOUTS` from `../../shared/hook-constants.js`
|
||||
- Replace `{ timeout: 60000 }` with `{ timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND }` at lines 93, 123, 175, 241
|
||||
|
||||
### Verification Checklist
|
||||
|
||||
- [ ] `grep -n "60000" src/services/infrastructure/ProcessManager.ts` returns 0 matches
|
||||
- [ ] `grep -n "POWERSHELL_COMMAND" src/services/infrastructure/ProcessManager.ts` returns 4 matches
|
||||
- [ ] `npm run build` succeeds
|
||||
- [ ] `npm test` passes (22/22 PowerShell tests still pass)
|
||||
|
||||
### Anti-Pattern Guards
|
||||
|
||||
- DO NOT use `getPlatformTimeout()` for PowerShell commands (they already run only on Windows)
|
||||
- DO NOT change timeout values in other files (only ProcessManager.ts uses PowerShell)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Document Exit Code Strategy in CLAUDE.md
|
||||
|
||||
### What to Implement
|
||||
|
||||
Add an "Exit Code Strategy" section to the main CLAUDE.md to explain the graceful exit philosophy adopted in this PR.
|
||||
|
||||
### Documentation References
|
||||
|
||||
1. Copy exit code definitions from `private/context/claude-code/exit-codes.md`
|
||||
2. Follow format of existing CLAUDE.md sections
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Add section after "File Locations"** in `/Users/alexnewman/Scripts/claude-mem/CLAUDE.md`:
|
||||
|
||||
```markdown
|
||||
## Exit Code Strategy
|
||||
|
||||
Claude-mem hooks use specific exit codes per Claude Code's hook contract:
|
||||
|
||||
- **Exit 0**: Success or graceful shutdown (Windows Terminal closes tabs)
|
||||
- **Exit 1**: Non-blocking error (stderr shown to user, continues)
|
||||
- **Exit 2**: Blocking error (stderr fed to Claude for processing)
|
||||
|
||||
**Philosophy**: Worker/hook errors exit with code 0 to prevent Windows Terminal tab accumulation. The wrapper/plugin layer handles restart logic. ERROR-level logging is maintained for diagnostics.
|
||||
|
||||
See `private/context/claude-code/exit-codes.md` for full hook behavior matrix.
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
|
||||
- [ ] `grep -n "Exit Code Strategy" CLAUDE.md` returns 1 match
|
||||
- [ ] Section appears after "File Locations" section
|
||||
- [ ] No duplicate sections added
|
||||
|
||||
### Anti-Pattern Guards
|
||||
|
||||
- DO NOT copy the full exit-codes.md table (keep it brief, reference the source)
|
||||
- DO NOT change actual exit code behavior in code files
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Update Tests for New Timeout Constant
|
||||
|
||||
### What to Implement
|
||||
|
||||
Add test coverage for the new `POWERSHELL_COMMAND` timeout constant.
|
||||
|
||||
### Documentation References
|
||||
|
||||
1. Copy test pattern from `tests/hook-constants.test.ts:26-48`
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Add test to hook-constants.test.ts** after line 42:
|
||||
```typescript
|
||||
test('POWERSHELL_COMMAND timeout is 10000ms', () => {
|
||||
expect(HOOK_TIMEOUTS.POWERSHELL_COMMAND).toBe(10000);
|
||||
});
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
|
||||
- [ ] `npm test -- tests/hook-constants.test.ts` passes
|
||||
- [ ] New test appears in test output
|
||||
- [ ] All 22 PowerShell parsing tests still pass
|
||||
|
||||
### Anti-Pattern Guards
|
||||
|
||||
- DO NOT modify PowerShell parsing tests (they test parsing, not timeouts)
|
||||
- DO NOT add integration tests for actual PowerShell execution (out of scope)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Final Verification
|
||||
|
||||
### Verification Checklist
|
||||
|
||||
1. **Build passes**: `npm run build`
|
||||
2. **All tests pass**: `npm test`
|
||||
3. **No magic numbers remain**: `grep -rn "60000" src/services/infrastructure/ProcessManager.ts` returns 0
|
||||
4. **Exit code documentation exists**: `grep -n "Exit Code Strategy" CLAUDE.md` returns 1
|
||||
5. **Constant is used**: `grep -rn "POWERSHELL_COMMAND" src/` returns multiple matches
|
||||
|
||||
### Anti-Pattern Grep Checks
|
||||
|
||||
- [ ] `grep -rn "timeout: 60000" src/` returns 0 matches (no hardcoded 60s timeouts in ProcessManager)
|
||||
- [ ] `grep -rn "process.exit(3)" src/` returns 0 matches (exit code 3 not used)
|
||||
|
||||
### Commit Message Template
|
||||
|
||||
```
|
||||
polish: extract PowerShell timeout constant and document exit code strategy
|
||||
|
||||
- Extract magic number 60000ms to HOOK_TIMEOUTS.POWERSHELL_COMMAND (10000ms)
|
||||
- Reduce PowerShell timeout from 60s to 10s per review feedback
|
||||
- Document exit code strategy in CLAUDE.md
|
||||
- Add test coverage for new constant
|
||||
|
||||
Addresses review feedback from PR #628
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Description | Files Changed | Verification |
|
||||
|-------|-------------|---------------|--------------|
|
||||
| 0 | Documentation Discovery | N/A | Patterns identified |
|
||||
| 1 | Extract PowerShell timeout | hook-constants.ts, ProcessManager.ts | grep + build + test |
|
||||
| 2 | Document exit strategy | CLAUDE.md | grep |
|
||||
| 3 | Add test coverage | hook-constants.test.ts | npm test |
|
||||
| 4 | Final verification | N/A | All checks pass |
|
||||
|
||||
**Estimated Changes**: ~20 lines added/modified across 4 files
|
||||
**Risk Level**: Low (constants extraction, documentation only)
|
||||
**Breaking Changes**: None
|
||||
@@ -1,394 +0,0 @@
|
||||
# Plan: Remove Worker Start Calls - In-Process Architecture
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Current architecture has problematic spawn patterns:
|
||||
1. `hooks.json` calls `worker-service.cjs start` which spawns a daemon
|
||||
2. Spawning is buggy on Windows - **HARD RULE: NO SPAWN**
|
||||
3. `user-message` hook is deprecated
|
||||
4. `smart-install` was supposed to chain: `smart-install && stop && context`
|
||||
|
||||
## Target Architecture
|
||||
|
||||
**NO SPAWN - Worker runs in-process within hook command**
|
||||
|
||||
```
|
||||
SessionStart:
|
||||
smart-install && stop && context
|
||||
```
|
||||
|
||||
Flow:
|
||||
1. `smart-install` - Install dependencies if needed
|
||||
2. `stop` - Kill any existing worker (clean slate)
|
||||
3. `context` - Hook starts worker IN-PROCESS, becomes the worker
|
||||
|
||||
**Key insight:** The first hook that needs the worker **becomes** the worker. No spawn, no daemon. The hook process IS the worker process.
|
||||
|
||||
---
|
||||
|
||||
## Current vs Target hooks.json
|
||||
|
||||
### Current (BROKEN)
|
||||
```json
|
||||
"SessionStart": [
|
||||
{ "hooks": [
|
||||
{ "command": "node smart-install.js" },
|
||||
{ "command": "bun worker-service.cjs start" }, // REMOVE - spawn
|
||||
{ "command": "bun worker-service.cjs hook ... context" },
|
||||
{ "command": "bun worker-service.cjs hook ... user-message" } // REMOVE - deprecated
|
||||
]}
|
||||
]
|
||||
```
|
||||
|
||||
### Target
|
||||
```json
|
||||
"SessionStart": [
|
||||
{ "hooks": [
|
||||
{ "command": "node smart-install.js && bun worker-service.cjs stop && bun worker-service.cjs hook claude-code context" }
|
||||
]}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Involved
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `plugin/hooks/hooks.json` | Restructure to chained commands, remove start/user-message |
|
||||
| `src/services/worker-service.ts` | `hook` case: start worker in-process if not running |
|
||||
| `src/cli/handlers/*.ts` | May need adjustment for in-process execution |
|
||||
| `src/shared/worker-utils.ts` | `ensureWorkerRunning()` → adapt for in-process |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery
|
||||
|
||||
### Available APIs
|
||||
|
||||
**From `src/services/infrastructure/HealthMonitor.ts`:**
|
||||
- `isPortInUse(port): Promise<boolean>`
|
||||
- `waitForHealth(port, timeoutMs): Promise<boolean>`
|
||||
- `httpShutdown(port): Promise<void>`
|
||||
|
||||
**From `src/services/worker-service.ts`:**
|
||||
- `WorkerService` class - the actual worker
|
||||
- `stop` command - shuts down worker via HTTP
|
||||
- `--daemon` case - starts WorkerService (currently only used after spawn)
|
||||
|
||||
**BANNED (spawn patterns):**
|
||||
- ~~`spawnDaemon()`~~ - NO SPAWN
|
||||
- ~~`fork()`~~ - NO SPAWN
|
||||
- ~~`spawn()` with detached~~ - NO SPAWN
|
||||
|
||||
### Anti-Patterns
|
||||
- **NO SPAWN** - Hard rule, Windows buggy
|
||||
- No `restart` command - removed for same reason
|
||||
- No detached processes
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Modify `hook` Case for In-Process Worker
|
||||
|
||||
### Location
|
||||
`src/services/worker-service.ts:564-576`
|
||||
|
||||
### Current Code
|
||||
```typescript
|
||||
case 'hook': {
|
||||
const platform = process.argv[3];
|
||||
const event = process.argv[4];
|
||||
if (!platform || !event) {
|
||||
console.error('Usage: claude-mem hook <platform> <event>');
|
||||
process.exit(1);
|
||||
}
|
||||
const { hookCommand } = await import('../cli/hook-command.js');
|
||||
await hookCommand(platform, event);
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### Target Code
|
||||
```typescript
|
||||
case 'hook': {
|
||||
const platform = process.argv[3];
|
||||
const event = process.argv[4];
|
||||
if (!platform || !event) {
|
||||
console.error('Usage: claude-mem hook <platform> <event>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if worker already running (port in use = valid, another process has it)
|
||||
const portInUse = await isPortInUse(port);
|
||||
if (portInUse) {
|
||||
// Port in use - either healthy worker or something else
|
||||
// Proceed with hook via HTTP to existing worker
|
||||
const { hookCommand } = await import('../cli/hook-command.js');
|
||||
await hookCommand(platform, event);
|
||||
break;
|
||||
}
|
||||
|
||||
// Port free - start worker IN THIS PROCESS (no spawn!)
|
||||
logger.info('SYSTEM', 'Starting worker in-process for hook');
|
||||
const worker = new WorkerService();
|
||||
|
||||
// Start worker (non-blocking, returns when server listening)
|
||||
await worker.start();
|
||||
|
||||
// Now execute hook logic - worker is running in this process
|
||||
// Can call handler directly (in-process) or via HTTP to self
|
||||
const { hookCommand } = await import('../cli/hook-command.js');
|
||||
await hookCommand(platform, event);
|
||||
|
||||
// DON'T exit - this process IS the worker now
|
||||
// Worker stays alive serving requests
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Behavior
|
||||
- If port in use → hook runs via HTTP to existing worker, then exits
|
||||
- If port free → start worker in-process, run hook, process stays alive as worker
|
||||
|
||||
### Verification
|
||||
- [ ] Stop worker, run hook command → should start worker and stay alive
|
||||
- [ ] Worker already running, run hook command → should complete and exit
|
||||
- [ ] `lsof -i :37777` shows hook process IS the worker
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Update hooks.json - Chained Commands
|
||||
|
||||
### Location
|
||||
`plugin/hooks/hooks.json`
|
||||
|
||||
### Target Structure
|
||||
```json
|
||||
{
|
||||
"description": "Claude-mem memory system hooks",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/smart-install.js\" && bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" stop && bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code context",
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code session-init",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code observation",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code summarize",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Changes Summary
|
||||
1. SessionStart: Chain `smart-install && stop && context` in single command
|
||||
2. Remove `user-message` hook (deprecated)
|
||||
3. Remove all separate `start` commands
|
||||
4. Other hooks unchanged (just hook command, auto-starts if needed)
|
||||
|
||||
### Verification
|
||||
- [ ] JSON valid: `cat plugin/hooks/hooks.json | jq .`
|
||||
- [ ] No `start` command: `grep -c '"start"' plugin/hooks/hooks.json` = 0
|
||||
- [ ] No `user-message`: `grep -c 'user-message' plugin/hooks/hooks.json` = 0
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Handle "Port In Use" Gracefully
|
||||
|
||||
### Scenario
|
||||
Another process has port 37777 (not our worker). Hook should handle gracefully.
|
||||
|
||||
### Current Behavior
|
||||
`ensureWorkerRunning()` polls for 15 seconds, then throws error.
|
||||
|
||||
### Target Behavior
|
||||
If port in use but not healthy (not our worker):
|
||||
- Hook is "valid" - don't block Claude Code
|
||||
- Return graceful response (empty context, etc.)
|
||||
- Log warning for debugging
|
||||
|
||||
### Location
|
||||
`src/shared/worker-utils.ts:117-141`
|
||||
|
||||
### Changes
|
||||
```typescript
|
||||
export async function ensureWorkerRunning(): Promise<boolean> {
|
||||
const port = getWorkerPort();
|
||||
|
||||
// Quick health check (2 seconds max)
|
||||
try {
|
||||
if (await isWorkerHealthy()) {
|
||||
await checkWorkerVersion();
|
||||
return true; // Worker healthy
|
||||
}
|
||||
} catch (e) {
|
||||
// Not healthy
|
||||
}
|
||||
|
||||
// Port might be in use by something else
|
||||
// Return false but don't throw - let caller decide
|
||||
logger.warn('SYSTEM', 'Worker not healthy, hook will proceed gracefully');
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Updates
|
||||
Update handlers to handle `ensureWorkerRunning()` returning false:
|
||||
```typescript
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Return graceful empty response
|
||||
return { output: '', exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- [ ] Start non-worker process on 37777, run hook → completes gracefully
|
||||
- [ ] No 15-second hang when port blocked
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Remove Deprecated Code
|
||||
|
||||
### Remove `user-message` Handler (if unused elsewhere)
|
||||
- [ ] Check if `user-message.ts` is used anywhere else
|
||||
- [ ] Remove from `src/cli/handlers/index.ts` if safe
|
||||
- [ ] Consider keeping file but removing from hooks.json only
|
||||
|
||||
### Remove `start` Command (optional)
|
||||
The `start` command in worker-service.ts can stay for manual use:
|
||||
```bash
|
||||
bun worker-service.cjs start # Manual start if needed
|
||||
```
|
||||
But it should NOT be called from hooks.json.
|
||||
|
||||
### Verification
|
||||
- [ ] `npm run build` succeeds
|
||||
- [ ] No references to removed handlers in hooks.json
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Update Handler `ensureWorkerRunning()` Calls
|
||||
|
||||
### Context
|
||||
Each handler currently calls `ensureWorkerRunning()` which polls for 15 seconds.
|
||||
|
||||
With in-process architecture:
|
||||
- If hook started worker in-process → worker is THIS process, no HTTP needed
|
||||
- If worker already running → HTTP to existing worker
|
||||
|
||||
### Decision
|
||||
**Keep handler calls** but modify `ensureWorkerRunning()` to:
|
||||
1. Return quickly if port is in use (assume valid)
|
||||
2. Return true if in-process worker (detect via global flag?)
|
||||
3. Graceful false return instead of throwing
|
||||
|
||||
### Files
|
||||
- `src/cli/handlers/context.ts:15`
|
||||
- `src/cli/handlers/session-init.ts:15`
|
||||
- `src/cli/handlers/observation.ts:14`
|
||||
- `src/cli/handlers/summarize.ts:17`
|
||||
- `src/cli/handlers/file-edit.ts:15`
|
||||
|
||||
### Verification
|
||||
- [ ] Handlers don't hang on port-in-use scenarios
|
||||
- [ ] In-process worker scenario works
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Final Verification
|
||||
|
||||
### Tests
|
||||
- [ ] `bun test` - All tests pass
|
||||
- [ ] `npm run build-and-sync` - Build succeeds
|
||||
|
||||
### Manual Tests
|
||||
|
||||
**Test 1: Clean Start**
|
||||
```bash
|
||||
bun plugin/scripts/worker-service.cjs stop
|
||||
# Start new Claude Code session
|
||||
# Verify: context hook starts worker in-process
|
||||
# Verify: lsof -i :37777 shows the hook process
|
||||
```
|
||||
|
||||
**Test 2: Worker Already Running**
|
||||
```bash
|
||||
bun plugin/scripts/worker-service.cjs stop
|
||||
bun plugin/scripts/worker-service.cjs hook claude-code context &
|
||||
# Wait for worker to start
|
||||
bun plugin/scripts/worker-service.cjs hook claude-code observation
|
||||
# Verify: observation hook exits after completing (doesn't stay alive)
|
||||
```
|
||||
|
||||
**Test 3: Port Blocked**
|
||||
```bash
|
||||
bun plugin/scripts/worker-service.cjs stop
|
||||
nc -l 37777 & # Block port with netcat
|
||||
bun plugin/scripts/worker-service.cjs hook claude-code context
|
||||
# Verify: completes gracefully, doesn't hang
|
||||
kill %1 # Clean up netcat
|
||||
```
|
||||
|
||||
**Test 4: Full Session**
|
||||
```bash
|
||||
# Start fresh Claude Code session
|
||||
# Do some work (creates observations)
|
||||
# End session (Ctrl+C or /exit)
|
||||
# Verify: summarize hook ran, observations saved
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Hook stays alive forever | Expected - it's the worker now |
|
||||
| Multiple hooks compete for port | First one wins, others use HTTP |
|
||||
| Graceful shutdown on session end | Stop command in chain handles this |
|
||||
| Windows compatibility | No spawn = no Windows issues |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
1. Restore hooks.json with separate start commands
|
||||
2. Revert worker-service.ts hook case changes
|
||||
3. No database changes to rollback
|
||||
@@ -1,196 +0,0 @@
|
||||
# Plan: Integrate Workflow Agents and Commands into Claude-Mem
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan integrates the `/make-plan` and `/do` orchestration workflow from `~/.claude/commands/` into the claude-mem plugin as project-level development tools.
|
||||
|
||||
## Dependency Analysis
|
||||
|
||||
### Commands to Copy (from `~/.claude/commands/`)
|
||||
|
||||
| File | Purpose | Dependencies |
|
||||
|------|---------|--------------|
|
||||
| `make-plan.md` | Orchestrator for LLM-friendly phased planning | Uses Task tool with subagents |
|
||||
| `do.md` | Orchestrator for executing plans via subagents | Uses Task tool with subagents |
|
||||
| `anti-pattern-czar.md` | Error handling anti-pattern detection/fixing | Uses Read, Edit, Bash tools |
|
||||
|
||||
### Specialized Agents Referenced
|
||||
|
||||
The `/make-plan` and `/do` commands reference these **conceptual agent roles** (not actual agent files):
|
||||
|
||||
| Agent Role | Referenced In | Description |
|
||||
|------------|---------------|-------------|
|
||||
| "Documentation Discovery" | make-plan.md | Fact-gathering from docs/examples |
|
||||
| "Verification" | make-plan.md, do.md | Verify implementation matches plan |
|
||||
| "Implementation" | do.md | Execute implementation tasks |
|
||||
| "Anti-pattern" | do.md | Grep for known bad patterns |
|
||||
| "Code Quality" | do.md | Review code changes |
|
||||
| "Commit" | do.md | Commit after verification passes |
|
||||
| "Branch/Sync" | do.md | Push and prepare phase handoffs |
|
||||
|
||||
**Key Finding**: These are **role descriptions**, not separate agent files. The Task tool's `general-purpose` subagent_type executes all roles. The commands define *what* each role should do, not separate agent implementations.
|
||||
|
||||
### Existing Project Assets
|
||||
|
||||
Located in `.claude/`:
|
||||
- `agents/github-morning-reporter.md` - Already in project
|
||||
- `skills/version-bump/SKILL.md` - Already in project
|
||||
- No existing commands directory
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery (Complete)
|
||||
|
||||
### Sources Consulted
|
||||
1. `/Users/alexnewman/.claude/commands/make-plan.md` (62 lines)
|
||||
2. `/Users/alexnewman/.claude/commands/do.md` (39 lines)
|
||||
3. `/Users/alexnewman/.claude/commands/anti-pattern-czar.md` (122 lines)
|
||||
4. `/Users/alexnewman/.claude/settings.json` (36 lines)
|
||||
5. `.claude/skills/CLAUDE.md` (30 lines)
|
||||
6. `.claude/agents/github-morning-reporter.md` (102 lines)
|
||||
|
||||
### Allowed APIs/Patterns
|
||||
- **Commands**: `.claude/commands/*.md` files with `#$ARGUMENTS` placeholder for user input
|
||||
- **Skills**: `.claude/skills/<name>/SKILL.md` with YAML frontmatter (name, description)
|
||||
- **Agents**: `.claude/agents/*.md` with YAML frontmatter (name, description, model)
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- Skills require YAML frontmatter; commands do not
|
||||
- Commands use `#$ARGUMENTS` for input; skills/agents receive prompts differently
|
||||
- Don't create separate agent files for role descriptions - the Task tool handles routing
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Create Commands Directory
|
||||
|
||||
### What to Implement
|
||||
1. Create `.claude/commands/` directory
|
||||
2. Copy `make-plan.md` from `~/.claude/commands/make-plan.md`
|
||||
3. Copy `do.md` from `~/.claude/commands/do.md`
|
||||
4. Copy `anti-pattern-czar.md` from `~/.claude/commands/anti-pattern-czar.md`
|
||||
|
||||
### Documentation References
|
||||
- Pattern: `~/.claude/commands/*.md` (source files)
|
||||
- Existing example: `.claude/skills/version-bump/SKILL.md` for claude-mem project tools
|
||||
|
||||
### Verification Checklist
|
||||
```bash
|
||||
# Verify files exist
|
||||
ls -la .claude/commands/
|
||||
|
||||
# Verify content matches source
|
||||
diff ~/.claude/commands/make-plan.md .claude/commands/make-plan.md
|
||||
diff ~/.claude/commands/do.md .claude/commands/do.md
|
||||
diff ~/.claude/commands/anti-pattern-czar.md .claude/commands/anti-pattern-czar.md
|
||||
|
||||
# Verify #$ARGUMENTS placeholder exists
|
||||
grep '\$ARGUMENTS' .claude/commands/*.md
|
||||
```
|
||||
|
||||
### Anti-Pattern Guards
|
||||
- Do NOT add YAML frontmatter to commands (they don't need it)
|
||||
- Do NOT modify the source content (copy verbatim)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create CLAUDE.md Documentation
|
||||
|
||||
### What to Implement
|
||||
Create `.claude/commands/CLAUDE.md` documenting the commands directory (following pattern from `.claude/skills/CLAUDE.md`)
|
||||
|
||||
### Content Template
|
||||
```markdown
|
||||
# Project-Level Commands
|
||||
|
||||
This directory contains slash commands **for developing and maintaining the claude-mem project itself**.
|
||||
|
||||
## Commands in This Directory
|
||||
|
||||
### /make-plan
|
||||
Orchestrator for creating LLM-friendly implementation plans in phases. Deploys subagents for documentation discovery and fact gathering.
|
||||
|
||||
**Usage**: `/make-plan <task description>`
|
||||
|
||||
### /do
|
||||
Orchestrator for executing plans via subagents. Deploys specialized subagents for implementation, verification, and code quality review.
|
||||
|
||||
**Usage**: `/do <plan-file-path or inline plan>`
|
||||
|
||||
### /anti-pattern-czar
|
||||
Interactive workflow for detecting and fixing error handling anti-patterns using the automated scanner.
|
||||
|
||||
**Usage**: `/anti-pattern-czar`
|
||||
|
||||
## Adding New Commands
|
||||
|
||||
Commands are markdown files with `#$ARGUMENTS` placeholder for user input.
|
||||
```
|
||||
|
||||
### Verification Checklist
|
||||
```bash
|
||||
# Verify file exists
|
||||
cat .claude/commands/CLAUDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Update Settings (if needed)
|
||||
|
||||
### What to Implement
|
||||
Check if `.claude/settings.json` needs any permission updates for the new commands.
|
||||
|
||||
### Verification Checklist
|
||||
```bash
|
||||
# Check current settings
|
||||
cat .claude/settings.json
|
||||
|
||||
# Verify commands work by listing them
|
||||
# (After Claude Code restart, commands should appear in slash-command list)
|
||||
```
|
||||
|
||||
### Anti-Pattern Guards
|
||||
- Do NOT add skill permissions for commands (they're different)
|
||||
- Commands don't require explicit permissions
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Final Verification
|
||||
|
||||
### Verification Checklist
|
||||
1. All three command files exist in `.claude/commands/`
|
||||
2. Content matches source files exactly (byte-for-byte if possible)
|
||||
3. CLAUDE.md documentation exists
|
||||
4. Git status shows new files ready for commit
|
||||
|
||||
```bash
|
||||
# Full verification
|
||||
ls -la .claude/commands/
|
||||
wc -l .claude/commands/*.md
|
||||
git status
|
||||
```
|
||||
|
||||
### Commit Message Template
|
||||
```
|
||||
feat: add /make-plan, /do, and /anti-pattern-czar workflow commands
|
||||
|
||||
Add project-level orchestration commands for claude-mem development:
|
||||
- /make-plan: Create LLM-friendly implementation plans in phases
|
||||
- /do: Execute plans via coordinated subagents
|
||||
- /anti-pattern-czar: Detect and fix error handling anti-patterns
|
||||
|
||||
These commands enable structured, agent-driven development workflows.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Files to Create**:
|
||||
1. `.claude/commands/make-plan.md` (copy from ~/.claude/commands/)
|
||||
2. `.claude/commands/do.md` (copy from ~/.claude/commands/)
|
||||
3. `.claude/commands/anti-pattern-czar.md` (copy from ~/.claude/commands/)
|
||||
4. `.claude/commands/CLAUDE.md` (new documentation)
|
||||
|
||||
**No Agent Files Needed**: The "agents" referenced in make-plan.md and do.md are role descriptions, not separate files. The Task tool's built-in subagent types handle execution.
|
||||
|
||||
**Confidence**: High - analysis complete with full source file reads.
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Deploy Install Scripts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'openclaw/install.sh'
|
||||
- 'install/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Copy install scripts to deploy directory
|
||||
run: |
|
||||
mkdir -p install/public
|
||||
cp openclaw/install.sh install/public/openclaw.sh
|
||||
|
||||
- name: Deploy to Vercel
|
||||
uses: amondnet/vercel-action@v25
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercel-args: '--prod'
|
||||
working-directory: ./install
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: npm install --ignore-scripts
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -11,6 +11,8 @@ dist/
|
||||
.claude/settings.local.json
|
||||
.claude/agents/
|
||||
.claude/skills/
|
||||
.claude/plans/
|
||||
.claude/worktrees/
|
||||
plugin/data/
|
||||
plugin/data.backup/
|
||||
package-lock.json
|
||||
|
||||
+255
-216
@@ -2,6 +2,261 @@
|
||||
|
||||
All notable changes to claude-mem.
|
||||
|
||||
## [v10.0.8] - 2026-02-16
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Orphaned Subprocess Cleanup
|
||||
- Add explicit subprocess cleanup after SDK query loop using existing `ProcessRegistry` infrastructure (`getProcessBySession` + `ensureProcessExit`), preventing orphaned Claude subprocesses from accumulating
|
||||
- Closes #1010, #1089, #1090, #1068
|
||||
|
||||
### Chroma Binary Resolution
|
||||
- Replace `npx chroma run` with absolute binary path resolution via `require.resolve`, falling back to `npx` with explicit `cwd` when the binary isn't found directly
|
||||
- Closes #1120
|
||||
|
||||
### Cross-Platform Embedding Fix
|
||||
- Remove `@chroma-core/default-embed` which pulled in `onnxruntime` + `sharp` native binaries that fail on many platforms
|
||||
- Use WASM backend for Chroma embeddings, eliminating native binary compilation issues
|
||||
- Closes #1104, #1105, #1110
|
||||
|
||||
## [v10.0.7] - 2026-02-14
|
||||
|
||||
## Chroma HTTP Server Architecture
|
||||
|
||||
- **Persistent HTTP server**: Switched from in-process Chroma to a persistent HTTP server managed by the new `ChromaServerManager` for better reliability and performance
|
||||
- **Local embeddings**: Added `DefaultEmbeddingFunction` for local vector embeddings — no external API required
|
||||
- **Pinned chromadb v3.2.2**: Fixed compatibility with v2 API heartbeat endpoint
|
||||
- **Server lifecycle improvements**: Addressed PR review feedback for proper start/stop/health check handling
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fixed SDK spawn failures and sharp native binary crashes
|
||||
- Added `plugin.json` to root `.claude-plugin` directory for proper plugin structure
|
||||
- Removed duplicate else block from merge artifact
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- Added multi-tenancy support for claude-mem Pro
|
||||
- Updated OpenClaw install URLs to `install.cmem.ai`
|
||||
- Added Vercel deploy workflow for install scripts
|
||||
- Added `.claude/plans` and `.claude/worktrees` to `.gitignore`
|
||||
|
||||
## [v10.0.6] - 2026-02-13
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **OpenClaw: Fix MEMORY.md project query mismatch** — `syncMemoryToWorkspace` now includes both the base project name and the agent-scoped project name (e.g., both "openclaw" and "openclaw-main") when querying for context injection, ensuring the correct observations are pulled into MEMORY.md.
|
||||
|
||||
- **OpenClaw: Add feed botToken support for Telegram** — Feeds can now configure a dedicated `botToken` for direct Telegram message delivery, bypassing the OpenClaw gateway channel. This fixes scenarios where the gateway bot token couldn't be used for feed messages.
|
||||
|
||||
## Other
|
||||
|
||||
- Changed OpenClaw plugin kind from "integration" to "memory" for accuracy.
|
||||
|
||||
## [v10.0.5] - 2026-02-13
|
||||
|
||||
## OpenClaw Installer & Distribution
|
||||
|
||||
This release introduces the OpenClaw one-liner installer and fixes several OpenClaw plugin issues.
|
||||
|
||||
### New Features
|
||||
|
||||
- **OpenClaw Installer** (`openclaw/install.sh`): Full cross-platform installer script with `curl | bash` support
|
||||
- Platform detection (macOS, Linux, WSL)
|
||||
- Automatic dependency management (Bun, uv, Node.js)
|
||||
- Interactive AI provider setup with settings writer
|
||||
- OpenClaw gateway detection, plugin install, and memory slot configuration
|
||||
- Worker startup and health verification with rich diagnostics
|
||||
- TTY detection, `--provider`/`--api-key` CLI flags
|
||||
- Error recovery and upgrade handling for existing installations
|
||||
- jq/python3/node fallback chain for JSON config writing
|
||||
- **Distribution readiness tests** (`openclaw/test-install.sh`): Comprehensive test suite for the installer
|
||||
- **Enhanced `/api/health` endpoint**: Now returns version, uptime, workerPath, and AI status
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: use `event.prompt` instead of `ctx.sessionKey` for prompt storage in OpenClaw plugin
|
||||
- Fix: detect both `openclaw` and `openclaw.mjs` binary names in gateway discovery
|
||||
- Fix: pass file paths via env vars instead of bash interpolation in `node -e` calls
|
||||
- Fix: handle stale plugin config that blocks OpenClaw CLI during reinstall
|
||||
- Fix: remove stale memory slot reference during reinstall cleanup
|
||||
- Fix: remove opinionated filters from OpenClaw plugin
|
||||
|
||||
## [v10.0.4] - 2026-02-12
|
||||
|
||||
## Revert: v10.0.3 chroma-mcp spawn storm fix
|
||||
|
||||
v10.0.3 introduced regressions. This release reverts the codebase to the stable v10.0.2 state.
|
||||
|
||||
### What was reverted
|
||||
|
||||
- Connection mutex via promise memoization
|
||||
- Pre-spawn process count guard
|
||||
- Hardened `close()` with try-finally + Unix `pkill -P` fallback
|
||||
- Count-based orphan reaper in `ProcessManager`
|
||||
- Circuit breaker (3 failures → 60s cooldown)
|
||||
- `etime`-based sorting for process guards
|
||||
|
||||
### Files restored to v10.0.2
|
||||
|
||||
- `src/services/sync/ChromaSync.ts`
|
||||
- `src/services/infrastructure/GracefulShutdown.ts`
|
||||
- `src/services/infrastructure/ProcessManager.ts`
|
||||
- `src/services/worker-service.ts`
|
||||
- `src/services/worker/ProcessRegistry.ts`
|
||||
- `tests/infrastructure/process-manager.test.ts`
|
||||
- `tests/integration/chroma-vector-sync.test.ts`
|
||||
|
||||
## [v10.0.3] - 2026-02-11
|
||||
|
||||
## Fix: Prevent chroma-mcp spawn storm (PR #1065)
|
||||
|
||||
Fixes a critical bug where killing the worker daemon during active sessions caused **641 chroma-mcp Python processes** to spawn in ~5 minutes, consuming 75%+ CPU and ~64GB virtual memory.
|
||||
|
||||
### Root Cause
|
||||
|
||||
`ChromaSync.ensureConnection()` had no connection mutex. Concurrent fire-and-forget `syncObservation()` calls from multiple sessions raced through the check-then-act guard, each spawning a chroma-mcp subprocess via `StdioClientTransport`. Error-driven reconnection created a positive feedback loop.
|
||||
|
||||
### 5-Layer Defense
|
||||
|
||||
| Layer | Mechanism | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **0** | Connection mutex via promise memoization | Coalesces concurrent callers onto a single spawn attempt |
|
||||
| **1** | Pre-spawn process count guard (`execFileSync('ps')`) | Kills excess chroma-mcp processes before spawning new ones |
|
||||
| **2** | Hardened `close()` with try-finally + Unix `pkill -P` fallback | Guarantees state reset even on error, kills orphaned children |
|
||||
| **3** | Count-based orphan reaper in `ProcessManager` | Kills by count (not age), catches spawn storms where all processes are young |
|
||||
| **4** | Circuit breaker (3 failures → 60s cooldown) | Stops error-driven reconnection positive feedback loop |
|
||||
|
||||
### Additional Fix
|
||||
|
||||
- Process guards now use `etime`-based sorting instead of PID ordering for reliable age determination (PIDs wrap and don't guarantee ordering)
|
||||
|
||||
### Testing
|
||||
|
||||
- 16 new tests for mutex, circuit breaker, close() hardening, and count guard
|
||||
- All tests pass (947 pass, 3 skip)
|
||||
|
||||
Closes #1063, closes #695. Relates to #1010, #707.
|
||||
|
||||
**Contributors:** @rodboev
|
||||
|
||||
## [v10.0.2] - 2026-02-11
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Prevent daemon silent death from SIGHUP + unhandled errors** — Worker process could silently die when receiving SIGHUP signals or encountering unhandled errors, leaving hooks without a backend. Now properly handles these signals and prevents silent crashes.
|
||||
- **Hook resilience and worker lifecycle improvements** — Comprehensive fixes for hook command error classification, addressing issues #957, #923, #984, #987, and #1042. Hooks now correctly distinguish between worker unavailability errors and other failures.
|
||||
- **Clarify TypeError order dependency in error classifier** — Fixed error classification logic to properly handle TypeError ordering edge cases.
|
||||
|
||||
## New Features
|
||||
|
||||
- **Project-scoped statusline counter utility** — Added `statusline-counts.js` for tracking observation counts per project in the Claude Code status line.
|
||||
|
||||
## Internal
|
||||
|
||||
- Added test coverage for hook command error classification and process manager
|
||||
- Worker service and MCP server lifecycle improvements
|
||||
- Process manager enhancements for better cross-platform stability
|
||||
|
||||
### Contributors
|
||||
- @rodboev — Hook resilience and worker lifecycle fixes (PR #1056)
|
||||
|
||||
## [v10.0.1] - 2026-02-11
|
||||
|
||||
## What's Changed
|
||||
|
||||
### OpenClaw Observation Feed
|
||||
- Enabled SSE observation feed for OpenClaw agent sessions, allowing real-time streaming of observations to connected OpenClaw clients
|
||||
- Fixed `ObservationSSEPayload.project` type to be nullable, preventing type errors when project context is unavailable
|
||||
- Added `EnvManager` support for OpenClaw environment configuration
|
||||
|
||||
### Build Artifacts
|
||||
- Rebuilt worker service and MCP server with latest changes
|
||||
|
||||
## [v10.0.0] - 2026-02-11
|
||||
|
||||
## OpenClaw Plugin — Persistent Memory for OpenClaw Agents
|
||||
|
||||
Claude-mem now has an official [OpenClaw](https://openclaw.ai) plugin, bringing persistent memory to agents running on the OpenClaw gateway. This is a major milestone — claude-mem's memory system is no longer limited to Claude Code sessions.
|
||||
|
||||
### What It Does
|
||||
|
||||
The plugin bridges claude-mem's observation pipeline with OpenClaw's embedded runner (`pi-embedded`), which calls the Anthropic API directly without spawning a `claude` process. Three core capabilities:
|
||||
|
||||
1. **Observation Recording** — Captures every tool call from OpenClaw agents and sends it to the claude-mem worker for AI-powered compression and storage
|
||||
2. **MEMORY.md Live Sync** — Writes a continuously-updated memory timeline to each agent's workspace, so agents start every session with full context from previous work
|
||||
3. **Observation Feed** — Streams new observations to messaging channels (Telegram, Discord, Slack, Signal, WhatsApp, LINE) in real-time via SSE
|
||||
|
||||
### Quick Start
|
||||
|
||||
Add claude-mem to your OpenClaw gateway config:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"claude-mem": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"project": "my-project",
|
||||
"syncMemoryFile": true,
|
||||
"observationFeed": {
|
||||
"enabled": true,
|
||||
"channel": "telegram",
|
||||
"to": "your-chat-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The claude-mem worker service must be running on the same machine (`localhost:37777`).
|
||||
|
||||
### Commands
|
||||
|
||||
- `/claude-mem-status` — Worker health check, active sessions, feed connection state
|
||||
- `/claude-mem-feed` — Show/toggle observation feed status
|
||||
- `/claude-mem-feed on|off` — Enable/disable feed
|
||||
|
||||
### How the Event Lifecycle Works
|
||||
|
||||
```
|
||||
OpenClaw Gateway
|
||||
├── session_start ──────────→ Init claude-mem session
|
||||
├── before_agent_start ─────→ Sync MEMORY.md + track workspace
|
||||
├── tool_result_persist ────→ Record observation + re-sync MEMORY.md
|
||||
├── agent_end ──────────────→ Summarize + complete session
|
||||
├── session_end ────────────→ Clean up session tracking
|
||||
└── gateway_start ──────────→ Reset all tracking
|
||||
```
|
||||
|
||||
All observation recording and MEMORY.md syncs are fire-and-forget — they never block the agent.
|
||||
|
||||
📖 Full documentation: [OpenClaw Integration Guide](https://docs.claude-mem.ai/docs/openclaw-integration)
|
||||
|
||||
---
|
||||
|
||||
## Windows Platform Improvements
|
||||
|
||||
- **ProcessManager**: Migrated daemon spawning from deprecated WMIC to PowerShell `Start-Process` with `-WindowStyle Hidden`
|
||||
- **ChromaSync**: Re-enabled vector search on Windows (was previously disabled entirely)
|
||||
- **Worker Service**: Added unified DB-ready gate middleware — all DB-dependent endpoints now wait for initialization instead of returning "Database not initialized" errors
|
||||
- **EnvManager**: Switched from fragile allowlist to simple blocklist for subprocess env vars (only strips `ANTHROPIC_API_KEY` per Issue #733)
|
||||
|
||||
## Session Management Fixes
|
||||
|
||||
- Fixed unbounded session tracking map growth — maps are now cleaned up on `session_end`
|
||||
- Session init moved to `session_start` and `after_compaction` hooks for correct lifecycle handling
|
||||
|
||||
## SSE Fixes
|
||||
|
||||
- Fixed stream URL consistency across the codebase
|
||||
- Fixed multi-line SSE data frame parsing (concatenates `data:` lines per SSE spec)
|
||||
|
||||
## Issue Triage
|
||||
|
||||
Closed 37+ duplicate/stale/invalid issues across multiple triage phases, significantly cleaning up the issue tracker.
|
||||
|
||||
## [v9.1.1] - 2026-02-07
|
||||
|
||||
## Critical Bug Fix: Worker Initialization Failure
|
||||
@@ -1247,219 +1502,3 @@ Huge thanks to **Alexander Knigge** ([@AlexanderKnigge](https://x.com/AlexanderK
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.1.0...v8.2.0
|
||||
|
||||
## [v8.1.0] - 2025-12-25
|
||||
|
||||
## The 3-Month Battle Against Complexity
|
||||
|
||||
**TL;DR:** For three months, Claude's instinct to add code instead of delete it caused the same bugs to recur. What should have been 5 lines of code became ~1000 lines, 11 useless methods, and 7+ failed "fixes." The timestamp corruption that finally broke things was just a symptom. The real achievement: **984 lines of code deleted.**
|
||||
|
||||
---
|
||||
|
||||
## What Actually Happened
|
||||
|
||||
Every Claude Code hook receives a session ID. That's all you need.
|
||||
|
||||
But Claude built an entire redundant session management system on top:
|
||||
- An `sdk_sessions` table with status tracking, port assignment, and prompt counting
|
||||
- 11 methods in `SessionStore` to manage this artificial complexity
|
||||
- Auto-creation logic scattered across 3 locations
|
||||
- A cleanup hook that "completed" sessions at the end
|
||||
|
||||
**Why?** Because it seemed "robust." Because "what if the session doesn't exist?"
|
||||
|
||||
But the edge cases didn't exist. Hooks ALWAYS provide session IDs. The "defensive" code was solving imaginary problems while creating real ones.
|
||||
|
||||
---
|
||||
|
||||
## The Pattern of Failure
|
||||
|
||||
Every time a bug appeared, Claude's instinct was to **ADD** more code:
|
||||
|
||||
| Bug | What Claude Added | What Should Have Happened |
|
||||
|-----|------------------|--------------------------|
|
||||
| Race conditions | Auto-create fallbacks | Delete the auto-create logic |
|
||||
| Duplicate observations | Validation layers | Delete the code path allowing duplicates |
|
||||
| UNIQUE constraint violations | Try-catch with fallbacks | Use `INSERT OR IGNORE` (5 characters) |
|
||||
| Session not found | Silent auto-creation | **FAIL LOUDLY** (it's a hook bug) |
|
||||
|
||||
---
|
||||
|
||||
## The 7+ Failed Attempts
|
||||
|
||||
- **Nov 4**: "Always store session data regardless of pre-existence." Complexity planted.
|
||||
- **Nov 11**: `INSERT OR IGNORE` recognized. But complexity documented, not removed.
|
||||
- **Nov 21**: Duplicate observations bug. Fixed. Then broken again by endless mode.
|
||||
- **Dec 5**: "6 hours of work delivered zero value." User requests self-audit.
|
||||
- **Dec 20**: "Phase 2: Eliminated Race Conditions" — felt like progress. Complexity remained.
|
||||
- **Dec 24**: Finally, forced deletion.
|
||||
|
||||
The user stated "hooks provide session IDs, no extra management needed" **seven times** across months. Claude didn't listen.
|
||||
|
||||
---
|
||||
|
||||
## The Fix
|
||||
|
||||
### Deleted (984 lines):
|
||||
- 11 `SessionStore` methods: `incrementPromptCounter`, `getPromptCounter`, `setWorkerPort`, `getWorkerPort`, `markSessionCompleted`, `markSessionFailed`, `reactivateSession`, `findActiveSDKSession`, `findAnySDKSession`, `updateSDKSessionId`
|
||||
- Auto-create logic from `storeObservation` and `storeSummary`
|
||||
- The entire cleanup hook (was aborting SDK agent and causing data loss)
|
||||
- 117 lines from `worker-utils.ts`
|
||||
|
||||
### What remains (~10 lines):
|
||||
```javascript
|
||||
createSDKSession(sessionId) {
|
||||
db.run('INSERT OR IGNORE INTO sdk_sessions (...) VALUES (...)');
|
||||
return db.query('SELECT id FROM sdk_sessions WHERE ...').get(sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
**That's it.**
|
||||
|
||||
---
|
||||
|
||||
## Behavior Change
|
||||
|
||||
- **Before:** Missing session? Auto-create silently. Bug hidden.
|
||||
- **After:** Missing session? Storage fails. Bug visible immediately.
|
||||
|
||||
---
|
||||
|
||||
## New Tools
|
||||
|
||||
Since we're now explicit about recovery instead of silently papering over problems:
|
||||
|
||||
- `GET /api/pending-queue` - See what's stuck
|
||||
- `POST /api/pending-queue/process` - Manually trigger recovery
|
||||
- `npm run queue:check` / `npm run queue:process` - CLI equivalents
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
- Upgraded `@anthropic-ai/claude-agent-sdk` from `^0.1.67` to `^0.1.76`
|
||||
|
||||
---
|
||||
|
||||
**PR #437:** https://github.com/thedotmack/claude-mem/pull/437
|
||||
|
||||
*The evidence: Observations #3646, #6738, #7598, #12860, #12866, #13046, #15259, #20995, #21055, #30524, #31080, #32114, #32116, #32125, #32126, #32127, #32146, #32324—the complete record of a 3-month battle.*
|
||||
|
||||
## [v8.0.6] - 2025-12-24
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Add error handlers to Chroma sync operations to prevent worker crashes on timeout (#428)
|
||||
|
||||
This patch release improves stability by adding proper error handling to Chroma vector database sync operations, preventing worker crashes when sync operations timeout.
|
||||
|
||||
## [v8.0.5] - 2025-12-24
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Context Loading**: Fixed observation filtering for non-code modes, ensuring observations are properly retrieved across all mode types
|
||||
|
||||
## Technical Details
|
||||
|
||||
Refactored context loading logic to differentiate between code and non-code modes, resolving issues where mode-specific observations were filtered by stale settings.
|
||||
|
||||
## [v8.0.4] - 2025-12-23
|
||||
|
||||
## Changes
|
||||
|
||||
- Changed worker start script
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
## [v8.0.3] - 2025-12-23
|
||||
|
||||
Fix critical worker crashes on startup (v8.0.2 regression)
|
||||
|
||||
## [v8.0.2] - 2025-12-23
|
||||
|
||||
New "chill" remix of code mode for users who want fewer, more selective observations.
|
||||
|
||||
## Features
|
||||
|
||||
- **code--chill mode**: A behavioral variant that produces fewer observations
|
||||
- Only records things "painful to rediscover" - shipped features, architectural decisions, non-obvious gotchas
|
||||
- Skips routine work, straightforward implementations, and obvious changes
|
||||
- Philosophy: "When in doubt, skip it"
|
||||
|
||||
## Documentation
|
||||
|
||||
- Updated modes.mdx with all 28 language modes (was 10)
|
||||
- Added Code Mode Variants section documenting chill mode
|
||||
|
||||
## Usage
|
||||
|
||||
Set in ~/.claude-mem/settings.json:
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_MODE": "code--chill"
|
||||
}
|
||||
```
|
||||
|
||||
## [v8.0.1] - 2025-12-23
|
||||
|
||||
## 🎨 UI Improvements
|
||||
|
||||
- **Header Redesign**: Moved documentation and X (Twitter) links from settings modal to main header for better accessibility
|
||||
- **Removed Product Hunt Badge**: Cleaned up header layout by removing the Product Hunt badge
|
||||
- **Icon Reorganization**: Reordered header icons for improved UX flow (Docs → X → Discord → GitHub)
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
## [v8.0.0] - 2025-12-23
|
||||
|
||||
## 🌍 Major Features
|
||||
|
||||
### **Mode System**: Context-aware observation capture tailored to different workflows
|
||||
- **Code Development mode** (default): Tracks bugfixes, features, refactors, and more
|
||||
- **Email Investigation mode**: Optimized for email analysis workflows
|
||||
- Extensible architecture for custom domains
|
||||
|
||||
### **28 Language Support**: Full multilingual memory
|
||||
- Arabic, Bengali, Chinese, Czech, Danish, Dutch, Finnish, French, German, Greek
|
||||
- Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Norwegian, Polish
|
||||
- Portuguese (Brazilian), Romanian, Russian, Spanish, Swedish, Thai, Turkish
|
||||
- Ukrainian, Vietnamese
|
||||
- All observations, summaries, and narratives generated in your chosen language
|
||||
|
||||
### **Inheritance Architecture**: Language modes inherit from base modes
|
||||
- Consistent observation types across languages
|
||||
- Locale-specific output while maintaining structural integrity
|
||||
- JSON-based configuration for easy customization
|
||||
|
||||
## 🔧 Technical Improvements
|
||||
|
||||
- **ModeManager**: Centralized mode loading and configuration validation
|
||||
- **Dynamic Prompts**: SDK prompts now adapt based on active mode
|
||||
- **Mode-Specific Icons**: Observation types display contextual icons/emojis per mode
|
||||
- **Fail-Fast Error Handling**: Complete removal of silent failures across all layers
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- New docs/public/modes.mdx documenting the mode system
|
||||
- 28 translated README files for multilingual community support
|
||||
- Updated configuration guide for mode selection
|
||||
|
||||
## 🔨 Breaking Changes
|
||||
|
||||
- **None** - Mode system is fully backward compatible
|
||||
- Default mode is 'code' (existing behavior)
|
||||
- Settings: New `CLAUDE_MEM_MODE` option (defaults to 'code')
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.4.5...v8.0.0
|
||||
**View PR**: https://github.com/thedotmack/claude-mem/pull/412
|
||||
|
||||
## [v7.4.5] - 2025-12-21
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix missing `formatDateTime` import in SearchManager that broke `get_context_timeline` mem-search function
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
|
||||
@@ -118,6 +118,16 @@ Start a new Claude Code session in the terminal and enter the following commands
|
||||
|
||||
Restart Claude Code. Context from previous sessions will automatically appear in new sessions.
|
||||
|
||||
### 🦞 OpenClaw Gateway
|
||||
|
||||
Install claude-mem as a persistent memory plugin on [OpenClaw](https://openclaw.ai) gateways with a single command:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash
|
||||
```
|
||||
|
||||
The installer handles dependencies, plugin setup, AI provider configuration, worker startup, and optional real-time observation feeds to Telegram, Discord, Slack, and more. See the [OpenClaw Integration Guide](https://docs.claude-mem.ai/openclaw-integration) for details.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- 🧠 **Persistent Memory** - Context survives across sessions
|
||||
|
||||
@@ -263,6 +263,29 @@ The feed only sends `new_observation` events — not raw tool usage. Observation
|
||||
|
||||
## Installation
|
||||
|
||||
Run this one-liner to install everything automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash
|
||||
```
|
||||
|
||||
The installer handles dependency checks (Bun, uv), plugin installation, memory slot configuration, AI provider setup, worker startup, and optional observation feed configuration.
|
||||
|
||||
You can also pre-select options:
|
||||
|
||||
```bash
|
||||
# With a specific AI provider
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --provider=gemini --api-key=YOUR_KEY
|
||||
|
||||
# Fully unattended (defaults to Claude Max Plan)
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --non-interactive
|
||||
|
||||
# Upgrade existing installation
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --upgrade
|
||||
```
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
Add `claude-mem` to your OpenClaw gateway's plugin configuration:
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.vercel
|
||||
public/openclaw.sh
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)\\.sh",
|
||||
"headers": [
|
||||
{ "key": "Content-Type", "value": "text/plain; charset=utf-8" },
|
||||
{ "key": "Cache-Control", "value": "public, max-age=300, s-maxage=60" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+48
-8
@@ -1,8 +1,46 @@
|
||||
# Claude-Mem OpenClaw Plugin — Setup Guide
|
||||
|
||||
This guide walks through setting up the claude-mem plugin on an OpenClaw gateway from scratch. Follow every step in order. By the end, your agents will have persistent memory across sessions, a live-updating MEMORY.md in their workspace, and optionally a real-time observation feed streaming to a messaging channel.
|
||||
This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions, a live-updating MEMORY.md in their workspace, and optionally a real-time observation feed streaming to a messaging channel.
|
||||
|
||||
## Step 1: Clone the Claude-Mem Repo
|
||||
## Quick Install (Recommended)
|
||||
|
||||
Run this one-liner to install everything automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash
|
||||
```
|
||||
|
||||
The installer handles dependency checks (Bun, uv), plugin installation, memory slot configuration, AI provider setup, worker startup, and optional observation feed configuration — all interactively.
|
||||
|
||||
### Install with options
|
||||
|
||||
Pre-select your AI provider and API key to skip interactive prompts:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --provider=gemini --api-key=YOUR_KEY
|
||||
```
|
||||
|
||||
For fully unattended installation (defaults to Claude Max Plan, skips observation feed):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --non-interactive
|
||||
```
|
||||
|
||||
To upgrade an existing installation (preserves settings, updates plugin):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --upgrade
|
||||
```
|
||||
|
||||
After installation, skip to [Step 4: Restart the Gateway and Verify](#step-4-restart-the-gateway-and-verify) to confirm everything is working.
|
||||
|
||||
---
|
||||
|
||||
## Manual Setup
|
||||
|
||||
The steps below are for manual installation if you prefer not to use the automated installer, or need to troubleshoot individual steps.
|
||||
|
||||
### Step 1: Clone the Claude-Mem Repo
|
||||
|
||||
First, clone the claude-mem repository to a location accessible by your OpenClaw gateway. This gives you the worker service source and the plugin code.
|
||||
|
||||
@@ -20,11 +58,11 @@ You'll need **bun** installed for the worker service. If you don't have it:
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
|
||||
## Step 2: Get the Worker Running
|
||||
### Step 2: Get the Worker Running
|
||||
|
||||
The claude-mem worker is an HTTP service on port 37777. It stores observations, generates summaries, and serves the context timeline. The plugin talks to it over HTTP — it doesn't matter where the worker is running, just that it's reachable on localhost:37777.
|
||||
|
||||
### Check if it's already running
|
||||
#### Check if it's already running
|
||||
|
||||
If this machine also runs Claude Code with claude-mem installed, the worker may already be running:
|
||||
|
||||
@@ -36,7 +74,7 @@ curl http://localhost:37777/api/health
|
||||
|
||||
**Got connection refused or no response?** The worker isn't running. Continue below.
|
||||
|
||||
### If Claude Code has claude-mem installed
|
||||
#### If Claude Code has claude-mem installed
|
||||
|
||||
If claude-mem is installed as a Claude Code plugin (at `~/.claude/plugins/marketplaces/thedotmack/`), start the worker from that installation:
|
||||
|
||||
@@ -54,7 +92,7 @@ curl http://localhost:37777/api/health
|
||||
|
||||
**Still not working?** Check `npm run worker:status` for error details, or check that bun is installed and on your PATH.
|
||||
|
||||
### If there's no Claude Code installation
|
||||
#### If there's no Claude Code installation
|
||||
|
||||
Run the worker from the cloned repo:
|
||||
|
||||
@@ -77,7 +115,7 @@ curl http://localhost:37777/api/health
|
||||
- Check logs: `npm run worker:logs` (if available)
|
||||
- Try running it directly to see errors: `bun plugin/scripts/worker-service.cjs start`
|
||||
|
||||
## Step 3: Add the Plugin to Your Gateway
|
||||
### Step 3: Add the Plugin to Your Gateway
|
||||
|
||||
Add the `claude-mem` plugin to your OpenClaw gateway configuration:
|
||||
|
||||
@@ -96,7 +134,7 @@ Add the `claude-mem` plugin to your OpenClaw gateway configuration:
|
||||
}
|
||||
```
|
||||
|
||||
### Config fields explained
|
||||
#### Config fields explained
|
||||
|
||||
- **`project`** (string, default: `"openclaw"`) — The project name that scopes all observations in the memory database. Use a unique name per gateway/use-case so observations don't mix. For example, if this gateway runs a coding bot, use `"coding-bot"`.
|
||||
|
||||
@@ -104,6 +142,8 @@ Add the `claude-mem` plugin to your OpenClaw gateway configuration:
|
||||
|
||||
- **`workerPort`** (number, default: `37777`) — The port where the claude-mem worker service is listening. Only change this if you configured the worker to use a different port.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Restart the Gateway and Verify
|
||||
|
||||
Restart your OpenClaw gateway so it picks up the new plugin configuration. After restart, check the gateway logs for:
|
||||
|
||||
Executable
+1801
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,10 @@
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Target chat/user ID to send observations to"
|
||||
},
|
||||
"botToken": {
|
||||
"type": "string",
|
||||
"description": "Optional dedicated Telegram bot token for the feed (bypasses gateway channel)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@claude-mem/openclaw-plugin",
|
||||
"name": "@openclaw/claude-mem",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -11,5 +11,10 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.1",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ describe("Observation I/O event handlers", () => {
|
||||
assert.equal(initRequests.length, 1, "should re-init after compaction");
|
||||
});
|
||||
|
||||
it("before_agent_start does not call init", async () => {
|
||||
it("before_agent_start calls init for session privacy check", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
@@ -354,7 +354,7 @@ describe("Observation I/O event handlers", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
|
||||
assert.equal(initRequests.length, 0, "before_agent_start should not init");
|
||||
assert.equal(initRequests.length, 1, "before_agent_start should init session");
|
||||
});
|
||||
|
||||
it("tool_result_persist sends observation to worker", async () => {
|
||||
|
||||
+151
-25
@@ -67,13 +67,27 @@ interface SessionEndEvent {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface MessageReceivedEvent {
|
||||
from: string;
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface EventContext {
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
interface MessageContext {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
type EventCallback<T> = (event: T, ctx: EventContext) => void | Promise<void>;
|
||||
type MessageEventCallback<T> = (event: T, ctx: MessageContext) => void | Promise<void>;
|
||||
|
||||
interface OpenClawPluginApi {
|
||||
id: string;
|
||||
@@ -100,6 +114,7 @@ interface OpenClawPluginApi {
|
||||
((event: "agent_end", callback: EventCallback<AgentEndEvent>) => void) &
|
||||
((event: "session_start", callback: EventCallback<SessionStartEvent>) => void) &
|
||||
((event: "session_end", callback: EventCallback<SessionEndEvent>) => void) &
|
||||
((event: "message_received", callback: MessageEventCallback<MessageReceivedEvent>) => void) &
|
||||
((event: "after_compaction", callback: EventCallback<AfterCompactionEvent>) => void) &
|
||||
((event: "gateway_start", callback: EventCallback<Record<string, never>>) => void);
|
||||
runtime: {
|
||||
@@ -124,7 +139,7 @@ interface ObservationSSEPayload {
|
||||
concepts: string | null;
|
||||
files_read: string | null;
|
||||
files_modified: string | null;
|
||||
project: string;
|
||||
project: string | null;
|
||||
prompt_number: number;
|
||||
created_at_epoch: number;
|
||||
}
|
||||
@@ -149,6 +164,7 @@ interface ClaudeMemPluginConfig {
|
||||
enabled?: boolean;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
botToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,7 +174,44 @@ interface ClaudeMemPluginConfig {
|
||||
|
||||
const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
|
||||
const DEFAULT_WORKER_PORT = 37777;
|
||||
const TOOL_RESULT_MAX_LENGTH = 1000;
|
||||
|
||||
// Agent emoji map for observation feed messages.
|
||||
// When creating a new OpenClaw agent, add its agentId and emoji here.
|
||||
const AGENT_EMOJI_MAP: Record<string, string> = {
|
||||
"main": "🦞",
|
||||
"openclaw": "🦞",
|
||||
"devops": "🔧",
|
||||
"architect": "📐",
|
||||
"researcher": "🔍",
|
||||
"code-reviewer": "🔎",
|
||||
"coder": "💻",
|
||||
"tester": "🧪",
|
||||
"debugger": "🐛",
|
||||
"opsec": "🛡️",
|
||||
"cloudfarm": "☁️",
|
||||
"extractor": "📦",
|
||||
};
|
||||
|
||||
// Project prefixes that indicate Claude Code sessions (not OpenClaw agents)
|
||||
const CLAUDE_CODE_EMOJI = "⌨️";
|
||||
const OPENCLAW_DEFAULT_EMOJI = "🦀";
|
||||
|
||||
function getSourceLabel(project: string | null | undefined): string {
|
||||
if (!project) return OPENCLAW_DEFAULT_EMOJI;
|
||||
// OpenClaw agent projects are formatted as "openclaw-<agentId>"
|
||||
if (project.startsWith("openclaw-")) {
|
||||
const agentId = project.slice("openclaw-".length);
|
||||
const emoji = AGENT_EMOJI_MAP[agentId] || OPENCLAW_DEFAULT_EMOJI;
|
||||
return `${emoji} ${agentId}`;
|
||||
}
|
||||
// OpenClaw project without agent suffix
|
||||
if (project === "openclaw") {
|
||||
return `🦞 openclaw`;
|
||||
}
|
||||
// Everything else is from Claude Code (project = working directory name)
|
||||
const emoji = CLAUDE_CODE_EMOJI;
|
||||
return `${emoji} ${project}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worker HTTP Client
|
||||
@@ -233,7 +286,8 @@ async function workerGetText(
|
||||
|
||||
function formatObservationMessage(observation: ObservationSSEPayload): string {
|
||||
const title = observation.title || "Untitled";
|
||||
let message = `🧠 Claude-Mem Observation\n**${title}**`;
|
||||
const source = getSourceLabel(observation.project);
|
||||
let message = `${source}\n**${title}**`;
|
||||
if (observation.subtitle) {
|
||||
message += `\n${observation.subtitle}`;
|
||||
}
|
||||
@@ -252,12 +306,44 @@ const CHANNEL_SEND_MAP: Record<string, { namespace: string; functionName: string
|
||||
line: { namespace: "line", functionName: "sendMessageLine" },
|
||||
};
|
||||
|
||||
async function sendDirectTelegram(
|
||||
botToken: string,
|
||||
chatId: string,
|
||||
text: string,
|
||||
logger: PluginLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: "Markdown",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
logger.warn(`[claude-mem] Direct Telegram send failed (${response.status}): ${body}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`[claude-mem] Direct Telegram send error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sendToChannel(
|
||||
api: OpenClawPluginApi,
|
||||
channel: string,
|
||||
to: string,
|
||||
text: string
|
||||
text: string,
|
||||
botToken?: string
|
||||
): Promise<void> {
|
||||
// If a dedicated bot token is provided for Telegram, send directly
|
||||
if (botToken && channel === "telegram") {
|
||||
return sendDirectTelegram(botToken, to, text, api.logger);
|
||||
}
|
||||
|
||||
const mapping = CHANNEL_SEND_MAP[channel];
|
||||
if (!mapping) {
|
||||
api.logger.warn(`[claude-mem] Unsupported channel type: ${channel}`);
|
||||
@@ -293,7 +379,8 @@ async function connectToSSEStream(
|
||||
channel: string,
|
||||
to: string,
|
||||
abortController: AbortController,
|
||||
setConnectionState: (state: ConnectionState) => void
|
||||
setConnectionState: (state: ConnectionState) => void,
|
||||
botToken?: string
|
||||
): Promise<void> {
|
||||
let backoffMs = 1000;
|
||||
const maxBackoffMs = 30000;
|
||||
@@ -354,7 +441,7 @@ async function connectToSSEStream(
|
||||
if (parsed.type === "new_observation" && parsed.observation) {
|
||||
const event = parsed as SSENewObservationEvent;
|
||||
const message = formatObservationMessage(event.observation);
|
||||
await sendToChannel(api, channel, to, message);
|
||||
await sendToChannel(api, channel, to, message, botToken);
|
||||
}
|
||||
} catch (parseError: unknown) {
|
||||
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
||||
@@ -387,7 +474,14 @@ async function connectToSSEStream(
|
||||
export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig;
|
||||
const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT;
|
||||
const projectName = userConfig.project || "openclaw";
|
||||
const baseProjectName = userConfig.project || "openclaw";
|
||||
|
||||
function getProjectName(ctx: EventContext): string {
|
||||
if (ctx.agentId) {
|
||||
return `openclaw-${ctx.agentId}`;
|
||||
}
|
||||
return baseProjectName;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Session tracking for observation I/O
|
||||
@@ -404,10 +498,16 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
return sessionIds.get(key)!;
|
||||
}
|
||||
|
||||
async function syncMemoryToWorkspace(workspaceDir: string): Promise<void> {
|
||||
async function syncMemoryToWorkspace(workspaceDir: string, ctx?: EventContext): Promise<void> {
|
||||
// Include both the base project and agent-scoped project (e.g. "openclaw" + "openclaw-main")
|
||||
const projects = [baseProjectName];
|
||||
const agentProject = ctx ? getProjectName(ctx) : null;
|
||||
if (agentProject && agentProject !== baseProjectName) {
|
||||
projects.push(agentProject);
|
||||
}
|
||||
const contextText = await workerGetText(
|
||||
workerPort,
|
||||
`/api/context/inject?projects=${encodeURIComponent(projectName)}`,
|
||||
`/api/context/inject?projects=${encodeURIComponent(projects.join(","))}`,
|
||||
api.logger
|
||||
);
|
||||
if (contextText && contextText.trim().length > 0) {
|
||||
@@ -429,13 +529,27 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: projectName,
|
||||
project: getProjectName(ctx),
|
||||
prompt: "",
|
||||
}, api.logger);
|
||||
|
||||
api.logger.info(`[claude-mem] Session initialized: ${contentSessionId}`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: message_received — capture inbound user prompts from channels
|
||||
// ------------------------------------------------------------------
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
const sessionKey = ctx.conversationId || ctx.channelId || "default";
|
||||
const contentSessionId = getContentSessionId(sessionKey);
|
||||
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: baseProjectName,
|
||||
prompt: event.content || "[media prompt]",
|
||||
}, api.logger);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: after_compaction — re-init session after context compaction
|
||||
// ------------------------------------------------------------------
|
||||
@@ -444,7 +558,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: projectName,
|
||||
project: getProjectName(ctx),
|
||||
prompt: "",
|
||||
}, api.logger);
|
||||
|
||||
@@ -452,17 +566,26 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: before_agent_start — sync MEMORY.md + track workspace
|
||||
// Event: before_agent_start — init session + sync MEMORY.md + track workspace
|
||||
// ------------------------------------------------------------------
|
||||
api.on("before_agent_start", async (_event, ctx) => {
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
// Track workspace dir so tool_result_persist can sync MEMORY.md later
|
||||
if (ctx.workspaceDir) {
|
||||
workspaceDirsBySessionKey.set(ctx.sessionKey || "default", ctx.workspaceDir);
|
||||
}
|
||||
|
||||
// Initialize session in the worker so observations are not skipped
|
||||
// (the privacy check requires a stored user prompt to exist)
|
||||
const contentSessionId = getContentSessionId(ctx.sessionKey);
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: getProjectName(ctx),
|
||||
prompt: event.prompt || "agent run",
|
||||
}, api.logger);
|
||||
|
||||
// Sync MEMORY.md before agent runs (provides context to agent)
|
||||
if (syncMemoryFile && ctx.workspaceDir) {
|
||||
await syncMemoryToWorkspace(ctx.workspaceDir);
|
||||
await syncMemoryToWorkspace(ctx.workspaceDir, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -470,21 +593,20 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
// Event: tool_result_persist — record tool observations + sync MEMORY.md
|
||||
// ------------------------------------------------------------------
|
||||
api.on("tool_result_persist", (event, ctx) => {
|
||||
api.logger.info(`[claude-mem] tool_result_persist fired: tool=${event.toolName ?? "unknown"} agent=${ctx.agentId ?? "none"} session=${ctx.sessionKey ?? "none"}`);
|
||||
const toolName = event.toolName;
|
||||
if (!toolName || toolName.startsWith("memory_")) return;
|
||||
if (!toolName) return;
|
||||
|
||||
const contentSessionId = getContentSessionId(ctx.sessionKey);
|
||||
|
||||
// Extract result text from message content
|
||||
// Extract result text from all content blocks
|
||||
let toolResponseText = "";
|
||||
const content = event.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block.type === "tool_result" || block.type === "text"
|
||||
);
|
||||
if (textBlock && "text" in textBlock) {
|
||||
toolResponseText = String(textBlock.text).slice(0, TOOL_RESULT_MAX_LENGTH);
|
||||
}
|
||||
toolResponseText = content
|
||||
.filter((block) => (block.type === "tool_result" || block.type === "text") && "text" in block)
|
||||
.map((block) => String(block.text))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Fire-and-forget: send observation + sync MEMORY.md in parallel
|
||||
@@ -498,7 +620,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
|
||||
const workspaceDir = ctx.workspaceDir || workspaceDirsBySessionKey.get(ctx.sessionKey || "default");
|
||||
if (syncMemoryFile && workspaceDir) {
|
||||
syncMemoryToWorkspace(workspaceDir);
|
||||
syncMemoryToWorkspace(workspaceDir, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -527,7 +649,10 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
}
|
||||
}
|
||||
|
||||
workerPostFireAndForget(workerPort, "/api/sessions/summarize", {
|
||||
// Await summarize so the worker receives it before complete.
|
||||
// This also gives in-flight tool_result_persist observations time to arrive
|
||||
// (they use fire-and-forget and may still be in transit).
|
||||
await workerPost(workerPort, "/api/sessions/summarize", {
|
||||
contentSessionId,
|
||||
last_assistant_message: lastAssistantMessage,
|
||||
}, api.logger);
|
||||
@@ -594,7 +719,8 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
feedConfig.channel,
|
||||
feedConfig.to,
|
||||
sseAbortController,
|
||||
(state) => { connectionState = state; }
|
||||
(state) => { connectionState = state; },
|
||||
feedConfig.botToken
|
||||
);
|
||||
},
|
||||
stop: async (_ctx) => {
|
||||
|
||||
Executable
+2339
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.0.0",
|
||||
"version": "10.1.0",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -98,7 +98,9 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@chroma-core/default-embed": "^0.1.9",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"chromadb": "^3.2.2",
|
||||
"dompurify": "^3.3.1",
|
||||
"express": "^4.18.2",
|
||||
"glob": "^11.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.0.0",
|
||||
"version": "10.1.0",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "10.0.0",
|
||||
"version": "10.1.0",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"@chroma-core/default-embed": "^0.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"bun": ">=1.0.0"
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Statusline Counts — lightweight project-scoped observation counter
|
||||
*
|
||||
* Returns JSON with observation and prompt counts for the given project,
|
||||
* suitable for integration into Claude Code's statusLineCommand.
|
||||
*
|
||||
* Usage:
|
||||
* bun statusline-counts.js <cwd>
|
||||
* bun statusline-counts.js /home/user/my-project
|
||||
*
|
||||
* Output (JSON, stdout):
|
||||
* {"observations": 42, "prompts": 15, "project": "my-project"}
|
||||
*
|
||||
* The project name is derived from basename(cwd). Observations are counted
|
||||
* with a WHERE project = ? filter so only the current project's data is
|
||||
* returned — preventing inflated counts from cross-project observations.
|
||||
*
|
||||
* Performance: ~10ms typical (direct SQLite read, no HTTP, no worker dependency)
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join, basename } from "path";
|
||||
|
||||
const cwd = process.argv[2] || process.env.CLAUDE_CWD || process.cwd();
|
||||
const project = basename(cwd);
|
||||
|
||||
try {
|
||||
// Resolve data directory: env var → settings.json → default
|
||||
let dataDir = process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), ".claude-mem");
|
||||
if (!process.env.CLAUDE_MEM_DATA_DIR) {
|
||||
const settingsPath = join(dataDir, "settings.json");
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
if (settings.CLAUDE_MEM_DATA_DIR) dataDir = settings.CLAUDE_MEM_DATA_DIR;
|
||||
} catch { /* use default */ }
|
||||
}
|
||||
}
|
||||
|
||||
const dbPath = join(dataDir, "claude-mem.db");
|
||||
if (!existsSync(dbPath)) {
|
||||
console.log(JSON.stringify({ observations: 0, prompts: 0, project }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
const obs = db.query("SELECT COUNT(*) as c FROM observations WHERE project = ?").get(project);
|
||||
// user_prompts links to projects through sdk_sessions.content_session_id
|
||||
const prompts = db.query(
|
||||
`SELECT COUNT(*) as c FROM user_prompts up
|
||||
JOIN sdk_sessions s ON s.content_session_id = up.content_session_id
|
||||
WHERE s.project = ?`
|
||||
).get(project);
|
||||
console.log(JSON.stringify({ observations: obs.c, prompts: prompts.c, project }));
|
||||
db.close();
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify({ observations: 0, prompts: 0, project, error: e.message }));
|
||||
}
|
||||
+210
-213
File diff suppressed because one or more lines are too long
+13
-2
@@ -58,7 +58,10 @@ async function buildHooks() {
|
||||
private: true,
|
||||
description: 'Runtime dependencies for claude-mem bundled hooks',
|
||||
type: 'module',
|
||||
dependencies: {},
|
||||
dependencies: {
|
||||
// Chroma embedding function with native ONNX binaries (can't be bundled)
|
||||
'@chroma-core/default-embed': '^0.1.9'
|
||||
},
|
||||
engines: {
|
||||
node: '>=18.0.0',
|
||||
bun: '>=1.0.0'
|
||||
@@ -92,7 +95,15 @@ async function buildHooks() {
|
||||
outfile: `${hooksDir}/${WORKER_SERVICE.name}.cjs`,
|
||||
minify: true,
|
||||
logLevel: 'error', // Suppress warnings (import.meta warning is benign)
|
||||
external: ['bun:sqlite'],
|
||||
external: [
|
||||
'bun:sqlite',
|
||||
// Optional chromadb embedding providers
|
||||
'cohere-ai',
|
||||
'ollama',
|
||||
// Default embedding function with native binaries
|
||||
'@chroma-core/default-embed',
|
||||
'onnxruntime-node'
|
||||
],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
|
||||
@@ -61,10 +61,20 @@ function getPluginVersion() {
|
||||
console.log('Syncing to marketplace...');
|
||||
try {
|
||||
execSync(
|
||||
'rsync -av --delete --exclude=.git --exclude=/.mcp.json ./ ~/.claude/plugins/marketplaces/thedotmack/',
|
||||
'rsync -av --delete --exclude=.git --exclude=/.mcp.json --exclude=bun.lock --exclude=package-lock.json ./ ~/.claude/plugins/marketplaces/thedotmack/',
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
// Remove stale lockfiles before install — they pin old native dep versions
|
||||
const { unlinkSync } = require('fs');
|
||||
for (const lockfile of ['package-lock.json', 'bun.lock']) {
|
||||
const lockpath = path.join(INSTALLED_PATH, lockfile);
|
||||
if (existsSync(lockpath)) {
|
||||
unlinkSync(lockpath);
|
||||
console.log(`Removed stale ${lockfile}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Running npm install in marketplace...');
|
||||
execSync(
|
||||
'cd ~/.claude/plugins/marketplaces/thedotmack/ && npm install',
|
||||
|
||||
@@ -17,7 +17,11 @@ export const claudeCodeAdapter: PlatformAdapter = {
|
||||
},
|
||||
formatOutput(result) {
|
||||
if (result.hookSpecificOutput) {
|
||||
return { hookSpecificOutput: result.hookSpecificOutput };
|
||||
const output: Record<string, unknown> = { hookSpecificOutput: result.hookSpecificOutput };
|
||||
if (result.systemMessage) {
|
||||
output.systemMessage = result.systemMessage;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return { continue: result.continue ?? true, suppressOutput: result.suppressOutput ?? true };
|
||||
}
|
||||
|
||||
+42
-13
@@ -9,6 +9,7 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
export const contextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -35,20 +36,48 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||
// Worker service has its own timeouts, so client-side timeout is redundant
|
||||
const response = await fetch(url);
|
||||
try {
|
||||
// Fetch both markdown (for Claude context) and colored (for user display) truly in parallel
|
||||
const colorUrl = `${url}&colors=true`;
|
||||
const [response, colorResponse] = await Promise.all([
|
||||
fetch(url),
|
||||
fetch(colorUrl).catch(() => null)
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Context generation failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.text();
|
||||
const additionalContext = result.trim();
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext
|
||||
if (!response.ok) {
|
||||
// Log but don't throw — context fetch failure should not block session start
|
||||
logger.warn('HOOK', 'Context generation failed, returning empty', { status: response.status });
|
||||
return {
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
|
||||
exitCode: HOOK_EXIT_CODES.SUCCESS
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const [contextResult, colorResult] = await Promise.all([
|
||||
response.text(),
|
||||
colorResponse?.ok ? colorResponse.text() : Promise.resolve('')
|
||||
]);
|
||||
|
||||
const additionalContext = contextResult.trim();
|
||||
const coloredTimeline = colorResult.trim();
|
||||
const systemMessage = coloredTimeline
|
||||
? `${coloredTimeline}\n\nView Observations Live @ http://localhost:${port}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext
|
||||
},
|
||||
systemMessage
|
||||
};
|
||||
} catch (error) {
|
||||
// Worker unreachable — return empty context gracefully
|
||||
logger.warn('HOOK', 'Context fetch error, returning empty', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
|
||||
exitCode: HOOK_EXIT_CODES.SUCCESS
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,25 +39,33 @@ export const fileEditHandler: EventHandler = {
|
||||
|
||||
// Send to worker as an observation with file edit metadata
|
||||
// The observation handler on the worker will process this appropriately
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`File edit observation storage failed: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
// Log but don't throw — file edit observation failure should not block editing
|
||||
logger.warn('HOOK', 'File edit observation storage failed, skipping', { status: response.status, filePath });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
|
||||
} catch (error) {
|
||||
// Worker unreachable — skip file edit observation gracefully
|
||||
logger.warn('HOOK', 'File edit observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
|
||||
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler } from '../types.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { contextHandler } from './context.js';
|
||||
import { sessionInitHandler } from './session-init.js';
|
||||
import { observationHandler } from './observation.js';
|
||||
@@ -35,14 +36,22 @@ const handlers: Record<EventType, EventHandler> = {
|
||||
/**
|
||||
* Get the event handler for a given event type.
|
||||
*
|
||||
* Returns a no-op handler for unknown event types instead of throwing (fix #984).
|
||||
* Claude Code may send new event types that the plugin doesn't handle yet —
|
||||
* throwing would surface as a BLOCKING_ERROR to the user.
|
||||
*
|
||||
* @param eventType The type of event to handle
|
||||
* @returns The appropriate EventHandler
|
||||
* @throws Error if event type is not recognized
|
||||
* @returns The appropriate EventHandler, or a no-op handler for unknown types
|
||||
*/
|
||||
export function getEventHandler(eventType: EventType): EventHandler {
|
||||
const handler = handlers[eventType];
|
||||
export function getEventHandler(eventType: string): EventHandler {
|
||||
const handler = handlers[eventType as EventType];
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown event type: ${eventType}`);
|
||||
console.error(`[claude-mem] Unknown event type: ${eventType}, returning no-op`);
|
||||
return {
|
||||
async execute() {
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
};
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
@@ -48,25 +48,33 @@ export const observationHandler: EventHandler = {
|
||||
}
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Observation storage failed: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
// Log but don't throw — observation storage failure should not block tool use
|
||||
logger.warn('HOOK', 'Observation storage failed, skipping', { status: response.status, toolName });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'Observation sent successfully', { toolName });
|
||||
} catch (error) {
|
||||
// Worker unreachable — skip observation gracefully
|
||||
logger.warn('HOOK', 'Observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'Observation sent successfully', { toolName });
|
||||
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,11 @@ import { logger } from '../../utils/logger.js';
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running
|
||||
await ensureWorkerRunning();
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available — skip session completion gracefully
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
const { sessionId } = input;
|
||||
const port = getWorkerPort();
|
||||
|
||||
@@ -13,37 +13,46 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
export const userMessageHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running
|
||||
await ensureWorkerRunning();
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available — skip user message gracefully
|
||||
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const port = getWorkerPort();
|
||||
const project = basename(input.cwd ?? process.cwd());
|
||||
|
||||
// Fetch formatted context directly from worker API
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Don't throw - context fetch failure should not block the user's prompt
|
||||
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
if (!response.ok) {
|
||||
// Don't throw - context fetch failure should not block the user's prompt
|
||||
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const output = await response.text();
|
||||
|
||||
// Write to stderr for user visibility
|
||||
// Note: Using process.stderr.write instead of console.error to avoid
|
||||
// Claude Code treating this as a hook error. The actual hook output
|
||||
// goes to stdout via hook-command.ts JSON serialization.
|
||||
process.stderr.write(
|
||||
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" +
|
||||
output +
|
||||
"\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with <private> ... </private> to prevent storing sensitive information.\n" +
|
||||
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
|
||||
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
|
||||
);
|
||||
} catch (error) {
|
||||
// Worker unreachable — skip user message gracefully
|
||||
// User message context error is non-critical — skip gracefully
|
||||
}
|
||||
|
||||
const output = await response.text();
|
||||
|
||||
// Write to stderr for user visibility
|
||||
// Note: Using process.stderr.write instead of console.error to avoid
|
||||
// Claude Code treating this as a hook error. The actual hook output
|
||||
// goes to stdout via hook-command.ts JSON serialization.
|
||||
process.stderr.write(
|
||||
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" +
|
||||
output +
|
||||
"\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with <private> ... </private> to prevent storing sensitive information.\n" +
|
||||
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
|
||||
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
|
||||
);
|
||||
|
||||
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
};
|
||||
|
||||
+66
-2
@@ -8,6 +8,62 @@ export interface HookCommandOptions {
|
||||
skipExit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an error indicates the worker is unavailable (graceful degradation)
|
||||
* vs a handler/client bug (blocking error that developers need to see).
|
||||
*
|
||||
* Exit 0 (graceful degradation):
|
||||
* - Transport failures: ECONNREFUSED, ECONNRESET, EPIPE, ETIMEDOUT, fetch failed
|
||||
* - Timeout errors: timed out, timeout
|
||||
* - Server errors: HTTP 5xx status codes
|
||||
*
|
||||
* Exit 2 (blocking error — handler/client bug):
|
||||
* - HTTP 4xx status codes (bad request, not found, validation error)
|
||||
* - Programming errors (TypeError, ReferenceError, SyntaxError)
|
||||
* - All other unexpected errors
|
||||
*/
|
||||
export function isWorkerUnavailableError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
// Transport failures — worker unreachable
|
||||
const transportPatterns = [
|
||||
'econnrefused',
|
||||
'econnreset',
|
||||
'epipe',
|
||||
'etimedout',
|
||||
'enotfound',
|
||||
'econnaborted',
|
||||
'enetunreach',
|
||||
'ehostunreach',
|
||||
'fetch failed',
|
||||
'unable to connect',
|
||||
'socket hang up',
|
||||
];
|
||||
if (transportPatterns.some(p => lower.includes(p))) return true;
|
||||
|
||||
// Timeout errors — worker didn't respond in time
|
||||
if (lower.includes('timed out') || lower.includes('timeout')) return true;
|
||||
|
||||
// HTTP 5xx server errors — worker has internal problems
|
||||
if (/failed:\s*5\d{2}/.test(message) || /status[:\s]+5\d{2}/.test(message)) return true;
|
||||
|
||||
// HTTP 429 (rate limit) — treat as transient unavailability, not a bug
|
||||
if (/failed:\s*429/.test(message) || /status[:\s]+429/.test(message)) return true;
|
||||
|
||||
// HTTP 4xx client errors — our bug, NOT worker unavailability
|
||||
if (/failed:\s*4\d{2}/.test(message) || /status[:\s]+4\d{2}/.test(message)) return false;
|
||||
|
||||
// Programming errors — code bugs, not worker unavailability
|
||||
// Note: TypeError('fetch failed') already handled by transport patterns above
|
||||
if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default: treat unknown errors as blocking (conservative — surface bugs)
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function hookCommand(platform: string, event: string, options: HookCommandOptions = {}): Promise<number> {
|
||||
try {
|
||||
const adapter = getPlatformAdapter(platform);
|
||||
@@ -26,9 +82,17 @@ export async function hookCommand(platform: string, event: string, options: Hook
|
||||
}
|
||||
return exitCode;
|
||||
} catch (error) {
|
||||
if (isWorkerUnavailableError(error)) {
|
||||
// Worker unavailable — degrade gracefully, don't block the user
|
||||
console.error(`[claude-mem] Worker unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
|
||||
if (!options.skipExit) {
|
||||
process.exit(HOOK_EXIT_CODES.SUCCESS); // = 0 (graceful)
|
||||
}
|
||||
return HOOK_EXIT_CODES.SUCCESS;
|
||||
}
|
||||
|
||||
// Handler/client bug — show as blocking error so developers see it
|
||||
console.error(`Hook error: ${error}`);
|
||||
// Use exit code 2 (blocking error) so users see the error message
|
||||
// Exit code 1 only shows in verbose mode per Claude Code docs
|
||||
if (!options.skipExit) {
|
||||
process.exit(HOOK_EXIT_CODES.BLOCKING_ERROR); // = 2
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface HookResult {
|
||||
continue?: boolean;
|
||||
suppressOutput?: boolean;
|
||||
hookSpecificOutput?: { hookEventName: string; additionalContext: string };
|
||||
systemMessage?: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ export interface CloseableDatabase {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppable service interface for Chroma server
|
||||
*/
|
||||
export interface StoppableServer {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for graceful shutdown
|
||||
*/
|
||||
@@ -37,6 +44,7 @@ export interface GracefulShutdownConfig {
|
||||
sessionManager: ShutdownableService;
|
||||
mcpClient?: CloseableClient;
|
||||
dbManager?: CloseableDatabase;
|
||||
chromaServer?: StoppableServer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,12 +79,19 @@ export async function performGracefulShutdown(config: GracefulShutdownConfig): P
|
||||
logger.info('SYSTEM', 'MCP client closed');
|
||||
}
|
||||
|
||||
// STEP 5: Close database connection (includes ChromaSync cleanup)
|
||||
// STEP 5: Stop Chroma server (local mode only)
|
||||
if (config.chromaServer) {
|
||||
logger.info('SHUTDOWN', 'Stopping Chroma server...');
|
||||
await config.chromaServer.stop();
|
||||
logger.info('SHUTDOWN', 'Chroma server stopped');
|
||||
}
|
||||
|
||||
// STEP 6: Close database connection (includes ChromaSync cleanup)
|
||||
if (config.dbManager) {
|
||||
await config.dbManager.close();
|
||||
}
|
||||
|
||||
// STEP 6: Force kill any remaining child processes (Windows zombie port fix)
|
||||
// STEP 7: Force kill any remaining child processes (Windows zombie port fix)
|
||||
if (childPids.length > 0) {
|
||||
logger.info('SYSTEM', 'Force killing remaining children');
|
||||
for (const pid of childPids) {
|
||||
|
||||
@@ -77,7 +77,11 @@ export function removePidFile(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-adjusted timeout (Windows socket cleanup is slower)
|
||||
* Get platform-adjusted timeout for worker-side socket operations (2.0x on Windows).
|
||||
*
|
||||
* Note: Two platform multiplier functions exist intentionally:
|
||||
* - getTimeout() in hook-constants.ts uses 1.5x for hook-side operations (fast path)
|
||||
* - getPlatformTimeout() here uses 2.0x for worker-side socket operations (slower path)
|
||||
*/
|
||||
export function getPlatformTimeout(baseMs: number): number {
|
||||
const WINDOWS_MULTIPLIER = 2.0;
|
||||
@@ -380,7 +384,27 @@ export function spawnDaemon(
|
||||
}
|
||||
}
|
||||
|
||||
// Unix: standard detached spawn
|
||||
// Unix: Use setsid to create a new session, fully detaching from the
|
||||
// controlling terminal. This prevents SIGHUP from reaching the daemon
|
||||
// even if the in-process SIGHUP handler somehow fails (belt-and-suspenders).
|
||||
// Fall back to standard detached spawn if setsid is not available.
|
||||
const setsidPath = '/usr/bin/setsid';
|
||||
if (existsSync(setsidPath)) {
|
||||
const child = spawn(setsidPath, [process.execPath, scriptPath, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env
|
||||
});
|
||||
|
||||
if (child.pid === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
child.unref();
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
// Fallback: standard detached spawn (macOS, systems without setsid)
|
||||
const child = spawn(process.execPath, [scriptPath, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
@@ -396,6 +420,56 @@ export function spawnDaemon(
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process with the given PID is alive.
|
||||
*
|
||||
* Uses the process.kill(pid, 0) idiom: signal 0 doesn't send a signal,
|
||||
* it just checks if the process exists and is reachable.
|
||||
*
|
||||
* EPERM is treated as "alive" because it means the process exists but
|
||||
* belongs to a different user/session (common in multi-user setups).
|
||||
* PID 0 (Windows WMIC sentinel for unknown PID) is treated as alive.
|
||||
*/
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
// PID 0 is the Windows WMIC sentinel value — process was spawned but PID unknown
|
||||
if (pid === 0) return true;
|
||||
|
||||
// Invalid PIDs are not alive
|
||||
if (!Number.isInteger(pid) || pid < 0) return false;
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
// EPERM = process exists but different user/session — treat as alive
|
||||
if (code === 'EPERM') return true;
|
||||
// ESRCH = no such process — it's dead
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the PID file and remove it if the recorded process is dead (stale).
|
||||
*
|
||||
* This is a cheap operation: one filesystem read + one signal-0 check.
|
||||
* Called at the top of ensureWorkerStarted() to clean up after WSL2
|
||||
* hibernate, OOM kills, or other ungraceful worker deaths.
|
||||
*/
|
||||
export function cleanStalePidFile(): void {
|
||||
const pidInfo = readPidFile();
|
||||
if (!pidInfo) return;
|
||||
|
||||
if (!isProcessAlive(pidInfo.pid)) {
|
||||
logger.info('SYSTEM', 'Removing stale PID file (worker process is dead)', {
|
||||
pid: pidInfo.pid,
|
||||
port: pidInfo.port,
|
||||
startedAt: pidInfo.startedAt
|
||||
});
|
||||
removePidFile();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signal handler factory for graceful shutdown
|
||||
* Returns a handler function that can be passed to process.on('SIGTERM') etc.
|
||||
|
||||
@@ -30,6 +30,19 @@ export interface RouteHandler {
|
||||
setupRoutes(app: Application): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI provider status for health endpoint
|
||||
*/
|
||||
export interface AiStatus {
|
||||
provider: string;
|
||||
authMethod: string;
|
||||
lastInteraction: {
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for initializing the server
|
||||
*/
|
||||
@@ -42,6 +55,10 @@ export interface ServerOptions {
|
||||
onShutdown: () => Promise<void>;
|
||||
/** Restart function for admin endpoints */
|
||||
onRestart: () => Promise<void>;
|
||||
/** Filesystem path to the worker entry point */
|
||||
workerPath: string;
|
||||
/** Callback to get current AI provider status */
|
||||
getAiStatus: () => AiStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,20 +157,20 @@ export class Server {
|
||||
* Setup core system routes (health, readiness, version, admin)
|
||||
*/
|
||||
private setupCoreRoutes(): void {
|
||||
// Test build ID for debugging which build is running
|
||||
const TEST_BUILD_ID = 'TEST-008-wrapper-ipc';
|
||||
|
||||
// Health check endpoint - always responds, even during initialization
|
||||
this.app.get('/api/health', (_req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
build: TEST_BUILD_ID,
|
||||
version: BUILT_IN_VERSION,
|
||||
workerPath: this.options.workerPath,
|
||||
uptime: Date.now() - this.startTime,
|
||||
managed: process.env.CLAUDE_MEM_MANAGED === 'true',
|
||||
hasIpc: typeof process.send === 'function',
|
||||
platform: process.platform,
|
||||
pid: process.pid,
|
||||
initialized: this.options.getInitializationComplete(),
|
||||
mcpReady: this.options.getMcpReady(),
|
||||
ai: this.options.getAiStatus(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* ChromaServerManager - Singleton managing local Chroma HTTP server lifecycle
|
||||
*
|
||||
* Starts a persistent Chroma server via `npx chroma run` at worker startup
|
||||
* and manages its lifecycle. In 'remote' mode, skips server start and connects
|
||||
* to an existing server (future cloud support).
|
||||
*
|
||||
* Cross-platform: Linux, macOS, Windows
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess, execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs, { existsSync } from 'fs';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
export interface ChromaServerConfig {
|
||||
dataDir: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export class ChromaServerManager {
|
||||
private static instance: ChromaServerManager | null = null;
|
||||
private serverProcess: ChildProcess | null = null;
|
||||
private config: ChromaServerConfig;
|
||||
private starting: boolean = false;
|
||||
private ready: boolean = false;
|
||||
private startPromise: Promise<boolean> | null = null;
|
||||
|
||||
private constructor(config: ChromaServerConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton instance
|
||||
*/
|
||||
static getInstance(config?: ChromaServerConfig): ChromaServerManager {
|
||||
if (!ChromaServerManager.instance) {
|
||||
const defaultConfig: ChromaServerConfig = {
|
||||
dataDir: path.join(os.homedir(), '.claude-mem', 'vector-db'),
|
||||
host: '127.0.0.1',
|
||||
port: 8000
|
||||
};
|
||||
ChromaServerManager.instance = new ChromaServerManager(config || defaultConfig);
|
||||
}
|
||||
return ChromaServerManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Chroma HTTP server
|
||||
* Reuses in-flight startup if already starting
|
||||
* Spawns `npx chroma run` as a background process
|
||||
* If a server is already running (from previous worker), reuses it
|
||||
*/
|
||||
async start(timeoutMs: number = 60000): Promise<boolean> {
|
||||
if (this.ready) {
|
||||
logger.debug('CHROMA_SERVER', 'Server already started or starting', {
|
||||
ready: this.ready,
|
||||
starting: this.starting
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.startPromise) {
|
||||
logger.debug('CHROMA_SERVER', 'Awaiting existing startup', {
|
||||
host: this.config.host,
|
||||
port: this.config.port
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
this.starting = true;
|
||||
this.startPromise = this.startInternal(timeoutMs);
|
||||
|
||||
try {
|
||||
return await this.startPromise;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
if (!this.ready) {
|
||||
this.starting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal startup path used behind a single shared startPromise lock
|
||||
*/
|
||||
private async startInternal(timeoutMs: number): Promise<boolean> {
|
||||
// Check if a server is already running (from previous worker or manual start)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://${this.config.host}:${this.config.port}/api/v2/heartbeat`,
|
||||
{ signal: AbortSignal.timeout(3000) }
|
||||
);
|
||||
if (response.ok) {
|
||||
logger.info('CHROMA_SERVER', 'Existing server detected, reusing', {
|
||||
host: this.config.host,
|
||||
port: this.config.port
|
||||
});
|
||||
this.ready = true;
|
||||
this.starting = false;
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// No server running, proceed to start one
|
||||
}
|
||||
|
||||
// Cross-platform: use npx.cmd on Windows
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Resolve chroma binary absolutely — npx fails when spawned from cache dirs (#1120)
|
||||
let command: string;
|
||||
let args: string[];
|
||||
try {
|
||||
// chromadb package installs a 'chroma' bin entry
|
||||
const chromaBinDir = path.dirname(require.resolve('chromadb/package.json'));
|
||||
// Check project-level .bin first (most common npm/bun installation layout)
|
||||
const projectBin = path.join(chromaBinDir, '..', '.bin', isWindows ? 'chroma.cmd' : 'chroma');
|
||||
// Fallback: nested node_modules .bin (rare — pnpm or workspace hoisting)
|
||||
const nestedBin = path.join(chromaBinDir, 'node_modules', '.bin', isWindows ? 'chroma.cmd' : 'chroma');
|
||||
|
||||
if (existsSync(projectBin)) {
|
||||
command = projectBin;
|
||||
} else if (existsSync(nestedBin)) {
|
||||
command = nestedBin;
|
||||
} else {
|
||||
// Last resort: npx with explicit cwd
|
||||
command = isWindows ? 'npx.cmd' : 'npx';
|
||||
}
|
||||
} catch {
|
||||
command = isWindows ? 'npx.cmd' : 'npx';
|
||||
}
|
||||
|
||||
if (command.includes('npx')) {
|
||||
args = ['chroma', 'run', '--path', this.config.dataDir, '--host', this.config.host, '--port', String(this.config.port)];
|
||||
} else {
|
||||
args = ['run', '--path', this.config.dataDir, '--host', this.config.host, '--port', String(this.config.port)];
|
||||
}
|
||||
|
||||
logger.info('CHROMA_SERVER', 'Starting Chroma server', {
|
||||
command,
|
||||
args: args.join(' '),
|
||||
dataDir: this.config.dataDir
|
||||
});
|
||||
|
||||
const spawnEnv = this.getSpawnEnv();
|
||||
|
||||
// Resolve cwd for npx fallback — ensures node_modules is findable (#1120)
|
||||
let spawnCwd: string | undefined;
|
||||
try {
|
||||
spawnCwd = path.dirname(require.resolve('chromadb/package.json'));
|
||||
} catch {
|
||||
// If chromadb isn't resolvable, omit cwd and let npx handle it
|
||||
}
|
||||
|
||||
this.serverProcess = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: !isWindows, // Don't detach on Windows (no process groups)
|
||||
windowsHide: true, // Hide console window on Windows
|
||||
env: spawnEnv,
|
||||
...(spawnCwd && { cwd: spawnCwd })
|
||||
});
|
||||
|
||||
// Log server output for debugging
|
||||
this.serverProcess.stdout?.on('data', (data) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) {
|
||||
logger.debug('CHROMA_SERVER', msg);
|
||||
}
|
||||
});
|
||||
|
||||
this.serverProcess.stderr?.on('data', (data) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) {
|
||||
// Filter out noisy startup messages
|
||||
if (!msg.includes('Chroma') || msg.includes('error') || msg.includes('Error')) {
|
||||
logger.debug('CHROMA_SERVER', msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.serverProcess.on('error', (err) => {
|
||||
logger.error('CHROMA_SERVER', 'Server process error', {}, err);
|
||||
this.ready = false;
|
||||
this.starting = false;
|
||||
});
|
||||
|
||||
this.serverProcess.on('exit', (code, signal) => {
|
||||
logger.info('CHROMA_SERVER', 'Server process exited', { code, signal });
|
||||
this.ready = false;
|
||||
this.starting = false;
|
||||
this.serverProcess = null;
|
||||
});
|
||||
|
||||
return this.waitForReady(timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the server to become ready
|
||||
* Polls the heartbeat endpoint until success or timeout
|
||||
*/
|
||||
async waitForReady(timeoutMs: number = 60000): Promise<boolean> {
|
||||
if (this.ready) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const checkInterval = 500;
|
||||
|
||||
logger.info('CHROMA_SERVER', 'Waiting for server to be ready', {
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
timeoutMs
|
||||
});
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://${this.config.host}:${this.config.port}/api/v2/heartbeat`
|
||||
);
|
||||
if (response.ok) {
|
||||
this.ready = true;
|
||||
this.starting = false;
|
||||
logger.info('CHROMA_SERVER', 'Server ready', {
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
startupTimeMs: Date.now() - startTime
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Server not ready yet, continue polling
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
this.starting = false;
|
||||
logger.error('CHROMA_SERVER', 'Server failed to start within timeout', {
|
||||
timeoutMs,
|
||||
elapsedMs: Date.now() - startTime
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server is running and ready
|
||||
* Returns true if we manage the process OR if a server is responding
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async check if server is running by pinging heartbeat
|
||||
* Use this when you need to verify server is actually reachable
|
||||
*/
|
||||
async isServerReachable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://${this.config.host}:${this.config.port}/api/v2/heartbeat`
|
||||
);
|
||||
if (response.ok) {
|
||||
this.ready = true;
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Server not reachable
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server URL for client connections
|
||||
*/
|
||||
getUrl(): string {
|
||||
return `http://${this.config.host}:${this.config.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server configuration
|
||||
*/
|
||||
getConfig(): ChromaServerConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Chroma server
|
||||
* Gracefully terminates the server process
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.serverProcess) {
|
||||
logger.debug('CHROMA_SERVER', 'No server process to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('CHROMA_SERVER', 'Stopping server', { pid: this.serverProcess.pid });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = this.serverProcess!;
|
||||
const pid = proc.pid;
|
||||
|
||||
const cleanup = () => {
|
||||
this.serverProcess = null;
|
||||
this.ready = false;
|
||||
this.starting = false;
|
||||
this.startPromise = null;
|
||||
logger.info('CHROMA_SERVER', 'Server stopped', { pid });
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Set up exit handler
|
||||
proc.once('exit', cleanup);
|
||||
|
||||
// Cross-platform graceful shutdown
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: just send SIGTERM
|
||||
proc.kill('SIGTERM');
|
||||
} else {
|
||||
// Unix: kill the process group to ensure all children are killed
|
||||
if (pid !== undefined) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch (err) {
|
||||
// Process group kill failed, try direct kill
|
||||
proc.kill('SIGTERM');
|
||||
}
|
||||
} else {
|
||||
proc.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
// Force kill after timeout if still running
|
||||
setTimeout(() => {
|
||||
if (this.serverProcess) {
|
||||
logger.warn('CHROMA_SERVER', 'Force killing server after timeout', { pid });
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create combined SSL certificate bundle for Zscaler/corporate proxy environments.
|
||||
* This ports previous MCP SSL handling so local `npx chroma run` works behind enterprise proxies.
|
||||
*/
|
||||
private getCombinedCertPath(): string | undefined {
|
||||
const combinedCertPath = path.join(os.homedir(), '.claude-mem', 'combined_certs.pem');
|
||||
|
||||
if (fs.existsSync(combinedCertPath)) {
|
||||
const stats = fs.statSync(combinedCertPath);
|
||||
const ageMs = Date.now() - stats.mtimeMs;
|
||||
if (ageMs < 24 * 60 * 60 * 1000) {
|
||||
return combinedCertPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
let certifiPath: string | undefined;
|
||||
try {
|
||||
certifiPath = execSync(
|
||||
'uvx --with certifi python -c "import certifi; print(certifi.where())"',
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 }
|
||||
).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!certifiPath || !fs.existsSync(certifiPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let zscalerCert = '';
|
||||
try {
|
||||
zscalerCert = execSync(
|
||||
'security find-certificate -a -c "Zscaler" -p /Library/Keychains/System.keychain',
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!zscalerCert ||
|
||||
!zscalerCert.includes('-----BEGIN CERTIFICATE-----') ||
|
||||
!zscalerCert.includes('-----END CERTIFICATE-----')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const certifiContent = fs.readFileSync(certifiPath, 'utf8');
|
||||
const tempPath = combinedCertPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, certifiContent + '\n' + zscalerCert);
|
||||
fs.renameSync(tempPath, combinedCertPath);
|
||||
|
||||
logger.info('CHROMA_SERVER', 'Created combined SSL certificate bundle for Zscaler', {
|
||||
path: combinedCertPath
|
||||
});
|
||||
|
||||
return combinedCertPath;
|
||||
} catch (error) {
|
||||
logger.debug('CHROMA_SERVER', 'Could not create combined cert bundle', {}, error as Error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build subprocess env and preserve Zscaler compatibility from previous architecture.
|
||||
*/
|
||||
private getSpawnEnv(): NodeJS.ProcessEnv {
|
||||
const combinedCertPath = this.getCombinedCertPath();
|
||||
if (!combinedCertPath) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
logger.info('CHROMA_SERVER', 'Using combined SSL certificates for enterprise compatibility', {
|
||||
certPath: combinedCertPath
|
||||
});
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
SSL_CERT_FILE: combinedCertPath,
|
||||
REQUESTS_CA_BUNDLE: combinedCertPath,
|
||||
CURL_CA_BUNDLE: combinedCertPath,
|
||||
NODE_EXTRA_CA_CERTS: combinedCertPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for testing)
|
||||
*/
|
||||
static reset(): void {
|
||||
if (ChromaServerManager.instance) {
|
||||
// Don't await - just trigger stop
|
||||
ChromaServerManager.instance.stop().catch(() => {});
|
||||
}
|
||||
ChromaServerManager.instance = null;
|
||||
}
|
||||
}
|
||||
+160
-343
@@ -1,28 +1,26 @@
|
||||
/**
|
||||
* ChromaSync Service
|
||||
*
|
||||
* Automatically syncs observations and session summaries to ChromaDB via MCP.
|
||||
* Automatically syncs observations and session summaries to ChromaDB via HTTP.
|
||||
* This service provides real-time semantic search capabilities by maintaining
|
||||
* a vector database synchronized with SQLite.
|
||||
*
|
||||
* Uses the chromadb npm package's built-in ChromaClient for HTTP connections.
|
||||
* Supports both local server (managed by ChromaServerManager) and remote/cloud
|
||||
* servers for future claude-mem pro features.
|
||||
*
|
||||
* Design: Fail-fast with no fallbacks - if Chroma is unavailable, syncing fails.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { ChromaClient, Collection } from 'chromadb';
|
||||
import { ParsedObservation, ParsedSummary } from '../../sdk/parser.js';
|
||||
import { SessionStore } from '../sqlite/SessionStore.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { ChromaServerManager } from './ChromaServerManager.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// Version injected at build time by esbuild define
|
||||
declare const __DEFAULT_PACKAGE_VERSION__: string;
|
||||
const packageVersion = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev';
|
||||
|
||||
interface ChromaDocument {
|
||||
id: string;
|
||||
@@ -77,19 +75,13 @@ interface StoredUserPrompt {
|
||||
}
|
||||
|
||||
export class ChromaSync {
|
||||
private client: Client | null = null;
|
||||
private transport: StdioClientTransport | null = null;
|
||||
private connected: boolean = false;
|
||||
private chromaClient: ChromaClient | null = null;
|
||||
private collection: Collection | null = null;
|
||||
private project: string;
|
||||
private collectionName: string;
|
||||
private readonly VECTOR_DB_DIR: string;
|
||||
private readonly BATCH_SIZE = 100;
|
||||
|
||||
// Windows popup concern resolved: the worker daemon starts with -WindowStyle Hidden,
|
||||
// so child processes (uvx/chroma-mcp) inherit the hidden console and don't create new windows.
|
||||
// MCP SDK's StdioClientTransport uses shell:false and no detached flag, so console is inherited.
|
||||
private readonly disabled: boolean = false;
|
||||
|
||||
constructor(project: string) {
|
||||
this.project = project;
|
||||
this.collectionName = `cm__${project}`;
|
||||
@@ -97,150 +89,83 @@ export class ChromaSync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create combined SSL certificate bundle for Zscaler/corporate proxy environments
|
||||
* Combines standard certifi certificates with enterprise security certificates (e.g., Zscaler)
|
||||
*/
|
||||
private getCombinedCertPath(): string | undefined {
|
||||
const combinedCertPath = path.join(os.homedir(), '.claude-mem', 'combined_certs.pem');
|
||||
|
||||
// If combined certs already exist and are recent (less than 24 hours old), use them
|
||||
if (fs.existsSync(combinedCertPath)) {
|
||||
const stats = fs.statSync(combinedCertPath);
|
||||
const ageMs = Date.now() - stats.mtimeMs;
|
||||
if (ageMs < 24 * 60 * 60 * 1000) {
|
||||
return combinedCertPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Only create on macOS (Zscaler certificate extraction uses macOS security command)
|
||||
if (process.platform !== 'darwin') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use uvx to resolve the correct certifi path for the exact Python environment it uses
|
||||
// This is more reliable than scanning the uv cache directory structure
|
||||
let certifiPath: string | undefined;
|
||||
try {
|
||||
certifiPath = execSync(
|
||||
'uvx --with certifi python -c "import certifi; print(certifi.where())"',
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 }
|
||||
).trim();
|
||||
} catch {
|
||||
// uvx or certifi not available
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!certifiPath || !fs.existsSync(certifiPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Try to extract Zscaler certificate from macOS keychain
|
||||
let zscalerCert = '';
|
||||
try {
|
||||
zscalerCert = execSync(
|
||||
'security find-certificate -a -c "Zscaler" -p /Library/Keychains/System.keychain',
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
||||
);
|
||||
} catch {
|
||||
// Zscaler not found, which is fine - not all environments have it
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate PEM certificate format (must have both BEGIN and END markers)
|
||||
if (!zscalerCert ||
|
||||
!zscalerCert.includes('-----BEGIN CERTIFICATE-----') ||
|
||||
!zscalerCert.includes('-----END CERTIFICATE-----')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Create combined certificate bundle with atomic write (write to temp, then rename)
|
||||
const certifiContent = fs.readFileSync(certifiPath, 'utf8');
|
||||
const tempPath = combinedCertPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, certifiContent + '\n' + zscalerCert);
|
||||
fs.renameSync(tempPath, combinedCertPath);
|
||||
logger.info('CHROMA_SYNC', 'Created combined SSL certificate bundle for Zscaler', {
|
||||
path: combinedCertPath
|
||||
});
|
||||
|
||||
return combinedCertPath;
|
||||
} catch (error) {
|
||||
logger.debug('CHROMA_SYNC', 'Could not create combined cert bundle', {}, error as Error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Chroma is disabled (Windows)
|
||||
*/
|
||||
isDisabled(): boolean {
|
||||
return this.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure MCP client is connected to Chroma server
|
||||
* Ensure HTTP client is connected to Chroma server
|
||||
* In local mode, verifies ChromaServerManager has started the server
|
||||
* In remote mode, connects directly to configured host
|
||||
* Throws error if connection fails
|
||||
*/
|
||||
private async ensureConnection(): Promise<void> {
|
||||
if (this.connected && this.client) {
|
||||
if (this.chromaClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('CHROMA_SYNC', 'Connecting to Chroma MCP server...', { project: this.project });
|
||||
logger.info('CHROMA_SYNC', 'Connecting to Chroma HTTP server...', { project: this.project });
|
||||
|
||||
try {
|
||||
// Use Python 3.13 by default to avoid onnxruntime compatibility issues with Python 3.14+
|
||||
// See: https://github.com/thedotmack/claude-mem/issues/170 (Python 3.14 incompatibility)
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
const pythonVersion = settings.CLAUDE_MEM_PYTHON_VERSION;
|
||||
const mode = settings.CLAUDE_MEM_CHROMA_MODE || 'local';
|
||||
const host = settings.CLAUDE_MEM_CHROMA_HOST || '127.0.0.1';
|
||||
const port = parseInt(settings.CLAUDE_MEM_CHROMA_PORT || '8000', 10);
|
||||
const ssl = settings.CLAUDE_MEM_CHROMA_SSL === 'true';
|
||||
|
||||
// Get combined SSL certificate bundle for Zscaler/corporate proxy environments
|
||||
const combinedCertPath = this.getCombinedCertPath();
|
||||
// Multi-tenancy settings (used in remote/pro mode)
|
||||
const tenant = settings.CLAUDE_MEM_CHROMA_TENANT || 'default_tenant';
|
||||
const database = settings.CLAUDE_MEM_CHROMA_DATABASE || 'default_database';
|
||||
const apiKey = settings.CLAUDE_MEM_CHROMA_API_KEY || '';
|
||||
|
||||
const transportOptions: any = {
|
||||
command: 'uvx',
|
||||
args: [
|
||||
'--python', pythonVersion,
|
||||
'chroma-mcp',
|
||||
'--client-type', 'persistent',
|
||||
'--data-dir', this.VECTOR_DB_DIR
|
||||
],
|
||||
stderr: 'ignore'
|
||||
// In local mode, verify server is reachable
|
||||
if (mode === 'local') {
|
||||
const serverManager = ChromaServerManager.getInstance();
|
||||
const reachable = await serverManager.isServerReachable();
|
||||
if (!reachable) {
|
||||
throw new Error('Chroma server not reachable. Ensure worker started correctly.');
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
const protocol = ssl ? 'https' : 'http';
|
||||
const chromaPath = `${protocol}://${host}:${port}`;
|
||||
|
||||
// Build client options
|
||||
const clientOptions: { path: string; tenant?: string; database?: string; headers?: Record<string, string> } = {
|
||||
path: chromaPath
|
||||
};
|
||||
|
||||
// Add SSL certificate environment variables for corporate proxy/Zscaler environments
|
||||
if (combinedCertPath) {
|
||||
transportOptions.env = {
|
||||
...process.env,
|
||||
SSL_CERT_FILE: combinedCertPath,
|
||||
REQUESTS_CA_BUNDLE: combinedCertPath,
|
||||
CURL_CA_BUNDLE: combinedCertPath
|
||||
};
|
||||
logger.info('CHROMA_SYNC', 'Using combined SSL certificates for Zscaler compatibility', {
|
||||
certPath: combinedCertPath
|
||||
// In remote mode, use tenant isolation for pro users
|
||||
if (mode === 'remote') {
|
||||
clientOptions.tenant = tenant;
|
||||
clientOptions.database = database;
|
||||
|
||||
// Add API key header if configured
|
||||
if (apiKey) {
|
||||
clientOptions.headers = {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('CHROMA_SYNC', 'Connecting with tenant isolation', {
|
||||
tenant,
|
||||
database,
|
||||
hasApiKey: !!apiKey
|
||||
});
|
||||
}
|
||||
|
||||
// Note: windowsHide is not needed here because the worker daemon starts with
|
||||
// -WindowStyle Hidden, so child processes inherit the hidden console.
|
||||
// The MCP SDK ignores custom windowsHide anyway (overridden internally).
|
||||
this.chromaClient = new ChromaClient(clientOptions);
|
||||
|
||||
this.transport = new StdioClientTransport(transportOptions);
|
||||
// Verify connection with heartbeat
|
||||
await this.chromaClient.heartbeat();
|
||||
|
||||
// Empty capabilities object: this client only calls Chroma tools, doesn't expose any
|
||||
this.client = new Client({
|
||||
name: 'claude-mem-chroma-sync',
|
||||
version: packageVersion
|
||||
}, {
|
||||
capabilities: {}
|
||||
logger.info('CHROMA_SYNC', 'Connected to Chroma HTTP server', {
|
||||
project: this.project,
|
||||
host,
|
||||
port,
|
||||
ssl,
|
||||
mode,
|
||||
tenant: mode === 'remote' ? tenant : 'default_tenant'
|
||||
});
|
||||
|
||||
await this.client.connect(this.transport);
|
||||
this.connected = true;
|
||||
|
||||
logger.info('CHROMA_SYNC', 'Connected to Chroma MCP server', { project: this.project });
|
||||
} catch (error) {
|
||||
logger.error('CHROMA_SYNC', 'Failed to connect to Chroma MCP server', { project: this.project }, error as Error);
|
||||
logger.error('CHROMA_SYNC', 'Failed to connect to Chroma HTTP server', { project: this.project }, error as Error);
|
||||
this.chromaClient = null;
|
||||
throw new Error(`Chroma connection failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +177,11 @@ export class ChromaSync {
|
||||
private async ensureCollection(): Promise<void> {
|
||||
await this.ensureConnection();
|
||||
|
||||
if (!this.client) {
|
||||
if (this.collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.chromaClient) {
|
||||
throw new Error(
|
||||
'Chroma client not initialized. Call ensureConnection() before using client methods.' +
|
||||
` Project: ${this.project}`
|
||||
@@ -260,60 +189,23 @@ export class ChromaSync {
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get collection info (will fail if doesn't exist)
|
||||
await this.client.callTool({
|
||||
name: 'chroma_get_collection_info',
|
||||
arguments: {
|
||||
collection_name: this.collectionName
|
||||
}
|
||||
// Use WASM backend to avoid native ONNX binary issues (#1104, #1105, #1110).
|
||||
// Same model (all-MiniLM-L6-v2), same embeddings, but runs in WASM —
|
||||
// no native binary loading, no segfaults, no ENOENT errors.
|
||||
const { DefaultEmbeddingFunction } = await import('@chroma-core/default-embed');
|
||||
const embeddingFunction = new DefaultEmbeddingFunction({ wasm: true });
|
||||
|
||||
this.collection = await this.chromaClient.getOrCreateCollection({
|
||||
name: this.collectionName,
|
||||
embeddingFunction
|
||||
});
|
||||
|
||||
logger.debug('CHROMA_SYNC', 'Collection exists', { collection: this.collectionName });
|
||||
logger.debug('CHROMA_SYNC', 'Collection ready', {
|
||||
collection: this.collectionName
|
||||
});
|
||||
} catch (error) {
|
||||
// Check if this is a connection error - don't try to create collection
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isConnectionError =
|
||||
errorMessage.includes('Not connected') ||
|
||||
errorMessage.includes('Connection closed') ||
|
||||
errorMessage.includes('MCP error -32000');
|
||||
|
||||
if (isConnectionError) {
|
||||
// FIX: Close transport to kill subprocess before resetting state
|
||||
// Without this, old chroma-mcp processes leak as zombies
|
||||
if (this.transport) {
|
||||
try {
|
||||
await this.transport.close();
|
||||
} catch (closeErr) {
|
||||
logger.debug('CHROMA_SYNC', 'Transport close error (expected if already dead)', {}, closeErr as Error);
|
||||
}
|
||||
}
|
||||
// Reset connection state so next call attempts reconnect
|
||||
this.connected = false;
|
||||
this.client = null;
|
||||
this.transport = null;
|
||||
logger.error('CHROMA_SYNC', 'Connection lost during collection check',
|
||||
{ collection: this.collectionName }, error as Error);
|
||||
throw new Error(`Chroma connection lost: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Only attempt creation if it's genuinely a "collection not found" error
|
||||
logger.error('CHROMA_SYNC', 'Collection check failed, attempting to create', { collection: this.collectionName }, error as Error);
|
||||
logger.info('CHROMA_SYNC', 'Creating collection', { collection: this.collectionName });
|
||||
|
||||
try {
|
||||
await this.client.callTool({
|
||||
name: 'chroma_create_collection',
|
||||
arguments: {
|
||||
collection_name: this.collectionName,
|
||||
embedding_function_name: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('CHROMA_SYNC', 'Collection created', { collection: this.collectionName });
|
||||
} catch (createError) {
|
||||
logger.error('CHROMA_SYNC', 'Failed to create collection', { collection: this.collectionName }, createError as Error);
|
||||
throw new Error(`Collection creation failed: ${createError instanceof Error ? createError.message : String(createError)}`);
|
||||
}
|
||||
logger.error('CHROMA_SYNC', 'Failed to get/create collection', { collection: this.collectionName }, error as Error);
|
||||
throw new Error(`Collection setup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,22 +355,18 @@ export class ChromaSync {
|
||||
|
||||
await this.ensureCollection();
|
||||
|
||||
if (!this.client) {
|
||||
if (!this.collection) {
|
||||
throw new Error(
|
||||
'Chroma client not initialized. Call ensureConnection() before using client methods.' +
|
||||
'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
|
||||
` Project: ${this.project}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.callTool({
|
||||
name: 'chroma_add_documents',
|
||||
arguments: {
|
||||
collection_name: this.collectionName,
|
||||
documents: documents.map(d => d.document),
|
||||
ids: documents.map(d => d.id),
|
||||
metadatas: documents.map(d => d.metadata)
|
||||
}
|
||||
await this.collection.add({
|
||||
ids: documents.map(d => d.id),
|
||||
documents: documents.map(d => d.document),
|
||||
metadatas: documents.map(d => d.metadata)
|
||||
});
|
||||
|
||||
logger.debug('CHROMA_SYNC', 'Documents added', {
|
||||
@@ -497,7 +385,6 @@ export class ChromaSync {
|
||||
/**
|
||||
* Sync a single observation to Chroma
|
||||
* Blocks until sync completes, throws on error
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async syncObservation(
|
||||
observationId: number,
|
||||
@@ -508,8 +395,6 @@ export class ChromaSync {
|
||||
createdAtEpoch: number,
|
||||
discoveryTokens: number = 0
|
||||
): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
// Convert ParsedObservation to StoredObservation format
|
||||
const stored: StoredObservation = {
|
||||
id: observationId,
|
||||
@@ -544,7 +429,6 @@ export class ChromaSync {
|
||||
/**
|
||||
* Sync a single summary to Chroma
|
||||
* Blocks until sync completes, throws on error
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async syncSummary(
|
||||
summaryId: number,
|
||||
@@ -555,8 +439,6 @@ export class ChromaSync {
|
||||
createdAtEpoch: number,
|
||||
discoveryTokens: number = 0
|
||||
): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
// Convert ParsedSummary to StoredSummary format
|
||||
const stored: StoredSummary = {
|
||||
id: summaryId,
|
||||
@@ -607,7 +489,6 @@ export class ChromaSync {
|
||||
/**
|
||||
* Sync a single user prompt to Chroma
|
||||
* Blocks until sync completes, throws on error
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async syncUserPrompt(
|
||||
promptId: number,
|
||||
@@ -617,8 +498,6 @@ export class ChromaSync {
|
||||
promptNumber: number,
|
||||
createdAtEpoch: number
|
||||
): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
// Create StoredUserPrompt format
|
||||
const stored: StoredUserPrompt = {
|
||||
id: promptId,
|
||||
@@ -650,11 +529,11 @@ export class ChromaSync {
|
||||
summaries: Set<number>;
|
||||
prompts: Set<number>;
|
||||
}> {
|
||||
await this.ensureConnection();
|
||||
await this.ensureCollection();
|
||||
|
||||
if (!this.client) {
|
||||
if (!this.collection) {
|
||||
throw new Error(
|
||||
'Chroma client not initialized. Call ensureConnection() before using client methods.' +
|
||||
'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
|
||||
` Project: ${this.project}`
|
||||
);
|
||||
}
|
||||
@@ -670,24 +549,14 @@ export class ChromaSync {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await this.client.callTool({
|
||||
name: 'chroma_get_documents',
|
||||
arguments: {
|
||||
collection_name: this.collectionName,
|
||||
limit,
|
||||
offset,
|
||||
where: { project: this.project }, // Filter by project
|
||||
include: ['metadatas']
|
||||
}
|
||||
const result = await this.collection.get({
|
||||
limit,
|
||||
offset,
|
||||
where: { project: this.project },
|
||||
include: ['metadatas']
|
||||
});
|
||||
|
||||
const data = result.content[0];
|
||||
if (data.type !== 'text') {
|
||||
throw new Error('Unexpected response type from chroma_get_documents');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(data.text);
|
||||
const metadatas = parsed.metadatas || [];
|
||||
const metadatas = result.metadatas || [];
|
||||
|
||||
if (metadatas.length === 0) {
|
||||
break; // No more documents
|
||||
@@ -695,13 +564,14 @@ export class ChromaSync {
|
||||
|
||||
// Extract SQLite IDs from metadata
|
||||
for (const meta of metadatas) {
|
||||
if (meta.sqlite_id) {
|
||||
if (meta && meta.sqlite_id) {
|
||||
const sqliteId = meta.sqlite_id as number;
|
||||
if (meta.doc_type === 'observation') {
|
||||
observationIds.add(meta.sqlite_id);
|
||||
observationIds.add(sqliteId);
|
||||
} else if (meta.doc_type === 'session_summary') {
|
||||
summaryIds.add(meta.sqlite_id);
|
||||
summaryIds.add(sqliteId);
|
||||
} else if (meta.doc_type === 'user_prompt') {
|
||||
promptIds.add(meta.sqlite_id);
|
||||
promptIds.add(sqliteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -733,11 +603,8 @@ export class ChromaSync {
|
||||
* Backfill: Sync all observations missing from Chroma
|
||||
* Reads from SQLite and syncs in batches
|
||||
* Throws error if backfill fails
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async ensureBackfilled(): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
logger.info('CHROMA_SYNC', 'Starting smart backfill', { project: this.project });
|
||||
|
||||
await this.ensureCollection();
|
||||
@@ -904,141 +771,91 @@ export class ChromaSync {
|
||||
/**
|
||||
* Query Chroma collection for semantic search
|
||||
* Used by SearchManager for vector-based search
|
||||
* Returns empty results on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async queryChroma(
|
||||
query: string,
|
||||
limit: number,
|
||||
whereFilter?: Record<string, any>
|
||||
): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> {
|
||||
if (this.disabled) {
|
||||
return { ids: [], distances: [], metadatas: [] };
|
||||
}
|
||||
await this.ensureCollection();
|
||||
|
||||
await this.ensureConnection();
|
||||
|
||||
if (!this.client) {
|
||||
if (!this.collection) {
|
||||
throw new Error(
|
||||
'Chroma client not initialized. Call ensureConnection() before using client methods.' +
|
||||
'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
|
||||
` Project: ${this.project}`
|
||||
);
|
||||
}
|
||||
|
||||
const whereStringified = whereFilter ? JSON.stringify(whereFilter) : undefined;
|
||||
|
||||
const arguments_obj = {
|
||||
collection_name: this.collectionName,
|
||||
query_texts: [query],
|
||||
n_results: limit,
|
||||
include: ['documents', 'metadatas', 'distances'],
|
||||
where: whereStringified
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await this.client.callTool({
|
||||
name: 'chroma_query_documents',
|
||||
arguments: arguments_obj
|
||||
const results = await this.collection.query({
|
||||
queryTexts: [query],
|
||||
nResults: limit,
|
||||
where: whereFilter,
|
||||
include: ['documents', 'metadatas', 'distances']
|
||||
});
|
||||
|
||||
// Extract unique SQLite IDs from document IDs
|
||||
const ids: number[] = [];
|
||||
const docIds = results.ids?.[0] || [];
|
||||
for (const docId of docIds) {
|
||||
// Extract sqlite_id from document ID (supports three formats):
|
||||
// - obs_{id}_narrative, obs_{id}_fact_0, etc (observations)
|
||||
// - summary_{id}_request, summary_{id}_learned, etc (session summaries)
|
||||
// - prompt_{id} (user prompts)
|
||||
const obsMatch = docId.match(/obs_(\d+)_/);
|
||||
const summaryMatch = docId.match(/summary_(\d+)_/);
|
||||
const promptMatch = docId.match(/prompt_(\d+)/);
|
||||
|
||||
let sqliteId: number | null = null;
|
||||
if (obsMatch) {
|
||||
sqliteId = parseInt(obsMatch[1], 10);
|
||||
} else if (summaryMatch) {
|
||||
sqliteId = parseInt(summaryMatch[1], 10);
|
||||
} else if (promptMatch) {
|
||||
sqliteId = parseInt(promptMatch[1], 10);
|
||||
}
|
||||
|
||||
if (sqliteId !== null && !ids.includes(sqliteId)) {
|
||||
ids.push(sqliteId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ids,
|
||||
distances: results.distances?.[0] || [],
|
||||
metadatas: results.metadatas?.[0] || []
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check for connection errors
|
||||
const isConnectionError =
|
||||
errorMessage.includes('Not connected') ||
|
||||
errorMessage.includes('Connection closed') ||
|
||||
errorMessage.includes('MCP error -32000');
|
||||
errorMessage.includes('ECONNREFUSED') ||
|
||||
errorMessage.includes('ENOTFOUND') ||
|
||||
errorMessage.includes('fetch failed');
|
||||
|
||||
if (isConnectionError) {
|
||||
// FIX: Close transport to kill subprocess before resetting state
|
||||
if (this.transport) {
|
||||
try {
|
||||
await this.transport.close();
|
||||
} catch (closeErr) {
|
||||
logger.debug('CHROMA_SYNC', 'Transport close error (expected if already dead)', {}, closeErr as Error);
|
||||
}
|
||||
}
|
||||
// Reset connection state so next call attempts reconnect
|
||||
this.connected = false;
|
||||
this.client = null;
|
||||
this.transport = null;
|
||||
this.chromaClient = null;
|
||||
this.collection = null;
|
||||
logger.error('CHROMA_SYNC', 'Connection lost during query',
|
||||
{ project: this.project, query }, error as Error);
|
||||
throw new Error(`Chroma query failed - connection lost: ${errorMessage}`);
|
||||
}
|
||||
|
||||
logger.error('CHROMA_SYNC', 'Query failed', { project: this.project, query }, error as Error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resultText = result.content[0]?.text || (() => {
|
||||
logger.error('CHROMA', 'Missing text in MCP chroma_query_documents result', {
|
||||
project: this.project,
|
||||
query_text: query
|
||||
});
|
||||
return '';
|
||||
})();
|
||||
|
||||
// Parse JSON response
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(resultText);
|
||||
} catch (error) {
|
||||
logger.error('CHROMA_SYNC', 'Failed to parse Chroma response', { project: this.project }, error as Error);
|
||||
return { ids: [], distances: [], metadatas: [] };
|
||||
}
|
||||
|
||||
// Extract unique IDs from document IDs
|
||||
const ids: number[] = [];
|
||||
const docIds = parsed.ids?.[0] || [];
|
||||
for (const docId of docIds) {
|
||||
// Extract sqlite_id from document ID (supports three formats):
|
||||
// - obs_{id}_narrative, obs_{id}_fact_0, etc (observations)
|
||||
// - summary_{id}_request, summary_{id}_learned, etc (session summaries)
|
||||
// - prompt_{id} (user prompts)
|
||||
const obsMatch = docId.match(/obs_(\d+)_/);
|
||||
const summaryMatch = docId.match(/summary_(\d+)_/);
|
||||
const promptMatch = docId.match(/prompt_(\d+)/);
|
||||
|
||||
let sqliteId: number | null = null;
|
||||
if (obsMatch) {
|
||||
sqliteId = parseInt(obsMatch[1], 10);
|
||||
} else if (summaryMatch) {
|
||||
sqliteId = parseInt(summaryMatch[1], 10);
|
||||
} else if (promptMatch) {
|
||||
sqliteId = parseInt(promptMatch[1], 10);
|
||||
}
|
||||
|
||||
if (sqliteId !== null && !ids.includes(sqliteId)) {
|
||||
ids.push(sqliteId);
|
||||
}
|
||||
}
|
||||
|
||||
const distances = parsed.distances?.[0] || [];
|
||||
const metadatas = parsed.metadatas?.[0] || [];
|
||||
|
||||
return { ids, distances, metadatas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the Chroma client connection and cleanup subprocess
|
||||
* Close the Chroma client connection
|
||||
* Server lifecycle is managed by ChromaServerManager, not here
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.connected && !this.client && !this.transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close client first
|
||||
if (this.client) {
|
||||
await this.client.close();
|
||||
}
|
||||
|
||||
// Explicitly close transport to kill subprocess
|
||||
if (this.transport) {
|
||||
await this.transport.close();
|
||||
}
|
||||
|
||||
logger.info('CHROMA_SYNC', 'Chroma client and subprocess closed', { project: this.project });
|
||||
|
||||
// Always reset state
|
||||
this.connected = false;
|
||||
this.client = null;
|
||||
this.transport = null;
|
||||
// Just clear references - server lifecycle managed by ChromaServerManager
|
||||
this.chromaClient = null;
|
||||
this.collection = null;
|
||||
logger.info('CHROMA_SYNC', 'Chroma client closed', { project: this.project });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './config.js';
|
||||
import { TranscriptWatcher } from './watcher.js';
|
||||
|
||||
function getArgValue(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
return args[index + 1] ?? null;
|
||||
}
|
||||
|
||||
export async function runTranscriptCommand(subcommand: string | undefined, args: string[]): Promise<number> {
|
||||
switch (subcommand) {
|
||||
case 'init': {
|
||||
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||
writeSampleConfig(configPath);
|
||||
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||
return 0;
|
||||
}
|
||||
case 'watch': {
|
||||
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||
let config;
|
||||
try {
|
||||
config = loadTranscriptWatchConfig(configPath);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
writeSampleConfig(configPath);
|
||||
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||
config = loadTranscriptWatchConfig(configPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const statePath = expandHomePath(config.stateFile ?? DEFAULT_STATE_PATH);
|
||||
const watcher = new TranscriptWatcher(config, statePath);
|
||||
await watcher.start();
|
||||
console.log('Transcript watcher running. Press Ctrl+C to stop.');
|
||||
|
||||
const shutdown = () => {
|
||||
watcher.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
return await new Promise(() => undefined);
|
||||
}
|
||||
case 'validate': {
|
||||
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||
try {
|
||||
loadTranscriptWatchConfig(configPath);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
writeSampleConfig(configPath);
|
||||
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||
loadTranscriptWatchConfig(configPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log(`Config OK: ${expandHomePath(configPath)}`);
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
console.log('Usage: claude-mem transcript <init|watch|validate> [--config <path>]');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join, dirname } from 'path';
|
||||
import type { TranscriptSchema, TranscriptWatchConfig } from './types.js';
|
||||
|
||||
export const DEFAULT_CONFIG_PATH = join(homedir(), '.claude-mem', 'transcript-watch.json');
|
||||
export const DEFAULT_STATE_PATH = join(homedir(), '.claude-mem', 'transcript-watch-state.json');
|
||||
|
||||
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
||||
name: 'codex',
|
||||
version: '0.2',
|
||||
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
||||
events: [
|
||||
{
|
||||
name: 'session-meta',
|
||||
match: { path: 'type', equals: 'session_meta' },
|
||||
action: 'session_context',
|
||||
fields: {
|
||||
sessionId: 'payload.id',
|
||||
cwd: 'payload.cwd'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'turn-context',
|
||||
match: { path: 'type', equals: 'turn_context' },
|
||||
action: 'session_context',
|
||||
fields: {
|
||||
cwd: 'payload.cwd'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user-message',
|
||||
match: { path: 'payload.type', equals: 'user_message' },
|
||||
action: 'session_init',
|
||||
fields: {
|
||||
prompt: 'payload.message'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'assistant-message',
|
||||
match: { path: 'payload.type', equals: 'agent_message' },
|
||||
action: 'assistant_message',
|
||||
fields: {
|
||||
message: 'payload.message'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tool-use',
|
||||
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call'] },
|
||||
action: 'tool_use',
|
||||
fields: {
|
||||
toolId: 'payload.call_id',
|
||||
toolName: {
|
||||
coalesce: [
|
||||
'payload.name',
|
||||
{ value: 'web_search' }
|
||||
]
|
||||
},
|
||||
toolInput: {
|
||||
coalesce: [
|
||||
'payload.arguments',
|
||||
'payload.input',
|
||||
'payload.action'
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tool-result',
|
||||
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output'] },
|
||||
action: 'tool_result',
|
||||
fields: {
|
||||
toolId: 'payload.call_id',
|
||||
toolResponse: 'payload.output'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'session-end',
|
||||
match: { path: 'payload.type', equals: 'turn_aborted' },
|
||||
action: 'session_end'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const SAMPLE_CONFIG: TranscriptWatchConfig = {
|
||||
version: 1,
|
||||
schemas: {
|
||||
codex: CODEX_SAMPLE_SCHEMA
|
||||
},
|
||||
watches: [
|
||||
{
|
||||
name: 'codex',
|
||||
path: '~/.codex/sessions/**/*.jsonl',
|
||||
schema: 'codex',
|
||||
startAtEnd: true,
|
||||
context: {
|
||||
mode: 'agents',
|
||||
path: '~/.codex/AGENTS.md',
|
||||
updateOn: ['session_start', 'session_end']
|
||||
}
|
||||
}
|
||||
],
|
||||
stateFile: DEFAULT_STATE_PATH
|
||||
};
|
||||
|
||||
export function expandHomePath(inputPath: string): string {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath.startsWith('~')) {
|
||||
return join(homedir(), inputPath.slice(1));
|
||||
}
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
export function loadTranscriptWatchConfig(path = DEFAULT_CONFIG_PATH): TranscriptWatchConfig {
|
||||
const resolvedPath = expandHomePath(path);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new Error(`Transcript watch config not found: ${resolvedPath}`);
|
||||
}
|
||||
const raw = readFileSync(resolvedPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
|
||||
if (!parsed.version || !parsed.watches) {
|
||||
throw new Error(`Invalid transcript watch config: ${resolvedPath}`);
|
||||
}
|
||||
if (!parsed.stateFile) {
|
||||
parsed.stateFile = DEFAULT_STATE_PATH;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function writeSampleConfig(path = DEFAULT_CONFIG_PATH): void {
|
||||
const resolvedPath = expandHomePath(path);
|
||||
const dir = dirname(resolvedPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(resolvedPath, JSON.stringify(SAMPLE_CONFIG, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { FieldSpec, MatchRule, TranscriptSchema, WatchTarget } from './types.js';
|
||||
|
||||
interface ResolveContext {
|
||||
watch: WatchTarget;
|
||||
schema: TranscriptSchema;
|
||||
session?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parsePath(path: string): Array<string | number> {
|
||||
const cleaned = path.trim().replace(/^\$\.?/, '');
|
||||
if (!cleaned) return [];
|
||||
|
||||
const tokens: Array<string | number> = [];
|
||||
const parts = cleaned.split('.');
|
||||
|
||||
for (const part of parts) {
|
||||
const regex = /([^[\]]+)|\[(\d+)\]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(part)) !== null) {
|
||||
if (match[1]) {
|
||||
tokens.push(match[1]);
|
||||
} else if (match[2]) {
|
||||
tokens.push(parseInt(match[2], 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function getValueByPath(input: unknown, path: string): unknown {
|
||||
if (!path) return undefined;
|
||||
const tokens = parsePath(path);
|
||||
let current: any = input;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[token as any];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function isEmptyValue(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
function resolveFromContext(path: string, ctx: ResolveContext): unknown {
|
||||
if (path.startsWith('$watch.')) {
|
||||
const key = path.slice('$watch.'.length);
|
||||
return (ctx.watch as any)[key];
|
||||
}
|
||||
if (path.startsWith('$schema.')) {
|
||||
const key = path.slice('$schema.'.length);
|
||||
return (ctx.schema as any)[key];
|
||||
}
|
||||
if (path.startsWith('$session.')) {
|
||||
const key = path.slice('$session.'.length);
|
||||
return ctx.session ? (ctx.session as any)[key] : undefined;
|
||||
}
|
||||
if (path === '$cwd') return ctx.watch.workspace;
|
||||
if (path === '$project') return ctx.watch.project;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveFieldSpec(
|
||||
spec: FieldSpec | undefined,
|
||||
entry: unknown,
|
||||
ctx: ResolveContext
|
||||
): unknown {
|
||||
if (spec === undefined) return undefined;
|
||||
|
||||
if (typeof spec === 'string') {
|
||||
const fromContext = resolveFromContext(spec, ctx);
|
||||
if (fromContext !== undefined) return fromContext;
|
||||
return getValueByPath(entry, spec);
|
||||
}
|
||||
|
||||
if (spec.coalesce && Array.isArray(spec.coalesce)) {
|
||||
for (const candidate of spec.coalesce) {
|
||||
const value = resolveFieldSpec(candidate, entry, ctx);
|
||||
if (!isEmptyValue(value)) return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.path) {
|
||||
const fromContext = resolveFromContext(spec.path, ctx);
|
||||
if (fromContext !== undefined) return fromContext;
|
||||
const value = getValueByPath(entry, spec.path);
|
||||
if (!isEmptyValue(value)) return value;
|
||||
}
|
||||
|
||||
if (spec.value !== undefined) return spec.value;
|
||||
|
||||
if (spec.default !== undefined) return spec.default;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveFields(
|
||||
fields: Record<string, FieldSpec> | undefined,
|
||||
entry: unknown,
|
||||
ctx: ResolveContext
|
||||
): Record<string, unknown> {
|
||||
const resolved: Record<string, unknown> = {};
|
||||
if (!fields) return resolved;
|
||||
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
resolved[key] = resolveFieldSpec(spec, entry, ctx);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function matchesRule(
|
||||
entry: unknown,
|
||||
rule: MatchRule | undefined,
|
||||
schema: TranscriptSchema
|
||||
): boolean {
|
||||
if (!rule) return true;
|
||||
|
||||
const path = rule.path || schema.eventTypePath || 'type';
|
||||
const value = path ? getValueByPath(entry, path) : undefined;
|
||||
|
||||
if (rule.exists) {
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
}
|
||||
|
||||
if (rule.equals !== undefined) {
|
||||
return value === rule.equals;
|
||||
}
|
||||
|
||||
if (rule.in && Array.isArray(rule.in)) {
|
||||
return rule.in.includes(value);
|
||||
}
|
||||
|
||||
if (rule.contains !== undefined) {
|
||||
return typeof value === 'string' && value.includes(rule.contains);
|
||||
}
|
||||
|
||||
if (rule.regex) {
|
||||
try {
|
||||
const regex = new RegExp(rule.regex);
|
||||
return regex.test(String(value ?? ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { sessionInitHandler } from '../../cli/handlers/session-init.js';
|
||||
import { observationHandler } from '../../cli/handlers/observation.js';
|
||||
import { fileEditHandler } from '../../cli/handlers/file-edit.js';
|
||||
import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getProjectContext, getProjectName } from '../../utils/project-name.js';
|
||||
import { writeAgentsMd } from '../../utils/agents-md-utils.js';
|
||||
import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js';
|
||||
import { expandHomePath } from './config.js';
|
||||
import type { TranscriptSchema, WatchTarget, SchemaEvent } from './types.js';
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
project?: string;
|
||||
lastUserMessage?: string;
|
||||
lastAssistantMessage?: string;
|
||||
pendingTools: Map<string, { name?: string; input?: unknown }>;
|
||||
}
|
||||
|
||||
interface PendingTool {
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export class TranscriptEventProcessor {
|
||||
private sessions = new Map<string, SessionState>();
|
||||
|
||||
async processEntry(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
sessionIdOverride?: string | null
|
||||
): Promise<void> {
|
||||
for (const event of schema.events) {
|
||||
if (!matchesRule(entry, event.match, schema)) continue;
|
||||
await this.handleEvent(entry, watch, schema, event, sessionIdOverride ?? undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionKey(watch: WatchTarget, sessionId: string): string {
|
||||
return `${watch.name}:${sessionId}`;
|
||||
}
|
||||
|
||||
private getOrCreateSession(watch: WatchTarget, sessionId: string): SessionState {
|
||||
const key = this.getSessionKey(watch, sessionId);
|
||||
let session = this.sessions.get(key);
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId,
|
||||
pendingTools: new Map()
|
||||
};
|
||||
this.sessions.set(key, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private resolveSessionId(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
sessionIdOverride?: string
|
||||
): string | null {
|
||||
const ctx = { watch, schema } as any;
|
||||
const fieldSpec = event.fields?.sessionId ?? (schema.sessionIdPath ? { path: schema.sessionIdPath } : undefined);
|
||||
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||
if (typeof resolved === 'number') return String(resolved);
|
||||
if (sessionIdOverride && sessionIdOverride.trim()) return sessionIdOverride;
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveCwd(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
session: SessionState
|
||||
): string | undefined {
|
||||
const ctx = { watch, schema, session } as any;
|
||||
const fieldSpec = event.fields?.cwd ?? (schema.cwdPath ? { path: schema.cwdPath } : undefined);
|
||||
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||
if (watch.workspace) return watch.workspace;
|
||||
return session.cwd;
|
||||
}
|
||||
|
||||
private resolveProject(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
session: SessionState
|
||||
): string | undefined {
|
||||
const ctx = { watch, schema, session } as any;
|
||||
const fieldSpec = event.fields?.project ?? (schema.projectPath ? { path: schema.projectPath } : undefined);
|
||||
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||
if (watch.project) return watch.project;
|
||||
if (session.cwd) return getProjectName(session.cwd);
|
||||
return session.project;
|
||||
}
|
||||
|
||||
private async handleEvent(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
sessionIdOverride?: string
|
||||
): Promise<void> {
|
||||
const sessionId = this.resolveSessionId(entry, watch, schema, event, sessionIdOverride);
|
||||
if (!sessionId) {
|
||||
logger.debug('TRANSCRIPT', 'Skipping event without sessionId', { event: event.name, watch: watch.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.getOrCreateSession(watch, sessionId);
|
||||
const cwd = this.resolveCwd(entry, watch, schema, event, session);
|
||||
if (cwd) session.cwd = cwd;
|
||||
const project = this.resolveProject(entry, watch, schema, event, session);
|
||||
if (project) session.project = project;
|
||||
|
||||
const fields = resolveFields(event.fields, entry, { watch, schema, session });
|
||||
|
||||
switch (event.action) {
|
||||
case 'session_context':
|
||||
this.applySessionContext(session, fields);
|
||||
break;
|
||||
case 'session_init':
|
||||
await this.handleSessionInit(session, fields);
|
||||
if (watch.context?.updateOn?.includes('session_start')) {
|
||||
await this.updateContext(session, watch);
|
||||
}
|
||||
break;
|
||||
case 'user_message':
|
||||
if (typeof fields.message === 'string') session.lastUserMessage = fields.message;
|
||||
if (typeof fields.prompt === 'string') session.lastUserMessage = fields.prompt;
|
||||
break;
|
||||
case 'assistant_message':
|
||||
if (typeof fields.message === 'string') session.lastAssistantMessage = fields.message;
|
||||
break;
|
||||
case 'tool_use':
|
||||
await this.handleToolUse(session, fields);
|
||||
break;
|
||||
case 'tool_result':
|
||||
await this.handleToolResult(session, fields);
|
||||
break;
|
||||
case 'observation':
|
||||
await this.sendObservation(session, fields);
|
||||
break;
|
||||
case 'file_edit':
|
||||
await this.sendFileEdit(session, fields);
|
||||
break;
|
||||
case 'session_end':
|
||||
await this.handleSessionEnd(session, watch);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private applySessionContext(session: SessionState, fields: Record<string, unknown>): void {
|
||||
const cwd = typeof fields.cwd === 'string' ? fields.cwd : undefined;
|
||||
const project = typeof fields.project === 'string' ? fields.project : undefined;
|
||||
if (cwd) session.cwd = cwd;
|
||||
if (project) session.project = project;
|
||||
}
|
||||
|
||||
private async handleSessionInit(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const prompt = typeof fields.prompt === 'string' ? fields.prompt : '';
|
||||
const cwd = session.cwd ?? process.cwd();
|
||||
if (prompt) {
|
||||
session.lastUserMessage = prompt;
|
||||
}
|
||||
|
||||
await sessionInitHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd,
|
||||
prompt,
|
||||
platform: 'transcript'
|
||||
});
|
||||
}
|
||||
|
||||
private async handleToolUse(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const toolId = typeof fields.toolId === 'string' ? fields.toolId : undefined;
|
||||
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||
const toolInput = this.maybeParseJson(fields.toolInput);
|
||||
const toolResponse = this.maybeParseJson(fields.toolResponse);
|
||||
|
||||
const pending: PendingTool = { id: toolId, name: toolName, input: toolInput, response: toolResponse };
|
||||
|
||||
if (toolId) {
|
||||
session.pendingTools.set(toolId, { name: pending.name, input: pending.input });
|
||||
}
|
||||
|
||||
if (toolName === 'apply_patch' && typeof toolInput === 'string') {
|
||||
const files = this.parseApplyPatchFiles(toolInput);
|
||||
for (const filePath of files) {
|
||||
await this.sendFileEdit(session, {
|
||||
filePath,
|
||||
edits: [{ type: 'apply_patch', patch: toolInput }]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toolResponse !== undefined && toolName) {
|
||||
await this.sendObservation(session, {
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResponse
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleToolResult(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const toolId = typeof fields.toolId === 'string' ? fields.toolId : undefined;
|
||||
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||
const toolResponse = this.maybeParseJson(fields.toolResponse);
|
||||
|
||||
let toolInput: unknown = this.maybeParseJson(fields.toolInput);
|
||||
let name = toolName;
|
||||
|
||||
if (toolId && session.pendingTools.has(toolId)) {
|
||||
const pending = session.pendingTools.get(toolId)!;
|
||||
toolInput = pending.input ?? toolInput;
|
||||
name = name ?? pending.name;
|
||||
session.pendingTools.delete(toolId);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
await this.sendObservation(session, {
|
||||
toolName: name,
|
||||
toolInput,
|
||||
toolResponse
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendObservation(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||
if (!toolName) return;
|
||||
|
||||
await observationHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
toolName,
|
||||
toolInput: this.maybeParseJson(fields.toolInput),
|
||||
toolResponse: this.maybeParseJson(fields.toolResponse),
|
||||
platform: 'transcript'
|
||||
});
|
||||
}
|
||||
|
||||
private async sendFileEdit(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const filePath = typeof fields.filePath === 'string' ? fields.filePath : undefined;
|
||||
if (!filePath) return;
|
||||
|
||||
await fileEditHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
filePath,
|
||||
edits: Array.isArray(fields.edits) ? fields.edits : undefined,
|
||||
platform: 'transcript'
|
||||
});
|
||||
}
|
||||
|
||||
private maybeParseJson(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private parseApplyPatchFiles(patch: string): string[] {
|
||||
const files: string[] = [];
|
||||
const lines = patch.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('*** Update File: ')) {
|
||||
files.push(trimmed.replace('*** Update File: ', '').trim());
|
||||
} else if (trimmed.startsWith('*** Add File: ')) {
|
||||
files.push(trimmed.replace('*** Add File: ', '').trim());
|
||||
} else if (trimmed.startsWith('*** Delete File: ')) {
|
||||
files.push(trimmed.replace('*** Delete File: ', '').trim());
|
||||
} else if (trimmed.startsWith('*** Move to: ')) {
|
||||
files.push(trimmed.replace('*** Move to: ', '').trim());
|
||||
} else if (trimmed.startsWith('+++ ')) {
|
||||
const path = trimmed.replace('+++ ', '').replace(/^b\//, '').trim();
|
||||
if (path && path !== '/dev/null') files.push(path);
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(files));
|
||||
}
|
||||
|
||||
private async handleSessionEnd(session: SessionState, watch: WatchTarget): Promise<void> {
|
||||
await this.queueSummary(session);
|
||||
await sessionCompleteHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
platform: 'transcript'
|
||||
});
|
||||
await this.updateContext(session, watch);
|
||||
session.pendingTools.clear();
|
||||
const key = this.getSessionKey(watch, session.sessionId);
|
||||
this.sessions.delete(key);
|
||||
}
|
||||
|
||||
private async queueSummary(session: SessionState): Promise<void> {
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) return;
|
||||
|
||||
const port = getWorkerPort();
|
||||
const lastAssistantMessage = session.lastAssistantMessage ?? '';
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: session.sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Summary request failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async updateContext(session: SessionState, watch: WatchTarget): Promise<void> {
|
||||
if (!watch.context) return;
|
||||
if (watch.context.mode !== 'agents') return;
|
||||
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) return;
|
||||
|
||||
const cwd = session.cwd ?? watch.workspace;
|
||||
if (!cwd) return;
|
||||
|
||||
const context = getProjectContext(cwd);
|
||||
const projectsParam = context.allProjects.join(',');
|
||||
const port = getWorkerPort();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?projects=${encodeURIComponent(projectsParam)}`
|
||||
);
|
||||
if (!response.ok) return;
|
||||
|
||||
const content = (await response.text()).trim();
|
||||
if (!content) return;
|
||||
|
||||
const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`);
|
||||
writeAgentsMd(agentsPath, content);
|
||||
logger.debug('TRANSCRIPT', 'Updated AGENTS.md context', { agentsPath, watch: watch.name });
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Failed to update AGENTS.md context', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
export interface TranscriptWatchState {
|
||||
offsets: Record<string, number>;
|
||||
}
|
||||
|
||||
export function loadWatchState(statePath: string): TranscriptWatchState {
|
||||
try {
|
||||
if (!existsSync(statePath)) {
|
||||
return { offsets: {} };
|
||||
}
|
||||
const raw = readFileSync(statePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as TranscriptWatchState;
|
||||
if (!parsed.offsets) return { offsets: {} };
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Failed to load watch state, starting fresh', {
|
||||
statePath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return { offsets: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWatchState(statePath: string, state: TranscriptWatchState): void {
|
||||
try {
|
||||
const dir = dirname(statePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Failed to save watch state', {
|
||||
statePath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type FieldSpec =
|
||||
| string
|
||||
| {
|
||||
path?: string;
|
||||
value?: unknown;
|
||||
coalesce?: FieldSpec[];
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
export interface MatchRule {
|
||||
path?: string;
|
||||
equals?: unknown;
|
||||
in?: unknown[];
|
||||
contains?: string;
|
||||
exists?: boolean;
|
||||
regex?: string;
|
||||
}
|
||||
|
||||
export type EventAction =
|
||||
| 'session_init'
|
||||
| 'session_context'
|
||||
| 'user_message'
|
||||
| 'assistant_message'
|
||||
| 'tool_use'
|
||||
| 'tool_result'
|
||||
| 'observation'
|
||||
| 'file_edit'
|
||||
| 'session_end';
|
||||
|
||||
export interface SchemaEvent {
|
||||
name: string;
|
||||
match?: MatchRule;
|
||||
action: EventAction;
|
||||
fields?: Record<string, FieldSpec>;
|
||||
}
|
||||
|
||||
export interface TranscriptSchema {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
eventTypePath?: string;
|
||||
sessionIdPath?: string;
|
||||
cwdPath?: string;
|
||||
projectPath?: string;
|
||||
events: SchemaEvent[];
|
||||
}
|
||||
|
||||
export interface WatchContextConfig {
|
||||
mode: 'agents';
|
||||
path?: string;
|
||||
updateOn?: Array<'session_start' | 'session_end'>;
|
||||
}
|
||||
|
||||
export interface WatchTarget {
|
||||
name: string;
|
||||
path: string;
|
||||
schema: string | TranscriptSchema;
|
||||
workspace?: string;
|
||||
project?: string;
|
||||
context?: WatchContextConfig;
|
||||
rescanIntervalMs?: number;
|
||||
startAtEnd?: boolean;
|
||||
}
|
||||
|
||||
export interface TranscriptWatchConfig {
|
||||
version: 1;
|
||||
schemas?: Record<string, TranscriptSchema>;
|
||||
watches: WatchTarget[];
|
||||
stateFile?: string;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { existsSync, statSync, watch as fsWatch, createReadStream } from 'fs';
|
||||
import { basename, join } from 'path';
|
||||
import { globSync } from 'glob';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { expandHomePath } from './config.js';
|
||||
import { loadWatchState, saveWatchState, type TranscriptWatchState } from './state.js';
|
||||
import type { TranscriptWatchConfig, TranscriptSchema, WatchTarget } from './types.js';
|
||||
import { TranscriptEventProcessor } from './processor.js';
|
||||
|
||||
interface TailState {
|
||||
offset: number;
|
||||
partial: string;
|
||||
}
|
||||
|
||||
class FileTailer {
|
||||
private watcher: ReturnType<typeof fsWatch> | null = null;
|
||||
private tailState: TailState;
|
||||
|
||||
constructor(
|
||||
private filePath: string,
|
||||
initialOffset: number,
|
||||
private onLine: (line: string) => Promise<void>,
|
||||
private onOffset: (offset: number) => void
|
||||
) {
|
||||
this.tailState = { offset: initialOffset, partial: '' };
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.readNewData().catch(() => undefined);
|
||||
this.watcher = fsWatch(this.filePath, { persistent: true }, () => {
|
||||
this.readNewData().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.watcher?.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
private async readNewData(): Promise<void> {
|
||||
if (!existsSync(this.filePath)) return;
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
size = statSync(this.filePath).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (size < this.tailState.offset) {
|
||||
this.tailState.offset = 0;
|
||||
}
|
||||
|
||||
if (size === this.tailState.offset) return;
|
||||
|
||||
const stream = createReadStream(this.filePath, {
|
||||
start: this.tailState.offset,
|
||||
end: size - 1,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
let data = '';
|
||||
for await (const chunk of stream) {
|
||||
data += chunk as string;
|
||||
}
|
||||
|
||||
this.tailState.offset = size;
|
||||
this.onOffset(this.tailState.offset);
|
||||
|
||||
const combined = this.tailState.partial + data;
|
||||
const lines = combined.split('\n');
|
||||
this.tailState.partial = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
await this.onLine(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TranscriptWatcher {
|
||||
private processor = new TranscriptEventProcessor();
|
||||
private tailers = new Map<string, FileTailer>();
|
||||
private state: TranscriptWatchState;
|
||||
private rescanTimers: Array<NodeJS.Timeout> = [];
|
||||
|
||||
constructor(private config: TranscriptWatchConfig, private statePath: string) {
|
||||
this.state = loadWatchState(statePath);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
for (const watch of this.config.watches) {
|
||||
await this.setupWatch(watch);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const tailer of this.tailers.values()) {
|
||||
tailer.close();
|
||||
}
|
||||
this.tailers.clear();
|
||||
for (const timer of this.rescanTimers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.rescanTimers = [];
|
||||
}
|
||||
|
||||
private async setupWatch(watch: WatchTarget): Promise<void> {
|
||||
const schema = this.resolveSchema(watch);
|
||||
if (!schema) {
|
||||
logger.warn('TRANSCRIPT', 'Missing schema for watch', { watch: watch.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = expandHomePath(watch.path);
|
||||
const files = this.resolveWatchFiles(resolvedPath);
|
||||
|
||||
for (const filePath of files) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
}
|
||||
|
||||
const rescanIntervalMs = watch.rescanIntervalMs ?? 5000;
|
||||
const timer = setInterval(async () => {
|
||||
const newFiles = this.resolveWatchFiles(resolvedPath);
|
||||
for (const filePath of newFiles) {
|
||||
if (!this.tailers.has(filePath)) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
}
|
||||
}
|
||||
}, rescanIntervalMs);
|
||||
this.rescanTimers.push(timer);
|
||||
}
|
||||
|
||||
private resolveSchema(watch: WatchTarget): TranscriptSchema | null {
|
||||
if (typeof watch.schema === 'string') {
|
||||
return this.config.schemas?.[watch.schema] ?? null;
|
||||
}
|
||||
return watch.schema;
|
||||
}
|
||||
|
||||
private resolveWatchFiles(inputPath: string): string[] {
|
||||
if (this.hasGlob(inputPath)) {
|
||||
return globSync(inputPath, { nodir: true, absolute: true });
|
||||
}
|
||||
|
||||
if (existsSync(inputPath)) {
|
||||
try {
|
||||
const stat = statSync(inputPath);
|
||||
if (stat.isDirectory()) {
|
||||
const pattern = join(inputPath, '**', '*.jsonl');
|
||||
return globSync(pattern, { nodir: true, absolute: true });
|
||||
}
|
||||
return [inputPath];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private hasGlob(inputPath: string): boolean {
|
||||
return /[*?[\]{}()]/.test(inputPath);
|
||||
}
|
||||
|
||||
private async addTailer(filePath: string, watch: WatchTarget, schema: TranscriptSchema): Promise<void> {
|
||||
if (this.tailers.has(filePath)) return;
|
||||
|
||||
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
|
||||
|
||||
let offset = this.state.offsets[filePath] ?? 0;
|
||||
if (offset === 0 && watch.startAtEnd) {
|
||||
try {
|
||||
offset = statSync(filePath).size;
|
||||
} catch {
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const tailer = new FileTailer(
|
||||
filePath,
|
||||
offset,
|
||||
async (line: string) => {
|
||||
await this.handleLine(line, watch, schema, filePath, sessionIdOverride);
|
||||
},
|
||||
(newOffset: number) => {
|
||||
this.state.offsets[filePath] = newOffset;
|
||||
saveWatchState(this.statePath, this.state);
|
||||
}
|
||||
);
|
||||
|
||||
tailer.start();
|
||||
this.tailers.set(filePath, tailer);
|
||||
logger.info('TRANSCRIPT', 'Watching transcript file', {
|
||||
file: filePath,
|
||||
watch: watch.name,
|
||||
schema: schema.name
|
||||
});
|
||||
}
|
||||
|
||||
private async handleLine(
|
||||
line: string,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
filePath: string,
|
||||
sessionIdOverride?: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
await this.processor.processEntry(entry, watch, schema, sessionIdOverride ?? undefined);
|
||||
} catch (error) {
|
||||
logger.debug('TRANSCRIPT', 'Failed to parse transcript line', {
|
||||
watch: watch.name,
|
||||
file: basename(filePath)
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private extractSessionIdFromPath(filePath: string): string | null {
|
||||
const match = filePath.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,11 @@ import { existsSync, writeFileSync, unlinkSync, statSync } from 'fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
|
||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||
import { getAuthMethodDescription } from '../shared/EnvManager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ChromaServerManager } from './sync/ChromaServerManager.js';
|
||||
|
||||
// Windows: avoid repeated spawn popups when startup fails (issue #921)
|
||||
const WINDOWS_SPAWN_COOLDOWN_MS = 2 * 60 * 1000;
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
removePidFile,
|
||||
getPlatformTimeout,
|
||||
cleanupOrphanedProcesses,
|
||||
cleanStalePidFile,
|
||||
spawnDaemon,
|
||||
createSignalHandler
|
||||
} from './infrastructure/ProcessManager.js';
|
||||
@@ -161,6 +165,9 @@ export class WorkerService {
|
||||
// Route handlers
|
||||
private searchRoutes: SearchRoutes | null = null;
|
||||
|
||||
// Chroma server (local mode)
|
||||
private chromaServer: ChromaServerManager | null = null;
|
||||
|
||||
// Initialization tracking
|
||||
private initializationComplete: Promise<void>;
|
||||
private resolveInitialization!: () => void;
|
||||
@@ -168,6 +175,14 @@ export class WorkerService {
|
||||
// Orphan reaper cleanup function (Issue #737)
|
||||
private stopOrphanReaper: (() => void) | null = null;
|
||||
|
||||
// AI interaction tracking for health endpoint
|
||||
private lastAiInteraction: {
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
provider: string;
|
||||
error?: string;
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
// Initialize the promise that will resolve when background initialization completes
|
||||
this.initializationComplete = new Promise((resolve) => {
|
||||
@@ -204,7 +219,24 @@ export class WorkerService {
|
||||
getInitializationComplete: () => this.initializationCompleteFlag,
|
||||
getMcpReady: () => this.mcpReady,
|
||||
onShutdown: () => this.shutdown(),
|
||||
onRestart: () => this.shutdown()
|
||||
onRestart: () => this.shutdown(),
|
||||
workerPath: __filename,
|
||||
getAiStatus: () => {
|
||||
let provider = 'claude';
|
||||
if (isOpenRouterSelected() && isOpenRouterAvailable()) provider = 'openrouter';
|
||||
else if (isGeminiSelected() && isGeminiAvailable()) provider = 'gemini';
|
||||
return {
|
||||
provider,
|
||||
authMethod: getAuthMethodDescription(),
|
||||
lastInteraction: this.lastAiInteraction
|
||||
? {
|
||||
timestamp: this.lastAiInteraction.timestamp,
|
||||
success: this.lastAiInteraction.success,
|
||||
...(this.lastAiInteraction.error && { error: this.lastAiInteraction.error }),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Register route handlers
|
||||
@@ -229,6 +261,22 @@ export class WorkerService {
|
||||
this.isShuttingDown = shutdownRef.value;
|
||||
handler('SIGINT');
|
||||
});
|
||||
|
||||
// SIGHUP: sent by kernel when controlling terminal closes.
|
||||
// Daemon mode: ignore it (survive parent shell exit).
|
||||
// Interactive mode: treat like SIGTERM (graceful shutdown).
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.argv.includes('--daemon')) {
|
||||
process.on('SIGHUP', () => {
|
||||
logger.debug('SYSTEM', 'Ignoring SIGHUP in daemon mode');
|
||||
});
|
||||
} else {
|
||||
process.on('SIGHUP', () => {
|
||||
this.isShuttingDown = shutdownRef.value;
|
||||
handler('SIGHUP');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,8 +369,32 @@ export class WorkerService {
|
||||
const { ModeManager } = await import('./domain/ModeManager.js');
|
||||
const { SettingsDefaultsManager } = await import('../shared/SettingsDefaultsManager.js');
|
||||
const { USER_SETTINGS_PATH } = await import('../shared/paths.js');
|
||||
const os = await import('os');
|
||||
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
|
||||
// Start Chroma server if in local mode
|
||||
const chromaMode = settings.CLAUDE_MEM_CHROMA_MODE || 'local';
|
||||
if (chromaMode === 'local') {
|
||||
logger.info('SYSTEM', 'Starting local Chroma server...');
|
||||
this.chromaServer = ChromaServerManager.getInstance({
|
||||
dataDir: path.join(os.homedir(), '.claude-mem', 'vector-db'),
|
||||
host: settings.CLAUDE_MEM_CHROMA_HOST || '127.0.0.1',
|
||||
port: parseInt(settings.CLAUDE_MEM_CHROMA_PORT || '8000', 10)
|
||||
});
|
||||
|
||||
const ready = await this.chromaServer.start(60000);
|
||||
|
||||
if (ready) {
|
||||
logger.success('SYSTEM', 'Chroma server ready');
|
||||
} else {
|
||||
logger.warn('SYSTEM', 'Chroma server failed to start - vector search disabled');
|
||||
this.chromaServer = null;
|
||||
}
|
||||
} else {
|
||||
logger.info('SYSTEM', 'Chroma remote mode - skipping local server');
|
||||
}
|
||||
|
||||
const modeId = settings.CLAUDE_MEM_MODE;
|
||||
ModeManager.getInstance().loadMode(modeId);
|
||||
logger.info('SYSTEM', `Mode loaded: ${modeId}`);
|
||||
@@ -441,6 +513,7 @@ export class WorkerService {
|
||||
|
||||
// Track whether generator failed with an unrecoverable error to prevent infinite restart loops
|
||||
let hadUnrecoverableError = false;
|
||||
let sessionFailed = false;
|
||||
|
||||
logger.info('SYSTEM', `Starting generator (${source}) using ${providerName}`, { sessionId: sid });
|
||||
|
||||
@@ -455,9 +528,16 @@ export class WorkerService {
|
||||
'CLAUDE_CODE_PATH',
|
||||
'ENOENT',
|
||||
'spawn',
|
||||
'Invalid API key',
|
||||
];
|
||||
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
|
||||
hadUnrecoverableError = true;
|
||||
this.lastAiInteraction = {
|
||||
timestamp: Date.now(),
|
||||
success: false,
|
||||
provider: providerName,
|
||||
error: errorMessage,
|
||||
};
|
||||
logger.error('SDK', 'Unrecoverable generator error - will NOT restart', {
|
||||
sessionId: session.sessionDbId,
|
||||
project: session.project,
|
||||
@@ -494,11 +574,27 @@ export class WorkerService {
|
||||
project: session.project,
|
||||
provider: providerName
|
||||
}, error as Error);
|
||||
sessionFailed = true;
|
||||
this.lastAiInteraction = {
|
||||
timestamp: Date.now(),
|
||||
success: false,
|
||||
provider: providerName,
|
||||
error: errorMessage,
|
||||
};
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
session.generatorPromise = null;
|
||||
|
||||
// Record successful AI interaction if no error occurred
|
||||
if (!sessionFailed && !hadUnrecoverableError) {
|
||||
this.lastAiInteraction = {
|
||||
timestamp: Date.now(),
|
||||
success: true,
|
||||
provider: providerName,
|
||||
};
|
||||
}
|
||||
|
||||
// Do NOT restart after unrecoverable errors - prevents infinite loops
|
||||
if (hadUnrecoverableError) {
|
||||
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
|
||||
@@ -708,7 +804,8 @@ export class WorkerService {
|
||||
server: this.server.getHttpServer(),
|
||||
sessionManager: this.sessionManager,
|
||||
mcpClient: this.mcpClient,
|
||||
dbManager: this.dbManager
|
||||
dbManager: this.dbManager,
|
||||
chromaServer: this.chromaServer || undefined
|
||||
});
|
||||
}
|
||||
|
||||
@@ -746,6 +843,9 @@ export class WorkerService {
|
||||
* @returns true if worker is healthy (existing or newly started), false on failure
|
||||
*/
|
||||
async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
// Clean stale PID file first (cheap: 1 fs read + 1 signal-0 check)
|
||||
cleanStalePidFile();
|
||||
|
||||
// Check if worker is already running and healthy
|
||||
if (await waitForHealth(port, 1000)) {
|
||||
const versionCheck = await checkVersionMatch(port);
|
||||
@@ -756,7 +856,7 @@ async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
});
|
||||
|
||||
await httpShutdown(port);
|
||||
const freed = await waitForPortFree(port, getPlatformTimeout(15000));
|
||||
const freed = await waitForPortFree(port, getPlatformTimeout(HOOK_TIMEOUTS.PORT_IN_USE_WAIT));
|
||||
if (!freed) {
|
||||
logger.error('SYSTEM', 'Port did not free up after shutdown for version mismatch restart', { port });
|
||||
return false;
|
||||
@@ -772,7 +872,7 @@ async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
const portInUse = await isPortInUse(port);
|
||||
if (portInUse) {
|
||||
logger.info('SYSTEM', 'Port in use, waiting for worker to become healthy');
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(15000));
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(HOOK_TIMEOUTS.PORT_IN_USE_WAIT));
|
||||
if (healthy) {
|
||||
logger.info('SYSTEM', 'Worker is now healthy');
|
||||
return true;
|
||||
@@ -799,7 +899,7 @@ async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
// PID file is written by the worker itself after listen() succeeds
|
||||
// This is race-free and works correctly on Windows where cmd.exe PID is useless
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(HOOK_TIMEOUTS.POST_SPAWN_WAIT));
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
logger.error('SYSTEM', 'Worker failed to start (health check timeout)');
|
||||
@@ -871,7 +971,7 @@ async function main() {
|
||||
// PID file is written by the worker itself after listen() succeeds
|
||||
// This is race-free and works correctly on Windows where cmd.exe PID is useless
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(HOOK_TIMEOUTS.POST_SPAWN_WAIT));
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
logger.error('SYSTEM', 'Worker failed to restart');
|
||||
@@ -966,6 +1066,18 @@ async function main() {
|
||||
|
||||
case '--daemon':
|
||||
default: {
|
||||
// Prevent daemon from dying silently on unhandled errors.
|
||||
// The HTTP server can continue serving even if a background task throws.
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('SYSTEM', 'Unhandled rejection in daemon', {
|
||||
reason: reason instanceof Error ? reason.message : String(reason)
|
||||
});
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('SYSTEM', 'Uncaught exception in daemon', {}, error as Error);
|
||||
// Don't exit — keep the HTTP server running
|
||||
});
|
||||
|
||||
const worker = new WorkerService();
|
||||
worker.start().catch((error) => {
|
||||
logger.failure('SYSTEM', 'Worker failed to start', {}, error as Error);
|
||||
|
||||
@@ -290,12 +290,22 @@ export function createPidCapturingSpawn(sessionDbId: number) {
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
// Capture stderr for debugging spawn failures
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
logger.debug('SDK_SPAWN', `[session-${sessionDbId}] stderr: ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Register PID
|
||||
if (child.pid) {
|
||||
registerProcess(child.pid, sessionDbId, child);
|
||||
|
||||
// Auto-unregister on exit
|
||||
child.on('exit', () => {
|
||||
child.on('exit', (code: number | null, signal: string | null) => {
|
||||
if (code !== 0) {
|
||||
logger.warn('SDK_SPAWN', `[session-${sessionDbId}] Claude process exited`, { code, signal, pid: child.pid });
|
||||
}
|
||||
if (child.pid) {
|
||||
unregisterProcess(child.pid);
|
||||
}
|
||||
@@ -306,6 +316,7 @@ export function createPidCapturingSpawn(sessionDbId: number) {
|
||||
return {
|
||||
stdin: child.stdin,
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
get killed() { return child.killed; },
|
||||
get exitCode() { return child.exitCode; },
|
||||
kill: child.kill.bind(child),
|
||||
|
||||
+127
-112
@@ -141,128 +141,143 @@ export class SDKAgent {
|
||||
}
|
||||
});
|
||||
|
||||
// Process SDK messages
|
||||
for await (const message of queryResult) {
|
||||
// Capture or update memory session ID from SDK message
|
||||
// IMPORTANT: The SDK may return a DIFFERENT session_id on resume than what we sent!
|
||||
// We must always sync the DB to match what the SDK actually uses.
|
||||
//
|
||||
// MULTI-TERMINAL COLLISION FIX (FK constraint bug):
|
||||
// Use ensureMemorySessionIdRegistered() instead of updateMemorySessionId() because:
|
||||
// 1. It's idempotent - safe to call multiple times
|
||||
// 2. It verifies the update happened (SELECT before UPDATE)
|
||||
// 3. Consistent with ResponseProcessor's usage pattern
|
||||
// This ensures FK constraint compliance BEFORE any observations are stored.
|
||||
if (message.session_id && message.session_id !== session.memorySessionId) {
|
||||
const previousId = session.memorySessionId;
|
||||
session.memorySessionId = message.session_id;
|
||||
// Persist to database IMMEDIATELY for FK constraint compliance
|
||||
// This must happen BEFORE any observations referencing this ID are stored
|
||||
this.dbManager.getSessionStore().ensureMemorySessionIdRegistered(
|
||||
session.sessionDbId,
|
||||
message.session_id
|
||||
);
|
||||
// Verify the update by reading back from DB
|
||||
const verification = this.dbManager.getSessionStore().getSessionById(session.sessionDbId);
|
||||
const dbVerified = verification?.memory_session_id === message.session_id;
|
||||
const logMessage = previousId
|
||||
? `MEMORY_ID_CHANGED | sessionDbId=${session.sessionDbId} | from=${previousId} | to=${message.session_id} | dbVerified=${dbVerified}`
|
||||
: `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`;
|
||||
logger.info('SESSION', logMessage, {
|
||||
sessionId: session.sessionDbId,
|
||||
memorySessionId: message.session_id,
|
||||
previousId
|
||||
});
|
||||
if (!dbVerified) {
|
||||
logger.error('SESSION', `MEMORY_ID_MISMATCH | sessionDbId=${session.sessionDbId} | expected=${message.session_id} | got=${verification?.memory_session_id}`, {
|
||||
sessionId: session.sessionDbId
|
||||
// Process SDK messages — cleanup in finally ensures subprocess termination
|
||||
// even if the loop throws (e.g., context overflow, invalid API key)
|
||||
try {
|
||||
for await (const message of queryResult) {
|
||||
// Capture or update memory session ID from SDK message
|
||||
// IMPORTANT: The SDK may return a DIFFERENT session_id on resume than what we sent!
|
||||
// We must always sync the DB to match what the SDK actually uses.
|
||||
//
|
||||
// MULTI-TERMINAL COLLISION FIX (FK constraint bug):
|
||||
// Use ensureMemorySessionIdRegistered() instead of updateMemorySessionId() because:
|
||||
// 1. It's idempotent - safe to call multiple times
|
||||
// 2. It verifies the update happened (SELECT before UPDATE)
|
||||
// 3. Consistent with ResponseProcessor's usage pattern
|
||||
// This ensures FK constraint compliance BEFORE any observations are stored.
|
||||
if (message.session_id && message.session_id !== session.memorySessionId) {
|
||||
const previousId = session.memorySessionId;
|
||||
session.memorySessionId = message.session_id;
|
||||
// Persist to database IMMEDIATELY for FK constraint compliance
|
||||
// This must happen BEFORE any observations referencing this ID are stored
|
||||
this.dbManager.getSessionStore().ensureMemorySessionIdRegistered(
|
||||
session.sessionDbId,
|
||||
message.session_id
|
||||
);
|
||||
// Verify the update by reading back from DB
|
||||
const verification = this.dbManager.getSessionStore().getSessionById(session.sessionDbId);
|
||||
const dbVerified = verification?.memory_session_id === message.session_id;
|
||||
const logMessage = previousId
|
||||
? `MEMORY_ID_CHANGED | sessionDbId=${session.sessionDbId} | from=${previousId} | to=${message.session_id} | dbVerified=${dbVerified}`
|
||||
: `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`;
|
||||
logger.info('SESSION', logMessage, {
|
||||
sessionId: session.sessionDbId,
|
||||
memorySessionId: message.session_id,
|
||||
previousId
|
||||
});
|
||||
}
|
||||
// Debug-level alignment log for detailed tracing
|
||||
logger.debug('SDK', `[ALIGNMENT] ${previousId ? 'Updated' : 'Captured'} | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`);
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
if (message.type === 'assistant') {
|
||||
const content = message.message.content;
|
||||
const textContent = Array.isArray(content)
|
||||
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
|
||||
: typeof content === 'string' ? content : '';
|
||||
|
||||
// Check for context overflow - prevents infinite retry loops
|
||||
if (textContent.includes('prompt is too long') ||
|
||||
textContent.includes('context window')) {
|
||||
logger.error('SDK', 'Context overflow detected - terminating session');
|
||||
session.abortController.abort();
|
||||
return;
|
||||
if (!dbVerified) {
|
||||
logger.error('SESSION', `MEMORY_ID_MISMATCH | sessionDbId=${session.sessionDbId} | expected=${message.session_id} | got=${verification?.memory_session_id}`, {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
}
|
||||
// Debug-level alignment log for detailed tracing
|
||||
logger.debug('SDK', `[ALIGNMENT] ${previousId ? 'Updated' : 'Captured'} | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`);
|
||||
}
|
||||
|
||||
const responseSize = textContent.length;
|
||||
// Handle assistant messages
|
||||
if (message.type === 'assistant') {
|
||||
const content = message.message.content;
|
||||
const textContent = Array.isArray(content)
|
||||
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
|
||||
: typeof content === 'string' ? content : '';
|
||||
|
||||
// Capture token state BEFORE updating (for delta calculation)
|
||||
const tokensBeforeResponse = session.cumulativeInputTokens + session.cumulativeOutputTokens;
|
||||
|
||||
// Extract and track token usage
|
||||
const usage = message.message.usage;
|
||||
if (usage) {
|
||||
session.cumulativeInputTokens += usage.input_tokens || 0;
|
||||
session.cumulativeOutputTokens += usage.output_tokens || 0;
|
||||
|
||||
// Cache creation counts as discovery, cache read doesn't
|
||||
if (usage.cache_creation_input_tokens) {
|
||||
session.cumulativeInputTokens += usage.cache_creation_input_tokens;
|
||||
// Check for context overflow - prevents infinite retry loops
|
||||
if (textContent.includes('prompt is too long') ||
|
||||
textContent.includes('context window')) {
|
||||
logger.error('SDK', 'Context overflow detected - terminating session');
|
||||
session.abortController.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('SDK', 'Token usage captured', {
|
||||
sessionId: session.sessionDbId,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreation: usage.cache_creation_input_tokens || 0,
|
||||
cacheRead: usage.cache_read_input_tokens || 0,
|
||||
cumulativeInput: session.cumulativeInputTokens,
|
||||
cumulativeOutput: session.cumulativeOutputTokens
|
||||
});
|
||||
const responseSize = textContent.length;
|
||||
|
||||
// Capture token state BEFORE updating (for delta calculation)
|
||||
const tokensBeforeResponse = session.cumulativeInputTokens + session.cumulativeOutputTokens;
|
||||
|
||||
// Extract and track token usage
|
||||
const usage = message.message.usage;
|
||||
if (usage) {
|
||||
session.cumulativeInputTokens += usage.input_tokens || 0;
|
||||
session.cumulativeOutputTokens += usage.output_tokens || 0;
|
||||
|
||||
// Cache creation counts as discovery, cache read doesn't
|
||||
if (usage.cache_creation_input_tokens) {
|
||||
session.cumulativeInputTokens += usage.cache_creation_input_tokens;
|
||||
}
|
||||
|
||||
logger.debug('SDK', 'Token usage captured', {
|
||||
sessionId: session.sessionDbId,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreation: usage.cache_creation_input_tokens || 0,
|
||||
cacheRead: usage.cache_read_input_tokens || 0,
|
||||
cumulativeInput: session.cumulativeInputTokens,
|
||||
cumulativeOutput: session.cumulativeOutputTokens
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate discovery tokens (delta for this response only)
|
||||
const discoveryTokens = (session.cumulativeInputTokens + session.cumulativeOutputTokens) - tokensBeforeResponse;
|
||||
|
||||
// Process response (empty or not) and mark messages as processed
|
||||
// Capture earliest timestamp BEFORE processing (will be cleared after)
|
||||
const originalTimestamp = session.earliestPendingTimestamp;
|
||||
|
||||
if (responseSize > 0) {
|
||||
const truncatedResponse = responseSize > 100
|
||||
? textContent.substring(0, 100) + '...'
|
||||
: textContent;
|
||||
logger.dataOut('SDK', `Response received (${responseSize} chars)`, {
|
||||
sessionId: session.sessionDbId,
|
||||
promptNumber: session.lastPromptNumber
|
||||
}, truncatedResponse);
|
||||
}
|
||||
|
||||
// Detect fatal context overflow and terminate gracefully (issue #870)
|
||||
if (typeof textContent === 'string' && textContent.includes('Prompt is too long')) {
|
||||
throw new Error('Claude session context overflow: prompt is too long');
|
||||
}
|
||||
|
||||
// Detect invalid API key — SDK returns this as response text, not an error.
|
||||
// Throw so it surfaces in health endpoint and prevents silent failures.
|
||||
if (typeof textContent === 'string' && textContent.includes('Invalid API key')) {
|
||||
throw new Error('Invalid API key: check your API key configuration in ~/.claude-mem/settings.json or ~/.claude-mem/.env');
|
||||
}
|
||||
|
||||
// Parse and process response using shared ResponseProcessor
|
||||
await processAgentResponse(
|
||||
textContent,
|
||||
session,
|
||||
this.dbManager,
|
||||
this.sessionManager,
|
||||
worker,
|
||||
discoveryTokens,
|
||||
originalTimestamp,
|
||||
'SDK',
|
||||
cwdTracker.lastCwd
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate discovery tokens (delta for this response only)
|
||||
const discoveryTokens = (session.cumulativeInputTokens + session.cumulativeOutputTokens) - tokensBeforeResponse;
|
||||
|
||||
// Process response (empty or not) and mark messages as processed
|
||||
// Capture earliest timestamp BEFORE processing (will be cleared after)
|
||||
const originalTimestamp = session.earliestPendingTimestamp;
|
||||
|
||||
if (responseSize > 0) {
|
||||
const truncatedResponse = responseSize > 100
|
||||
? textContent.substring(0, 100) + '...'
|
||||
: textContent;
|
||||
logger.dataOut('SDK', `Response received (${responseSize} chars)`, {
|
||||
sessionId: session.sessionDbId,
|
||||
promptNumber: session.lastPromptNumber
|
||||
}, truncatedResponse);
|
||||
// Log result messages
|
||||
if (message.type === 'result' && message.subtype === 'success') {
|
||||
// Usage telemetry is captured at SDK level
|
||||
}
|
||||
|
||||
// Detect fatal context overflow and terminate gracefully (issue #870)
|
||||
if (typeof textContent === 'string' && textContent.includes('Prompt is too long')) {
|
||||
throw new Error('Claude session context overflow: prompt is too long');
|
||||
}
|
||||
|
||||
// Parse and process response using shared ResponseProcessor
|
||||
await processAgentResponse(
|
||||
textContent,
|
||||
session,
|
||||
this.dbManager,
|
||||
this.sessionManager,
|
||||
worker,
|
||||
discoveryTokens,
|
||||
originalTimestamp,
|
||||
'SDK',
|
||||
cwdTracker.lastCwd
|
||||
);
|
||||
}
|
||||
|
||||
// Log result messages
|
||||
if (message.type === 'result' && message.subtype === 'success') {
|
||||
// Usage telemetry is captured at SDK level
|
||||
} finally {
|
||||
// Ensure subprocess is terminated after query completes (or on error)
|
||||
const tracked = getProcessBySession(session.sessionDbId);
|
||||
if (tracked && !tracked.process.killed && tracked.process.exitCode === null) {
|
||||
await ensureProcessExit(tracked, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ENV_FILE_PATH = join(DATA_DIR, '.env');
|
||||
// are passed through to avoid breaking CLI authentication, proxies, and platform features.
|
||||
const BLOCKED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
||||
'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error
|
||||
];
|
||||
|
||||
// Credential keys that claude-mem manages
|
||||
@@ -217,6 +218,14 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
|
||||
if (credentials.OPENROUTER_API_KEY) {
|
||||
isolatedEnv.OPENROUTER_API_KEY = credentials.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
// 4. Pass through Claude CLI's OAuth token if available (fallback for CLI subscription billing)
|
||||
// When no ANTHROPIC_API_KEY is configured, the spawned CLI uses subscription billing
|
||||
// which requires either ~/.claude/.credentials.json or CLAUDE_CODE_OAUTH_TOKEN.
|
||||
// The worker inherits this token from the Claude Code session that started it.
|
||||
if (!isolatedEnv.ANTHROPIC_API_KEY && process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
isolatedEnv.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
return isolatedEnv;
|
||||
@@ -257,5 +266,8 @@ export function getAuthMethodDescription(): string {
|
||||
if (hasAnthropicApiKey()) {
|
||||
return 'API key (from ~/.claude-mem/.env)';
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return 'Claude Code OAuth token (from parent process)';
|
||||
}
|
||||
return 'Claude Code CLI (subscription billing)';
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ export interface SettingsDefaults {
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
|
||||
CLAUDE_MEM_CHROMA_HOST: string;
|
||||
CLAUDE_MEM_CHROMA_PORT: string;
|
||||
CLAUDE_MEM_CHROMA_SSL: string;
|
||||
// Future cloud support
|
||||
CLAUDE_MEM_CHROMA_API_KEY: string;
|
||||
CLAUDE_MEM_CHROMA_TENANT: string;
|
||||
CLAUDE_MEM_CHROMA_DATABASE: string;
|
||||
}
|
||||
|
||||
export class SettingsDefaultsManager {
|
||||
@@ -86,15 +95,15 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_CODE_PATH: '', // Empty means auto-detect via 'which claude'
|
||||
CLAUDE_MEM_MODE: 'code', // Default mode profile
|
||||
// Token Economics
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: 'true',
|
||||
// Observation Filtering
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: DEFAULT_OBSERVATION_TYPES_STRING,
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: DEFAULT_OBSERVATION_CONCEPTS_STRING,
|
||||
// Display Configuration
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: '5',
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: '0',
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
|
||||
CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10',
|
||||
// Feature Toggles
|
||||
@@ -104,6 +113,15 @@ export class SettingsDefaultsManager {
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' starts npx chroma run, 'remote' connects to existing server
|
||||
CLAUDE_MEM_CHROMA_HOST: '127.0.0.1',
|
||||
CLAUDE_MEM_CHROMA_PORT: '8000',
|
||||
CLAUDE_MEM_CHROMA_SSL: 'false',
|
||||
// Future cloud support (claude-mem pro)
|
||||
CLAUDE_MEM_CHROMA_API_KEY: '',
|
||||
CLAUDE_MEM_CHROMA_TENANT: 'default_tenant',
|
||||
CLAUDE_MEM_CHROMA_DATABASE: 'default_database',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export const HOOK_TIMEOUTS = {
|
||||
DEFAULT: 300000, // Standard HTTP timeout (5 min for slow systems)
|
||||
HEALTH_CHECK: 30000, // Worker health check (30s for slow systems)
|
||||
HEALTH_CHECK: 3000, // Worker health check (3s — healthy worker responds in <100ms)
|
||||
POST_SPAWN_WAIT: 5000, // Wait for daemon to start after spawn (starts in <1s on Linux)
|
||||
PORT_IN_USE_WAIT: 3000, // Wait when port occupied but health failing
|
||||
WORKER_STARTUP_WAIT: 1000,
|
||||
WORKER_STARTUP_RETRIES: 300,
|
||||
PRE_RESTART_SETTLE_DELAY: 2000, // Give files time to sync before restart
|
||||
POWERSHELL_COMMAND: 10000, // PowerShell process enumeration (10s - typically completes in <1s)
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment for hook-side operations
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
+53
-14
@@ -6,7 +6,21 @@ import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js";
|
||||
import { MARKETPLACE_ROOT } from "./paths.js";
|
||||
|
||||
// Named constants for health checks
|
||||
const HEALTH_CHECK_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
// Allow env var override for users on slow systems (e.g., CLAUDE_MEM_HEALTH_TIMEOUT_MS=10000)
|
||||
const HEALTH_CHECK_TIMEOUT_MS = (() => {
|
||||
const envVal = process.env.CLAUDE_MEM_HEALTH_TIMEOUT_MS;
|
||||
if (envVal) {
|
||||
const parsed = parseInt(envVal, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 500 && parsed <= 300000) {
|
||||
return parsed;
|
||||
}
|
||||
// Invalid env var — log once and use default
|
||||
logger.warn('SYSTEM', 'Invalid CLAUDE_MEM_HEALTH_TIMEOUT_MS, using default', {
|
||||
value: envVal, min: 500, max: 300000
|
||||
});
|
||||
}
|
||||
return getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Fetch with a timeout using Promise.race instead of AbortSignal.
|
||||
@@ -89,12 +103,22 @@ async function isWorkerHealthy(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plugin version from package.json
|
||||
* Get the current plugin version from package.json.
|
||||
* Returns 'unknown' on ENOENT/EBUSY (shutdown race condition, fix #1042).
|
||||
*/
|
||||
function getPluginVersion(): string {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
return packageJson.version;
|
||||
try {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
return packageJson.version;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'EBUSY') {
|
||||
logger.debug('SYSTEM', 'Could not read plugin version (shutdown race)', { code });
|
||||
return 'unknown';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,18 +139,33 @@ async function getWorkerVersion(): Promise<string> {
|
||||
/**
|
||||
* Check if worker version matches plugin version
|
||||
* Note: Auto-restart on version mismatch is now handled in worker-service.ts start command (issue #484)
|
||||
* This function logs for informational purposes only
|
||||
* This function logs for informational purposes only.
|
||||
* Skips comparison when either version is 'unknown' (fix #1042 — avoids restart loops).
|
||||
*/
|
||||
async function checkWorkerVersion(): Promise<void> {
|
||||
const pluginVersion = getPluginVersion();
|
||||
const workerVersion = await getWorkerVersion();
|
||||
try {
|
||||
const pluginVersion = getPluginVersion();
|
||||
|
||||
if (pluginVersion !== workerVersion) {
|
||||
// Just log debug info - auto-restart handles the mismatch in worker-service.ts
|
||||
logger.debug('SYSTEM', 'Version check', {
|
||||
pluginVersion,
|
||||
workerVersion,
|
||||
note: 'Mismatch will be auto-restarted by worker-service start command'
|
||||
// Skip version check if plugin version couldn't be read (shutdown race)
|
||||
if (pluginVersion === 'unknown') return;
|
||||
|
||||
const workerVersion = await getWorkerVersion();
|
||||
|
||||
// Skip version check if worker version is 'unknown' (avoids restart loops)
|
||||
if (workerVersion === 'unknown') return;
|
||||
|
||||
if (pluginVersion !== workerVersion) {
|
||||
// Just log debug info - auto-restart handles the mismatch in worker-service.ts
|
||||
logger.debug('SYSTEM', 'Version check', {
|
||||
pluginVersion,
|
||||
workerVersion,
|
||||
note: 'Mismatch will be auto-restarted by worker-service start command'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Version check is informational — don't fail the hook
|
||||
logger.debug('SYSTEM', 'Version check failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { replaceTaggedContent } from './claude-md-utils.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Write AGENTS.md with claude-mem context, preserving user content outside tags.
|
||||
* Uses atomic write to prevent partial writes.
|
||||
*/
|
||||
export function writeAgentsMd(agentsPath: string, context: string): void {
|
||||
if (!agentsPath) return;
|
||||
|
||||
const dir = dirname(agentsPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let existingContent = '';
|
||||
if (existsSync(agentsPath)) {
|
||||
existingContent = readFileSync(agentsPath, 'utf-8');
|
||||
}
|
||||
|
||||
const contentBlock = `# Memory Context\n\n${context}`;
|
||||
const finalContent = replaceTaggedContent(existingContent, contentBlock);
|
||||
const tempFile = `${agentsPath}.tmp`;
|
||||
|
||||
try {
|
||||
writeFileSync(tempFile, finalContent);
|
||||
renameSync(tempFile, agentsPath);
|
||||
} catch (error) {
|
||||
logger.error('AGENTS_MD', 'Failed to write AGENTS.md', { agentsPath }, error as Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Tests for hook-command error classifier
|
||||
*
|
||||
* Validates that isWorkerUnavailableError correctly distinguishes between:
|
||||
* - Transport failures (ECONNREFUSED, etc.) → true (graceful degradation)
|
||||
* - Server errors (5xx) → true (graceful degradation)
|
||||
* - Client errors (4xx) → false (handler bug, blocking)
|
||||
* - Programming errors (TypeError, etc.) → false (code bug, blocking)
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isWorkerUnavailableError } from '../src/cli/hook-command.js';
|
||||
|
||||
describe('isWorkerUnavailableError', () => {
|
||||
describe('transport failures → true (graceful)', () => {
|
||||
it('should classify ECONNREFUSED as worker unavailable', () => {
|
||||
const error = new Error('connect ECONNREFUSED 127.0.0.1:37777');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ECONNRESET as worker unavailable', () => {
|
||||
const error = new Error('socket hang up ECONNRESET');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify EPIPE as worker unavailable', () => {
|
||||
const error = new Error('write EPIPE');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ETIMEDOUT as worker unavailable', () => {
|
||||
const error = new Error('connect ETIMEDOUT 127.0.0.1:37777');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "fetch failed" as worker unavailable', () => {
|
||||
const error = new TypeError('fetch failed');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "Unable to connect" as worker unavailable', () => {
|
||||
const error = new Error('Unable to connect to server');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ENOTFOUND as worker unavailable', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND localhost');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "socket hang up" as worker unavailable', () => {
|
||||
const error = new Error('socket hang up');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ECONNABORTED as worker unavailable', () => {
|
||||
const error = new Error('ECONNABORTED');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout errors → true (graceful)', () => {
|
||||
it('should classify "timed out" as worker unavailable', () => {
|
||||
const error = new Error('Request timed out after 3000ms');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "timeout" as worker unavailable', () => {
|
||||
const error = new Error('Connection timeout');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 5xx server errors → true (graceful)', () => {
|
||||
it('should classify 500 status as worker unavailable', () => {
|
||||
const error = new Error('Context generation failed: 500');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify 502 status as worker unavailable', () => {
|
||||
const error = new Error('Observation storage failed: 502');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify 503 status as worker unavailable', () => {
|
||||
const error = new Error('Request failed: 503');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "status: 500" format as worker unavailable', () => {
|
||||
const error = new Error('HTTP error status: 500');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 429 rate limit → true (graceful)', () => {
|
||||
it('should classify 429 as worker unavailable (rate limit is transient)', () => {
|
||||
const error = new Error('Request failed: 429');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "status: 429" format as worker unavailable', () => {
|
||||
const error = new Error('HTTP error status: 429');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 4xx client errors → false (blocking)', () => {
|
||||
it('should NOT classify 400 Bad Request as worker unavailable', () => {
|
||||
const error = new Error('Request failed: 400');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify 404 Not Found as worker unavailable', () => {
|
||||
const error = new Error('Observation storage failed: 404');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify 422 Validation Error as worker unavailable', () => {
|
||||
const error = new Error('Request failed: 422');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify "status: 400" format as worker unavailable', () => {
|
||||
const error = new Error('HTTP error status: 400');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('programming errors → false (blocking)', () => {
|
||||
it('should NOT classify TypeError as worker unavailable', () => {
|
||||
const error = new TypeError('Cannot read properties of undefined');
|
||||
// Note: TypeError with "fetch failed" IS classified as unavailable (transport layer)
|
||||
// But generic TypeErrors are NOT
|
||||
expect(isWorkerUnavailableError(new TypeError('Cannot read properties of undefined'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify ReferenceError as worker unavailable', () => {
|
||||
const error = new ReferenceError('foo is not defined');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify SyntaxError as worker unavailable', () => {
|
||||
const error = new SyntaxError('Unexpected token');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown errors → false (blocking, conservative)', () => {
|
||||
it('should NOT classify generic Error as worker unavailable', () => {
|
||||
const error = new Error('Something unexpected happened');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle string errors', () => {
|
||||
expect(isWorkerUnavailableError('ECONNREFUSED')).toBe(true);
|
||||
expect(isWorkerUnavailableError('random error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined errors', () => {
|
||||
expect(isWorkerUnavailableError(null)).toBe(false);
|
||||
expect(isWorkerUnavailableError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,18 +28,22 @@ describe('hook-constants', () => {
|
||||
expect(HOOK_TIMEOUTS.DEFAULT).toBe(300000);
|
||||
});
|
||||
|
||||
it('should define HEALTH_CHECK timeout', () => {
|
||||
expect(HOOK_TIMEOUTS.HEALTH_CHECK).toBe(30000);
|
||||
it('should define HEALTH_CHECK timeout as 3s (reduced from 30s)', () => {
|
||||
expect(HOOK_TIMEOUTS.HEALTH_CHECK).toBe(3000);
|
||||
});
|
||||
|
||||
it('should define POST_SPAWN_WAIT as 5s', () => {
|
||||
expect(HOOK_TIMEOUTS.POST_SPAWN_WAIT).toBe(5000);
|
||||
});
|
||||
|
||||
it('should define PORT_IN_USE_WAIT as 3s', () => {
|
||||
expect(HOOK_TIMEOUTS.PORT_IN_USE_WAIT).toBe(3000);
|
||||
});
|
||||
|
||||
it('should define WORKER_STARTUP_WAIT', () => {
|
||||
expect(HOOK_TIMEOUTS.WORKER_STARTUP_WAIT).toBe(1000);
|
||||
});
|
||||
|
||||
it('should define WORKER_STARTUP_RETRIES', () => {
|
||||
expect(HOOK_TIMEOUTS.WORKER_STARTUP_RETRIES).toBe(300);
|
||||
});
|
||||
|
||||
it('should define PRE_RESTART_SETTLE_DELAY', () => {
|
||||
expect(HOOK_TIMEOUTS.PRE_RESTART_SETTLE_DELAY).toBe(2000);
|
||||
});
|
||||
|
||||
@@ -87,6 +87,12 @@ describe('GracefulShutdown', () => {
|
||||
})
|
||||
};
|
||||
|
||||
const mockChromaServer = {
|
||||
stop: mock(async () => {
|
||||
callOrder.push('chromaServer.stop');
|
||||
})
|
||||
};
|
||||
|
||||
// Create a PID file so we can verify it's removed
|
||||
writePidFile({ pid: 12345, port: 37777, startedAt: new Date().toISOString() });
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
@@ -95,16 +101,18 @@ describe('GracefulShutdown', () => {
|
||||
server: mockServer,
|
||||
sessionManager: mockSessionManager,
|
||||
mcpClient: mockMcpClient,
|
||||
dbManager: mockDbManager
|
||||
dbManager: mockDbManager,
|
||||
chromaServer: mockChromaServer
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then DB
|
||||
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then Chroma, then DB
|
||||
expect(callOrder).toContain('closeAllConnections');
|
||||
expect(callOrder).toContain('serverClose');
|
||||
expect(callOrder).toContain('sessionManager.shutdownAll');
|
||||
expect(callOrder).toContain('mcpClient.close');
|
||||
expect(callOrder).toContain('chromaServer.stop');
|
||||
expect(callOrder).toContain('dbManager.close');
|
||||
|
||||
// Verify server closes before session manager
|
||||
@@ -115,6 +123,9 @@ describe('GracefulShutdown', () => {
|
||||
|
||||
// Verify MCP closes before database
|
||||
expect(callOrder.indexOf('mcpClient.close')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
|
||||
// Verify Chroma stops before DB closes
|
||||
expect(callOrder.indexOf('chromaServer.stop')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
});
|
||||
|
||||
it('should remove PID file during shutdown', async () => {
|
||||
@@ -184,7 +195,7 @@ describe('GracefulShutdown', () => {
|
||||
expect(mockSessionManager.shutdownAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should close database after MCP client', async () => {
|
||||
it('should stop chroma server before database close', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
@@ -205,16 +216,23 @@ describe('GracefulShutdown', () => {
|
||||
})
|
||||
};
|
||||
|
||||
const mockChromaServer = {
|
||||
stop: mock(async () => {
|
||||
callOrder.push('chromaServer');
|
||||
})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager,
|
||||
mcpClient: mockMcpClient,
|
||||
dbManager: mockDbManager
|
||||
dbManager: mockDbManager,
|
||||
chromaServer: mockChromaServer
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
expect(callOrder).toEqual(['sessionManager', 'mcpClient', 'dbManager']);
|
||||
expect(callOrder).toEqual(['sessionManager', 'mcpClient', 'chromaServer', 'dbManager']);
|
||||
});
|
||||
|
||||
it('should handle shutdown when PID file does not exist', async () => {
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
removePidFile,
|
||||
getPlatformTimeout,
|
||||
parseElapsedTime,
|
||||
isProcessAlive,
|
||||
cleanStalePidFile,
|
||||
spawnDaemon,
|
||||
type PidInfo
|
||||
} from '../../src/services/infrastructure/index.js';
|
||||
|
||||
@@ -221,4 +224,138 @@ describe('ProcessManager', () => {
|
||||
expect(result).toBe(666);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProcessAlive', () => {
|
||||
it('should return true for the current process', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a non-existent PID', () => {
|
||||
// Use a very high PID that's extremely unlikely to exist
|
||||
expect(isProcessAlive(2147483647)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for PID 0 (Windows WMIC sentinel)', () => {
|
||||
expect(isProcessAlive(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for negative PIDs', () => {
|
||||
expect(isProcessAlive(-1)).toBe(false);
|
||||
expect(isProcessAlive(-999)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-integer PIDs', () => {
|
||||
expect(isProcessAlive(1.5)).toBe(false);
|
||||
expect(isProcessAlive(NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanStalePidFile', () => {
|
||||
it('should remove PID file when process is dead', () => {
|
||||
// Write a PID file with a non-existent PID
|
||||
const staleInfo: PidInfo = {
|
||||
pid: 2147483647,
|
||||
port: 37777,
|
||||
startedAt: '2024-01-01T00:00:00.000Z'
|
||||
};
|
||||
writePidFile(staleInfo);
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
cleanStalePidFile();
|
||||
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep PID file when process is alive', () => {
|
||||
// Write a PID file with the current process PID (definitely alive)
|
||||
const liveInfo: PidInfo = {
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
writePidFile(liveInfo);
|
||||
|
||||
cleanStalePidFile();
|
||||
|
||||
// PID file should still exist since process.pid is alive
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('should do nothing when PID file does not exist', () => {
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => cleanStalePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnDaemon', () => {
|
||||
it('should use setsid on Linux when available', () => {
|
||||
// setsid should exist at /usr/bin/setsid on Linux
|
||||
if (process.platform === 'win32') return; // Skip on Windows
|
||||
|
||||
const setsidAvailable = existsSync('/usr/bin/setsid');
|
||||
if (!setsidAvailable) return; // Skip if setsid not installed
|
||||
|
||||
// Spawn a daemon with a non-existent script (it will fail to start, but we can verify the spawn attempt)
|
||||
// Use a harmless script path — the child will exit immediately
|
||||
const pid = spawnDaemon('/dev/null', 39999);
|
||||
|
||||
// setsid spawn should return a PID (the setsid process itself)
|
||||
expect(pid).toBeDefined();
|
||||
expect(typeof pid).toBe('number');
|
||||
|
||||
// Clean up: kill the spawned process if it's still alive
|
||||
if (pid !== undefined && pid > 0) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('should return undefined when spawn fails on Windows path', () => {
|
||||
// On non-Windows, this tests the Unix path which should succeed
|
||||
// The function should not throw, only return undefined on failure
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Spawning with a totally invalid script should still return a PID
|
||||
// (setsid/spawn succeeds even if the child will exit immediately)
|
||||
const result = spawnDaemon('/nonexistent/script.cjs', 39998);
|
||||
// spawn itself should succeed (returns PID), even if child exits
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
if (result !== undefined && result > 0) {
|
||||
try { process.kill(result, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SIGHUP handling', () => {
|
||||
it('should have SIGHUP listeners registered (integration check)', () => {
|
||||
// Verify that SIGHUP listener registration is possible on Unix
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Register a test handler, verify it works, then remove it
|
||||
let received = false;
|
||||
const testHandler = () => { received = true; };
|
||||
|
||||
process.on('SIGHUP', testHandler);
|
||||
expect(process.listenerCount('SIGHUP')).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Clean up the test handler
|
||||
process.removeListener('SIGHUP', testHandler);
|
||||
});
|
||||
|
||||
it('should ignore SIGHUP when --daemon is in process.argv', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Simulate the daemon SIGHUP handler logic
|
||||
const isDaemon = process.argv.includes('--daemon');
|
||||
// In test context, --daemon is not in argv, so this tests the branch logic
|
||||
expect(isDaemon).toBe(false);
|
||||
|
||||
// Verify the non-daemon path: SIGHUP should trigger shutdown (covered by registerSignalHandlers)
|
||||
// This is a logic verification test — actual signal delivery is tested manually
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as childProcess from 'child_process';
|
||||
import { ChromaServerManager } from '../../../src/services/sync/ChromaServerManager.js';
|
||||
|
||||
function createFakeProcess(pid: number = 4242): childProcess.ChildProcess {
|
||||
const proc = new EventEmitter() as childProcess.ChildProcess & EventEmitter;
|
||||
let exited = false;
|
||||
|
||||
(proc as any).stdout = new EventEmitter();
|
||||
(proc as any).stderr = new EventEmitter();
|
||||
(proc as any).pid = pid;
|
||||
(proc as any).kill = mock(() => {
|
||||
if (!exited) {
|
||||
exited = true;
|
||||
setTimeout(() => proc.emit('exit', 0, 'SIGTERM'), 0);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return proc as childProcess.ChildProcess;
|
||||
}
|
||||
|
||||
describe('ChromaServerManager', () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
ChromaServerManager.reset();
|
||||
|
||||
// Avoid macOS cert bundle shelling in tests; these tests only exercise startup races.
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
mock.restore();
|
||||
ChromaServerManager.reset();
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses in-flight startup and only spawns one server process', async () => {
|
||||
const fetchMock = mock(async () => {
|
||||
// First call: existing server check fails, second call: waitForReady succeeds.
|
||||
if (fetchMock.mock.calls.length === 1) {
|
||||
throw new Error('no server yet');
|
||||
}
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation(
|
||||
() => createFakeProcess() as unknown as ReturnType<typeof childProcess.spawn>
|
||||
);
|
||||
|
||||
const manager = ChromaServerManager.getInstance({
|
||||
dataDir: '/tmp/chroma-test',
|
||||
host: '127.0.0.1',
|
||||
port: 8000
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
manager.start(2000),
|
||||
manager.start(2000)
|
||||
]);
|
||||
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(true);
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reuses existing reachable server without spawning', async () => {
|
||||
global.fetch = mock(async () => new Response(null, { status: 200 })) as typeof fetch;
|
||||
const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation(
|
||||
() => createFakeProcess() as unknown as ReturnType<typeof childProcess.spawn>
|
||||
);
|
||||
|
||||
const manager = ChromaServerManager.getInstance({
|
||||
dataDir: '/tmp/chroma-test',
|
||||
host: '127.0.0.1',
|
||||
port: 8000
|
||||
});
|
||||
|
||||
const ready = await manager.start(2000);
|
||||
expect(ready).toBe(true);
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for ongoing startup instead of returning early', async () => {
|
||||
let resolveReady: ((value: Response) => void) | null = null;
|
||||
const delayedReady = new Promise<Response>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
|
||||
const fetchMock = mock(async () => {
|
||||
// 1st: existing server check -> fail, 2nd: waitForReady -> block until we resolve.
|
||||
if (fetchMock.mock.calls.length === 1) {
|
||||
throw new Error('no server yet');
|
||||
}
|
||||
return delayedReady;
|
||||
});
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
spyOn(childProcess, 'spawn').mockImplementation(
|
||||
() => createFakeProcess() as unknown as ReturnType<typeof childProcess.spawn>
|
||||
);
|
||||
|
||||
const manager = ChromaServerManager.getInstance({
|
||||
dataDir: '/tmp/chroma-test',
|
||||
host: '127.0.0.1',
|
||||
port: 8000
|
||||
});
|
||||
|
||||
const firstStart = manager.start(5000);
|
||||
let secondResolved = false;
|
||||
const secondStart = manager.start(5000).then((value) => {
|
||||
secondResolved = true;
|
||||
return value;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(secondResolved).toBe(false);
|
||||
|
||||
resolveReady!(new Response(null, { status: 200 }));
|
||||
|
||||
expect(await firstStart).toBe(true);
|
||||
expect(await secondStart).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"version": 1,
|
||||
"schemas": {
|
||||
"codex": {
|
||||
"name": "codex",
|
||||
"version": "0.2",
|
||||
"description": "Schema for Codex session JSONL files under ~/.codex/sessions.",
|
||||
"events": [
|
||||
{
|
||||
"name": "session-meta",
|
||||
"match": { "path": "type", "equals": "session_meta" },
|
||||
"action": "session_context",
|
||||
"fields": {
|
||||
"sessionId": "payload.id",
|
||||
"cwd": "payload.cwd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "turn-context",
|
||||
"match": { "path": "type", "equals": "turn_context" },
|
||||
"action": "session_context",
|
||||
"fields": {
|
||||
"cwd": "payload.cwd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user-message",
|
||||
"match": { "path": "payload.type", "equals": "user_message" },
|
||||
"action": "session_init",
|
||||
"fields": {
|
||||
"prompt": "payload.message"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assistant-message",
|
||||
"match": { "path": "payload.type", "equals": "agent_message" },
|
||||
"action": "assistant_message",
|
||||
"fields": {
|
||||
"message": "payload.message"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tool-use",
|
||||
"match": { "path": "payload.type", "in": ["function_call", "custom_tool_call", "web_search_call"] },
|
||||
"action": "tool_use",
|
||||
"fields": {
|
||||
"toolId": "payload.call_id",
|
||||
"toolName": {
|
||||
"coalesce": [
|
||||
"payload.name",
|
||||
{ "value": "web_search" }
|
||||
]
|
||||
},
|
||||
"toolInput": {
|
||||
"coalesce": [
|
||||
"payload.arguments",
|
||||
"payload.input",
|
||||
"payload.action"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tool-result",
|
||||
"match": { "path": "payload.type", "in": ["function_call_output", "custom_tool_call_output"] },
|
||||
"action": "tool_result",
|
||||
"fields": {
|
||||
"toolId": "payload.call_id",
|
||||
"toolResponse": "payload.output"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "session-end",
|
||||
"match": { "path": "payload.type", "equals": "turn_aborted" },
|
||||
"action": "session_end"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"watches": [
|
||||
{
|
||||
"name": "codex",
|
||||
"path": "~/.codex/sessions/**/*.jsonl",
|
||||
"schema": "codex",
|
||||
"startAtEnd": true,
|
||||
"context": {
|
||||
"mode": "agents",
|
||||
"path": "~/.codex/AGENTS.md",
|
||||
"updateOn": ["session_start", "session_end"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"stateFile": "~/.claude-mem/transcript-watch-state.json"
|
||||
}
|
||||
Reference in New Issue
Block a user