Compare commits

...

84 Commits

Author SHA1 Message Date
Alex Newman 327dd44992 chore: bump version to 10.1.0
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:17:34 -05:00
Alex Newman 0e11d4812a Merge pull request #1125 from thedotmack/feat/session-start-system-message
feat: SessionStart systemMessage + cleaner defaults
2026-02-16 00:15:52 -05:00
Alex Newman 676a3d175e fix: make context and colored timeline fetches truly parallel
Address PR #1125 review feedback - both fetches now start simultaneously
via Promise.all instead of sequential-then-parallel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:11:25 -05:00
Alex Newman 34358ab33d feat: add systemMessage support for SessionStart hook and tune defaults
Add systemMessage field to HookResult so SessionStart can display a
colored timeline directly to the user in the CLI. The handler now
parallel-fetches both markdown (for Claude context) and ANSI-colored
(for user display) timelines, appending a viewer URL link.

Also update default settings to hide verbose token columns (read/work
tokens, savings amount) and disable full observation expansion, keeping
the cleaner index-only view by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:05:13 -05:00
Alex Newman 5ccaf40ad0 docs: update CHANGELOG.md for v10.0.8
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:33:51 -05:00
Alex Newman 51abe5d1ff chore: bump version to 10.0.8
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:33:20 -05:00
Alex Newman 2dea824cc0 Merge pull request #1122 from thedotmack/claude/friendly-pascal
fix: resolve orphaned subprocesses and Chroma HTTP regressions
2026-02-15 23:31:10 -05:00
Alex Newman 055888e181 fix: address PR review feedback for subprocess cleanup and binary resolution
Wrap SDK query loop in try/finally so subprocess cleanup runs on error paths.
Swap Chroma binary check order to try project-level .bin first (common case).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:24:00 -05:00
Alex Newman 67ba17cc8a fix: use WASM backend for Chroma embeddings to fix cross-platform issues
Chroma requires client-side embeddings — the server is storage only.
The previous commit incorrectly removed @chroma-core/default-embed.

Uses DefaultEmbeddingFunction({ wasm: true }) which forces the WASM
backend instead of native ONNX binaries. Same model (all-MiniLM-L6-v2),
same embeddings, but works on all platforms without segfaults or
ENOENT errors (#1104, #1105, #1110).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:14:21 -05:00
Alex Newman e1ef14dbcc fix: resolve orphaned subprocesses and Chroma HTTP regressions
- Add subprocess cleanup after SDK query loop completes, using existing
  ProcessRegistry infrastructure (getProcessBySession + ensureProcessExit)
- Replace npx-based Chroma binary spawning with absolute path resolution
  via require.resolve, falling back to npx with explicit cwd (#1120)
- Remove @chroma-core/default-embed client-side dependency; let Chroma
  HTTP server handle embeddings server-side (#1104, #1105, #1110)

Closes #1010, #1089, #1090, #1068, #1120, #1104, #1105, #1110

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:04:52 -05:00
Alex Newman 685d54f2cb ci: add npm publish workflow on tag push
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:06:24 -05:00
Alex Newman 490f36099f docs: update CHANGELOG.md for v10.0.7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:53:19 -05:00
Alex Newman f9ff2b22f2 chore: gitignore .claude/plans and .claude/worktrees
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:52:36 -05:00
Alex Newman 0ac4c7b8a9 chore: bump version to 10.0.7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:52:11 -05:00
Alex Newman 47f6f0f239 Merge pull request #792 from bigph00t/feat/chroma-http-server
feat: Replace MCP subprocess with persistent Chroma HTTP server
2026-02-14 14:23:24 -05:00
Alex Newman c27314f896 fix: address PR review comments for chroma server lifecycle 2026-02-13 23:39:30 -05:00
Alex Newman 1b68c55763 fix: resolve SDK spawn failures and sharp native binary crashes
- Strip CLAUDECODE env var from SDK subprocesses to prevent "cannot be
  launched inside another Claude Code session" error (Claude Code 2.1.42+)
- Lazy-load @chroma-core/default-embed to avoid eagerly pulling in
  sharp native binaries at bundle startup (fixes ERR_DLOPEN_FAILED)
- Add stderr capture to SDK spawn for diagnosing future process failures
- Exclude lockfiles from marketplace rsync and delete stale lockfiles
  before npm install to prevent native dep version mismatches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:47:27 -05:00
Alex Newman ed313db742 Merge main into feat/chroma-http-server
Resolve conflicts between Chroma HTTP server PR and main branch changes
(folder CLAUDE.md, exclusion settings, Zscaler SSL, transport cleanup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:02:54 -05:00
Alex Newman f435ae32ce docs: update openclaw install URLs to install.cmem.ai
Replace raw.githubusercontent.com URLs with install.cmem.ai/openclaw.sh
across install script, SKILL.md, and docs. Add OpenClaw section with
install one-liner to README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:46:49 -05:00
Alex Newman 548d3677f0 fix: mkdir -p install/public before copying scripts in deploy workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:40:25 -05:00
Alex Newman b0e0bd23c9 Add Vercel deploy workflow for install scripts
Serves openclaw/install.sh at install.cmem.ai/openclaw.sh via Vercel.
Auto-deploys on changes to openclaw/install.sh on main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:39:43 -05:00
Alex Newman 2bd5e981bc docs: update CHANGELOG.md for v10.0.6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:03:17 -05:00
Alex Newman 5de728612e chore: bump version to 10.0.6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:02:37 -05:00
Alex Newman 64019ee28d Merge pull request #1084 from thedotmack/fix/openclaw-plugin-project-query-and-feed-bottoken
fix(openclaw): fix MEMORY.md project query mismatch and add feed botToken support
2026-02-12 23:51:22 -05:00
Alex Newman 6cad77328b fix(openclaw): fix MEMORY.md project query mismatch and add feed botToken support
Three fixes for the OpenClaw plugin:

1. Fix MEMORY.md sync returning empty content
   - syncMemoryToWorkspace() was querying basename(workspaceDir) as the
     project name (e.g. 'workspace'), but observations are stored under
     agent-scoped names like 'openclaw-main'
   - Now queries both the base project and agent-scoped project name
   - Passes EventContext through so the correct project can be derived

2. Add dedicated botToken support for observation feed
   - New optional 'botToken' field in observationFeed config
   - When set, sends observations directly via Telegram Bot API instead
     of routing through the gateway's channel plugin
   - Allows using a separate bot for the observation stream

3. Fix plugin kind for memory slot compatibility
   - Changed plugin kind from 'integration' to 'memory' so OpenClaw
     recognizes it as a valid memory slot plugin
   - Fixes 'memory slot plugin not found' warning when
     plugins.slots.memory = 'claude-mem' is configured
2026-02-12 23:48:44 -05:00
Alex Newman 26ac35ad40 docs: update CHANGELOG.md for v10.0.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:27:19 -05:00
Alex Newman 31514d1943 chore: bump version to 10.0.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:26:36 -05:00
Alex Newman 52ea452010 Merge pull request #1076 from thedotmack/openclaw-installer
Add OpenClaw one-liner installer script with comprehensive test suite
2026-02-12 22:22:25 -05:00
Alex Newman c469e0acc3 fix: remove opinionated filters from OpenClaw plugin
Remove arbitrary TOOL_RESULT_MAX_LENGTH truncation, capture all content
blocks instead of only the first, observe all tool uses including
memory_ tools, and filter context injection by workspace directory
instead of hardcoded project name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:14:16 -05:00
Alex Newman f05f9ca735 Merge remote-tracking branch 'origin/main' into openclaw-installer
# Conflicts:
#	plugin/scripts/mcp-server.cjs
#	plugin/scripts/worker-service.cjs
2026-02-12 22:04:03 -05:00
Alex Newman 1d76f93304 feat: harden installer with rich health check diagnostics
Two-stage health verification (health + readiness), 30s timeout,
parse_health_json() helper with jq/python3/node fallbacks, smart
port-conflict handling with version/provider mismatch detection,
and enhanced completion summary showing version, AI auth, and uptime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:58:15 -05:00
Alex Newman 05e904e613 feat: enhance /api/health with version, uptime, workerPath, and AI status
Replace hardcoded TEST-008 build ID with real package version. Add worker
filesystem path, uptime counter, and AI provider status (including last
interaction success/failure tracking) to the health endpoint response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:16:22 -05:00
Alex Newman 095f6fde47 fix: also remove stale memory slot reference during reinstall cleanup
The stale config cleanup removed plugins.entries['claude-mem'] but left
plugins.slots.memory pointing to it. OpenClaw's config validator rejects
ALL CLI commands when a slot references a non-existent plugin, blocking
`openclaw plugins install` from running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:55:59 -05:00
Alex Newman 1130bbc090 fix: handle stale plugin config that blocks OpenClaw CLI during reinstall
OpenClaw's config validator rejects unrecognized keys and references to
uninstalled plugins, blocking ALL CLI commands including `plugins install`.
The installer now temporarily removes the stale claude-mem entry before
installing, then restores the saved plugin config (workerPort, observationFeed,
etc.) after successful installation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:41:44 -05:00
Alex Newman 76f984ce7c fix: add --branch flag and handle existing plugin on reinstall
The installer always cloned from main branch, making it impossible to test
changes on feature branches. Added --branch flag (e.g. --branch=openclaw-installer)
to override the default.

Also fixes "plugin already exists" error by removing the existing plugin
directory (~/.openclaw/extensions/claude-mem) before running plugins install,
allowing clean reinstallation without manual cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 17:10:59 -05:00
Alex Newman 6535ad597f fix: use event.prompt instead of ctx.sessionKey for prompt storage in OpenClaw plugin
The before_agent_start handler was passing ctx.sessionKey (e.g. "agent:main:main")
as the prompt to the worker API, causing the viewer to display the session key
instead of actual user prompt text. Now correctly reads event.prompt from
OpenClaw's BeforeAgentStartEvent.

Also adds message_received hook to capture inbound user prompts from messaging
channels (Telegram, Discord, etc.) and stores them via the worker API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:17:32 -05:00
Alex Newman 40f81b4d2b fix: detect both openclaw and openclaw.mjs binary names in gateway discovery
find_openclaw() only searched for openclaw.mjs, missing systems where the
binary is named just "openclaw" (e.g., npm install -g openclaw). Now checks
both names in PATH and in hardcoded paths. Added run_openclaw() helper that
invokes via node for .mjs files and directly for standalone binaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:12:29 -05:00
Alex Newman 54ca601e8f fix: pass file paths via env vars instead of bash interpolation in node -e calls
Addresses PR review feedback: bash variable interpolation into JavaScript
string literals could allow injection if paths contain special characters.
All 4 node -e calls now receive paths via process.env instead of ${var}
interpolation: package.json writer, config creator, config updater, and
PID file writer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:04:13 -05:00
Alex Newman de549cac05 Update openclaw/install.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-12 12:37:50 -05:00
Alex Newman 83d474b13d Update openclaw/install.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-12 12:37:42 -05:00
Alex Newman 9480ef06ab Update openclaw/install.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-12 12:36:28 -05:00
Alex Newman c099e8eb27 Update openclaw/install.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-12 12:36:14 -05:00
Alex Newman 1325f05432 MAESTRO: finalize distribution with one-liner installer in SKILL.md and distribution readiness tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 01:38:32 -05:00
Alex Newman cfd19ae232 MAESTRO: fix check_uv test to handle system-path uv installations
The check_uv not-found test was failing because find_uv_path checks
hardcoded system paths (/opt/homebrew/bin/uv, /usr/local/bin/uv) that
can't be overridden via HOME or PATH. Added graceful skip when uv is
installed at non-overridable system paths.

All 171/171 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 01:34:27 -05:00
Alex Newman cb6ff8738b MAESTRO: add error recovery, upgrade handling, and edge case hardening to install.sh
- Add --upgrade flag that detects existing installations and skips clone/build/register
- Add global trap-based cleanup (register_cleanup_dir + cleanup_on_exit) for temp dirs
- Add check_git() with platform-specific install suggestions (xcode-select on macOS, apt on Linux)
- Add check_port_37777() to detect worker already running before starting a new one
- Add is_claude_mem_installed() for upgrade detection via plugin directory check
- Add ensure_jq_or_fallback() utility for JSON operations with jq/node fallback
- All 160 tests pass (23 new tests for error handling functions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 22:10:39 -05:00
Alex Newman 81ebb8b6c0 MAESTRO: add TTY detection, --provider/--api-key flags, and curl | bash support to install.sh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 22:06:20 -05:00
Alex Newman 9bdd00ea5a MAESTRO: add jq/python3/node fallback chain for observation feed config writing
write_observation_feed_config() now uses jq as the primary JSON
manipulation tool, falls back to python3, then to node. This gives
users the most reliable path regardless of their system tooling.
Added 15 new tests covering all three fallback paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:59:19 -05:00
Alex Newman 6f35e543ca MAESTRO: add observation feed interactive setup, config writer, and updated completion summary to install.sh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:55:24 -05:00
Alex Newman ee61270e1b MAESTRO: validate install.sh — fix IS_WSL bug and API key injection in write_settings
- Fixed IS_WSL=false (non-empty string) causing "(WSL)" to always display
  on Linux platforms; now uses empty string initialization
- Refactored write_settings() to pass API key via environment variables
  instead of interpolating into JavaScript string literals, preventing
  potential injection or breakage with special characters
- Passes bash -n and shellcheck with zero warnings
- All 74/74 existing tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:51:09 -05:00
Alex Newman e902b74267 MAESTRO: add worker startup, health verification, and completion summary to install.sh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:39:54 -05:00
Alex Newman 3eb6d9ea8e docs: update CHANGELOG.md for v10.0.4 2026-02-11 21:37:12 -05:00
Alex Newman 98d87d7573 chore: bump version to 10.0.4
Reverts v10.0.3 chroma-mcp spawn storm fix (broken release).
Restores codebase to v10.0.2 state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:36:34 -05:00
Alex Newman f7fea1f779 MAESTRO: add interactive AI provider setup and settings writer to install.sh
Add setup_ai_provider() with 3-option menu (Claude Max/Gemini/OpenRouter),
mask_api_key() for secure terminal display, and write_settings() that generates
~/.claude-mem/settings.json with all 35 defaults from SettingsDefaultsManager.ts
in flat JSON schema. Preserves existing user customizations on re-run.
23 new tests added (46/46 total pass). Passes bash -n and shellcheck clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:35:40 -05:00
Alex Newman 1f834863a7 MAESTRO: add OpenClaw gateway detection, plugin install, and memory slot config to install.sh
Add find_openclaw()/check_openclaw() for gateway detection across PATH,
~/.openclaw/, /usr/local/bin/, and node_modules paths. Add install_plugin()
that clones, builds, creates installable package, and runs plugins install/enable
following the Dockerfile.e2e flow. Add configure_memory_slot() that creates or
updates ~/.openclaw/openclaw.json with plugins.slots.memory="claude-mem" while
preserving existing config. Includes test-install.sh with 23 passing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:31:15 -05:00
Alex Newman e4846a2046 MAESTRO: add openclaw/install.sh script foundation with banner, platform detection, and dependency management
Creates the core installer script with:
- ASCII banner and ANSI color utility functions (info/success/warn/error/prompt_user)
- Automatic terminal color support detection
- Platform detection (macOS, Linux, WSL, Windows/MINGW)
- Bun detection, version checking (>=1.1.14), and auto-installation
- uv detection and auto-installation
- find_bun_path() helper returning full path to bun binary
- --non-interactive flag for curl|bash piping safety
- All dependency patterns translated from plugin/scripts/smart-install.js

Passes bash -n syntax check and shellcheck with zero warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:27:29 -05:00
Alex Newman 0dda593c45 docs: update CHANGELOG.md for v10.0.3 2026-02-11 15:45:25 -05:00
Alex Newman 1bfb473c19 chore: bump version to 10.0.3 2026-02-11 15:44:45 -05:00
Alex Newman 3f01baebfe Merge remote-tracking branch 'origin/main' into fix/chroma-mcp-spawn-storm
# Conflicts:
#	src/services/worker-service.ts
#	tests/infrastructure/process-manager.test.ts
2026-02-11 15:43:08 -05:00
Alex Newman 46b61857ab docs: update CHANGELOG.md for v10.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:26:31 -05:00
Alex Newman 0b214a59a1 chore: bump version to 10.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:25:50 -05:00
Alex Newman 77579669f2 Merge pull request #1056 from rodboev/fix/hook-resilience-worker-lifecycle
fix: hook resilience and worker lifecycle — 87% faster recovery from dead worker
2026-02-11 15:16:06 -05:00
Rod Boev 2c5c99c0c7 fix: use etime-based sorting instead of PID ordering for process guards
Addresses Greptile review feedback:
- ChromaSync: replace PID-based sort with ps etime column + parseElapsedTime()
  for reliable age ordering (PIDs wrap and don't guarantee ordering)
- ProcessManager: filter out entries with unparseable etime (-1) before
  sorting to prevent sort corruption in cleanupExcessChromaProcesses()
2026-02-11 07:19:28 -05:00
Rod Boev a3f9e7f638 fix: prevent chroma-mcp spawn storm with 5-layer defense (641 processes → max 2)
During SIGHUP testing with 6+ active sessions, ChromaSync.ensureConnection()
had no mutex — concurrent fire-and-forget syncObservation() calls each spawned
a chroma-mcp subprocess via StdioClientTransport, creating 641 orphans in ~5min.
Error-driven reconnection formed a positive feedback loop amplifying the storm.

Defense layers:
- Layer 0: Connection mutex via promise memoization (prevents concurrent spawns)
- Layer 1: Pre-spawn process count guard using execFileSync('ps') (kills excess)
- Layer 2: Hardened close() with try-finally + Unix pkill in GracefulShutdown
- Layer 3: Count-based orphan reaper in ProcessManager (not age-based)
- Layer 4: Circuit breaker stops retries after 3 consecutive failures for 60s

Closes #1063, closes #695
Relates to #1010, #707
2026-02-11 07:19:28 -05:00
Rod Boev 4e67393d27 fix: prevent daemon silent death from SIGHUP + unhandled errors
Root cause: registerSignalHandlers() handled SIGTERM/SIGINT but not
SIGHUP. When the parent hook process exits, the kernel sends SIGHUP
to the daemon, causing immediate termination (default signal action).

Belt-and-suspenders fix:
1. SIGHUP handler: ignore in daemon mode, graceful shutdown otherwise
2. setsid: spawn daemon in new session on Linux (prevents SIGHUP delivery)
3. Global unhandledRejection/uncaughtException guards in daemon mode
2026-02-11 00:35:53 -05:00
Alex Newman cb0933a908 fix: resolve merge conflict in isWorkerUnavailableError
Missing return statement and closing brace in the programming errors
check caused a build failure after merging main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:47:46 -05:00
Alex Newman af95461a70 Merge branch 'main' into fix/hook-resilience-worker-lifecycle
# Conflicts:
#	plugin/scripts/mcp-server.cjs
#	plugin/scripts/worker-service.cjs
2026-02-10 23:37:33 -05:00
Alex Newman 79b3a61ac8 docs: update CHANGELOG.md for v10.0.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:31:20 -05:00
Alex Newman a9e3b659d3 chore: bump version to 10.0.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:30:52 -05:00
Alex Newman af9584a174 Merge pull request #1059 from Glucksberg/feat/openclaw-observation-feed
feat(openclaw): enable observation feed for OpenClaw agent sessions
2026-02-10 22:29:28 -05:00
Glucksberg 63827c9dcb fix: type ObservationSSEPayload.project as nullable
The project field can be null/undefined for malformed SSE payloads.
Update the type and getSourceLabel signature to match the runtime
null guard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 03:17:32 +00:00
Glucksberg 809175612c feat(openclaw): enable observation feed for OpenClaw agent sessions
Three fixes to make OpenClaw agent observations work end-to-end:

1. Session init in before_agent_start — the worker's privacy check
   requires a stored user prompt; without calling /api/sessions/init,
   all observations were skipped as "private"

2. Race condition fix in agent_end — await summarize before sending
   complete, preventing session deletion before in-flight observation
   POSTs arrive

3. OAuth token pass-through in buildIsolatedEnv — spawned Claude CLI
   processes now receive CLAUDE_CODE_OAUTH_TOKEN from the worker's
   env when no explicit API key is configured

Also adds agent-specific emoji mapping and dynamic project naming
for the Telegram observation feed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 02:30:18 +00:00
Alex Newman 06d9ef24f1 docs: update CHANGELOG.md for v10.0.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:38:29 -05:00
Rod Boev 22683f6910 fix: clarify TypeError order dependency in error classifier
Address Greptile review: add comment noting that TypeError('fetch failed')
is already handled by transport patterns before the instanceof check.
2026-02-10 17:50:47 -05:00
Rod Boev 7ffa1b06ee Clarify order dependency
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-10 17:36:13 -05:00
Rod Boev 418e38ee46 fix: hook resilience and worker lifecycle improvements (#957, #923, #984, #987, #1042)
Reduce timeouts to eliminate 10-30s startup delay when worker is dead
(common on WSL2 after hibernate). Add stale PID detection, graceful
error handling across all handlers, and error classification that
distinguishes worker unavailability from handler bugs.

- HEALTH_CHECK 30s→3s, new POST_SPAWN_WAIT (5s), PORT_IN_USE_WAIT (3s)
- isProcessAlive() with EPERM handling, cleanStalePidFile()
- getPluginVersion() try-catch for shutdown race (#1042)
- isWorkerUnavailableError: transport+5xx+429→exit 0, 4xx→exit 2
- No-op handler for unknown event types (#984)
- Wrap all handler fetch calls in try-catch for graceful degradation
- CLAUDE_MEM_HEALTH_TIMEOUT_MS env var override with validation
2026-02-10 15:34:35 -05:00
Rod Boev 6ac5507e4e fix: address review feedback on statusline-counts.js
- Check CLAUDE_MEM_DATA_DIR env var before settings.json (Greptile)
- Derive project before DB check for consistent output (Greptile)
- Include project in error fallback output (Greptile)
- Set executable permission for shebang compatibility (Greptile)
2026-02-10 04:39:58 -05:00
Rod Boev 9bcef1774d feat: add project-scoped statusline counter utility
Lightweight script for Claude Code statusLineCommand integration.
Returns per-project observation and prompt counts via direct SQLite
read (~15ms, no HTTP, no worker dependency).

Counts are scoped with WHERE project = ? to prevent inflated totals
from cross-project observations. Supports CLAUDE_MEM_DATA_DIR from
settings.json for custom data directory configurations.
2026-02-10 04:15:27 -05:00
bigphoot 8dd4d15b1f fix: add plugin.json to root .claude-plugin directory
Claude Code's plugin discovery looks for plugin.json at the
marketplace root level in .claude-plugin/, not nested inside
plugin/.claude-plugin/. Without this file at the root level,
skills and commands are not discovered.

This matches the structure of working plugins like claude-research-team.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:03:18 -08:00
bigphoot 2a83e530e9 feat: Add multi-tenancy support for claude-mem pro
Wire tenant, database, and API key settings into ChromaSync for
remote/pro mode. In remote mode:
- Passes tenant and database to ChromaClient for data isolation
- Adds Authorization header when API key is configured
- Logs tenant isolation connection details

Local mode unchanged - uses default_tenant without explicit params.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:02:04 -08:00
bigphoot e5d763860c fix: Remove duplicate else block from merge
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:02:04 -08:00
Alexander Knigge 9e4b401f9b Update src/services/sync/ChromaServerManager.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-01-26 13:02:04 -08:00
bigphoot 2c304eafad feat: Add DefaultEmbeddingFunction for local vector embeddings
- Added @chroma-core/default-embed dependency for local embeddings
- Updated ChromaSync to use DefaultEmbeddingFunction with collections
- Added isServerReachable() async method for reliable server detection
- Fixed start() to detect and reuse existing Chroma servers
- Updated build script to externalize native ONNX binaries
- Added runtime dependency to plugin/package.json

The embedding function uses all-MiniLM-L6-v2 model locally via ONNX,
eliminating need for external embedding API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:02:04 -08:00
bigphoot 70d6ac9daf fix: Use chromadb v3.2.2 with v2 API heartbeat endpoint
- Updated chromadb from ^1.9.2 to ^3.2.2 (includes CLI binary)
- Changed heartbeat endpoint from /api/v1 to /api/v2

The 1.9.x version did not include the CLI, causing `npx chroma run` to fail.
Version 3.2.2 includes the chroma CLI and uses the v2 API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:02:04 -08:00
bigphoot 5b3804ac08 feat: Switch to persistent Chroma HTTP server
Replace MCP subprocess approach with persistent Chroma HTTP server for
improved performance and reliability. This re-enables Chroma on Windows
by eliminating the subprocess spawning that caused console popups.

Changes:
- NEW: ChromaServerManager.ts - Manages local Chroma server lifecycle
  via `npx chroma run`
- REFACTOR: ChromaSync.ts - Uses chromadb npm package's ChromaClient
  instead of MCP subprocess (removes Windows disabling)
- UPDATE: worker-service.ts - Starts Chroma server on initialization
- UPDATE: GracefulShutdown.ts - Stops Chroma server on shutdown
- UPDATE: SettingsDefaultsManager.ts - New Chroma configuration options
- UPDATE: build-hooks.js - Mark optional chromadb deps as external

Benefits:
- Eliminates subprocess spawn latency on first query
- Single server process instead of per-operation subprocesses
- No Python/uvx dependency for local mode
- Re-enables Chroma vector search on Windows
- Future-ready for cloud-hosted Chroma (claude-mem pro)
- Cross-platform: Linux, macOS, Windows

Configuration:
  CLAUDE_MEM_CHROMA_MODE=local|remote
  CLAUDE_MEM_CHROMA_HOST=127.0.0.1
  CLAUDE_MEM_CHROMA_PORT=8000
  CLAUDE_MEM_CHROMA_SSL=false

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:02:04 -08:00
76 changed files with 8004 additions and 5351 deletions
+1 -1
View File
@@ -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"
}
+17
View File
@@ -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.
-176
View File
@@ -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)
-516
View File
@@ -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 |
-266
View File
@@ -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
-298
View File
@@ -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.
-378
View File
@@ -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
-144
View File
@@ -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)
-223
View File
@@ -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
-394
View File
@@ -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
+21
View File
@@ -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 }}
+2
View File
@@ -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
View File
@@ -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)
+10
View File
@@ -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
+23
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
.vercel
public/openclaw.sh
+12
View File
@@ -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
View File
@@ -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:
+1801
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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)"
}
}
}
+6 -1
View File
@@ -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"
]
}
}
+2 -2
View File
@@ -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
View File
@@ -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) => {
+2339
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
+61
View File
@@ -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 }));
}
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -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}"`
},
+11 -1
View File
@@ -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',
+5 -1
View File
@@ -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
View File
@@ -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
};
}
}
};
+24 -16
View File
@@ -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 };
}
};
+14 -5
View File
@@ -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;
}
+24 -16
View File
@@ -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 };
}
};
+5 -1
View File
@@ -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();
+31 -22
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -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) {
+76 -2
View File
@@ -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.
+21 -4
View File
@@ -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(),
});
});
+446
View File
@@ -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
View File
@@ -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 });
}
}
+65
View File
@@ -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;
}
}
+137
View File
@@ -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));
}
+151
View File
@@ -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;
}
+371
View File
@@ -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)
});
}
}
}
+40
View File
@@ -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)
});
}
}
+70
View File
@@ -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;
}
+224
View File
@@ -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;
}
}
+118 -6
View File
@@ -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);
+12 -1
View File
@@ -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
View File
@@ -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);
}
}
+12
View File
@@ -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)';
}
+22 -4
View File
@@ -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',
};
/**
+4 -3
View File
@@ -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
View File
@@ -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)
});
}
}
+33
View File
@@ -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);
}
}
+164
View File
@@ -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);
});
});
});
+10 -6
View File
@@ -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);
});
+23 -5
View File
@@ -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);
});
});
+94
View File
@@ -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"
}