Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb57ea598 | |||
| 6885bdb019 | |||
| 0321f4266d | |||
| 80d1deedbe | |||
| 07ab7000a8 | |||
| a656af2bff | |||
| e2a230286d | |||
| 0524fa83cd | |||
| 4d7bec4d05 | |||
| 9f529a30f5 | |||
| b34aff1aa2 | |||
| d54e574251 | |||
| c7abb01dfc | |||
| 7e07210635 | |||
| 648c84804c | |||
| 8c79b99384 | |||
| 9361e33b6d | |||
| 9e7b08445f | |||
| 033c1c4503 | |||
| 8d74031213 | |||
| 3bc3697648 | |||
| 4d7b29786b | |||
| 4c697899e0 | |||
| ef0a07f606 | |||
| 472ed8e1e0 | |||
| 5ccd81b8a3 | |||
| 678ae1e7d3 | |||
| 80a8c90a1a | |||
| 237a4c37f8 | |||
| 626654f816 | |||
| ed5189ebe9 | |||
| e7ba9acaa7 | |||
| ad902bedd9 | |||
| b88566dcdd | |||
| 1fac57535e | |||
| 10e980cd69 | |||
| 38d9ac7adb | |||
| 23058d4b0c | |||
| 503bda4868 | |||
| 4616f7ab1c | |||
| 73113321a1 | |||
| 88be01910b | |||
| 9dbf63f5d4 | |||
| 3651a34e96 | |||
| 79bc3c85b3 | |||
| 6581d2ef45 | |||
| 39db5c4882 | |||
| 3af68b7dfe | |||
| e9b4f75fb2 | |||
| 2af37422da | |||
| a32151a166 | |||
| 97ea9e45fc | |||
| ecb09df420 | |||
| 6c7acfbc1c | |||
| 44a7b2fcb9 | |||
| 7015301d8f | |||
| a5e86ad4ab | |||
| d93bde059e | |||
| d60ae14a9b |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.5.0",
|
||||
"version": "10.6.3",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
+5
-8
@@ -20,7 +20,6 @@ plugin/data.backup/
|
||||
package-lock.json
|
||||
bun.lock
|
||||
private/
|
||||
datasets/
|
||||
Auto Run Docs/
|
||||
|
||||
# Generated UI files (built from viewer-template.html)
|
||||
@@ -30,12 +29,10 @@ src/ui/viewer.html
|
||||
.mcp.json
|
||||
.cursor/
|
||||
|
||||
# Prevent literal tilde directories (path validation bug artifacts)
|
||||
~*/
|
||||
|
||||
# Prevent other malformed path directories
|
||||
http*/
|
||||
https*/
|
||||
|
||||
# Ignore WebStorm project files (for dinosaur IDE users)
|
||||
.idea/
|
||||
|
||||
.claude-octopus/
|
||||
.claude/session-intent.md
|
||||
.claude/session-plan.md
|
||||
.octo/
|
||||
+171
-275
@@ -2,6 +2,177 @@
|
||||
|
||||
All notable changes to claude-mem.
|
||||
|
||||
## [v10.6.2] - 2026-03-21
|
||||
|
||||
## fix: Activity spinner stuck spinning forever
|
||||
|
||||
The viewer UI activity spinner would spin indefinitely because `isAnySessionProcessing()` queried all pending/processing messages in the database globally — including orphaned messages from dead sessions that no generator would ever process. These orphans caused `isProcessing=true` forever.
|
||||
|
||||
### Changes
|
||||
|
||||
- Scoped `isAnySessionProcessing()` and `hasPendingMessages()` to only check sessions in the active in-memory Map, so orphaned DB messages no longer affect the spinner
|
||||
- Added `terminateSession()` method enforcing a restart-or-terminate invariant — every generator exit must either restart or fully clean up
|
||||
- Fixed 3 zombie paths in the `.finally()` handler that previously left sessions alive in memory with no generator running
|
||||
- Fixed idle-timeout race condition where fresh messages arriving between idle abort and cleanup could be silently dropped
|
||||
- Removed redundant bare `isProcessing: true` broadcast and eliminated double-iteration in `broadcastProcessingStatus()`
|
||||
- Replaced inline `require()` with proper accessor via `sessionManager.getPendingMessageStore()`
|
||||
- Added 8 regression tests for session termination invariant
|
||||
|
||||
## [v10.6.1] - 2026-03-18
|
||||
|
||||
### New Features
|
||||
- **Timeline Report Skill** — New `/timeline-report` skill generates narrative "Journey Into [Project]" reports from claude-mem's development history with token-aware economics
|
||||
- **Git Worktree Detection** — Timeline report automatically detects git worktrees and uses parent project as data source
|
||||
- **Compressed Context Output** — Markdown context injection compressed ~53% (tables → compact flat lines), reducing token overhead in session starts
|
||||
- **Full Observation Fetch** — Added `full=true` parameter to `/api/context/inject` for fetching all observations
|
||||
|
||||
### Improvements
|
||||
- Split `TimelineRenderer` into separate markdown/color rendering paths
|
||||
- Fixed timestamp ditto marker leaking across session summary boundaries
|
||||
|
||||
### Security
|
||||
- Removed arbitrary file write vulnerability (`dump_to_file` parameter)
|
||||
|
||||
## [v10.6.0] - 2026-03-18
|
||||
|
||||
## OpenClaw: System prompt context injection
|
||||
|
||||
The OpenClaw plugin no longer writes to `MEMORY.md`. Instead, it injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook using `appendSystemContext`. This keeps `MEMORY.md` under the agent's control for curated long-term memory. Context is cached for 60 seconds per project.
|
||||
|
||||
## New `syncMemoryFileExclude` config
|
||||
|
||||
Exclude specific agent IDs from automatic context injection (e.g., `["snarf", "debugger"]`). Observations are still recorded for excluded agents — only the context injection is skipped.
|
||||
|
||||
## Fix: UI settings now preserve falsy values
|
||||
|
||||
The viewer settings hook used `||` instead of `??`, which silently replaced backend values like `'0'`, `'false'`, and `''` with UI defaults. Fixed with nullish coalescing. Frontend defaults now aligned with backend `SettingsDefaultsManager`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Updated `openclaw-integration.mdx` and `openclaw/SKILL.md` to reflect system prompt injection behavior
|
||||
- Fixed "prompt injection" → "context injection" terminology to avoid confusion with the OWASP security term
|
||||
|
||||
## [v10.5.6] - 2026-03-16
|
||||
|
||||
## Patch: Process Supervisor Hardening & Logging Cleanup
|
||||
|
||||
### Fixes
|
||||
- **Downgrade HTTP request/response logging from INFO to DEBUG** — eliminates noisy per-request log spam from the viewer UI polling
|
||||
- **Fix `isPidAlive(0)` returning true** — PID 0 is the kernel scheduler, not a valid child process
|
||||
- **Fix signal handler race condition** — added `shutdownInitiated` flag to prevent duplicate shutdown cascades when signals arrive before `stopPromise` is set
|
||||
- **Remove unused `dataDir` parameter** from `ShutdownCascadeOptions`
|
||||
- **Export and reuse env sanitizer constants** — `Server.ts` now imports `ENV_PREFIXES`/`ENV_EXACT_MATCHES` from `env-sanitizer.ts` instead of duplicating them
|
||||
- **Rename `zombiePidFiles` to `deadProcessPids`** — now returns actual PID array instead of a boolean
|
||||
- **Use `buildWorkerUrl` helper** in `workerHttpRequest` instead of inline URL construction
|
||||
- **Remove unused `getWorkerPort` imports** from observation and session-init handlers
|
||||
- **Upgrade `reapSession` failure log** from debug to warn level
|
||||
- **Clean up `.gitignore`** — remove stale `~*/`, `http*/`, `https*/` patterns and duplicate `datasets/` entry
|
||||
|
||||
### Tests
|
||||
- Rewrote supervisor index tests to use temp directories instead of relying on real `~/.claude-mem/worker.pid`
|
||||
- Added deterministic test cases for missing, invalid, stale, and alive PID file states
|
||||
- Removed unused `dataDir` from shutdown test fixtures
|
||||
|
||||
## [v10.5.5] - 2026-03-09
|
||||
|
||||
### Bug Fix
|
||||
|
||||
- **Fixed empty context queries after mode switching**: Switching from a non-code mode (e.g., law-study) back to code mode left stale observation type/concept filters in `settings.json`, causing all context queries to return empty results. All modes now read types/concepts from their mode JSON definition uniformly.
|
||||
|
||||
### Cleanup
|
||||
|
||||
- Removed dead `CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES` and `CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS` settings constants
|
||||
- Deleted `src/constants/observation-metadata.ts` (no longer needed)
|
||||
- Removed observation type/concept filter UI controls from the viewer's Context Settings modal
|
||||
|
||||
## [v10.5.4] - 2026-03-09
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **fix: restore modes to correct location** — All modes (`code`, code language variants, `email-investigation`) were erroneously moved from `plugin/modes/` to `plugin/hooks/modes/` during the v10.5.3 release, breaking mode loading. This patch restores them to `plugin/modes/` where they belong.
|
||||
|
||||
## [v10.5.3] - 2026-03-09
|
||||
|
||||
## What's New
|
||||
|
||||
### Law Study Mode
|
||||
|
||||
Adds `law-study` — a purpose-built claude-mem mode for law students.
|
||||
|
||||
**Observation Types:**
|
||||
- **Case Holding** — 2-3 sentence brief with extracted legal rule
|
||||
- **Issue Pattern** — exam trigger or fact pattern that signals a legal issue
|
||||
- **Prof Framework** — professor's analytical lens and emphasis for a topic
|
||||
- **Doctrine / Rule** — legal test or standard synthesized from cases/statutes
|
||||
- **Argument Structure** — legal argument or counter-argument worked through analytically
|
||||
- **Cross-Case Connection** — insight linking cases or doctrines to reveal a deeper principle
|
||||
|
||||
**Concepts (cross-cutting tags):**
|
||||
`exam-relevant` · `minority-position` · `gotcha` · `unsettled-law` · `policy-rationale` · `course-theme`
|
||||
|
||||
**Chill Variant** — `law-study--chill` records only high-signal items: issue patterns, gotchas, and professor frameworks. Skips routine case holdings unless the result is counterintuitive.
|
||||
|
||||
**CLAUDE.md Template** — `law-study-CLAUDE.md` is a drop-in template for any law study project directory. It configures Claude as a Socratic legal study partner: precise case briefs, critical document analysis, issue spotting, and doctrine synthesis — without writing exam answers for the student.
|
||||
|
||||
Activate with: `/mode law-study` or `/mode law-study--chill`
|
||||
|
||||
## [v10.5.2] - 2026-02-26
|
||||
|
||||
## Smart Explore Benchmark Docs & Skill Update
|
||||
|
||||
### Documentation
|
||||
- Published smart-explore benchmark report to public docs — full A/B comparison with methodology, raw data tables, quality assessment, and decision framework
|
||||
- Added benchmark report to docs.json navigation under Best Practices
|
||||
|
||||
### Smart Explore Skill
|
||||
- Updated token economics with benchmark-accurate data (11-18x savings on exploration, 4-8x on file understanding)
|
||||
- Added "map first" core principle as decision heuristic for tool selection
|
||||
- Added AST completeness guarantee to smart_unfold documentation (never truncates, unlike Explore agents)
|
||||
- Added Explore agent escalation guidance for multi-file synthesis tasks
|
||||
- Updated smart_unfold token range from ~1-7k to ~400-2,100 based on measurements
|
||||
- Updated Explore agent token range from ~20-40k to ~39-59k based on measurements
|
||||
|
||||
## [v10.5.1] - 2026-02-26
|
||||
|
||||
### Bug Fix
|
||||
|
||||
- Restored hooks.json to pre-smart-explore configuration (re-adds Setup hook, separate worker start command, PostToolUse matcher)
|
||||
|
||||
## [v10.5.0] - 2026-02-26
|
||||
|
||||
## Smart Explore: AST-Powered Code Navigation
|
||||
|
||||
This release introduces **Smart Explore**, a token-optimized structural code search system built on tree-sitter AST parsing. It applies the same progressive disclosure pattern used in human-readable code outlines — but programmatically, for AI agents.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The standard exploration cycle (Glob → Grep → Read) forces agents to consume entire files to understand code structure. A typical 800-line file costs ~12,000 tokens to read. Smart Explore replaces this with a 3-layer progressive disclosure workflow that delivers the same understanding at **6-12x lower token cost**.
|
||||
|
||||
### 3 New MCP Tools
|
||||
|
||||
- **`smart_search`** — Walks directories, parses all code files via tree-sitter, and returns ranked symbols with signatures and line numbers. Replaces the Glob → Grep discovery cycle in a single call (~2-6k tokens).
|
||||
- **`smart_outline`** — Returns the complete structural skeleton of a file: all functions, classes, methods, properties, imports (~1-2k tokens vs ~12k for a full Read).
|
||||
- **`smart_unfold`** — Expands a single symbol to its full source code including JSDoc, decorators, and implementation (~1-7k tokens).
|
||||
|
||||
### Token Economics
|
||||
|
||||
| Approach | Tokens | Savings |
|
||||
|----------|--------|---------|
|
||||
| smart_outline + smart_unfold | ~3,100 | 8x vs Read |
|
||||
| smart_search (cross-file) | ~2,000-6,000 | 6-12x vs Explore agent |
|
||||
| Read (full file) | ~12,000+ | baseline |
|
||||
| Explore agent | ~20,000-40,000 | baseline |
|
||||
|
||||
### Language Support
|
||||
|
||||
10 languages via tree-sitter grammars: TypeScript, JavaScript, Python, Rust, Go, Java, C, C++, Ruby, PHP.
|
||||
|
||||
### Other Changes
|
||||
|
||||
- Simplified hooks configuration
|
||||
- Removed legacy setup.sh script
|
||||
- Security fix: replaced `execSync` with `execFileSync` to prevent command injection in file path handling
|
||||
|
||||
## [v10.4.4] - 2026-02-26
|
||||
|
||||
## Fix
|
||||
@@ -940,278 +1111,3 @@ This release adds the `/do` and `/make-plan` development commands to the plugin
|
||||
|
||||
https://github.com/thedotmack/claude-mem/compare/v9.0.3...v9.0.4
|
||||
|
||||
## [v9.0.3] - 2026-01-10
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Hook Framework JSON Status Output (#655)
|
||||
|
||||
Fixed an issue where the worker service startup wasn't producing proper JSON status output for the Claude Code hook framework. This caused hooks to appear stuck or unresponsive during worker initialization.
|
||||
|
||||
**Changes:**
|
||||
- Added `buildStatusOutput()` function for generating structured JSON status output
|
||||
- Worker now outputs JSON with `status`, `message`, and `continue` fields on stdout
|
||||
- Proper exit code 0 ensures Windows Terminal compatibility (no tab accumulation)
|
||||
- `continue: true` flag ensures Claude Code continues processing after hook execution
|
||||
|
||||
**Technical Details:**
|
||||
- Extracted status output generation into a pure, testable function
|
||||
- Added comprehensive test coverage in `tests/infrastructure/worker-json-status.test.ts`
|
||||
- 23 passing tests covering unit, CLI integration, and hook framework compatibility
|
||||
|
||||
## Housekeeping
|
||||
|
||||
- Removed obsolete error handling baseline file
|
||||
|
||||
## [v9.0.2] - 2026-01-10
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Windows Terminal Tab Accumulation (#625, #628)**: Fixed terminal tab accumulation on Windows by implementing graceful exit strategy. All expected failure scenarios (port conflicts, version mismatches, health check timeouts) now exit with code 0 instead of code 1.
|
||||
- **Windows 11 Compatibility (#625)**: Replaced deprecated WMIC commands with PowerShell `Get-Process` and `Get-CimInstance` for process enumeration. WMIC is being removed from Windows 11.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- **Removed Obsolete CLAUDE.md Files**: Cleaned up auto-generated CLAUDE.md files from `~/.claude/plans/` and `~/.claude/plugins/marketplaces/` directories.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.1...v9.0.2
|
||||
|
||||
## [v9.0.1] - 2026-01-08
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Claude Code 2.1.1 Compatibility
|
||||
- Fixed hook architecture for compatibility with Claude Code 2.1.0/2.1.1
|
||||
- Context is now injected silently via SessionStart hook
|
||||
- Removed deprecated `user-message-hook` (no longer used in CC 2.1.0+)
|
||||
|
||||
### Path Validation for CLAUDE.md Distribution
|
||||
- Added `isValidPathForClaudeMd()` to reject malformed paths:
|
||||
- Tilde paths (`~`) that Node.js doesn't expand
|
||||
- URLs (`http://`, `https://`)
|
||||
- Paths with spaces (likely command text or PR references)
|
||||
- Paths with `#` (GitHub issue/PR references)
|
||||
- Relative paths that escape project boundary
|
||||
- Cleaned up 12 invalid CLAUDE.md files created by bug artifacts
|
||||
- Updated `.gitignore` to prevent future accidents
|
||||
|
||||
### Log-Level Audit
|
||||
- Promoted 38+ WARN messages to ERROR level for improved debugging:
|
||||
- Parser: observation type errors, data contamination
|
||||
- SDK/Agents: empty init responses (Gemini, OpenRouter)
|
||||
- Worker/Queue: session recovery, auto-recovery failures
|
||||
- Chroma: sync failures, search failures
|
||||
- SQLite: search failures
|
||||
- Session/Generator: failures, missing context
|
||||
- Infrastructure: shutdown, process management failures
|
||||
|
||||
## Internal Changes
|
||||
- Removed hardcoded fake token counts from context injection
|
||||
- Standardized Claude Code 2.1.0 note wording across documentation
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.0...v9.0.1
|
||||
|
||||
## [v9.0.0] - 2026-01-06
|
||||
|
||||
## 🚀 Live Context System
|
||||
|
||||
Version 9.0.0 introduces the **Live Context System** - a major new capability that provides folder-level activity context through auto-generated CLAUDE.md files.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
#### Live Context System
|
||||
- **Folder CLAUDE.md Files**: Each directory now gets an auto-generated CLAUDE.md file containing a chronological timeline of recent development activity
|
||||
- **Activity Timelines**: Tables show observation ID, time, type, title, and estimated token cost for relevant work in each folder
|
||||
- **Worktree Support**: Proper detection of git worktrees with project-aware filtering to show only relevant observations per worktree
|
||||
- **Configurable Limits**: Control observation count via `CLAUDE_MEM_CONTEXT_OBSERVATIONS` setting
|
||||
|
||||
#### Modular Architecture Refactor
|
||||
- **Service Layer Decomposition**: Major refactoring from monolithic worker-service to modular domain services
|
||||
- **SQLite Module Extraction**: Database operations split into dedicated modules (observations, sessions, summaries, prompts, timeline)
|
||||
- **Context Builder System**: New modular context generation with TimelineRenderer, FooterRenderer, and ObservationCompiler
|
||||
- **Error Handler Centralization**: Unified Express error handling via ErrorHandler module
|
||||
|
||||
#### SDK Agent Improvements
|
||||
- **Session Resume**: Memory sessions can now resume across Claude conversations using SDK session IDs
|
||||
- **Memory Session ID Tracking**: Proper separation of content session IDs from memory session IDs
|
||||
- **Response Processor Refactor**: Cleaner message handling and observation extraction
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
#### Windows Stability
|
||||
- Fixed Windows PowerShell variable escaping in hook execution
|
||||
- Improved IPC detection for Windows managed mode
|
||||
- Better PATH handling for Bun and uv on Windows
|
||||
|
||||
#### Settings & Configuration
|
||||
- **Auto-Creation**: Settings file automatically created with defaults on first run
|
||||
- **Worker Host Configuration**: `CLAUDE_MEM_WORKER_HOST` setting for custom worker endpoints
|
||||
- Settings validation with helpful error messages
|
||||
|
||||
#### MCP Tools
|
||||
- Standardized naming: "MCP tools" terminology instead of "mem-search skill"
|
||||
- Improved tool descriptions for better Claude integration
|
||||
- Context injection API now supports worktree parameter
|
||||
|
||||
### 📚 Documentation
|
||||
- New **Folder Context Files** documentation page
|
||||
- **Worktree Support** section explaining git worktree behavior
|
||||
- Updated architecture documentation reflecting modular refactor
|
||||
- v9.0 release notes in introduction page
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed stale session resume crash when SDK session is orphaned
|
||||
- Fixed logger serialization bug causing silent ChromaSync failures
|
||||
- Fixed CLAUDE.md path resolution in worktree environments
|
||||
- Fixed date preservation in folder timeline generation
|
||||
- Fixed foreign key constraint issues in observation storage
|
||||
- Resolved multiple TypeScript type errors across codebase
|
||||
|
||||
### 🗑️ Removed
|
||||
- Deprecated context-generator.ts (functionality moved to modular system)
|
||||
- Obsolete queue analysis documents
|
||||
- Legacy worker wrapper scripts
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.5.10...v9.0.0
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
## [v8.5.10] - 2026-01-06
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **#545**: Fixed `formatTool` crash when parsing non-JSON tool inputs (e.g., raw Bash commands)
|
||||
- **#544**: Fixed terminology in context hints - changed "mem-search skill" to "MCP tools"
|
||||
- **#557**: Settings file now auto-creates with defaults on first run (no more "module loader" errors)
|
||||
- **#543**: Fixed hook execution by switching runtime from `node` to `bun` (resolves `bun:sqlite` issues)
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Fixed circular dependency between Logger and SettingsDefaultsManager
|
||||
- Added 72 integration tests for critical coverage gaps
|
||||
- Cleaned up mock-heavy tests causing module cache pollution
|
||||
|
||||
## Full Changelog
|
||||
|
||||
See PR #558 for complete details and diagnostic reports.
|
||||
|
||||
## [v8.5.9] - 2026-01-04
|
||||
|
||||
## What's New
|
||||
|
||||
### Context Header Timestamp
|
||||
|
||||
The context injection header now displays the current date and time, making it easier to understand when context was generated.
|
||||
|
||||
**Example:** `[claude-mem] recent context, 2026-01-04 2:46am EST`
|
||||
|
||||
This appears in both terminal (colored) output and markdown format, including empty state messages.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.5.8...v8.5.9
|
||||
|
||||
## [v8.5.8] - 2026-01-04
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **#511**: Add `gemini-3-flash` model to GeminiAgent with proper rate limits and validation
|
||||
- **#517**: Fix Windows process management by replacing PowerShell with WMIC (fixes Git Bash/WSL compatibility)
|
||||
- **#527**: Add Apple Silicon Homebrew paths (`/opt/homebrew/bin`) for `bun` and `uv` detection
|
||||
- **#531**: Remove duplicate type definitions from `export-memories.ts` using shared bridge file
|
||||
|
||||
## Tests
|
||||
|
||||
- Added regression tests for PR #542 covering Gemini model support, WMIC parsing, Apple Silicon paths, and export type refactoring
|
||||
|
||||
## Documentation
|
||||
|
||||
- Added detailed analysis reports for GitHub issues #511, #514, #517, #520, #527, #531, #532
|
||||
|
||||
## [v8.5.7] - 2026-01-04
|
||||
|
||||
## Modular Architecture Refactor
|
||||
|
||||
This release refactors the monolithic service architecture into focused, single-responsibility modules with comprehensive test coverage.
|
||||
|
||||
### Architecture Improvements
|
||||
|
||||
- **SQLite Repositories** (`src/services/sqlite/`) - Modular repositories for sessions, observations, prompts, summaries, and timeline
|
||||
- **Worker Agents** (`src/services/worker/agents/`) - Extracted response processing, error handling, and session cleanup
|
||||
- **Search Strategies** (`src/services/worker/search/`) - Modular search with Chroma, SQLite, and Hybrid strategies plus orchestrator
|
||||
- **Context Generation** (`src/services/context/`) - Separated context building, token calculation, formatters, and renderers
|
||||
- **Infrastructure** (`src/services/infrastructure/`) - Graceful shutdown, health monitoring, and process management
|
||||
- **Server** (`src/services/server/`) - Express server setup, middleware, and error handling
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **595 tests** across 36 test files
|
||||
- **1,120 expect() assertions**
|
||||
- Coverage for SQLite repos, worker agents, search, context, infrastructure, and server modules
|
||||
|
||||
### Session ID Refactor
|
||||
|
||||
- Aligned tests with NULL-based memory session initialization pattern
|
||||
- Updated `SESSION_ID_ARCHITECTURE.md` documentation
|
||||
|
||||
### Other Improvements
|
||||
|
||||
- Added missing logger imports to 34 files for better observability
|
||||
- Updated esbuild and MCP SDK to latest versions
|
||||
- Removed `bun.lock` from version control
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.5.6...v8.5.7
|
||||
|
||||
## [v8.5.6] - 2026-01-04
|
||||
|
||||
## Major Architectural Refactoring
|
||||
|
||||
Decomposes monolithic services into modular, maintainable components:
|
||||
|
||||
### Worker Service
|
||||
Extracted infrastructure (GracefulShutdown, HealthMonitor, ProcessManager), server layer (ErrorHandler, Middleware, Server), and integrations (CursorHooksInstaller)
|
||||
|
||||
### Context Generator
|
||||
Split into ContextBuilder, ContextConfigLoader, ObservationCompiler, TokenCalculator, formatters (Color/Markdown), and section renderers (Header/Footer/Summary/Timeline)
|
||||
|
||||
### Search System
|
||||
Extracted SearchOrchestrator, ResultFormatter, TimelineBuilder, and strategy pattern (Chroma/SQLite/Hybrid search strategies) with dedicated filters (Date/Project/Type)
|
||||
|
||||
### Agent System
|
||||
Extracted shared logic into ResponseProcessor, ObservationBroadcaster, FallbackErrorHandler, and SessionCleanupHelper
|
||||
|
||||
### SQLite Layer
|
||||
Decomposed SessionStore into domain modules (observations, prompts, sessions, summaries, timeline) with proper type exports
|
||||
|
||||
## Bug Fixes
|
||||
- Fixed duplicate observation storage bug (observations stored multiple times when messages were batched)
|
||||
- Added duplicate observation cleanup script for production database remediation
|
||||
- Fixed FOREIGN KEY constraint and missing `failed_at_epoch` column issues
|
||||
|
||||
## Coming Next
|
||||
Comprehensive test suite in a new PR, targeting **v8.6.0**
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
## [v8.5.5] - 2026-01-03
|
||||
|
||||
## Improved Error Handling and Logging
|
||||
|
||||
This patch release enhances error handling and logging across all worker services for better debugging and reliability.
|
||||
|
||||
### Changes
|
||||
- **Enhanced Error Logging**: Improved error context across SessionStore, SearchManager, SDKAgent, GeminiAgent, and OpenRouterAgent
|
||||
- **SearchManager**: Restored error handling for Chroma calls with improved logging
|
||||
- **SessionStore**: Enhanced error logging throughout database operations
|
||||
- **Bug Fix**: Fixed critical bug where `memory_session_id` could incorrectly equal `content_session_id`
|
||||
- **Hooks**: Streamlined error handling and loading states for better maintainability
|
||||
|
||||
### Investigation Reports
|
||||
- Added detailed analysis documents for generator failures and observation duplication regressions
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.5.4...v8.5.5
|
||||
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
<p align="center">
|
||||
Official $CMEM Links:
|
||||
<a href="https://bags.fm/2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS">Bags.fm</a> •
|
||||
<a href="https://jup.ag/tokens/2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS">Jupiter</a> •
|
||||
<a href="https://photon-sol.tinyastro.io/en/lp/6MzFAkWnac6GSK1EdFX93dZeukGfzrFq4UHWarhGSQyd">Photon</a> •
|
||||
<a href="https://dexscreener.com/solana/6mzfakwnac6gsk1edfx93dzeukgfzrfq4uhwarhgsqyd">DEXScreener</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Official CA: 2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS (on Solana)</p>
|
||||
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<a href="https://github.com/thedotmack/claude-mem">
|
||||
@@ -84,13 +74,40 @@
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/thedotmack/claude-mem">
|
||||
<picture>
|
||||
<img src="https://raw.githubusercontent.com/thedotmack/claude-mem/main/docs/public/cm-preview.gif" alt="Claude-Mem Preview" width="800">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/thedotmack/claude-mem">
|
||||
<picture>
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/thedotmack/claude-mem/main/docs/public/cm-preview.gif"
|
||||
alt="Claude-Mem Preview"
|
||||
width="500"
|
||||
>
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.star-history.com/#thedotmack/claude-mem&Date">
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="https://api.star-history.com/image?repos=thedotmack/claude-mem&type=date&theme=dark&legend=top-left"
|
||||
/>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="https://api.star-history.com/image?repos=thedotmack/claude-mem&type=date&legend=top-left"
|
||||
/>
|
||||
<img
|
||||
alt="Star History Chart"
|
||||
src="https://api.star-history.com/image?repos=thedotmack/claude-mem&type=date&legend=top-left"
|
||||
width="500"
|
||||
/>
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
@@ -340,3 +357,9 @@ See the [LICENSE](LICENSE) file for full details.
|
||||
---
|
||||
|
||||
**Built with Claude Agent SDK** | **Powered by Claude Code** | **Made with TypeScript**
|
||||
|
||||
---
|
||||
|
||||
### What About $CMEM?
|
||||
|
||||
$CMEM is a solana token created by a 3rd party without Claude-Mem's prior consent, but officially embraced by the creator of Claude-Mem (Alex Newman, @thedotmack). The token acts as a community catalyst for growth and a vehicle for bringing real-time agent data to the developers and knowledge workers that need it most. $CMEM: 2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
"icon": "lightbulb",
|
||||
"pages": [
|
||||
"context-engineering",
|
||||
"progressive-disclosure"
|
||||
"progressive-disclosure",
|
||||
"smart-explore-benchmark"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: OpenClaw Integration
|
||||
description: Persistent memory for OpenClaw agents — observation recording, MEMORY.md live sync, and real-time observation feeds
|
||||
description: Persistent memory for OpenClaw agents — observation recording, system prompt context injection, and real-time observation feeds
|
||||
icon: dragon
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ icon: dragon
|
||||
The OpenClaw plugin gives claude-mem persistent memory to agents running on the [OpenClaw](https://openclaw.ai) gateway. It handles three things:
|
||||
|
||||
1. **Observation recording** — Captures tool usage from OpenClaw's embedded runner and sends it to the claude-mem worker for AI processing
|
||||
2. **MEMORY.md live sync** — Writes a continuously-updated timeline to each agent's workspace so agents always have context from previous sessions
|
||||
2. **System prompt context injection** — Injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook, keeping `MEMORY.md` free for agent-curated memory
|
||||
3. **Observation feed** — Streams new observations to messaging channels (Telegram, Discord, Slack, etc.) in real-time via SSE
|
||||
|
||||
<Info>
|
||||
@@ -21,10 +21,11 @@ OpenClaw's embedded runner (`pi-embedded`) calls the Anthropic API directly with
|
||||
```plaintext
|
||||
OpenClaw Gateway
|
||||
│
|
||||
├── before_agent_start ──→ Sync MEMORY.md + Init session
|
||||
├── tool_result_persist ──→ Record observation + Re-sync MEMORY.md
|
||||
├── before_agent_start ───→ Init session
|
||||
├── before_prompt_build ──→ Inject context into system prompt
|
||||
├── tool_result_persist ──→ Record observation
|
||||
├── agent_end ────────────→ Summarize + Complete session
|
||||
└── gateway_start ────────→ Reset session tracking
|
||||
└── gateway_start ────────→ Reset session tracking + context cache
|
||||
│
|
||||
▼
|
||||
Claude-Mem Worker (localhost:37777)
|
||||
@@ -32,7 +33,7 @@ OpenClaw Gateway
|
||||
├── POST /api/sessions/observations
|
||||
├── POST /api/sessions/summarize
|
||||
├── POST /api/sessions/complete
|
||||
├── GET /api/context/inject ──→ MEMORY.md content
|
||||
├── GET /api/context/inject ──→ System prompt context
|
||||
└── GET /stream ─────────────→ SSE → Messaging channels
|
||||
```
|
||||
|
||||
@@ -40,21 +41,15 @@ OpenClaw Gateway
|
||||
|
||||
<Steps>
|
||||
<Step title="Agent starts (before_agent_start)">
|
||||
When an OpenClaw agent starts, the plugin does two things:
|
||||
When an OpenClaw agent starts, the plugin initializes a session by sending the user prompt to `POST /api/sessions/init` so the worker can create a new session and start processing.
|
||||
</Step>
|
||||
<Step title="Context injected (before_prompt_build)">
|
||||
Before each LLM call, the plugin fetches the observation timeline from the worker's `/api/context/inject` endpoint and returns it as `appendSystemContext`. This injects cross-session context directly into the agent's system prompt without writing any files.
|
||||
|
||||
1. **Syncs MEMORY.md** — Fetches the latest timeline from the worker's `/api/context/inject` endpoint and writes it to `MEMORY.md` in the agent's workspace directory. This gives the agent context from all previous sessions before it starts working.
|
||||
|
||||
2. **Initializes a session** — Sends the user prompt to `POST /api/sessions/init` so the worker can create a new session and start processing.
|
||||
|
||||
Short prompts (under 10 characters) skip session init but still sync MEMORY.md.
|
||||
The context is cached for 60 seconds to avoid re-fetching on every LLM turn within a session.
|
||||
</Step>
|
||||
<Step title="Tool use recorded (tool_result_persist)">
|
||||
Every time the agent uses a tool (Read, Write, Bash, etc.), the plugin:
|
||||
|
||||
1. **Sends the observation** to `POST /api/sessions/observations` with the tool name, input, and truncated response (max 1000 chars)
|
||||
2. **Re-syncs MEMORY.md** with the latest timeline from the worker
|
||||
|
||||
Both operations are fire-and-forget — they don't block the agent from continuing work. The MEMORY.md file gets progressively richer as the session continues.
|
||||
Every time the agent uses a tool (Read, Write, Bash, etc.), the plugin sends the observation to `POST /api/sessions/observations` with the tool name, input, and truncated response (max 1000 chars). This is fire-and-forget — it doesn't block the agent from continuing work.
|
||||
|
||||
Tools prefixed with `memory_` are skipped to avoid recursive recording.
|
||||
</Step>
|
||||
@@ -62,21 +57,18 @@ OpenClaw Gateway
|
||||
When the agent completes, the plugin extracts the last assistant message and sends it to `POST /api/sessions/summarize`, then calls `POST /api/sessions/complete` to close the session. Both are fire-and-forget.
|
||||
</Step>
|
||||
<Step title="Gateway restarts (gateway_start)">
|
||||
Clears all session tracking (session IDs, workspace directory mappings) so agents get fresh state after a gateway restart.
|
||||
Clears all session tracking (session IDs, context cache) so agents get fresh state after a gateway restart.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### MEMORY.md Live Sync
|
||||
### System Prompt Context Injection
|
||||
|
||||
The plugin writes a `MEMORY.md` file to each agent's workspace directory containing the full timeline of observations and summaries from previous sessions. This file is updated:
|
||||
The plugin injects cross-session observation context into each agent's system prompt via OpenClaw's `before_prompt_build` hook. The content comes from the worker's `GET /api/context/inject?projects=<project>` endpoint, which generates a formatted markdown timeline from the SQLite database.
|
||||
|
||||
- On every `before_agent_start` event (agent gets fresh context before starting)
|
||||
- On every `tool_result_persist` event (context stays current during the session)
|
||||
|
||||
The content comes from the worker's `GET /api/context/inject?projects=<project>` endpoint, which generates a formatted markdown timeline from the SQLite database.
|
||||
This approach keeps `MEMORY.md` under the agent's control for curated long-term memory (decisions, preferences, durable facts), while the observation timeline is delivered through the system prompt where it belongs.
|
||||
|
||||
<Info>
|
||||
MEMORY.md updates are fire-and-forget. They run in the background without blocking the agent. The file reflects whatever the worker has processed so far — it doesn't wait for the current observation to be fully processed before writing.
|
||||
Context is cached for 60 seconds per project to avoid re-fetching on every LLM turn. The cache is cleared on gateway restart. Use `syncMemoryFileExclude` to opt specific agents out of context injection entirely.
|
||||
</Info>
|
||||
|
||||
### Observation Feed (SSE → Messaging)
|
||||
@@ -319,7 +311,11 @@ The claude-mem worker service must be running on the same machine as the OpenCla
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="syncMemoryFile" type="boolean" default={true}>
|
||||
Enable automatic MEMORY.md sync to agent workspaces. Set to `false` if you don't want the plugin writing files to workspace directories.
|
||||
Inject observation context into the agent system prompt via `before_prompt_build` hook. When `true`, agents receive cross-session context automatically. Set to `false` to disable context injection entirely (observations are still recorded).
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="syncMemoryFileExclude" type="string[]" default={[]}>
|
||||
Agent IDs excluded from automatic context injection. Useful for agents that curate their own memory and don't need the observation timeline (e.g., `["snarf", "debugger"]`). Observations are still recorded for excluded agents — only the context injection is skipped.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="workerPort" type="number" default={37777}>
|
||||
@@ -374,9 +370,9 @@ The plugin uses HTTP calls to the already-running claude-mem worker service rath
|
||||
Each OpenClaw agent session gets a unique `contentSessionId` (format: `openclaw-<sessionKey>-<timestamp>`) that maps to a claude-mem session in the worker. The plugin tracks:
|
||||
|
||||
- `sessionIds` — Maps OpenClaw session keys to content session IDs
|
||||
- `workspaceDirsBySessionKey` — Maps session keys to workspace directories so `tool_result_persist` events can sync MEMORY.md even when the event context doesn't include `workspaceDir`
|
||||
- `contextCache` — TTL cache (60s) for context injection responses, keyed by project
|
||||
|
||||
Both maps are cleared on `gateway_start`.
|
||||
Both are cleared on `gateway_start`.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
title: "Smart Explore Benchmark"
|
||||
description: "Token efficiency comparison between AST-based and traditional code exploration"
|
||||
---
|
||||
|
||||
# Smart Explore Benchmark
|
||||
|
||||
Smart Explore uses tree-sitter AST parsing to provide structural code navigation through three MCP tools: `smart_search`, `smart_outline`, and `smart_unfold`. This report documents a rigorous A/B comparison against the standard Explore agent (which uses Glob, Grep, and Read tools) to quantify the token savings and quality trade-offs.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Metric | Smart Explore | Explore Agent | Advantage |
|
||||
|--------|:---:|:---:|---|
|
||||
| Discovery (cross-file search) | ~14,200 tokens | ~252,500 tokens | **17.8x cheaper** |
|
||||
| Targeted reads (specific symbols) | ~5,650 tokens | ~109,400 tokens | **19.4x cheaper** |
|
||||
| End-to-end (search + read) | ~4,200 tokens | ~45,000 tokens | **10-12x cheaper** |
|
||||
| Completeness | 5/5 full source returned | 4/5 (truncated longest method) | Smart Explore more reliable |
|
||||
| Speed | Under 2s per call | 5-66s per call | **10-30x faster** |
|
||||
|
||||
## Methodology
|
||||
|
||||
### Test Environment
|
||||
|
||||
- **Codebase**: claude-mem (`src/` directory, 194 TypeScript files, 1,206 parsed symbols)
|
||||
- **Model**: Claude Opus 4.6 for both approaches
|
||||
- **Measurement**: Token counts from tool response metadata (`total_tokens` for Explore agents, self-reported `~N tokens for folded view` for Smart Explore)
|
||||
|
||||
### Controls
|
||||
|
||||
The Explore agents were explicitly instructed: *"Do NOT use smart_search, smart_outline, or smart_unfold tools. Only use Glob, Grep, and Read tools."* This was verified necessary after an initial round where agents opportunistically used the Smart Explore tools, invalidating the comparison.
|
||||
|
||||
### Queries
|
||||
|
||||
Five queries were selected to represent common exploration tasks:
|
||||
|
||||
1. **"session processing"** -- Cross-cutting feature spanning multiple services
|
||||
2. **"shutdown"** -- Infrastructure concern touching 6+ files
|
||||
3. **"hook registration"** -- Architecture question about plugin system
|
||||
4. **"sqlite database"** -- Technology-specific search across the data layer
|
||||
5. **"worker-service.ts outline"** -- Single large file (1,225 lines) structural understanding
|
||||
|
||||
## Round 1: Discovery
|
||||
|
||||
*"What exists and where is it?"* -- Finding relevant files and symbols across the codebase.
|
||||
|
||||
### Results
|
||||
|
||||
| Query | Smart Explore | Explore Agent | Ratio | Explore Tool Calls |
|
||||
|-------|:---:|:---:|:---:|:---:|
|
||||
| session processing | ~4,391 t | 51,659 t | **11.8x** | 15 |
|
||||
| shutdown | ~3,852 t | 51,523 t | **13.4x** | 18 |
|
||||
| hook registration | ~1,930 t | 51,688 t | **26.8x** | 37 |
|
||||
| sqlite database | ~2,543 t | 58,633 t | **23.1x** | 16 |
|
||||
| worker-service outline | ~1,500 t | 38,973 t | **26.0x** | 15 |
|
||||
| **Total** | **~14,216 t** | **252,476 t** | **17.8x** | **101** |
|
||||
|
||||
### What Each Returned
|
||||
|
||||
**Smart Explore** (1 tool call each): 10 ranked symbols with signatures, line numbers, and JSDoc summaries, plus folded structural views of all matching files showing every function/class/interface with bodies collapsed.
|
||||
|
||||
**Explore Agent** (15-37 tool calls each): Synthesized narrative reports with architecture diagrams, design pattern analysis, data flow explanations, complete interface dumps, and file structure maps. Significantly more explanatory prose.
|
||||
|
||||
### Analysis
|
||||
|
||||
The token gap is widest for narrowly-scoped queries ("hook registration" at 26.8x) because the Explore agent reads multiple full files to find relatively few relevant symbols. For broad queries ("session processing" at 11.8x), more of the file content is relevant, narrowing the ratio.
|
||||
|
||||
Smart Explore's consistent 1-tool-call pattern means its cost is predictable. The Explore agent's cost varies with how many files it reads and how much it synthesizes -- ranging from 15 to 37 tool calls for comparable scope.
|
||||
|
||||
## Round 2: Targeted Reads
|
||||
|
||||
*"Show me this specific function."* -- Reading the implementation of a known symbol after discovery.
|
||||
|
||||
Based on the Round 1 results, five specific symbols were selected as natural drill-down targets:
|
||||
|
||||
| Target Symbol | File | Lines |
|
||||
|---------------|------|:---:|
|
||||
| `SessionManager.initializeSession` | services/worker/SessionManager.ts | 135 |
|
||||
| `performGracefulShutdown` | services/infrastructure/GracefulShutdown.ts | 48 |
|
||||
| `hookCommand` | cli/hook-command.ts | 45 |
|
||||
| `DatabaseManager.initialize` | services/sqlite/Database.ts | 27 |
|
||||
| `WorkerService.startSessionProcessor` | services/worker-service.ts | 158 |
|
||||
|
||||
### Results
|
||||
|
||||
| Symbol | Smart Unfold | Explore Agent | Ratio | Completeness |
|
||||
|--------|:---:|:---:|:---:|---|
|
||||
| initializeSession (135 lines) | ~1,800 t | 27,816 t | **15.5x** | Both returned full source |
|
||||
| performGracefulShutdown (48 lines) | ~700 t | 19,621 t | **28.0x** | Both returned full source |
|
||||
| hookCommand (45 lines) | ~650 t | 18,680 t | **28.7x** | Both returned full source |
|
||||
| DatabaseManager.initialize (27 lines) | ~400 t | 22,334 t | **55.8x** | Both returned full source |
|
||||
| startSessionProcessor (158 lines) | ~2,100 t | 20,906 t | **10.0x** | Smart Unfold: complete. Explore: **truncated** |
|
||||
| **Total** | **~5,650 t** | **109,357 t** | **19.4x** | |
|
||||
|
||||
### Analysis
|
||||
|
||||
**The ratio scales inversely with symbol size.** The smallest function (`initialize`, 27 lines) shows the biggest gap at 55.8x because the Explore agent still reads the entire 235-line file to extract 27 lines. The largest method (`startSessionProcessor`, 158 lines) narrows to 10x since more of the file is "useful."
|
||||
|
||||
**Smart Unfold returned more complete code.** For the longest method (158 lines), the Explore agent truncated the error handling section with "... error handling continues ...", while `smart_unfold` returned the complete implementation. This is because smart_unfold extracts by AST node boundaries, guaranteeing completeness regardless of symbol size.
|
||||
|
||||
**Explore agents add zero unique information for targeted reads.** When you already know the file path and symbol name, the agent's overhead is pure waste -- it reads the file, locates the function, and echoes it back. The only addition is a brief explanatory paragraph.
|
||||
|
||||
## Combined Workflow
|
||||
|
||||
The realistic workflow is discovery followed by targeted reading. Here is the end-to-end cost comparison for understanding a single function:
|
||||
|
||||
### Smart Explore: search + unfold
|
||||
|
||||
```
|
||||
smart_search("shutdown", path="./src") ~3,852 tokens
|
||||
smart_unfold("GracefulShutdown.ts", "performGracefulShutdown") ~700 tokens
|
||||
────────────────────────────────────────────────────────────────
|
||||
Total: ~4,552 tokens (2 tool calls, under 3 seconds)
|
||||
```
|
||||
|
||||
### Explore Agent: single query
|
||||
|
||||
```
|
||||
"Find and explain the shutdown logic" ~51,523 tokens
|
||||
────────────────────────────────────────────────────────────────
|
||||
Total: ~51,523 tokens (18 tool calls, ~43 seconds)
|
||||
```
|
||||
|
||||
**End-to-end ratio: 11.3x** -- and the Smart Explore workflow gives you the actual source code, while the Explore agent gives you a prose summary that may paraphrase or truncate.
|
||||
|
||||
## Quality Assessment
|
||||
|
||||
Neither approach is universally better. They optimize for different outcomes.
|
||||
|
||||
### Smart Explore Strengths
|
||||
|
||||
- **Predictable cost**: 1 tool call per operation, consistent token ranges
|
||||
- **Complete source code**: AST-based extraction guarantees full symbol bodies
|
||||
- **Structural context**: Folded views show every symbol in matching files
|
||||
- **Speed**: Sub-second responses enable rapid iteration
|
||||
- **Composability**: Search, outline, and unfold chain naturally
|
||||
|
||||
### Explore Agent Strengths
|
||||
|
||||
- **Synthesized understanding**: Produces architecture narratives, data flow diagrams, and design pattern analysis
|
||||
- **Cross-cutting explanation**: Connects concepts across files that individual symbol reads cannot
|
||||
- **Onboarding quality**: Output reads like documentation, not raw code
|
||||
- **Error handling insight**: Identifies edge cases and design decisions that require reading multiple related functions
|
||||
- **No prior knowledge needed**: Can answer open-ended questions without knowing file paths or symbol names
|
||||
|
||||
### Quality by Task Type
|
||||
|
||||
| Task | Better Tool | Why |
|
||||
|------|-------------|-----|
|
||||
| "Where is X defined?" | Smart Explore | One call, exact answer |
|
||||
| "What functions are in this file?" | Smart Explore | Outline returns complete structural map |
|
||||
| "Show me this function" | Smart Explore | Unfold returns exact source, never truncates |
|
||||
| "How does feature X work end-to-end?" | Explore Agent | Reads multiple files and synthesizes narrative |
|
||||
| "What design patterns are used here?" | Explore Agent | Requires reading and interpreting, not just extracting |
|
||||
| "Help me understand this codebase" | Explore Agent | Produces onboarding-quality documentation |
|
||||
|
||||
## When to Use Which
|
||||
|
||||
**Use Smart Explore when:**
|
||||
- You know what you are looking for (function name, concept, file)
|
||||
- You need source code, not explanation
|
||||
- You are iterating quickly (read, modify, read again)
|
||||
- Token budget matters (large codebases, long sessions)
|
||||
- You need file structure at a glance
|
||||
|
||||
**Use the Explore Agent when:**
|
||||
- You need synthesized cross-cutting understanding
|
||||
- The question is open-ended ("how does this system work?")
|
||||
- You are writing documentation or architecture reviews
|
||||
- You need to understand *why*, not just *what*
|
||||
- You are onboarding to an unfamiliar codebase
|
||||
|
||||
**Use both when:**
|
||||
- Start with Smart Explore for discovery and navigation
|
||||
- Escalate to Explore Agent only for deep analysis that requires multi-file synthesis
|
||||
- This hybrid approach captures most of the token savings while preserving access to deep understanding when needed
|
||||
|
||||
## Token Economics Reference
|
||||
|
||||
| Operation | Tokens | Use Case |
|
||||
|-----------|:---:|----------|
|
||||
| `smart_search` | 2,000-6,000 | Cross-file symbol discovery |
|
||||
| `smart_outline` | 1,000-2,000 | Single file structural map |
|
||||
| `smart_unfold` | 400-2,100 | Single symbol full source |
|
||||
| `smart_search` + `smart_unfold` | 3,000-8,000 | End-to-end: find and read |
|
||||
| Explore Agent (targeted) | 18,000-28,000 | Single function with explanation |
|
||||
| Explore Agent (cross-cutting) | 39,000-59,000 | Architecture-level understanding |
|
||||
| Read (full file) | 8,000-15,000+ | Complete file contents |
|
||||
|
||||
### Savings by Workflow
|
||||
|
||||
| Workflow | Smart Explore | Traditional | Savings |
|
||||
|----------|:---:|:---:|:---:|
|
||||
| Understand one file | outline + unfold (~3,100 t) | Read full file (~12,000 t) | **4x** |
|
||||
| Find a function across codebase | search (~3,500 t) | Explore agent (~50,000 t) | **14x** |
|
||||
| Find and read a specific function | search + unfold (~4,500 t) | Explore agent (~50,000 t) | **11x** |
|
||||
| Navigate a 1,200-line file | outline (~1,500 t) | Read full file (~12,000 t) | **8x** |
|
||||
+24
-20
@@ -1,6 +1,6 @@
|
||||
# Claude-Mem OpenClaw Plugin — Setup Guide
|
||||
|
||||
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.
|
||||
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 via system prompt context injection, and optionally a real-time observation feed streaming to a messaging channel.
|
||||
|
||||
## Quick Install (Recommended)
|
||||
|
||||
@@ -138,7 +138,9 @@ Add the `claude-mem` plugin to your OpenClaw gateway configuration:
|
||||
|
||||
- **`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"`.
|
||||
|
||||
- **`syncMemoryFile`** (boolean, default: `true`) — When enabled, the plugin writes a `MEMORY.md` file to each agent's workspace directory. This file contains the full timeline of observations and summaries from previous sessions, and it updates on every tool use so agents always have fresh context. Set to `false` only if you don't want the plugin writing files to agent workspaces.
|
||||
- **`syncMemoryFile`** (boolean, default: `true`) — When enabled, the plugin injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook. This gives agents cross-session context without writing to MEMORY.md. Set to `false` to disable context injection entirely (observations are still recorded).
|
||||
|
||||
- **`syncMemoryFileExclude`** (string[], default: `[]`) — Agent IDs excluded from automatic context injection. Useful for agents that curate their own memory. Observations are still recorded for excluded agents.
|
||||
|
||||
- **`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.
|
||||
|
||||
@@ -168,13 +170,14 @@ The observation feed shows `disconnected` because we haven't configured it yet.
|
||||
|
||||
Have an agent do some work. The plugin automatically records observations through these OpenClaw events:
|
||||
|
||||
1. **`before_agent_start`** — Initializes a claude-mem session when the agent starts, syncs MEMORY.md to the workspace
|
||||
2. **`tool_result_persist`** — Records each tool use (Read, Write, Bash, etc.) as an observation, re-syncs MEMORY.md
|
||||
3. **`agent_end`** — Summarizes the session and marks it complete
|
||||
1. **`before_agent_start`** — Initializes a claude-mem session when the agent starts
|
||||
2. **`before_prompt_build`** — Injects the observation timeline into the agent's system prompt (cached for 60s)
|
||||
3. **`tool_result_persist`** — Records each tool use (Read, Write, Bash, etc.) as an observation
|
||||
4. **`agent_end`** — Summarizes the session and marks it complete
|
||||
|
||||
All of this happens automatically. No additional configuration needed.
|
||||
|
||||
To verify it's working, check the agent's workspace directory for a `MEMORY.md` file after the agent runs. It should contain a formatted timeline of observations.
|
||||
To verify it's working, check the worker's viewer UI at http://localhost:37777 to see observations appearing after the agent runs.
|
||||
|
||||
You can also check the worker's viewer UI at http://localhost:37777 to see observations appearing in real time.
|
||||
|
||||
@@ -372,10 +375,11 @@ Shows observation feed status. Accepts optional `on`/`off` argument.
|
||||
```
|
||||
OpenClaw Gateway
|
||||
│
|
||||
├── before_agent_start ──→ Sync MEMORY.md + Init session
|
||||
├── tool_result_persist ──→ Record observation + Re-sync MEMORY.md
|
||||
├── before_agent_start ───→ Init session
|
||||
├── before_prompt_build ──→ Inject context into system prompt
|
||||
├── tool_result_persist ──→ Record observation
|
||||
├── agent_end ────────────→ Summarize + Complete session
|
||||
└── gateway_start ────────→ Reset session tracking
|
||||
└── gateway_start ────────→ Reset session tracking + context cache
|
||||
│
|
||||
▼
|
||||
Claude-Mem Worker (localhost:37777)
|
||||
@@ -383,17 +387,15 @@ OpenClaw Gateway
|
||||
├── POST /api/sessions/observations
|
||||
├── POST /api/sessions/summarize
|
||||
├── POST /api/sessions/complete
|
||||
├── GET /api/context/inject ──→ MEMORY.md content
|
||||
├── GET /api/context/inject ──→ System prompt context
|
||||
└── GET /stream ─────────────→ SSE → Messaging channels
|
||||
```
|
||||
|
||||
### MEMORY.md live sync
|
||||
### System prompt context injection
|
||||
|
||||
The plugin writes `MEMORY.md` to each agent's workspace with the full observation timeline. It updates:
|
||||
- On every `before_agent_start` — agent gets fresh context before starting
|
||||
- On every `tool_result_persist` — context stays current as the agent works
|
||||
The plugin injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook. The content comes from the worker's `GET /api/context/inject` endpoint. Context is cached for 60 seconds per project to avoid re-fetching on every LLM turn. The cache is cleared on gateway restart.
|
||||
|
||||
Updates are fire-and-forget (non-blocking). The agent is never held up waiting for MEMORY.md to write.
|
||||
This keeps MEMORY.md under the agent's control for curated long-term memory, while the observation timeline is delivered through the system prompt.
|
||||
|
||||
### Observation recording
|
||||
|
||||
@@ -401,10 +403,11 @@ Every tool use (Read, Write, Bash, etc.) is sent to the claude-mem worker as an
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
- **`before_agent_start`** — Creates a session in the worker, syncs MEMORY.md. Short prompts (under 10 chars) skip session init but still sync.
|
||||
- **`tool_result_persist`** — Records observation (fire-and-forget), re-syncs MEMORY.md (fire-and-forget). Tool responses are truncated to 1000 characters.
|
||||
- **`before_agent_start`** — Creates a session in the worker.
|
||||
- **`before_prompt_build`** — Fetches the observation timeline and returns it as `appendSystemContext`. Cached for 60s.
|
||||
- **`tool_result_persist`** — Records observation (fire-and-forget). Tool responses are truncated to 1000 characters.
|
||||
- **`agent_end`** — Sends the last assistant message for summarization, then completes the session. Both fire-and-forget.
|
||||
- **`gateway_start`** — Clears all session tracking (session IDs, workspace mappings) so agents start fresh.
|
||||
- **`gateway_start`** — Clears all session tracking (session IDs, context cache) so agents start fresh.
|
||||
|
||||
### Observation feed
|
||||
|
||||
@@ -417,7 +420,7 @@ A background service connects to the worker's SSE stream and forwards `new_obser
|
||||
| Worker health check fails | Is bun installed? (`bun --version`). Is something else on port 37777? (`lsof -i :37777`). Try running directly: `bun plugin/scripts/worker-service.cjs start` |
|
||||
| Worker started from Claude Code install but not responding | Check `cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:status`. May need `npm run worker:restart`. |
|
||||
| Worker started from cloned repo but not responding | Check `cd /path/to/claude-mem && npm run worker:status`. Make sure you ran `npm install && npm run build` first. |
|
||||
| No MEMORY.md appearing | Check that `syncMemoryFile` is not set to `false`. Verify the agent's event context includes `workspaceDir`. |
|
||||
| No context in agent system prompt | Check that `syncMemoryFile` is not set to `false`. Check that the agent's ID is not in `syncMemoryFileExclude`. Verify the worker is running and has observations. |
|
||||
| Observations not being recorded | Check gateway logs for `[claude-mem]` messages. The worker must be running and reachable on localhost:37777. |
|
||||
| Feed shows `disconnected` | Worker's `/stream` endpoint not reachable. Check `workerPort` matches the actual worker port. |
|
||||
| Feed shows `reconnecting` | Connection dropped. The plugin auto-reconnects — wait up to 30 seconds. |
|
||||
@@ -451,7 +454,8 @@ A background service connects to the worker's SSE stream and forwards `new_obser
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `project` | string | `"openclaw"` | Project name scoping observations in the database |
|
||||
| `syncMemoryFile` | boolean | `true` | Write MEMORY.md to agent workspaces |
|
||||
| `syncMemoryFile` | boolean | `true` | Inject observation context into agent system prompt |
|
||||
| `syncMemoryFileExclude` | string[] | `[]` | Agent IDs excluded from context injection |
|
||||
| `workerPort` | number | `37777` | Claude-mem worker service port |
|
||||
| `observationFeed.enabled` | boolean | `false` | Stream observations to a messaging channel |
|
||||
| `observationFeed.channel` | string | — | Channel type: `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `line` |
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
"syncMemoryFile": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically sync MEMORY.md on session start"
|
||||
"description": "Inject observation context into the agent system prompt via before_prompt_build hook. When true, agents receive cross-session context without MEMORY.md being overwritten."
|
||||
},
|
||||
"syncMemoryFileExclude": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"default": [],
|
||||
"description": "Agent IDs excluded from automatic context injection (observations are still recorded, only prompt injection is skipped)"
|
||||
},
|
||||
"workerPort": {
|
||||
"type": "number",
|
||||
|
||||
+115
-96
@@ -87,9 +87,11 @@ function createMockApi(pluginConfigOverride: Record<string, any> = {}) {
|
||||
getEventHandlers: (event: string) => eventHandlers.get(event) || [],
|
||||
fireEvent: async (event: string, data: any, ctx: any = {}) => {
|
||||
const handlers = eventHandlers.get(event) || [];
|
||||
let lastResult: any;
|
||||
for (const handler of handlers) {
|
||||
await handler(data, ctx);
|
||||
lastResult = await handler(data, ctx);
|
||||
}
|
||||
return lastResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -106,6 +108,7 @@ describe("claudeMemPlugin", () => {
|
||||
assert.ok(getEventHandlers("session_start").length > 0, "session_start handler registered");
|
||||
assert.ok(getEventHandlers("after_compaction").length > 0, "after_compaction handler registered");
|
||||
assert.ok(getEventHandlers("before_agent_start").length > 0, "before_agent_start handler registered");
|
||||
assert.ok(getEventHandlers("before_prompt_build").length > 0, "before_prompt_build handler registered");
|
||||
assert.ok(getEventHandlers("tool_result_persist").length > 0, "tool_result_persist handler registered");
|
||||
assert.ok(getEventHandlers("agent_end").length > 0, "agent_end handler registered");
|
||||
assert.ok(getEventHandlers("gateway_start").length > 0, "gateway_start handler registered");
|
||||
@@ -535,11 +538,10 @@ describe("Observation I/O event handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MEMORY.md context sync", () => {
|
||||
describe("before_prompt_build context injection", () => {
|
||||
let workerServer: Server;
|
||||
let workerPort: number;
|
||||
let receivedRequests: Array<{ method: string; url: string; body: any }> = [];
|
||||
let tmpDir: string;
|
||||
let contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
|
||||
|
||||
function startWorkerMock(): Promise<number> {
|
||||
@@ -586,21 +588,20 @@ describe("MEMORY.md context sync", () => {
|
||||
receivedRequests = [];
|
||||
contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
|
||||
workerPort = await startWorkerMock();
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
workerServer?.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes MEMORY.md to workspace on before_agent_start", async () => {
|
||||
it("returns appendSystemContext from before_prompt_build", async () => {
|
||||
const { api, logs, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "sync-test", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
@@ -608,142 +609,143 @@ describe("MEMORY.md context sync", () => {
|
||||
assert.ok(contextRequest, "should request context from worker");
|
||||
assert.ok(contextRequest!.url!.includes("projects=openclaw"));
|
||||
|
||||
const memoryContent = await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
assert.ok(memoryContent.includes("Claude-Mem Context"), "MEMORY.md should contain context");
|
||||
assert.ok(memoryContent.includes("Session 1"), "MEMORY.md should contain timeline");
|
||||
assert.ok(logs.some((l) => l.includes("MEMORY.md synced")));
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok(result.appendSystemContext, "should return appendSystemContext");
|
||||
assert.ok(result.appendSystemContext.includes("Claude-Mem Context"), "should contain context");
|
||||
assert.ok(result.appendSystemContext.includes("Session 1"), "should contain timeline");
|
||||
assert.ok(logs.some((l) => l.includes("Context injected via system prompt")));
|
||||
});
|
||||
|
||||
it("syncs MEMORY.md on every before_agent_start call", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
it("does not write MEMORY.md on before_agent_start", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
||||
try {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "First prompt for this agent",
|
||||
}, { sessionKey: "agent-a", workspaceDir: tmpDir });
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "sync-test", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const firstContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(firstContextRequests.length, 1, "first call should fetch context");
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Second prompt for same agent",
|
||||
}, { sessionKey: "agent-a", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const allContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(allContextRequests.length, 2, "should re-fetch context on every call");
|
||||
let memoryExists = true;
|
||||
try {
|
||||
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
} catch {
|
||||
memoryExists = false;
|
||||
}
|
||||
assert.ok(!memoryExists, "MEMORY.md should not be created by before_agent_start");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("syncs MEMORY.md on tool_result_persist via fire-and-forget", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
it("does not sync MEMORY.md on tool_result_persist", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
||||
try {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
// Init session to register workspace dir
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "tool-sync", workspaceDir: tmpDir });
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "tool-sync", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const preToolContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(preToolContextRequests.length, 1, "before_agent_start should sync once");
|
||||
await fireEvent("tool_result_persist", {
|
||||
toolName: "Read",
|
||||
params: { file_path: "/src/app.ts" },
|
||||
message: { content: [{ type: "text", text: "file contents" }] },
|
||||
}, { sessionKey: "tool-sync" });
|
||||
|
||||
// Fire tool result — should trigger another MEMORY.md sync
|
||||
await fireEvent("tool_result_persist", {
|
||||
toolName: "Read",
|
||||
params: { file_path: "/src/app.ts" },
|
||||
message: { content: [{ type: "text", text: "file contents" }] },
|
||||
}, { sessionKey: "tool-sync" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const contextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(contextRequests.length, 0, "tool_result_persist should not fetch context");
|
||||
|
||||
const postToolContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(postToolContextRequests.length, 2, "tool_result_persist should trigger another sync");
|
||||
|
||||
const memoryContent = await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
assert.ok(memoryContent.includes("Claude-Mem Context"), "MEMORY.md should be updated");
|
||||
let memoryExists = true;
|
||||
try {
|
||||
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
} catch {
|
||||
memoryExists = false;
|
||||
}
|
||||
assert.ok(!memoryExists, "MEMORY.md should not be written by tool_result_persist");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips MEMORY.md sync when syncMemoryFile is false", async () => {
|
||||
it("skips context injection when syncMemoryFile is false", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFile: false });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "no-sync", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.ok(!contextRequest, "should not fetch context when sync disabled");
|
||||
assert.ok(!contextRequest, "should not fetch context when injection disabled");
|
||||
assert.equal(result, undefined, "should return undefined when injection disabled");
|
||||
});
|
||||
|
||||
it("skips MEMORY.md sync when no workspaceDir in context", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
it("skips context injection for excluded agents", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "no-workspace" });
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me",
|
||||
messages: [],
|
||||
}, { agentId: "snarf" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.ok(!contextRequest, "should not fetch context without workspaceDir");
|
||||
assert.ok(!contextRequest, "should not fetch context for excluded agent");
|
||||
assert.equal(result, undefined, "should return undefined for excluded agent");
|
||||
});
|
||||
|
||||
it("skips writing MEMORY.md when context is empty", async () => {
|
||||
it("injects context for non-excluded agents", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me",
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
assert.ok(result, "should return a result for non-excluded agent");
|
||||
assert.ok(result.appendSystemContext, "should inject context for non-excluded agent");
|
||||
});
|
||||
|
||||
it("returns undefined when context is empty", async () => {
|
||||
contextResponse = " ";
|
||||
const { api, logs, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "empty-ctx", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
assert.ok(!logs.some((l) => l.includes("MEMORY.md synced")), "should not log sync for empty context");
|
||||
});
|
||||
|
||||
it("gateway_start resets sync tracking so next agent re-syncs", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
// First sync
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "agent-1", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const firstContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(firstContextRequests.length, 1);
|
||||
|
||||
// Gateway restart
|
||||
await fireEvent("gateway_start", {}, {});
|
||||
|
||||
// Second sync after gateway restart — same workspace should re-sync
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me after gateway restart",
|
||||
}, { sessionKey: "agent-1", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const allContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(allContextRequests.length, 2, "should re-fetch context after gateway restart");
|
||||
assert.equal(result, undefined, "should return undefined for empty context");
|
||||
assert.ok(!logs.some((l) => l.includes("Context injected")), "should not log injection for empty context");
|
||||
});
|
||||
|
||||
it("uses custom project name in context inject URL", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, project: "my-bot" });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "proj-test", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
@@ -751,6 +753,23 @@ describe("MEMORY.md context sync", () => {
|
||||
assert.ok(contextRequest, "should request context");
|
||||
assert.ok(contextRequest!.url!.includes("projects=my-bot"), "should use custom project name");
|
||||
});
|
||||
|
||||
it("includes agent-scoped project in context request", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me",
|
||||
messages: [],
|
||||
}, { agentId: "debugger" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.ok(contextRequest, "should request context");
|
||||
const url = decodeURIComponent(contextRequest!.url!);
|
||||
assert.ok(url.includes("openclaw,openclaw-debugger"), "should include both base and agent-scoped projects");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE stream integration", () => {
|
||||
|
||||
+66
-31
@@ -1,5 +1,5 @@
|
||||
import { writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
// No file-system imports needed — context is injected via system prompt hook,
|
||||
// not by writing to MEMORY.md.
|
||||
|
||||
// Minimal type declarations for the OpenClaw Plugin SDK.
|
||||
// These match the real OpenClawPluginApi provided by the gateway at runtime.
|
||||
@@ -35,6 +35,18 @@ interface BeforeAgentStartEvent {
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
interface BeforePromptBuildEvent {
|
||||
prompt: string;
|
||||
messages: unknown[];
|
||||
}
|
||||
|
||||
interface BeforePromptBuildResult {
|
||||
systemPrompt?: string;
|
||||
prependContext?: string;
|
||||
prependSystemContext?: string;
|
||||
appendSystemContext?: string;
|
||||
}
|
||||
|
||||
interface ToolResultPersistEvent {
|
||||
toolName?: string;
|
||||
params?: Record<string, unknown>;
|
||||
@@ -87,6 +99,7 @@ interface MessageContext {
|
||||
}
|
||||
|
||||
type EventCallback<T> = (event: T, ctx: EventContext) => void | Promise<void>;
|
||||
type PromptBuildCallback = (event: BeforePromptBuildEvent, ctx: EventContext) => BeforePromptBuildResult | Promise<BeforePromptBuildResult | void> | void;
|
||||
type MessageEventCallback<T> = (event: T, ctx: MessageContext) => void | Promise<void>;
|
||||
|
||||
interface OpenClawPluginApi {
|
||||
@@ -109,7 +122,8 @@ interface OpenClawPluginApi {
|
||||
requireAuth?: boolean;
|
||||
handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise<PluginCommandResult>;
|
||||
}) => void;
|
||||
on: ((event: "before_agent_start", callback: EventCallback<BeforeAgentStartEvent>) => void) &
|
||||
on: ((event: "before_prompt_build", callback: PromptBuildCallback) => void) &
|
||||
((event: "before_agent_start", callback: EventCallback<BeforeAgentStartEvent>) => void) &
|
||||
((event: "tool_result_persist", callback: EventCallback<ToolResultPersistEvent>) => void) &
|
||||
((event: "agent_end", callback: EventCallback<AgentEndEvent>) => void) &
|
||||
((event: "session_start", callback: EventCallback<SessionStartEvent>) => void) &
|
||||
@@ -166,6 +180,7 @@ interface FeedEmojiConfig {
|
||||
|
||||
interface ClaudeMemPluginConfig {
|
||||
syncMemoryFile?: boolean;
|
||||
syncMemoryFileExclude?: string[];
|
||||
project?: string;
|
||||
workerPort?: number;
|
||||
observationFeed?: {
|
||||
@@ -532,8 +547,8 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
// Session tracking for observation I/O
|
||||
// ------------------------------------------------------------------
|
||||
const sessionIds = new Map<string, string>();
|
||||
const workspaceDirsBySessionKey = new Map<string, string>();
|
||||
const syncMemoryFile = userConfig.syncMemoryFile !== false; // default true
|
||||
const syncMemoryFileExclude = new Set(userConfig.syncMemoryFileExclude || []);
|
||||
|
||||
function getContentSessionId(sessionKey?: string): string {
|
||||
const key = sessionKey || "default";
|
||||
@@ -543,27 +558,45 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
return sessionIds.get(key)!;
|
||||
}
|
||||
|
||||
async function syncMemoryToWorkspace(workspaceDir: string, ctx?: EventContext): Promise<void> {
|
||||
function shouldInjectContext(ctx?: EventContext): boolean {
|
||||
if (!syncMemoryFile) return false;
|
||||
const agentId = ctx?.agentId;
|
||||
if (agentId && syncMemoryFileExclude.has(agentId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// TTL cache for context injection to avoid re-fetching on every LLM turn.
|
||||
// before_prompt_build fires on every turn; caching for 60s keeps the worker
|
||||
// load manageable while still picking up new observations reasonably quickly.
|
||||
const CONTEXT_CACHE_TTL_MS = 60_000;
|
||||
const contextCache = new Map<string, { text: string; fetchedAt: number }>();
|
||||
|
||||
async function getContextForPrompt(ctx?: EventContext): Promise<string | null> {
|
||||
// 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 cacheKey = projects.join(",");
|
||||
|
||||
// Return cached context if still fresh
|
||||
const cached = contextCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CONTEXT_CACHE_TTL_MS) {
|
||||
return cached.text;
|
||||
}
|
||||
|
||||
const contextText = await workerGetText(
|
||||
workerPort,
|
||||
`/api/context/inject?projects=${encodeURIComponent(projects.join(","))}`,
|
||||
`/api/context/inject?projects=${encodeURIComponent(cacheKey)}`,
|
||||
api.logger
|
||||
);
|
||||
if (contextText && contextText.trim().length > 0) {
|
||||
try {
|
||||
await writeFile(join(workspaceDir, "MEMORY.md"), contextText, "utf-8");
|
||||
api.logger.info(`[claude-mem] MEMORY.md synced to ${workspaceDir}`);
|
||||
} catch (writeError: unknown) {
|
||||
const msg = writeError instanceof Error ? writeError.message : String(writeError);
|
||||
api.logger.warn(`[claude-mem] Failed to write MEMORY.md: ${msg}`);
|
||||
}
|
||||
const trimmed = contextText.trim();
|
||||
contextCache.set(cacheKey, { text: trimmed, fetchedAt: Date.now() });
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -611,14 +644,9 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: before_agent_start — init session + sync MEMORY.md + track workspace
|
||||
// Event: before_agent_start — init session
|
||||
// ------------------------------------------------------------------
|
||||
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);
|
||||
@@ -627,15 +655,28 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
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, ctx);
|
||||
// ------------------------------------------------------------------
|
||||
// Event: before_prompt_build — inject context into system prompt
|
||||
//
|
||||
// Instead of writing to MEMORY.md (which conflicts with agent-curated
|
||||
// memory), inject the observation timeline via appendSystemContext.
|
||||
// This keeps MEMORY.md under the agent's control while still providing
|
||||
// cross-session context to the LLM.
|
||||
// ------------------------------------------------------------------
|
||||
api.on("before_prompt_build", async (_event, ctx) => {
|
||||
if (!shouldInjectContext(ctx)) return;
|
||||
|
||||
const contextText = await getContextForPrompt(ctx);
|
||||
if (contextText) {
|
||||
api.logger.info(`[claude-mem] Context injected via system prompt for agent=${ctx.agentId ?? "unknown"}`);
|
||||
return { appendSystemContext: contextText };
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: tool_result_persist — record tool observations + sync MEMORY.md
|
||||
// Event: tool_result_persist — record tool observations
|
||||
// ------------------------------------------------------------------
|
||||
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"}`);
|
||||
@@ -663,7 +704,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||
}
|
||||
|
||||
// Fire-and-forget: send observation + sync MEMORY.md in parallel
|
||||
// Fire-and-forget: send observation to worker
|
||||
workerPostFireAndForget(workerPort, "/api/sessions/observations", {
|
||||
contentSessionId,
|
||||
tool_name: toolName,
|
||||
@@ -671,11 +712,6 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
tool_response: toolResponseText,
|
||||
cwd: "",
|
||||
}, api.logger);
|
||||
|
||||
const workspaceDir = ctx.workspaceDir || workspaceDirsBySessionKey.get(ctx.sessionKey || "default");
|
||||
if (syncMemoryFile && workspaceDir) {
|
||||
syncMemoryToWorkspace(workspaceDir, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -722,15 +758,14 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
api.on("session_end", async (_event, ctx) => {
|
||||
const key = ctx.sessionKey || "default";
|
||||
sessionIds.delete(key);
|
||||
workspaceDirsBySessionKey.delete(key);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: gateway_start — clear session tracking for fresh start
|
||||
// ------------------------------------------------------------------
|
||||
api.on("gateway_start", async () => {
|
||||
workspaceDirsBySessionKey.clear();
|
||||
sessionIds.clear();
|
||||
contextCache.clear();
|
||||
api.logger.info("[claude-mem] Gateway started — session tracking reset");
|
||||
});
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.5.0",
|
||||
"version": "10.6.3",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -129,5 +129,8 @@
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"tree-kill": "^1.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.5.0",
|
||||
"version": "10.6.3",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+32
-8
@@ -1,18 +1,35 @@
|
||||
{
|
||||
"description": "Claude-mem memory system hooks",
|
||||
"hooks": {
|
||||
"Setup": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/smart-install.js\"",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start",
|
||||
"timeout": 60
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
@@ -23,7 +40,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
@@ -31,10 +48,11 @@
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
@@ -45,13 +63,19 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
|
||||
"timeout": 120
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
|
||||
"timeout": 120
|
||||
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}},()=>process.exit(0));r.on('error',()=>process.exit(0));r.end(JSON.stringify({contentSessionId:s}));setTimeout(()=>process.exit(0),3000)}catch{process.exit(0)}})\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Law Study (Chill)",
|
||||
"prompts": {
|
||||
"recording_focus": "WHAT TO RECORD (HIGH SIGNAL ONLY)\n----------------------------------\nOnly record what would be painful to reconstruct later:\n- Issue-spotting triggers: specific fact patterns that signal a testable issue\n- Professor's explicit emphasis, frameworks, or exam tips\n- Counterintuitive holdings or gotchas that contradict intuition\n- Cross-case connections that reframe how a doctrine works\n- A synthesized rule only if it distills something non-obvious from multiple sources\n\nSkip anything that could be looked up in a casebook in under 60 seconds.\n\nUse verbs like: held, established, revealed, distinguished, flagged",
|
||||
"skip_guidance": "WHEN TO SKIP (LIBERAL — WHEN IN DOUBT, SKIP)\n---------------------------------------------\nSkip freely:\n- All case briefs, even condensed ones, unless the holding is counterintuitive\n- Any rule or doctrine stated plainly in the casebook without nuance\n- Definitions of standard legal terms\n- Procedural history\n- Any fact pattern or case that wasn't specifically emphasized by the professor\n- Anything you could find again in under 60 seconds\n- **No output necessary if skipping.**"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Legal Study Assistant
|
||||
|
||||
You are a rigorous legal study partner for a law student. Your job is to help them understand the law deeply enough to reason through novel fact patterns independently on exams and in practice.
|
||||
|
||||
---
|
||||
|
||||
## Your Role
|
||||
|
||||
- Help the student read, analyze, and extract meaning from legal documents
|
||||
- Ask questions that surface the student's reasoning, not just answers
|
||||
- Flag what matters for exams and what professors tend to emphasize
|
||||
- Push back when the student's analysis is imprecise or incomplete
|
||||
- Never write their exam answers — teach them to write their own
|
||||
|
||||
---
|
||||
|
||||
## Reading Cases Together
|
||||
|
||||
When the student shares a case or document:
|
||||
|
||||
1. Read it fully before saying anything. No skimming.
|
||||
2. Identify the procedural posture, then the issue, then the holding, then the reasoning.
|
||||
3. Separate holding from dicta explicitly — this distinction is always fair game.
|
||||
4. Surface ambiguity when the court was evasive. That ambiguity is often the exam question.
|
||||
5. Ask: "Which facts were outcome-determinative? What if those facts changed?"
|
||||
|
||||
**Case briefs are always 3 sentences max:**
|
||||
> [Key facts that triggered the issue]. The court held [holding + extracted rule]. [Why this rule exists or how it fits the doctrine — only if non-obvious.]
|
||||
|
||||
---
|
||||
|
||||
## Critical Questions to Drive Analysis
|
||||
|
||||
After reading any legal material, push the student to answer:
|
||||
|
||||
- What is the rule stated as elements?
|
||||
- What did the dissent argue and why does it matter?
|
||||
- How does this fit — or conflict with — earlier cases?
|
||||
- What fact pattern on an exam triggers this rule?
|
||||
- What does the professor emphasize about this? Their framing is the exam framing.
|
||||
- Is the law settled or contested here?
|
||||
|
||||
---
|
||||
|
||||
## Issue Spotting
|
||||
|
||||
When working through a fact pattern:
|
||||
|
||||
1. Read the entire hypo before naming any issues.
|
||||
2. List every potential claim and defense — err toward inclusion.
|
||||
3. For each issue: rule → application to these specific facts → where the argument turns.
|
||||
4. Treat "irrelevant" facts as planted triggers. Nothing in an exam hypo is accidental.
|
||||
5. Calibrate to the professor's emphasis — they wrote the exam.
|
||||
|
||||
---
|
||||
|
||||
## Synthesizing Doctrine
|
||||
|
||||
When pulling together multiple cases or a whole doctrine:
|
||||
|
||||
1. Find the common principle across all the cases.
|
||||
2. Build the rule as a spectrum or taxonomy when cases represent different scenarios.
|
||||
3. State the limiting principle — where does this rule stop and why.
|
||||
4. Majority rule first, then minority positions with their rationale.
|
||||
5. Identify the live tension — what the courts haven't resolved yet.
|
||||
|
||||
---
|
||||
|
||||
## Tone and Pace
|
||||
|
||||
- Be direct. Law school trains precision — model it.
|
||||
- When the student is vague, say so and ask them to be specific.
|
||||
- Celebrate when they spot something sharp. Legal reasoning is hard.
|
||||
- Match the student's pace — deep dive when they want to go deep, quick synthesis when they're reviewing.
|
||||
|
||||
---
|
||||
|
||||
## Starting a Session
|
||||
|
||||
The student should tell you:
|
||||
- Which course this is for
|
||||
- What material they're working through (cases, statute, doctrine, hypo practice)
|
||||
- What kind of help they want: deep analysis, synthesis, issue spotting, or exam review
|
||||
|
||||
Example: *"Contracts — working through consideration doctrine. Here are four cases. Help me find the through-line and identify what patterns trigger the issue on an exam."*
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"name": "Law Study",
|
||||
"description": "Legal study and exam preparation for law students",
|
||||
"version": "1.0.0",
|
||||
"observation_types": [
|
||||
{
|
||||
"id": "case-holding",
|
||||
"label": "Case Holding",
|
||||
"description": "Case brief (2-3 sentences: key facts + holding) with extracted legal rule",
|
||||
"emoji": "⚖️",
|
||||
"work_emoji": "📖"
|
||||
},
|
||||
{
|
||||
"id": "issue-pattern",
|
||||
"label": "Issue Pattern",
|
||||
"description": "Exam trigger or fact pattern that signals a legal issue to spot",
|
||||
"emoji": "🎯",
|
||||
"work_emoji": "🔍"
|
||||
},
|
||||
{
|
||||
"id": "prof-framework",
|
||||
"label": "Prof Framework",
|
||||
"description": "Professor's analytical lens, emphasis, or approach to a topic or doctrine",
|
||||
"emoji": "🧑🏫",
|
||||
"work_emoji": "📝"
|
||||
},
|
||||
{
|
||||
"id": "doctrine-rule",
|
||||
"label": "Doctrine / Rule",
|
||||
"description": "Legal test, standard, or doctrine synthesized from cases, statutes, or restatements",
|
||||
"emoji": "📜",
|
||||
"work_emoji": "🔍"
|
||||
},
|
||||
{
|
||||
"id": "argument-structure",
|
||||
"label": "Argument Structure",
|
||||
"description": "Legal argument or counter-argument worked through with analytical steps",
|
||||
"emoji": "🗣️",
|
||||
"work_emoji": "⚖️"
|
||||
},
|
||||
{
|
||||
"id": "cross-case-connection",
|
||||
"label": "Cross-Case Connection",
|
||||
"description": "Insight linking multiple cases, doctrines, or topics that reveals a deeper principle",
|
||||
"emoji": "🔗",
|
||||
"work_emoji": "🔍"
|
||||
}
|
||||
],
|
||||
"observation_concepts": [
|
||||
{
|
||||
"id": "exam-relevant",
|
||||
"label": "Exam Relevant",
|
||||
"description": "Flagged by professor or likely to appear on exams based on emphasis"
|
||||
},
|
||||
{
|
||||
"id": "minority-position",
|
||||
"label": "Minority Position",
|
||||
"description": "Dissent, minority rule, or alternative jurisdictional approach worth knowing"
|
||||
},
|
||||
{
|
||||
"id": "gotcha",
|
||||
"label": "Gotcha",
|
||||
"description": "Subtle nuance, counterintuitive result, or common mistake students get wrong"
|
||||
},
|
||||
{
|
||||
"id": "unsettled-law",
|
||||
"label": "Unsettled Law",
|
||||
"description": "Circuit split, open question, or evolving area of law"
|
||||
},
|
||||
{
|
||||
"id": "policy-rationale",
|
||||
"label": "Policy Rationale",
|
||||
"description": "Normative or policy argument underlying a rule or holding"
|
||||
},
|
||||
{
|
||||
"id": "course-theme",
|
||||
"label": "Course Theme",
|
||||
"description": "How this case or rule connects to the overarching narrative or theory of the course"
|
||||
}
|
||||
],
|
||||
"prompts": {
|
||||
"system_identity": "You are Claude-Mem, a specialized observer tool for creating searchable memory FOR FUTURE SESSIONS.\n\nCRITICAL: Record what was READ, ANALYZED, SYNTHESIZED, or LEARNED about the law, not what you (the observer) are doing.\n\nYou do not have access to tools. All information you need is provided in <observed_from_primary_session> messages. Create observations from what you observe - no investigation needed.",
|
||||
"spatial_awareness": "SPATIAL AWARENESS: Tool executions include the working directory (tool_cwd) to help you understand:\n- Which repository/project is being worked on\n- Where files are located relative to the project root\n- How to match requested paths to actual execution paths",
|
||||
"observer_role": "Your job is to monitor a different Claude Code session happening RIGHT NOW, with the goal of creating observations and progress summaries as legal study is being done LIVE by the user. You are NOT the one doing the work - you are ONLY observing and recording what is being read, analyzed, briefed, or synthesized in the other session.",
|
||||
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on legal knowledge and exam-ready insights:\n- Case holdings distilled to 2-3 sentences (key facts + holding + rule)\n- Legal tests, elements, and standards extracted from cases or statutes\n- Issue-spotting triggers: what fact patterns signal which legal issues\n- Professor's framing, emphasis, or analytical approach to a doctrine\n- Arguments and counter-arguments worked through\n- Connections across cases or doctrines that reveal underlying principles\n\nUse verbs like: held, established, synthesized, identified, distinguished, analyzed, revealed, connected\n\n✅ GOOD EXAMPLES (describes what was learned about the law):\n- \"Palsgraf established proximate cause requires the harm be foreseeable to the defendant at the time of conduct\"\n- \"Prof frames consideration doctrine around the bargain theory, not benefit-detriment — exam answers should reflect this\"\n- \"When fact pattern shows concurrent causation, issue-spot both but-for AND substantial factor tests\"\n\n❌ BAD EXAMPLES (describes observation process - DO NOT DO THIS):\n- \"Analyzed the case and recorded findings about proximate cause\"\n- \"Tracked professor's comments and stored the framework\"\n- \"Monitored discussion of consideration and noted the approach\"",
|
||||
"skip_guidance": "WHEN TO SKIP\n------------\nSkip these — not worth recording:\n- Full case briefs (only record the 2-3 sentence distilled version with the rule)\n- Re-reading the same case or passage without new insight\n- Definitions of basic terms the student already knows\n- Routine case brief formatting with no analytical content\n- Simple fact summaries that don't extract a rule or pattern\n- Procedural history details not relevant to the legal rule\n- **No output necessary if skipping.**",
|
||||
"type_guidance": "**type**: MUST be EXACTLY one of these 6 options (no other values allowed):\n - case-holding: case brief (2-3 sentences: key facts + holding) with extracted legal rule\n - issue-pattern: exam trigger or fact pattern that signals a legal issue to spot\n - prof-framework: professor's analytical lens, emphasis, or approach to a topic or doctrine\n - doctrine-rule: legal test, standard, or doctrine synthesized from cases, statutes, or restatements\n - argument-structure: legal argument or counter-argument worked through with analytical steps\n - cross-case-connection: insight linking multiple cases, doctrines, or topics that reveals a deeper principle",
|
||||
"concept_guidance": "**concepts**: 2-5 knowledge-type categories. MUST use ONLY these exact keywords:\n - exam-relevant: flagged by professor or likely to appear on exams\n - minority-position: dissent, minority rule, or alternative jurisdictional approach\n - gotcha: subtle nuance, counterintuitive result, or common mistake\n - unsettled-law: circuit split, open question, or evolving area\n - policy-rationale: normative or policy argument underlying a rule\n - course-theme: connects to the overarching narrative or theory of the course\n\n IMPORTANT: Do NOT include the observation type (case-holding/issue-pattern/etc.) as a concept.\n Types and concepts are separate dimensions.",
|
||||
"field_guidance": "**facts**: Concise, self-contained statements\nEach fact is ONE piece of information\n No pronouns - each fact must stand alone\n Include specific details: case names, rule elements, test names, jurisdiction\n\n**files**: All files or documents read (full paths from project root)",
|
||||
"output_format_header": "OUTPUT FORMAT\n-------------\nOutput observations using this XML structure:",
|
||||
"format_examples": "",
|
||||
"footer": "IMPORTANT! DO NOT do any work right now other than generating this OBSERVATIONS from tool use messages - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the observation content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful observations.\n\nRemember that we record these observations as a way of helping us stay on track with our progress, and to help us keep important decisions and changes at the forefront of our minds! :) Thank you so much for your help!",
|
||||
|
||||
"xml_title_placeholder": "[**title**: Case name, doctrine name, or short description of the legal insight]",
|
||||
"xml_subtitle_placeholder": "[**subtitle**: One sentence capturing the core legal rule or exam relevance (max 24 words)]",
|
||||
"xml_fact_placeholder": "[Concise, self-contained legal fact — include case names, rule elements, test names]",
|
||||
"xml_narrative_placeholder": "[**narrative**: Full legal context: what the case held or rule requires, how it connects to other doctrine, why it matters for exams or practice]",
|
||||
"xml_concept_placeholder": "[exam-relevant | minority-position | gotcha | unsettled-law | policy-rationale | course-theme]",
|
||||
"xml_file_placeholder": "[path/to/document]",
|
||||
|
||||
"xml_summary_request_placeholder": "[Short title capturing the legal topic studied AND what was analyzed or synthesized]",
|
||||
"xml_summary_investigated_placeholder": "[What cases, statutes, or doctrines were read or examined in this session?]",
|
||||
"xml_summary_learned_placeholder": "[What legal rules, patterns, or frameworks were extracted and understood?]",
|
||||
"xml_summary_completed_placeholder": "[What study work was completed? Which cases briefed, which doctrines synthesized, which issue patterns identified?]",
|
||||
"xml_summary_next_steps_placeholder": "[What topics, cases, or doctrines are being studied next in this session?]",
|
||||
"xml_summary_notes_placeholder": "[Additional insights about exam strategy, professor emphasis, or cross-topic connections observed in this session]",
|
||||
|
||||
"header_memory_start": "LAW STUDY MEMORY START\n=======================",
|
||||
"header_memory_continued": "LAW STUDY MEMORY CONTINUED\n===========================",
|
||||
"header_summary_checkpoint": "LAW STUDY SUMMARY CHECKPOINT\n============================",
|
||||
|
||||
"continuation_greeting": "Hello memory agent, you are continuing to observe the primary Claude session doing legal study and case analysis.",
|
||||
"continuation_instruction": "IMPORTANT: Continue generating observations from tool use messages using the XML structure below.",
|
||||
|
||||
"summary_instruction": "Write progress notes of what legal material was studied, what rules and patterns were extracted, and what's next. This is a checkpoint to capture study progress so far. The session is ongoing - more cases or doctrines may be analyzed after this summary. Write \"next_steps\" as the current study trajectory (what topics or cases are actively being worked through), not as post-session plans. Always write at least a minimal summary explaining current progress, even if study is still early, so that users see a summary output tied to each study block.",
|
||||
"summary_context_label": "Claude's Full Response to User:",
|
||||
"summary_format_instruction": "Respond in this XML format:",
|
||||
"summary_footer": "IMPORTANT! DO NOT do any work right now other than generating this next PROGRESS SUMMARY - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the summary content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful summary content.\n\nThank you, this summary will be very useful for keeping track of legal study progress!"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "10.5.0",
|
||||
"version": "10.6.3",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -220,14 +220,14 @@ function installBun() {
|
||||
// Windows: Use PowerShell installer
|
||||
console.error(' Installing via PowerShell...');
|
||||
execSync('powershell -c "irm bun.sh/install.ps1 | iex"', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
} else {
|
||||
// Unix/macOS: Use curl installer
|
||||
console.error(' Installing via curl...');
|
||||
execSync('curl -fsSL https://bun.sh/install | bash', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
}
|
||||
@@ -285,14 +285,14 @@ function installUv() {
|
||||
// Windows: Use PowerShell installer
|
||||
console.error(' Installing via PowerShell...');
|
||||
execSync('powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
} else {
|
||||
// Unix/macOS: Use curl installer
|
||||
console.error(' Installing via curl...');
|
||||
execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
}
|
||||
@@ -426,14 +426,18 @@ function installDeps() {
|
||||
// Quote path for Windows paths with spaces
|
||||
const bunCmd = IS_WINDOWS && bunPath.includes(' ') ? `"${bunPath}"` : bunPath;
|
||||
|
||||
// Use pipe for stdout to prevent non-JSON output leaking to Claude Code hooks.
|
||||
// stderr is inherited so progress/errors are still visible to the user.
|
||||
const installStdio = ['pipe', 'pipe', 'inherit'];
|
||||
|
||||
let bunSucceeded = false;
|
||||
try {
|
||||
execSync(`${bunCmd} install`, { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync(`${bunCmd} install`, { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
bunSucceeded = true;
|
||||
} catch {
|
||||
// First attempt failed, try with force flag
|
||||
try {
|
||||
execSync(`${bunCmd} install --force`, { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync(`${bunCmd} install --force`, { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
bunSucceeded = true;
|
||||
} catch {
|
||||
// Bun failed completely, will try npm fallback
|
||||
@@ -445,7 +449,7 @@ function installDeps() {
|
||||
console.error('⚠️ Bun install failed, falling back to npm...');
|
||||
console.error(' (This can happen with npm alias packages like *-cjs)');
|
||||
try {
|
||||
execSync('npm install', { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync('npm install', { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
} catch (npmError) {
|
||||
throw new Error('Both bun and npm install failed: ' + npmError.message);
|
||||
}
|
||||
@@ -506,7 +510,7 @@ try {
|
||||
console.error(`⚠️ Bun ${currentVersion} is outdated. Minimum required: ${MIN_BUN_VERSION}`);
|
||||
console.error(' Upgrading bun...');
|
||||
try {
|
||||
execSync('bun upgrade', { stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync('bun upgrade', { stdio: ['pipe', 'pipe', 'inherit'], shell: IS_WINDOWS });
|
||||
if (!isBunVersionSufficient()) {
|
||||
console.error(`❌ Bun upgrade failed. Please manually upgrade: bun upgrade`);
|
||||
process.exit(1);
|
||||
@@ -542,7 +546,7 @@ try {
|
||||
if (!verifyCriticalModules()) {
|
||||
console.error('⚠️ Retrying install with npm...');
|
||||
try {
|
||||
execSync('npm install --production', { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync('npm install --production', { cwd: ROOT, stdio: ['pipe', 'pipe', 'inherit'], shell: IS_WINDOWS });
|
||||
} catch {
|
||||
// npm also failed
|
||||
}
|
||||
@@ -577,7 +581,12 @@ try {
|
||||
|
||||
// Step 4: Install CLI to PATH
|
||||
installCLI();
|
||||
|
||||
// Output valid JSON for Claude Code hook contract
|
||||
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||
} catch (e) {
|
||||
console.error('❌ Installation failed:', e.message);
|
||||
// Still output valid JSON so Claude Code doesn't show a confusing error
|
||||
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+209
-211
File diff suppressed because one or more lines are too long
@@ -7,6 +7,8 @@ description: Token-optimized structural code search using tree-sitter AST parsin
|
||||
|
||||
Structural code exploration using AST parsing. **This skill overrides your default exploration behavior.** While this skill is active, use smart_search/smart_outline/smart_unfold as your primary tools instead of Read, Grep, and Glob.
|
||||
|
||||
**Core principle:** Index first, fetch on demand. Give yourself a map of the code before loading implementation details. The question before every file read should be: "do I need to see all of this, or can I get a structural overview first?" The answer is almost always: get the map.
|
||||
|
||||
## Your Next Tool Call
|
||||
|
||||
This skill only loads instructions. You must call the MCP tools yourself. Your next action should be one of:
|
||||
@@ -71,7 +73,7 @@ Review symbols from Steps 1-2. Pick the ones you need. Unfold only those:
|
||||
smart_unfold(file_path="services/worker-service.ts", symbol_name="shutdown")
|
||||
```
|
||||
|
||||
**Returns:** Full source code of the specified symbol including JSDoc, decorators, and complete implementation (~1-7k tokens depending on symbol size)
|
||||
**Returns:** Full source code of the specified symbol including JSDoc, decorators, and complete implementation (~400-2,100 tokens depending on symbol size). AST node boundaries guarantee completeness regardless of symbol size — unlike Read + agent summarization, which may truncate long methods.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
@@ -85,6 +87,7 @@ Use these only when smart_* tools are the wrong fit:
|
||||
- **Grep:** Exact string/regex search ("find all TODO comments", "where is `ensureWorkerStarted` defined?")
|
||||
- **Read:** Small files under ~100 lines, non-code files (JSON, markdown, config)
|
||||
- **Glob:** File path patterns ("find all test files")
|
||||
- **Explore agent:** When you need synthesized understanding across 6+ files, architecture narratives, or answers to open-ended questions like "how does this entire system work end-to-end?" Smart-explore is a scalpel — it answers "where is this?" and "show me that." It doesn't synthesize cross-file data flows, design decisions, or edge cases across an entire feature.
|
||||
|
||||
For code files over ~100 lines, prefer smart_outline + smart_unfold over Read.
|
||||
|
||||
@@ -132,10 +135,11 @@ Use smart_* tools for code exploration, Read for non-code files. Mix freely.
|
||||
|
||||
| Approach | Tokens | Use Case |
|
||||
|----------|--------|----------|
|
||||
| smart_outline | ~1,500 | "What's in this file?" |
|
||||
| smart_unfold | ~1,600 | "Show me this function" |
|
||||
| smart_search | ~2,000-6,000 | "How does X work?" |
|
||||
| smart_outline | ~1,000-2,000 | "What's in this file?" |
|
||||
| smart_unfold | ~400-2,100 | "Show me this function" |
|
||||
| smart_search | ~2,000-6,000 | "Find all X across the codebase" |
|
||||
| search + unfold | ~3,000-8,000 | End-to-end: find and read (the primary workflow) |
|
||||
| Read (full file) | ~12,000+ | When you truly need everything |
|
||||
| Explore agent | ~20,000-40,000 | Same as smart_search, 6-12x more expensive |
|
||||
| Explore agent | ~39,000-59,000 | Cross-file synthesis with narrative |
|
||||
|
||||
**8x savings** on file understanding (outline + unfold vs Read). **6-12x savings** on exploration vs Explore agent.
|
||||
**4-8x savings** on file understanding (outline + unfold vs Read). **11-18x savings** on codebase exploration vs Explore agent. The narrower the query, the wider the gap — a 27-line function costs 55x less to read via unfold than via an Explore agent, because the agent still reads the entire file.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: timeline-report
|
||||
description: Generate a "Journey Into [Project]" narrative report analyzing a project's entire development history from claude-mem's timeline. Use when asked for a timeline report, project history analysis, development journey, or full project report.
|
||||
---
|
||||
|
||||
# Timeline Report
|
||||
|
||||
Generate a comprehensive narrative analysis of a project's entire development history using claude-mem's persistent memory timeline.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use when users ask for:
|
||||
|
||||
- "Write a timeline report"
|
||||
- "Journey into [project]"
|
||||
- "Analyze my project history"
|
||||
- "Full project report"
|
||||
- "Summarize the entire development history"
|
||||
- "What's the story of this project?"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The claude-mem worker must be running on localhost:37777. The project must have claude-mem observations recorded.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Determine the Project Name
|
||||
|
||||
Ask the user which project to analyze if not obvious from context. The project name is typically the directory name of the project (e.g., "tokyo", "my-app"). If the user says "this project", use the current working directory's basename.
|
||||
|
||||
**Worktree Detection:** Before using the directory basename, check if the current directory is a git worktree. In a worktree, the data source is the **parent project**, not the worktree directory itself. Run:
|
||||
|
||||
```bash
|
||||
git_dir=$(git rev-parse --git-dir 2>/dev/null)
|
||||
git_common_dir=$(git rev-parse --git-common-dir 2>/dev/null)
|
||||
if [ "$git_dir" != "$git_common_dir" ]; then
|
||||
# We're in a worktree — resolve the parent project name
|
||||
parent_project=$(basename "$(dirname "$git_common_dir")")
|
||||
echo "Worktree detected. Parent project: $parent_project"
|
||||
else
|
||||
parent_project=$(basename "$PWD")
|
||||
fi
|
||||
echo "$parent_project"
|
||||
```
|
||||
|
||||
If a worktree is detected, use `$parent_project` (the basename of the parent repo) as the project name for all API calls. Inform the user: "Detected git worktree. Using parent project '[name]' as the data source."
|
||||
|
||||
### Step 2: Fetch the Full Timeline
|
||||
|
||||
Use Bash to fetch the complete timeline from the claude-mem worker API:
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:37777/api/context/inject?project=PROJECT_NAME&full=true"
|
||||
```
|
||||
|
||||
This returns the entire compressed timeline -- every observation, session boundary, and summary across the project's full history. The response is pre-formatted markdown optimized for LLM consumption.
|
||||
|
||||
**Token estimates:** The full timeline size depends on the project's history:
|
||||
- Small project (< 1,000 observations): ~20-50K tokens
|
||||
- Medium project (1,000-10,000 observations): ~50-300K tokens
|
||||
- Large project (10,000-35,000 observations): ~300-750K tokens
|
||||
|
||||
If the response is empty or returns an error, the worker may not be running or the project name may be wrong. Try `curl -s "http://localhost:37777/api/search?query=*&limit=1"` to verify the worker is healthy.
|
||||
|
||||
### Step 3: Estimate Token Count
|
||||
|
||||
Before proceeding, estimate the token count of the fetched timeline (roughly 1 token per 4 characters). Report this to the user:
|
||||
|
||||
```
|
||||
Timeline fetched: ~X observations, estimated ~Yk tokens.
|
||||
This analysis will consume approximately Yk input tokens + ~5-10k output tokens.
|
||||
Proceed? (y/n)
|
||||
```
|
||||
|
||||
Wait for user confirmation before continuing if the timeline exceeds 100K tokens.
|
||||
|
||||
### Step 4: Analyze with a Subagent
|
||||
|
||||
Deploy an Agent (using the Task tool) with the full timeline and the following analysis prompt. Pass the ENTIRE timeline as context to the agent. The agent should also be instructed to query the SQLite database at `~/.claude-mem/claude-mem.db` for the Token Economics section.
|
||||
|
||||
**Agent prompt:**
|
||||
|
||||
```
|
||||
You are a technical historian analyzing a software project's complete development timeline from claude-mem's persistent memory system. The timeline below contains every observation, session boundary, and summary recorded across the project's entire history.
|
||||
|
||||
You also have access to the claude-mem SQLite database at ~/.claude-mem/claude-mem.db. Use it to run queries for the Token Economics & Memory ROI section. The database has an "observations" table with columns: id, memory_session_id, project, text, type, title, subtitle, facts, narrative, concepts, files_read, files_modified, prompt_number, discovery_tokens, created_at, created_at_epoch, source_tool, source_input_summary.
|
||||
|
||||
Write a comprehensive narrative report titled "Journey Into [PROJECT_NAME]" that covers:
|
||||
|
||||
## Required Sections
|
||||
|
||||
1. **Project Genesis** -- When and how the project started. What were the first commits, the initial vision, the founding technical decisions? What problem was being solved?
|
||||
|
||||
2. **Architectural Evolution** -- How did the architecture change over time? What were the major pivots? Why did they happen? Trace the evolution from initial design through each significant restructuring.
|
||||
|
||||
3. **Key Breakthroughs** -- Identify the "aha" moments: when a difficult problem was finally solved, when a new approach unlocked progress, when a prototype first worked. These are the observations where the tone shifts from investigation to resolution.
|
||||
|
||||
4. **Work Patterns** -- Analyze the rhythm of development. Identify debugging cycles (clusters of bug fixes), feature sprints (rapid observation sequences), refactoring phases (architectural changes without new features), and exploration phases (many discoveries without changes).
|
||||
|
||||
5. **Technical Debt** -- Track where shortcuts were taken and when they were paid back. Identify patterns of accumulation (rapid feature work) and resolution (dedicated refactoring sessions).
|
||||
|
||||
6. **Challenges and Debugging Sagas** -- The hardest problems encountered. Multi-session debugging efforts, architectural dead-ends that required backtracking, platform-specific issues that took days to resolve.
|
||||
|
||||
7. **Memory and Continuity** -- How did persistent memory (claude-mem itself, if applicable) affect the development process? Were there moments where recalled context from prior sessions saved significant time or prevented repeated mistakes?
|
||||
|
||||
8. **Token Economics & Memory ROI** -- Quantitative analysis of how memory recall saved work:
|
||||
- Query the database directly for these metrics using `sqlite3 ~/.claude-mem/claude-mem.db`
|
||||
- Count total discovery_tokens across all observations (the original cost of all work)
|
||||
- Count sessions that had context injection available (sessions after the first)
|
||||
- Calculate the compression ratio: average discovery_tokens vs average read_tokens per observation
|
||||
- Identify the highest-value observations (highest discovery_tokens -- these are the most expensive decisions, bugs, and discoveries that memory prevents re-doing)
|
||||
- Identify explicit recall events (observations where source_tool contains "search", "smart_search", "get_observations", "timeline", or where narrative mentions "recalled", "from memory", "previous session")
|
||||
- Estimate passive recall savings: each session with context injection receives ~50 observations. Use a 30% relevance factor (conservative estimate that 30% of injected context prevents re-work). Savings = sessions_with_context × avg_discovery_value_of_50_obs_window × 0.30
|
||||
- Estimate explicit recall savings: ~10K tokens per explicit recall query
|
||||
- Calculate net ROI: total_savings / total_read_tokens_invested
|
||||
- Present as a table with monthly breakdown
|
||||
- Highlight the top 5 most expensive observations by discovery_tokens -- these represent the highest-value memories in the system (architecture decisions, hard bugs, implementation plans that cost 100K+ tokens to produce originally)
|
||||
|
||||
Use these SQL queries as a starting point:
|
||||
```sql
|
||||
-- Total discovery tokens
|
||||
SELECT SUM(discovery_tokens) FROM observations WHERE project = 'PROJECT_NAME';
|
||||
|
||||
-- Sessions with context available (not the first session)
|
||||
SELECT COUNT(DISTINCT memory_session_id) FROM observations WHERE project = 'PROJECT_NAME';
|
||||
|
||||
-- Average tokens per observation
|
||||
SELECT AVG(discovery_tokens) as avg_discovery, AVG(LENGTH(title || COALESCE(subtitle,'') || COALESCE(narrative,'') || COALESCE(facts,'')) / 4) as avg_read FROM observations WHERE project = 'PROJECT_NAME' AND discovery_tokens > 0;
|
||||
|
||||
-- Top 5 most expensive observations (highest-value memories)
|
||||
SELECT id, title, discovery_tokens FROM observations WHERE project = 'PROJECT_NAME' ORDER BY discovery_tokens DESC LIMIT 5;
|
||||
|
||||
-- Monthly breakdown
|
||||
SELECT strftime('%Y-%m', created_at) as month, COUNT(*) as obs, SUM(discovery_tokens) as total_discovery, COUNT(DISTINCT memory_session_id) as sessions FROM observations WHERE project = 'PROJECT_NAME' GROUP BY month ORDER BY month;
|
||||
|
||||
-- Explicit recall events
|
||||
SELECT COUNT(*) FROM observations WHERE project = 'PROJECT_NAME' AND (source_tool LIKE '%search%' OR source_tool LIKE '%timeline%' OR source_tool LIKE '%get_observations%' OR narrative LIKE '%recalled%' OR narrative LIKE '%from memory%' OR narrative LIKE '%previous session%');
|
||||
```
|
||||
|
||||
9. **Timeline Statistics** -- Quantitative summary:
|
||||
- Date range (first observation to last)
|
||||
- Total observations and sessions
|
||||
- Breakdown by observation type (features, bug fixes, discoveries, decisions, changes)
|
||||
- Most active days/weeks
|
||||
- Longest debugging sessions
|
||||
|
||||
10. **Lessons and Meta-Observations** -- What patterns emerge from the full history? What would a new developer learn about this codebase from reading the timeline? What recurring themes or principles guided development?
|
||||
|
||||
## Writing Style
|
||||
|
||||
- Write as a technical narrative, not a list of bullet points
|
||||
- Use specific observation IDs and timestamps when referencing events (e.g., "On Dec 14 (#26766), the root cause was finally identified...")
|
||||
- Connect events across time -- show how early decisions created later consequences
|
||||
- Be honest about struggles and dead ends, not just successes
|
||||
- Target 3,000-6,000 words depending on project size
|
||||
- Use markdown formatting with headers, emphasis, and code references where appropriate
|
||||
|
||||
## Important
|
||||
|
||||
- Analyze the ENTIRE timeline chronologically -- do not skip early history
|
||||
- Look for narrative arcs: problem -> investigation -> solution
|
||||
- Identify turning points where the project's direction fundamentally changed
|
||||
- Note any observations about the development process itself (tooling, workflow, collaboration patterns)
|
||||
|
||||
Here is the complete project timeline:
|
||||
|
||||
[TIMELINE CONTENT GOES HERE]
|
||||
```
|
||||
|
||||
### Step 5: Save the Report
|
||||
|
||||
Save the agent's output as a markdown file. Default location:
|
||||
|
||||
```
|
||||
./journey-into-PROJECT_NAME.md
|
||||
```
|
||||
|
||||
Or if the user specified a different output path, use that instead.
|
||||
|
||||
### Step 6: Report Completion
|
||||
|
||||
Tell the user:
|
||||
- Where the report was saved
|
||||
- The approximate token cost (input timeline + output report)
|
||||
- The date range covered
|
||||
- Number of observations analyzed
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Empty timeline:** "No observations found for project 'X'. Check the project name with: `curl -s 'http://localhost:37777/api/search?query=*&limit=1'`"
|
||||
- **Worker not running:** "The claude-mem worker is not responding on port 37777. Start it with your usual method or check `ps aux | grep worker-service`."
|
||||
- **Timeline too large:** For projects with 50,000+ observations, the timeline may exceed context limits. Suggest using date range filtering: `curl -s "http://localhost:37777/api/context/inject?project=X&full=true"` -- the current endpoint returns all observations; for extremely large projects, the user may want to analyze in time-windowed segments.
|
||||
|
||||
## Example
|
||||
|
||||
User: "Write a journey report for the tokyo project"
|
||||
|
||||
1. Fetch: `curl -s "http://localhost:37777/api/context/inject?project=tokyo&full=true"`
|
||||
2. Estimate: "Timeline fetched: ~34,722 observations, estimated ~718K tokens. Proceed?"
|
||||
3. User confirms
|
||||
4. Deploy analysis agent with full timeline
|
||||
5. Save to `./journey-into-tokyo.md`
|
||||
6. Report: "Report saved. Analyzed 34,722 observations spanning Oct 2025 - Mar 2026 (~718K input tokens, ~8K output tokens)."
|
||||
+10
-10
File diff suppressed because one or more lines are too long
@@ -116,7 +116,11 @@ async function buildHooks() {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
banner: {
|
||||
js: '#!/usr/bin/env bun'
|
||||
js: [
|
||||
'#!/usr/bin/env bun',
|
||||
'var __filename = require("node:url").fileURLToPath(import.meta.url);',
|
||||
'var __dirname = require("node:path").dirname(__filename);'
|
||||
].join('\n')
|
||||
}
|
||||
});
|
||||
|
||||
@@ -176,7 +180,8 @@ async function buildHooks() {
|
||||
external: ['bun:sqlite'],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
}
|
||||
},
|
||||
// No banner needed: CJS files under Node.js have __dirname/__filename natively
|
||||
});
|
||||
|
||||
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
||||
|
||||
@@ -16,13 +16,20 @@ export const claudeCodeAdapter: PlatformAdapter = {
|
||||
};
|
||||
},
|
||||
formatOutput(result) {
|
||||
if (result.hookSpecificOutput) {
|
||||
const r = result ?? ({} as HookResult);
|
||||
if (r.hookSpecificOutput) {
|
||||
const output: Record<string, unknown> = { hookSpecificOutput: result.hookSpecificOutput };
|
||||
if (result.systemMessage) {
|
||||
output.systemMessage = result.systemMessage;
|
||||
if (r.systemMessage) {
|
||||
output.systemMessage = r.systemMessage;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return { continue: result.continue ?? true, suppressOutput: result.suppressOutput ?? true };
|
||||
// Only emit fields in the Claude Code hook contract — unrecognized fields
|
||||
// cause "JSON validation failed" in Stop hooks.
|
||||
const output: Record<string, unknown> = {};
|
||||
if (r.systemMessage) {
|
||||
output.systemMessage = r.systemMessage;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PlatformAdapter } from '../types.js';
|
||||
|
||||
/**
|
||||
* Gemini CLI Platform Adapter
|
||||
*
|
||||
* Normalizes Gemini CLI's hook JSON to NormalizedHookInput.
|
||||
* Gemini CLI supports 11 lifecycle hooks; we register 8:
|
||||
*
|
||||
* Lifecycle:
|
||||
* SessionStart → context (inject memory context)
|
||||
* SessionEnd → session-complete
|
||||
* PreCompress → summarize
|
||||
* Notification → observation (system events like ToolPermission)
|
||||
*
|
||||
* Agent:
|
||||
* BeforeAgent → user-message (captures user prompt)
|
||||
* AfterAgent → observation (full agent response)
|
||||
*
|
||||
* Tool:
|
||||
* BeforeTool → observation (tool intent before execution)
|
||||
* AfterTool → observation (tool result after execution)
|
||||
*
|
||||
* Unmapped (not useful for memory):
|
||||
* BeforeModel, AfterModel, BeforeToolSelection — model-level events
|
||||
* that fire per-LLM-call, too chatty for observation capture.
|
||||
*
|
||||
* Base fields (all events): session_id, transcript_path, cwd, hook_event_name, timestamp
|
||||
*
|
||||
* Output format: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
|
||||
* Advisory hooks (SessionStart, SessionEnd, PreCompress, Notification) ignore flow-control fields.
|
||||
*/
|
||||
export const geminiCliAdapter: PlatformAdapter = {
|
||||
normalizeInput(raw) {
|
||||
const r = (raw ?? {}) as any;
|
||||
|
||||
// CWD resolution chain: JSON field → env vars → process.cwd()
|
||||
const cwd = r.cwd
|
||||
?? process.env.GEMINI_CWD
|
||||
?? process.env.GEMINI_PROJECT_DIR
|
||||
?? process.env.CLAUDE_PROJECT_DIR
|
||||
?? process.cwd();
|
||||
|
||||
const sessionId = r.session_id
|
||||
?? process.env.GEMINI_SESSION_ID
|
||||
?? undefined;
|
||||
|
||||
const hookEventName: string | undefined = r.hook_event_name;
|
||||
|
||||
// Tool fields — present in BeforeTool, AfterTool
|
||||
let toolName: string | undefined = r.tool_name;
|
||||
let toolInput: unknown = r.tool_input;
|
||||
let toolResponse: unknown = r.tool_response;
|
||||
|
||||
// AfterAgent: synthesize observation shape from the full agent response
|
||||
if (hookEventName === 'AfterAgent' && r.prompt_response) {
|
||||
toolName = toolName ?? 'GeminiAgent';
|
||||
toolInput = toolInput ?? { prompt: r.prompt };
|
||||
toolResponse = toolResponse ?? { response: r.prompt_response };
|
||||
}
|
||||
|
||||
// BeforeTool: has tool_name and tool_input but no tool_response yet
|
||||
// Synthesize a marker so observation handler knows this is pre-execution
|
||||
if (hookEventName === 'BeforeTool' && toolName && !toolResponse) {
|
||||
toolResponse = { _preExecution: true };
|
||||
}
|
||||
|
||||
// Notification: capture as an observation with notification details
|
||||
if (hookEventName === 'Notification') {
|
||||
toolName = toolName ?? 'GeminiNotification';
|
||||
toolInput = toolInput ?? {
|
||||
notification_type: r.notification_type,
|
||||
message: r.message,
|
||||
};
|
||||
toolResponse = toolResponse ?? { details: r.details };
|
||||
}
|
||||
|
||||
// Collect platform-specific metadata
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (r.source) metadata.source = r.source; // SessionStart: startup|resume|clear
|
||||
if (r.reason) metadata.reason = r.reason; // SessionEnd: exit|clear|logout|...
|
||||
if (r.trigger) metadata.trigger = r.trigger; // PreCompress: auto|manual
|
||||
if (r.mcp_context) metadata.mcp_context = r.mcp_context; // Tool hooks: MCP server context
|
||||
if (r.notification_type) metadata.notification_type = r.notification_type;
|
||||
if (r.stop_hook_active !== undefined) metadata.stop_hook_active = r.stop_hook_active;
|
||||
if (r.original_request_name) metadata.original_request_name = r.original_request_name;
|
||||
if (hookEventName) metadata.hook_event_name = hookEventName;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
cwd,
|
||||
prompt: r.prompt,
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResponse,
|
||||
transcriptPath: r.transcript_path,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
formatOutput(result) {
|
||||
// Gemini CLI expects: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
// Flow control — always include `continue` to prevent accidental agent termination
|
||||
output.continue = result.continue ?? true;
|
||||
|
||||
if (result.suppressOutput !== undefined) {
|
||||
output.suppressOutput = result.suppressOutput;
|
||||
}
|
||||
|
||||
if (result.systemMessage) {
|
||||
// Strip ANSI escape sequences: matches colors, text formatting, and terminal control codes
|
||||
// Gemini CLI often has issues with ANSI escape sequences in tool output (showing them as raw text)
|
||||
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
output.systemMessage = result.systemMessage.replace(ansiRegex, '');
|
||||
}
|
||||
|
||||
// hookSpecificOutput is a first-class Gemini CLI field — pass through directly
|
||||
// This includes additionalContext for context injection in SessionStart, BeforeAgent, AfterTool
|
||||
if (result.hookSpecificOutput) {
|
||||
output.hookSpecificOutput = {
|
||||
additionalContext: result.hookSpecificOutput.additionalContext,
|
||||
};
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
};
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { PlatformAdapter } from '../types.js';
|
||||
import { claudeCodeAdapter } from './claude-code.js';
|
||||
import { cursorAdapter } from './cursor.js';
|
||||
import { geminiCliAdapter } from './gemini-cli.js';
|
||||
import { rawAdapter } from './raw.js';
|
||||
|
||||
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
switch (platform) {
|
||||
case 'claude-code': return claudeCodeAdapter;
|
||||
case 'cursor': return cursorAdapter;
|
||||
case 'gemini':
|
||||
case 'gemini-cli': return geminiCliAdapter;
|
||||
case 'raw': return rawAdapter;
|
||||
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
|
||||
default: return rawAdapter;
|
||||
}
|
||||
}
|
||||
|
||||
export { claudeCodeAdapter, cursorAdapter, rawAdapter };
|
||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter };
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, getWorkerPort, workerHttpRequest } 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';
|
||||
@@ -38,16 +38,16 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
// Pass all projects (parent + worktree if applicable) for unified timeline
|
||||
const projectsParam = context.allProjects.join(',');
|
||||
const url = `http://127.0.0.1:${port}/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||
const colorApiPath = `${apiPath}&colors=true`;
|
||||
|
||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||
// Worker service has its own timeouts, so client-side timeout is redundant
|
||||
try {
|
||||
// Fetch markdown (for Claude context) and optionally colored (for user display)
|
||||
const colorUrl = `${url}&colors=true`;
|
||||
const [response, colorResponse] = await Promise.all([
|
||||
fetch(url),
|
||||
showTerminalOutput ? fetch(colorUrl).catch(() => null) : Promise.resolve(null)
|
||||
workerHttpRequest(apiPath),
|
||||
showTerminalOutput ? workerHttpRequest(colorApiPath).catch(() => null) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -66,9 +66,15 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
const additionalContext = contextResult.trim();
|
||||
const coloredTimeline = colorResult.trim();
|
||||
const platform = input.platform;
|
||||
|
||||
const systemMessage = showTerminalOutput && coloredTimeline
|
||||
? `${coloredTimeline}\n\nView Observations Live @ http://localhost:${port}`
|
||||
// Use colored timeline for display if available, otherwise fall back to
|
||||
// plain markdown context (especially useful for platforms like Gemini
|
||||
// where we want to ensure visibility even if colors aren't fetched).
|
||||
const displayContent = coloredTimeline || (platform === 'gemini-cli' || platform === 'gemini' ? additionalContext : '');
|
||||
|
||||
const systemMessage = showTerminalOutput && displayContent
|
||||
? `${displayContent}\n\nView Observations Live @ http://localhost:${port}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
|
||||
@@ -25,10 +25,7 @@ export const fileEditHandler: EventHandler = {
|
||||
throw new Error('fileEditHandler requires filePath');
|
||||
}
|
||||
|
||||
const port = getWorkerPort();
|
||||
|
||||
logger.dataIn('HOOK', `FileEdit: ${filePath}`, {
|
||||
workerPort: port,
|
||||
editCount: edits?.length ?? 0
|
||||
});
|
||||
|
||||
@@ -40,7 +37,7 @@ export const fileEditHandler: EventHandler = {
|
||||
// Send to worker as an observation with file edit metadata
|
||||
// The observation handler on the worker will process this appropriately
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
const response = await workerHttpRequest('/api/sessions/observations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -50,7 +47,6 @@ export const fileEditHandler: EventHandler = {
|
||||
tool_response: { success: true },
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
@@ -28,13 +28,9 @@ export const observationHandler: EventHandler = {
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const port = getWorkerPort();
|
||||
|
||||
const toolStr = logger.formatTool(toolName, toolInput);
|
||||
|
||||
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
|
||||
workerPort: port
|
||||
});
|
||||
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {});
|
||||
|
||||
// Validate required fields before sending to worker
|
||||
if (!cwd) {
|
||||
@@ -50,7 +46,7 @@ export const observationHandler: EventHandler = {
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
const response = await workerHttpRequest('/api/sessions/observations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -60,7 +56,6 @@ export const observationHandler: EventHandler = {
|
||||
tool_response: toolResponse,
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
@@ -23,7 +23,6 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId } = input;
|
||||
const port = getWorkerPort();
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||
@@ -31,13 +30,12 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
}
|
||||
|
||||
logger.info('HOOK', '→ session-complete: Removing session from active map', {
|
||||
workerPort: port,
|
||||
contentSessionId: sessionId
|
||||
});
|
||||
|
||||
try {
|
||||
// Call the session complete endpoint by contentSessionId
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/complete`, {
|
||||
const response = await workerHttpRequest('/api/sessions/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { getProjectName } from '../../utils/project-name.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
@@ -42,12 +42,11 @@ export const sessionInitHandler: EventHandler = {
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
|
||||
const project = getProjectName(cwd);
|
||||
const port = getWorkerPort();
|
||||
|
||||
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
|
||||
|
||||
// Initialize session via HTTP - handles DB operations and privacy checks
|
||||
const initResponse = await fetch(`http://127.0.0.1:${port}/api/sessions/init`, {
|
||||
const initResponse = await workerHttpRequest('/api/sessions/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -55,7 +54,6 @@ export const sessionInitHandler: EventHandler = {
|
||||
project,
|
||||
prompt
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
@@ -107,11 +105,10 @@ export const sessionInitHandler: EventHandler = {
|
||||
logger.debug('HOOK', 'session-init: Calling /sessions/{sessionDbId}/init', { sessionDbId, promptNumber });
|
||||
|
||||
// Initialize SDK agent session via HTTP (starts the agent!)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
|
||||
const response = await workerHttpRequest(`/sessions/${sessionDbId}/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userPrompt: cleanedPrompt, promptNumber })
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort, fetchWithTimeout } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { extractLastMessage } from '../../shared/transcript-parser.js';
|
||||
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
||||
@@ -25,8 +25,6 @@ export const summarizeHandler: EventHandler = {
|
||||
|
||||
const { sessionId, transcriptPath } = input;
|
||||
|
||||
const port = getWorkerPort();
|
||||
|
||||
// Validate required fields before processing
|
||||
if (!transcriptPath) {
|
||||
// No transcript available - skip summary gracefully (not an error)
|
||||
@@ -37,26 +35,28 @@ export const summarizeHandler: EventHandler = {
|
||||
// Extract last assistant message from transcript (the work Claude did)
|
||||
// Note: "user" messages in transcripts are mostly tool_results, not actual user input.
|
||||
// The user's original request is already stored in user_prompts table.
|
||||
const lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
let lastAssistantMessage = '';
|
||||
try {
|
||||
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
} catch (err) {
|
||||
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
||||
workerPort: port,
|
||||
hasLastAssistantMessage: !!lastAssistantMessage
|
||||
});
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/sessions/summarize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
}),
|
||||
},
|
||||
SUMMARIZE_TIMEOUT_MS
|
||||
);
|
||||
const response = await workerHttpRequest('/api/sessions/summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
}),
|
||||
timeoutMs: SUMMARIZE_TIMEOUT_MS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Return standard response even on failure (matches original behavior)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { basename } from 'path';
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, getWorkerPort, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
|
||||
export const userMessageHandler: EventHandler = {
|
||||
@@ -23,11 +23,9 @@ export const userMessageHandler: EventHandler = {
|
||||
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)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
|
||||
{ method: 'GET' }
|
||||
const response = await workerHttpRequest(
|
||||
`/api/context/inject?project=${encodeURIComponent(project)}&colors=true`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Observation metadata constants
|
||||
* Shared across hooks, worker service, and UI components
|
||||
*
|
||||
* Note: These are fallback defaults for the code mode.
|
||||
* Actual observation types and concepts are defined per-mode in the modes/ directory.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default observation types (comma-separated string for settings)
|
||||
* Uses code mode defaults as fallback
|
||||
*/
|
||||
export const DEFAULT_OBSERVATION_TYPES_STRING = 'bugfix,feature,refactor,discovery,decision,change';
|
||||
|
||||
/**
|
||||
* Default observation concepts (comma-separated string for settings)
|
||||
* Uses code mode defaults as fallback
|
||||
*/
|
||||
export const DEFAULT_OBSERVATION_CONCEPTS_STRING = 'how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off';
|
||||
@@ -120,6 +120,11 @@ export function parseSummary(text: string, sessionId?: number): ParsedSummary |
|
||||
const summaryMatch = summaryRegex.exec(text);
|
||||
|
||||
if (!summaryMatch) {
|
||||
// Log when the response contains <observation> instead of <summary>
|
||||
// to help diagnose prompt conditioning issues (see #1312)
|
||||
if (/<observation>/.test(text)) {
|
||||
logger.warn('PARSER', 'Summary response contained <observation> tags instead of <summary> — prompt conditioning may need strengthening', { sessionId });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -130,7 +130,11 @@ export function buildSummaryPrompt(session: SDKSession, mode: ModeConfig): strin
|
||||
return '';
|
||||
})();
|
||||
|
||||
return `${mode.prompts.header_summary_checkpoint}
|
||||
return `--- MODE SWITCH: PROGRESS SUMMARY ---
|
||||
Do NOT output <observation> tags. This is a summary request, not an observation request.
|
||||
Your response MUST use <summary> tags ONLY. Any <observation> output will be discarded.
|
||||
|
||||
${mode.prompts.header_summary_checkpoint}
|
||||
${mode.prompts.summary_instruction}
|
||||
|
||||
${mode.prompts.summary_context_label}
|
||||
|
||||
@@ -27,19 +27,12 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
import { workerHttpRequest } from '../shared/worker-utils.js';
|
||||
import { searchCodebase, formatSearchResults } from '../services/smart-file-read/search.js';
|
||||
import { parseFile, formatFoldedView, unfoldSymbol } from '../services/smart-file-read/parser.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Worker HTTP API configuration
|
||||
*/
|
||||
const WORKER_PORT = getWorkerPort();
|
||||
const WORKER_HOST = getWorkerHost();
|
||||
const WORKER_BASE_URL = `http://${WORKER_HOST}:${WORKER_PORT}`;
|
||||
|
||||
/**
|
||||
* Map tool names to Worker HTTP endpoints
|
||||
*/
|
||||
@@ -49,7 +42,7 @@ const TOOL_ENDPOINT_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Call Worker HTTP API endpoint
|
||||
* Call Worker HTTP API endpoint (uses socket or TCP automatically)
|
||||
*/
|
||||
async function callWorkerAPI(
|
||||
endpoint: string,
|
||||
@@ -67,8 +60,8 @@ async function callWorkerAPI(
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${WORKER_BASE_URL}${endpoint}?${searchParams}`;
|
||||
const response = await fetch(url);
|
||||
const apiPath = `${endpoint}?${searchParams}`;
|
||||
const response = await workerHttpRequest(apiPath);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@@ -103,12 +96,9 @@ async function callWorkerAPIPost(
|
||||
logger.debug('HTTP', 'Worker API request (POST)', undefined, { endpoint });
|
||||
|
||||
try {
|
||||
const url = `${WORKER_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
const response = await workerHttpRequest(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
@@ -145,7 +135,7 @@ async function callWorkerAPIPost(
|
||||
*/
|
||||
async function verifyWorkerConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${WORKER_BASE_URL}/api/health`);
|
||||
const response = await workerHttpRequest('/api/health');
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
// Expected during worker startup or if worker is down
|
||||
@@ -448,11 +438,11 @@ async function main() {
|
||||
setTimeout(async () => {
|
||||
const workerAvailable = await verifyWorkerConnection();
|
||||
if (!workerAvailable) {
|
||||
logger.error('SYSTEM', 'Worker not available', undefined, { workerUrl: WORKER_BASE_URL });
|
||||
logger.error('SYSTEM', 'Worker not available', undefined, {});
|
||||
logger.error('SYSTEM', 'Tools will fail until Worker is started');
|
||||
logger.error('SYSTEM', 'Start Worker with: npm run worker:restart');
|
||||
} else {
|
||||
logger.info('SYSTEM', 'Worker available', undefined, { workerUrl: WORKER_BASE_URL });
|
||||
logger.info('SYSTEM', 'Worker available', undefined, {});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,12 @@ export async function generateContext(
|
||||
// Use provided projects array (for worktree support) or fall back to single project
|
||||
const projects = input?.projects || [project];
|
||||
|
||||
// Full mode: fetch all observations but keep normal rendering (level 1 summaries)
|
||||
if (input?.full) {
|
||||
config.totalObservationCount = 999999;
|
||||
config.sessionCount = 999999;
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
const db = initializeDatabase();
|
||||
if (!db) {
|
||||
@@ -155,7 +161,7 @@ export async function generateContext(
|
||||
}
|
||||
|
||||
// Build and return context
|
||||
return buildContextOutput(
|
||||
const output = buildContextOutput(
|
||||
project,
|
||||
observations,
|
||||
summaries,
|
||||
@@ -164,6 +170,8 @@ export async function generateContext(
|
||||
input?.session_id,
|
||||
useColors
|
||||
);
|
||||
|
||||
return output;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
@@ -18,27 +18,10 @@ export function loadContextConfig(): ContextConfig {
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// For non-code modes, use all types/concepts from active mode instead of settings
|
||||
const modeId = settings.CLAUDE_MEM_MODE;
|
||||
const isCodeMode = modeId === 'code' || modeId.startsWith('code--');
|
||||
|
||||
let observationTypes: Set<string>;
|
||||
let observationConcepts: Set<string>;
|
||||
|
||||
if (isCodeMode) {
|
||||
// Code mode: use settings-based filtering
|
||||
observationTypes = new Set(
|
||||
settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
);
|
||||
observationConcepts = new Set(
|
||||
settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS.split(',').map((c: string) => c.trim()).filter(Boolean)
|
||||
);
|
||||
} else {
|
||||
// Non-code modes: use all types/concepts from active mode
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
observationTypes = new Set(mode.observation_types.map(t => t.id));
|
||||
observationConcepts = new Set(mode.observation_concepts.map(c => c.id));
|
||||
}
|
||||
// Always read types/concepts from the active mode definition
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
const observationTypes = new Set(mode.observation_types.map(t => t.id));
|
||||
const observationConcepts = new Set(mode.observation_concepts.map(c => c.id));
|
||||
|
||||
return {
|
||||
totalObservationCount: parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* MarkdownFormatter - Formats context output as markdown (non-colored mode)
|
||||
* MarkdownFormatter - Formats context output as compact markdown for LLM injection
|
||||
*
|
||||
* Handles all markdown formatting for context injection.
|
||||
* Optimized for token efficiency: flat lines instead of tables, no repeated headers.
|
||||
* The colored terminal formatter (ColorFormatter.ts) handles human-readable display separately.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -34,7 +35,7 @@ function formatHeaderDateTime(): string {
|
||||
*/
|
||||
export function renderMarkdownHeader(project: string): string[] {
|
||||
return [
|
||||
`# [${project}] recent context, ${formatHeaderDateTime()}`,
|
||||
`# $CMEM ${project} ${formatHeaderDateTime()}`,
|
||||
''
|
||||
];
|
||||
}
|
||||
@@ -44,39 +45,28 @@ export function renderMarkdownHeader(project: string): string[] {
|
||||
*/
|
||||
export function renderMarkdownLegend(): string[] {
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
const typeLegendItems = mode.observation_types.map(t => `${t.emoji} ${t.id}`).join(' | ');
|
||||
const typeLegendItems = mode.observation_types.map(t => `${t.emoji}${t.id}`).join(' ');
|
||||
|
||||
return [
|
||||
`**Legend:** session-request | ${typeLegendItems}`,
|
||||
`Legend: 🎯session ${typeLegendItems}`,
|
||||
`Format: ID TIME TYPE TITLE`,
|
||||
`Fetch details: get_observations([IDs]) | Search: mem-search skill`,
|
||||
''
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown column key
|
||||
* Render markdown column key - no longer needed in compact format
|
||||
*/
|
||||
export function renderMarkdownColumnKey(): string[] {
|
||||
return [
|
||||
`**Column Key**:`,
|
||||
`- **Read**: Tokens to read this observation (cost to learn it now)`,
|
||||
`- **Work**: Tokens spent on work that produced this record ( research, building, deciding)`,
|
||||
''
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown context index instructions
|
||||
* Render markdown context index instructions - folded into legend
|
||||
*/
|
||||
export function renderMarkdownContextIndex(): string[] {
|
||||
return [
|
||||
`**Context Index:** This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.`,
|
||||
'',
|
||||
`When you need implementation details, rationale, or debugging context:`,
|
||||
`- Fetch by ID: get_observations([IDs]) for observations visible in this index`,
|
||||
`- Search history: Use the mem-search skill for past decisions, bugs, and deeper research`,
|
||||
`- Trust this index over re-reading code for past decisions and learnings`,
|
||||
''
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,21 +78,20 @@ export function renderMarkdownContextEconomics(
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(`**Context Economics**:`);
|
||||
output.push(`- Loading: ${economics.totalObservations} observations (${economics.totalReadTokens.toLocaleString()} tokens to read)`);
|
||||
output.push(`- Work investment: ${economics.totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions`);
|
||||
const parts: string[] = [
|
||||
`${economics.totalObservations} obs (${economics.totalReadTokens.toLocaleString()}t read)`,
|
||||
`${economics.totalDiscoveryTokens.toLocaleString()}t work`
|
||||
];
|
||||
|
||||
if (economics.totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) {
|
||||
let savingsLine = '- Your savings: ';
|
||||
if (config.showSavingsAmount && config.showSavingsPercent) {
|
||||
savingsLine += `${economics.savings.toLocaleString()} tokens (${economics.savingsPercent}% reduction from reuse)`;
|
||||
if (config.showSavingsPercent) {
|
||||
parts.push(`${economics.savingsPercent}% savings`);
|
||||
} else if (config.showSavingsAmount) {
|
||||
savingsLine += `${economics.savings.toLocaleString()} tokens`;
|
||||
} else {
|
||||
savingsLine += `${economics.savingsPercent}% reduction from reuse`;
|
||||
parts.push(`${economics.savings.toLocaleString()}t saved`);
|
||||
}
|
||||
output.push(savingsLine);
|
||||
}
|
||||
|
||||
output.push(`Stats: ${parts.join(' | ')}`);
|
||||
output.push('');
|
||||
|
||||
return output;
|
||||
@@ -114,37 +103,37 @@ export function renderMarkdownContextEconomics(
|
||||
export function renderMarkdownDayHeader(day: string): string[] {
|
||||
return [
|
||||
`### ${day}`,
|
||||
''
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown file header with table header
|
||||
* Render markdown file header - no longer renders table headers in compact format
|
||||
*/
|
||||
export function renderMarkdownFileHeader(file: string): string[] {
|
||||
return [
|
||||
`**${file}**`,
|
||||
`| ID | Time | T | Title | Read | Work |`,
|
||||
`|----|------|---|-------|------|------|`
|
||||
];
|
||||
export function renderMarkdownFileHeader(_file: string): string[] {
|
||||
// File grouping eliminated in compact format - file context is in observation titles
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown table row for observation
|
||||
* Format compact time: "9:23 AM" → "9:23a", "12:05 PM" → "12:05p"
|
||||
*/
|
||||
function compactTime(time: string): string {
|
||||
return time.toLowerCase().replace(' am', 'a').replace(' pm', 'p');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render compact flat line for observation (replaces table row)
|
||||
*/
|
||||
export function renderMarkdownTableRow(
|
||||
obs: Observation,
|
||||
timeDisplay: string,
|
||||
config: ContextConfig
|
||||
_config: ContextConfig
|
||||
): string {
|
||||
const title = obs.title || 'Untitled';
|
||||
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
|
||||
const { readTokens, discoveryDisplay } = formatObservationTokenDisplay(obs, config);
|
||||
const time = timeDisplay ? compactTime(timeDisplay) : '"';
|
||||
|
||||
const readCol = config.showReadTokens ? `~${readTokens}` : '';
|
||||
const workCol = config.showWorkTokens ? discoveryDisplay : '';
|
||||
|
||||
return `| #${obs.id} | ${timeDisplay || '"'} | ${icon} | ${title} | ${readCol} | ${workCol} |`;
|
||||
return `${obs.id} ${time} ${icon} ${title}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,24 +148,23 @@ export function renderMarkdownFullObservation(
|
||||
const output: string[] = [];
|
||||
const title = obs.title || 'Untitled';
|
||||
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
|
||||
const time = timeDisplay ? compactTime(timeDisplay) : '"';
|
||||
const { readTokens, discoveryDisplay } = formatObservationTokenDisplay(obs, config);
|
||||
|
||||
output.push(`**#${obs.id}** ${timeDisplay || '"'} ${icon} **${title}**`);
|
||||
output.push(`**${obs.id}** ${time} ${icon} **${title}**`);
|
||||
if (detailField) {
|
||||
output.push('');
|
||||
output.push(detailField);
|
||||
output.push('');
|
||||
}
|
||||
|
||||
const tokenParts: string[] = [];
|
||||
if (config.showReadTokens) {
|
||||
tokenParts.push(`Read: ~${readTokens}`);
|
||||
tokenParts.push(`~${readTokens}t`);
|
||||
}
|
||||
if (config.showWorkTokens) {
|
||||
tokenParts.push(`Work: ${discoveryDisplay}`);
|
||||
tokenParts.push(discoveryDisplay);
|
||||
}
|
||||
if (tokenParts.length > 0) {
|
||||
output.push(tokenParts.join(', '));
|
||||
output.push(tokenParts.join(' '));
|
||||
}
|
||||
output.push('');
|
||||
|
||||
@@ -190,10 +178,8 @@ export function renderMarkdownSummaryItem(
|
||||
summary: { id: number; request: string | null },
|
||||
formattedTime: string
|
||||
): string[] {
|
||||
const summaryTitle = `${summary.request || 'Session started'} (${formattedTime})`;
|
||||
return [
|
||||
`**#S${summary.id}** ${summaryTitle}`,
|
||||
''
|
||||
`S${summary.id} ${summary.request || 'Session started'} (${formattedTime})`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -229,7 +215,7 @@ export function renderMarkdownFooter(totalDiscoveryTokens: number, totalReadToke
|
||||
const workTokensK = Math.round(totalDiscoveryTokens / 1000);
|
||||
return [
|
||||
'',
|
||||
`Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the claude-mem skill to access memories by ID.`
|
||||
`Access ${workTokensK}k tokens of past work via get_observations([IDs]) or mem-search skill.`
|
||||
];
|
||||
}
|
||||
|
||||
@@ -237,5 +223,5 @@ export function renderMarkdownFooter(totalDiscoveryTokens: number, totalReadToke
|
||||
* Render markdown empty state
|
||||
*/
|
||||
export function renderMarkdownEmptyState(project: string): string {
|
||||
return `# [${project}] recent context, ${formatHeaderDateTime()}\n\nNo previous sessions found for this project yet.`;
|
||||
return `# $CMEM ${project} ${formatHeaderDateTime()}\n\nNo previous sessions found.`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* TimelineRenderer - Renders the chronological timeline of observations and summaries
|
||||
*
|
||||
* Handles day grouping, file grouping within days, and table rendering.
|
||||
* Handles day grouping and rendering. In markdown (LLM) mode, uses flat compact lines.
|
||||
* In color (terminal) mode, uses file grouping with visual formatting.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -49,6 +50,103 @@ function getDetailField(obs: Observation, config: ContextConfig): string | null
|
||||
return obs.facts ? parseJsonArray(obs.facts).join('\n') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items (markdown/LLM mode - flat compact lines)
|
||||
*/
|
||||
function renderDayTimelineMarkdown(
|
||||
day: string,
|
||||
dayItems: TimelineItem[],
|
||||
fullObservationIds: Set<number>,
|
||||
config: ContextConfig,
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(...Markdown.renderMarkdownDayHeader(day));
|
||||
|
||||
let lastTime = '';
|
||||
|
||||
for (const item of dayItems) {
|
||||
if (item.type === 'summary') {
|
||||
lastTime = '';
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
output.push(...Markdown.renderMarkdownSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const time = formatTime(obs.created_at);
|
||||
const showTime = time !== lastTime;
|
||||
const timeDisplay = showTime ? time : '';
|
||||
lastTime = time;
|
||||
|
||||
const shouldShowFull = fullObservationIds.has(obs.id);
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
output.push(...Markdown.renderMarkdownFullObservation(obs, timeDisplay, detailField, config));
|
||||
} else {
|
||||
output.push(Markdown.renderMarkdownTableRow(obs, timeDisplay, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items (color/terminal mode - file grouped with tables)
|
||||
*/
|
||||
function renderDayTimelineColor(
|
||||
day: string,
|
||||
dayItems: TimelineItem[],
|
||||
fullObservationIds: Set<number>,
|
||||
config: ContextConfig,
|
||||
cwd: string,
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(...Color.renderColorDayHeader(day));
|
||||
|
||||
let currentFile: string | null = null;
|
||||
let lastTime = '';
|
||||
|
||||
for (const item of dayItems) {
|
||||
if (item.type === 'summary') {
|
||||
currentFile = null;
|
||||
lastTime = '';
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
output.push(...Color.renderColorSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const file = extractFirstFile(obs.files_modified, cwd, obs.files_read);
|
||||
const time = formatTime(obs.created_at);
|
||||
const showTime = time !== lastTime;
|
||||
lastTime = time;
|
||||
|
||||
const shouldShowFull = fullObservationIds.has(obs.id);
|
||||
|
||||
// Check if we need a new file section
|
||||
if (file !== currentFile) {
|
||||
output.push(...Color.renderColorFileHeader(file));
|
||||
currentFile = file;
|
||||
}
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
output.push(...Color.renderColorFullObservation(obs, time, showTime, detailField, config));
|
||||
} else {
|
||||
output.push(Color.renderColorTableRow(obs, time, showTime, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output.push('');
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items
|
||||
*/
|
||||
@@ -60,93 +158,10 @@ export function renderDayTimeline(
|
||||
cwd: string,
|
||||
useColors: boolean
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
// Day header
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorDayHeader(day));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownDayHeader(day));
|
||||
return renderDayTimelineColor(day, dayItems, fullObservationIds, config, cwd);
|
||||
}
|
||||
|
||||
let currentFile: string | null = null;
|
||||
let lastTime = '';
|
||||
let tableOpen = false;
|
||||
|
||||
for (const item of dayItems) {
|
||||
if (item.type === 'summary') {
|
||||
// Close any open table before summary
|
||||
if (tableOpen) {
|
||||
output.push('');
|
||||
tableOpen = false;
|
||||
currentFile = null;
|
||||
lastTime = '';
|
||||
}
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownSummaryItem(summary, formattedTime));
|
||||
}
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const file = extractFirstFile(obs.files_modified, cwd, obs.files_read);
|
||||
const time = formatTime(obs.created_at);
|
||||
const showTime = time !== lastTime;
|
||||
const timeDisplay = showTime ? time : '';
|
||||
lastTime = time;
|
||||
|
||||
const shouldShowFull = fullObservationIds.has(obs.id);
|
||||
|
||||
// Check if we need a new file section
|
||||
if (file !== currentFile) {
|
||||
if (tableOpen) {
|
||||
output.push('');
|
||||
}
|
||||
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorFileHeader(file));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownFileHeader(file));
|
||||
}
|
||||
|
||||
currentFile = file;
|
||||
tableOpen = true;
|
||||
}
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorFullObservation(obs, time, showTime, detailField, config));
|
||||
} else {
|
||||
// Close table for full observation in markdown mode
|
||||
if (tableOpen && !useColors) {
|
||||
output.push('');
|
||||
tableOpen = false;
|
||||
}
|
||||
output.push(...Markdown.renderMarkdownFullObservation(obs, timeDisplay, detailField, config));
|
||||
currentFile = null; // Reset to trigger new table header if needed
|
||||
}
|
||||
} else {
|
||||
if (useColors) {
|
||||
output.push(Color.renderColorTableRow(obs, time, showTime, config));
|
||||
} else {
|
||||
output.push(Markdown.renderMarkdownTableRow(obs, timeDisplay, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open table
|
||||
if (tableOpen) {
|
||||
output.push('');
|
||||
}
|
||||
|
||||
return output;
|
||||
return renderDayTimelineMarkdown(day, dayItems, fullObservationIds, config);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ContextInput {
|
||||
source?: "startup" | "resume" | "clear" | "compact";
|
||||
/** Array of projects to query (for worktree support: [parent, worktree]) */
|
||||
projects?: string[];
|
||||
/** When true, return ALL observations with no limit */
|
||||
full?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,7 @@
|
||||
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import {
|
||||
getChildProcesses,
|
||||
forceKillProcess,
|
||||
waitForProcessesExit,
|
||||
removePidFile
|
||||
} from './ProcessManager.js';
|
||||
import { stopSupervisor } from '../../supervisor/index.js';
|
||||
|
||||
export interface ShutdownableService {
|
||||
shutdownAll(): Promise<void>;
|
||||
@@ -57,49 +52,35 @@ export interface GracefulShutdownConfig {
|
||||
export async function performGracefulShutdown(config: GracefulShutdownConfig): Promise<void> {
|
||||
logger.info('SYSTEM', 'Shutdown initiated');
|
||||
|
||||
// Clean up PID file on shutdown
|
||||
removePidFile();
|
||||
|
||||
// STEP 1: Enumerate all child processes BEFORE we start closing things
|
||||
const childPids = await getChildProcesses(process.pid);
|
||||
logger.info('SYSTEM', 'Found child processes', { count: childPids.length, pids: childPids });
|
||||
|
||||
// STEP 2: Close HTTP server first
|
||||
// STEP 1: Close HTTP server first
|
||||
if (config.server) {
|
||||
await closeHttpServer(config.server);
|
||||
logger.info('SYSTEM', 'HTTP server closed');
|
||||
}
|
||||
|
||||
// STEP 3: Shutdown active sessions
|
||||
// STEP 2: Shutdown active sessions
|
||||
await config.sessionManager.shutdownAll();
|
||||
|
||||
// STEP 4: Close MCP client connection (signals child to exit gracefully)
|
||||
// STEP 3: Close MCP client connection (signals child to exit gracefully)
|
||||
if (config.mcpClient) {
|
||||
await config.mcpClient.close();
|
||||
logger.info('SYSTEM', 'MCP client closed');
|
||||
}
|
||||
|
||||
// STEP 5: Stop Chroma MCP connection
|
||||
// STEP 4: Stop Chroma MCP connection
|
||||
if (config.chromaMcpManager) {
|
||||
logger.info('SHUTDOWN', 'Stopping Chroma MCP connection...');
|
||||
await config.chromaMcpManager.stop();
|
||||
logger.info('SHUTDOWN', 'Chroma MCP connection stopped');
|
||||
}
|
||||
|
||||
// STEP 6: Close database connection (includes ChromaSync cleanup)
|
||||
// STEP 5: Close database connection (includes ChromaSync cleanup)
|
||||
if (config.dbManager) {
|
||||
await config.dbManager.close();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
await forceKillProcess(pid);
|
||||
}
|
||||
// Wait for children to fully exit
|
||||
await waitForProcessesExit(childPids, 5000);
|
||||
}
|
||||
// STEP 6: Supervisor handles tracked child termination, PID cleanup, and stale sockets.
|
||||
await stopSupervisor();
|
||||
|
||||
logger.info('SYSTEM', 'Worker shutdown complete');
|
||||
}
|
||||
|
||||
@@ -14,6 +14,26 @@ import { readFileSync } from 'fs';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { MARKETPLACE_ROOT } from '../../shared/paths.js';
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the worker via TCP.
|
||||
* Returns { ok, statusCode, body } or throws on transport error.
|
||||
*/
|
||||
async function httpRequestToWorker(
|
||||
port: number,
|
||||
endpointPath: string,
|
||||
method: string = 'GET'
|
||||
): Promise<{ ok: boolean; statusCode: number; body: string }> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${endpointPath}`, { method });
|
||||
// Gracefully handle cases where response body isn't available (e.g., test mocks)
|
||||
let body = '';
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch {
|
||||
// Body unavailable — health/readiness checks only need .ok
|
||||
}
|
||||
return { ok: response.ok, statusCode: response.status, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is in use by querying the health endpoint
|
||||
*/
|
||||
@@ -29,7 +49,7 @@ export async function isPortInUse(port: number): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a localhost endpoint until it returns 200 OK or timeout.
|
||||
* Poll a worker endpoint until it returns 200 OK or timeout.
|
||||
* Shared implementation for liveness and readiness checks.
|
||||
*/
|
||||
async function pollEndpointUntilOk(
|
||||
@@ -41,12 +61,11 @@ async function pollEndpointUntilOk(
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}${endpointPath}`);
|
||||
if (response.ok) return true;
|
||||
const result = await httpRequestToWorker(port, endpointPath);
|
||||
if (result.ok) return true;
|
||||
} catch (error) {
|
||||
// [ANTI-PATTERN IGNORED]: Retry loop - expected failures during startup, will retry
|
||||
logger.debug('SYSTEM', retryLogMessage, { port }, error as Error);
|
||||
logger.debug('SYSTEM', retryLogMessage, {}, error as Error);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
@@ -87,28 +106,24 @@ export async function waitForPortFree(port: number, timeoutMs: number = 10000):
|
||||
|
||||
/**
|
||||
* Send HTTP shutdown request to a running worker
|
||||
* @param port Worker port
|
||||
* @returns true if shutdown request was acknowledged, false otherwise
|
||||
*/
|
||||
export async function httpShutdown(port: number): Promise<boolean> {
|
||||
try {
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/admin/shutdown`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn('SYSTEM', 'Shutdown request returned error', { port, status: response.status });
|
||||
const result = await httpRequestToWorker(port, '/api/admin/shutdown', 'POST');
|
||||
if (!result.ok) {
|
||||
logger.warn('SYSTEM', 'Shutdown request returned error', { status: result.statusCode });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Connection refused is expected if worker already stopped
|
||||
if (error instanceof Error && error.message?.includes('ECONNREFUSED')) {
|
||||
logger.debug('SYSTEM', 'Worker already stopped', { port }, error);
|
||||
logger.debug('SYSTEM', 'Worker already stopped', {}, error);
|
||||
return false;
|
||||
}
|
||||
// Unexpected error - log full details
|
||||
logger.error('SYSTEM', 'Shutdown request failed unexpectedly', { port }, error as Error);
|
||||
logger.error('SYSTEM', 'Shutdown request failed unexpectedly', {}, error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -135,17 +150,17 @@ export function getInstalledPluginVersion(): string {
|
||||
|
||||
/**
|
||||
* Get the running worker's version via API
|
||||
* This is the "actual" version currently running
|
||||
* This is the "actual" version currently running.
|
||||
*/
|
||||
export async function getRunningWorkerVersion(port: number): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/version`);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as { version: string };
|
||||
const result = await httpRequestToWorker(port, '/api/version');
|
||||
if (!result.ok) return null;
|
||||
const data = JSON.parse(result.body) as { version: string };
|
||||
return data.version;
|
||||
} catch {
|
||||
// Expected: worker not running or version endpoint unavailable
|
||||
logger.debug('SYSTEM', 'Could not fetch worker version', { port });
|
||||
logger.debug('SYSTEM', 'Could not fetch worker version', {});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { exec, execSync, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_TIMEOUTS } from '../../shared/hook-constants.js';
|
||||
import { sanitizeEnv } from '../../supervisor/env-sanitizer.js';
|
||||
import { getSupervisor, validateWorkerPidFile, type ValidateWorkerPidStatus } from '../../supervisor/index.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -625,11 +627,13 @@ export function spawnDaemon(
|
||||
extraEnv: Record<string, string> = {}
|
||||
): number | undefined {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const env = {
|
||||
getSupervisor().assertCanSpawn('worker daemon');
|
||||
|
||||
const env = sanitizeEnv({
|
||||
...process.env,
|
||||
CLAUDE_MEM_WORKER_PORT: String(port),
|
||||
...extraEnv
|
||||
};
|
||||
});
|
||||
|
||||
if (isWindows) {
|
||||
// Use PowerShell Start-Process to spawn a hidden, independent process
|
||||
@@ -642,18 +646,19 @@ export function spawnDaemon(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const escapedRuntimePath = runtimePath.replace(/'/g, "''");
|
||||
const escapedScriptPath = scriptPath.replace(/'/g, "''");
|
||||
const psCommand = `Start-Process -FilePath '${escapedRuntimePath}' -ArgumentList '${escapedScriptPath}','--daemon' -WindowStyle Hidden`;
|
||||
// Use -EncodedCommand to avoid all shell quoting issues with spaces in paths
|
||||
const psScript = `Start-Process -FilePath '${runtimePath.replace(/'/g, "''")}' -ArgumentList @('${scriptPath.replace(/'/g, "''")}','--daemon') -WindowStyle Hidden`;
|
||||
const encodedCommand = Buffer.from(psScript, 'utf16le').toString('base64');
|
||||
|
||||
try {
|
||||
execSync(`powershell -NoProfile -Command "${psCommand}"`, {
|
||||
execSync(`powershell -NoProfile -EncodedCommand ${encodedCommand}`, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env
|
||||
});
|
||||
return 0;
|
||||
} catch (error) {
|
||||
// APPROVED OVERRIDE: Windows daemon spawn is best-effort; log and let callers fall back to health checks/retry flow.
|
||||
logger.error('SYSTEM', 'Failed to spawn worker daemon on Windows', { runtimePath }, error as Error);
|
||||
return undefined;
|
||||
}
|
||||
@@ -764,18 +769,8 @@ export function touchPidFile(): void {
|
||||
* 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();
|
||||
}
|
||||
export function cleanStalePidFile(): ValidateWorkerPidStatus {
|
||||
return validateWorkerPidFile({ logAlive: false });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from '
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { getWorkerPort, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { DATA_DIR, MARKETPLACE_ROOT, CLAUDE_CONFIG_DIR } from '../../shared/paths.js';
|
||||
import {
|
||||
readCursorRegistry as readCursorRegistryFromFile,
|
||||
@@ -95,16 +95,16 @@ export function unregisterCursorProject(projectName: string): void {
|
||||
* Update Cursor context files for all registered projects matching this project name.
|
||||
* Called by SDK agents after saving a summary.
|
||||
*/
|
||||
export async function updateCursorContextForProject(projectName: string, port: number): Promise<void> {
|
||||
export async function updateCursorContextForProject(projectName: string, _port: number): Promise<void> {
|
||||
const registry = readCursorRegistry();
|
||||
const entry = registry[projectName];
|
||||
|
||||
if (!entry) return; // Project doesn't have Cursor hooks installed
|
||||
|
||||
try {
|
||||
// Fetch fresh context from worker
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||
// Fetch fresh context from worker (uses socket or TCP automatically)
|
||||
const response = await workerHttpRequest(
|
||||
`/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||
);
|
||||
|
||||
if (!response.ok) return;
|
||||
@@ -398,19 +398,18 @@ async function setupProjectContext(targetDir: string, workspaceRoot: string): Pr
|
||||
const rulesDir = path.join(targetDir, 'rules');
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
|
||||
const port = getWorkerPort();
|
||||
const projectName = path.basename(workspaceRoot);
|
||||
let contextGenerated = false;
|
||||
|
||||
console.log(` Generating initial context...`);
|
||||
|
||||
try {
|
||||
// Check if worker is running
|
||||
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`);
|
||||
// Check if worker is running (uses socket or TCP automatically)
|
||||
const healthResponse = await workerHttpRequest('/api/readiness');
|
||||
if (healthResponse.ok) {
|
||||
// Fetch context
|
||||
const contextResponse = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||
const contextResponse = await workerHttpRequest(
|
||||
`/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||
);
|
||||
if (contextResponse.ok) {
|
||||
const context = await contextResponse.text();
|
||||
|
||||
@@ -17,6 +17,9 @@ import { ALLOWED_OPERATIONS, ALLOWED_TOPICS } from './allowed-constants.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { createMiddleware, summarizeRequestBody, requireLocalhost } from './Middleware.js';
|
||||
import { errorHandler, notFoundHandler } from './ErrorHandler.js';
|
||||
import { getSupervisor } from '../../supervisor/index.js';
|
||||
import { isPidAlive } from '../../supervisor/process-registry.js';
|
||||
import { ENV_PREFIXES, ENV_EXACT_MATCHES } from '../../supervisor/env-sanitizer.js';
|
||||
|
||||
// Build-time injected version constant (set by esbuild define)
|
||||
declare const __DEFAULT_PACKAGE_VERSION__: string;
|
||||
@@ -285,6 +288,50 @@ export class Server {
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Doctor endpoint - diagnostic view of supervisor, processes, and health
|
||||
this.app.get('/api/admin/doctor', requireLocalhost, (_req: Request, res: Response) => {
|
||||
const supervisor = getSupervisor();
|
||||
const registry = supervisor.getRegistry();
|
||||
const allRecords = registry.getAll();
|
||||
|
||||
// Check each process liveness
|
||||
const processes = allRecords.map(record => ({
|
||||
id: record.id,
|
||||
pid: record.pid,
|
||||
type: record.type,
|
||||
status: isPidAlive(record.pid) ? 'alive' as const : 'dead' as const,
|
||||
startedAt: record.startedAt,
|
||||
}));
|
||||
|
||||
// Check for dead processes still in registry
|
||||
const deadProcessPids = processes.filter(p => p.status === 'dead').map(p => p.pid);
|
||||
|
||||
// Check if CLAUDECODE_* env vars are leaking into this process
|
||||
const envClean = !Object.keys(process.env).some(key =>
|
||||
ENV_EXACT_MATCHES.has(key) || ENV_PREFIXES.some(prefix => key.startsWith(prefix))
|
||||
);
|
||||
|
||||
// Format uptime
|
||||
const uptimeMs = Date.now() - this.startTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const hours = Math.floor(uptimeSeconds / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
const formattedUptime = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
|
||||
res.json({
|
||||
supervisor: {
|
||||
running: true,
|
||||
pid: process.pid,
|
||||
uptime: formattedUptime,
|
||||
},
|
||||
processes,
|
||||
health: {
|
||||
deadProcessPids,
|
||||
envClean,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { MigrationRunner } from './migrations/runner.js';
|
||||
@@ -15,6 +19,118 @@ export interface Migration {
|
||||
|
||||
let dbInstance: Database | null = null;
|
||||
|
||||
/**
|
||||
* Repair malformed database schema before migrations run.
|
||||
*
|
||||
* This handles the case where a database is synced between machines running
|
||||
* different claude-mem versions. A newer version may have added columns and
|
||||
* indexes that an older version (or even the same version on a fresh install)
|
||||
* cannot process. SQLite throws "malformed database schema" when it encounters
|
||||
* an index referencing a non-existent column, which prevents ALL queries —
|
||||
* including the migrations that would fix the schema.
|
||||
*
|
||||
* The fix: use Python's sqlite3 module (which supports writable_schema) to
|
||||
* drop the orphaned schema objects, then let the migration system recreate
|
||||
* them properly. bun:sqlite doesn't allow DELETE FROM sqlite_master even
|
||||
* with writable_schema = ON.
|
||||
*/
|
||||
function repairMalformedSchema(db: Database): void {
|
||||
try {
|
||||
// Quick test: if we can query sqlite_master, the schema is fine
|
||||
db.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
||||
return;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('malformed database schema')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn('DB', 'Detected malformed database schema, attempting repair', { error: message });
|
||||
|
||||
// Extract the problematic object name from the error message
|
||||
// Format: "malformed database schema (object_name) - details"
|
||||
const match = message.match(/malformed database schema \(([^)]+)\)/);
|
||||
if (!match) {
|
||||
logger.error('DB', 'Could not parse malformed schema error, cannot auto-repair', { error: message });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const objectName = match[1];
|
||||
logger.info('DB', `Dropping malformed schema object: ${objectName}`);
|
||||
|
||||
// Get the DB file path. For file-based DBs, we can use Python to repair.
|
||||
// For in-memory DBs, we can't shell out — just re-throw.
|
||||
const dbPath = db.filename;
|
||||
if (!dbPath || dbPath === ':memory:' || dbPath === '') {
|
||||
logger.error('DB', 'Cannot auto-repair in-memory database');
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Close the connection so Python can safely modify the file
|
||||
db.close();
|
||||
|
||||
// Use Python's sqlite3 module to drop the orphaned object and reset
|
||||
// related migration versions so they re-run and recreate things properly.
|
||||
// bun:sqlite doesn't support DELETE FROM sqlite_master even with writable_schema.
|
||||
//
|
||||
// We write a temp script rather than using -c to avoid shell escaping issues
|
||||
// with paths containing spaces or special characters. execFileSync passes
|
||||
// args directly without a shell, so dbPath and objectName are safe.
|
||||
const scriptPath = join(tmpdir(), `claude-mem-repair-${Date.now()}.py`);
|
||||
try {
|
||||
writeFileSync(scriptPath, `
|
||||
import sqlite3, sys
|
||||
db_path = sys.argv[1]
|
||||
obj_name = sys.argv[2]
|
||||
c = sqlite3.connect(db_path)
|
||||
c.execute('PRAGMA writable_schema = ON')
|
||||
c.execute('DELETE FROM sqlite_master WHERE name = ?', (obj_name,))
|
||||
c.execute('PRAGMA writable_schema = OFF')
|
||||
# Reset migration versions so affected migrations re-run.
|
||||
# Guard with existence check: schema_versions may not exist on a very fresh DB.
|
||||
has_sv = c.execute(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='schema_versions'"
|
||||
).fetchone()[0]
|
||||
if has_sv:
|
||||
c.execute('DELETE FROM schema_versions')
|
||||
c.commit()
|
||||
c.close()
|
||||
`);
|
||||
execFileSync('python3', [scriptPath, dbPath, objectName], { timeout: 10000 });
|
||||
logger.info('DB', `Dropped orphaned schema object "${objectName}" and reset migration versions via Python sqlite3. All migrations will re-run (they are idempotent).`);
|
||||
} catch (pyError: unknown) {
|
||||
const pyMessage = pyError instanceof Error ? pyError.message : String(pyError);
|
||||
logger.error('DB', 'Python sqlite3 repair failed', { error: pyMessage });
|
||||
throw new Error(`Schema repair failed: ${message}. Python repair error: ${pyMessage}`);
|
||||
} finally {
|
||||
if (existsSync(scriptPath)) unlinkSync(scriptPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that handles the close/reopen cycle needed for schema repair.
|
||||
* Returns a (possibly new) Database connection.
|
||||
*/
|
||||
function repairMalformedSchemaWithReopen(dbPath: string, db: Database): Database {
|
||||
try {
|
||||
db.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
||||
return db;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('malformed database schema')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// repairMalformedSchema closes the DB internally for Python access
|
||||
repairMalformedSchema(db);
|
||||
|
||||
// Reopen and check for additional malformed objects
|
||||
const newDb = new Database(dbPath, { create: true, readwrite: true });
|
||||
return repairMalformedSchemaWithReopen(dbPath, newDb);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ClaudeMemDatabase - New entry point for the sqlite module
|
||||
*
|
||||
@@ -38,6 +154,11 @@ export class ClaudeMemDatabase {
|
||||
// Create database connection
|
||||
this.db = new Database(dbPath, { create: true, readwrite: true });
|
||||
|
||||
// Repair any malformed schema before applying settings or running migrations.
|
||||
// Must happen first — even PRAGMA calls can fail on a corrupted schema.
|
||||
// This may close and reopen the connection if repair is needed.
|
||||
this.db = repairMalformedSchemaWithReopen(dbPath, this.db);
|
||||
|
||||
// Apply optimized SQLite settings
|
||||
this.db.run('PRAGMA journal_mode = WAL');
|
||||
this.db.run('PRAGMA synchronous = NORMAL');
|
||||
@@ -97,6 +218,10 @@ export class DatabaseManager {
|
||||
|
||||
this.db = new Database(DB_PATH, { create: true, readwrite: true });
|
||||
|
||||
// Repair any malformed schema before applying settings or running migrations.
|
||||
// Must happen first — even PRAGMA calls can fail on a corrupted schema.
|
||||
this.db = repairMalformedSchemaWithReopen(DB_PATH, this.db);
|
||||
|
||||
// Apply optimized SQLite settings
|
||||
this.db.run('PRAGMA journal_mode = WAL');
|
||||
this.db.run('PRAGMA synchronous = NORMAL');
|
||||
|
||||
@@ -839,19 +839,21 @@ export class SessionStore {
|
||||
* Add content_hash column to observations for deduplication (migration 22)
|
||||
*/
|
||||
private addObservationContentHashColumn(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(22) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
// Check actual schema first — cross-machine DB sync can leave schema_versions
|
||||
// claiming this migration ran while the column is actually missing.
|
||||
const tableInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
|
||||
const hasColumn = tableInfo.some(col => col.name === 'content_hash');
|
||||
|
||||
if (!hasColumn) {
|
||||
this.db.run('ALTER TABLE observations ADD COLUMN content_hash TEXT');
|
||||
this.db.run("UPDATE observations SET content_hash = substr(hex(randomblob(8)), 1, 16) WHERE content_hash IS NULL");
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_content_hash ON observations(content_hash, created_at_epoch)');
|
||||
logger.debug('DB', 'Added content_hash column to observations table with backfill and index');
|
||||
if (hasColumn) {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(22, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.run('ALTER TABLE observations ADD COLUMN content_hash TEXT');
|
||||
this.db.run("UPDATE observations SET content_hash = substr(hex(randomblob(8)), 1, 16) WHERE content_hash IS NULL");
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_content_hash ON observations(content_hash, created_at_epoch)');
|
||||
logger.debug('DB', 'Added content_hash column to observations table with backfill and index');
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(22, new Date().toISOString());
|
||||
}
|
||||
|
||||
@@ -1659,15 +1661,23 @@ export class SessionStore {
|
||||
const storeTx = this.db.transaction(() => {
|
||||
const observationIds: number[] = [];
|
||||
|
||||
// 1. Store all observations
|
||||
// 1. Store all observations (with content-hash deduplication)
|
||||
const obsStmt = this.db.prepare(`
|
||||
INSERT INTO observations
|
||||
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
|
||||
files_read, files_modified, prompt_number, discovery_tokens, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const observation of observations) {
|
||||
// Content-hash deduplication (same logic as storeObservation singular)
|
||||
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
|
||||
const existing = findDuplicateObservation(this.db, contentHash, timestampEpoch);
|
||||
if (existing) {
|
||||
observationIds.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = obsStmt.run(
|
||||
memorySessionId,
|
||||
project,
|
||||
@@ -1681,6 +1691,7 @@ export class SessionStore {
|
||||
JSON.stringify(observation.files_modified),
|
||||
promptNumber || null,
|
||||
discoveryTokens,
|
||||
contentHash,
|
||||
timestampIso,
|
||||
timestampEpoch
|
||||
);
|
||||
@@ -1779,15 +1790,23 @@ export class SessionStore {
|
||||
const storeAndMarkTx = this.db.transaction(() => {
|
||||
const observationIds: number[] = [];
|
||||
|
||||
// 1. Store all observations
|
||||
// 1. Store all observations (with content-hash deduplication)
|
||||
const obsStmt = this.db.prepare(`
|
||||
INSERT INTO observations
|
||||
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
|
||||
files_read, files_modified, prompt_number, discovery_tokens, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const observation of observations) {
|
||||
// Content-hash deduplication (same logic as storeObservation singular)
|
||||
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
|
||||
const existing = findDuplicateObservation(this.db, contentHash, timestampEpoch);
|
||||
if (existing) {
|
||||
observationIds.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = obsStmt.run(
|
||||
memorySessionId,
|
||||
project,
|
||||
@@ -1801,6 +1820,7 @@ export class SessionStore {
|
||||
JSON.stringify(observation.files_modified),
|
||||
promptNumber || null,
|
||||
discoveryTokens,
|
||||
contentHash,
|
||||
timestampIso,
|
||||
timestampEpoch
|
||||
);
|
||||
|
||||
@@ -823,21 +823,25 @@ export class MigrationRunner {
|
||||
* Backfills existing rows with unique random hashes so they don't block new inserts.
|
||||
*/
|
||||
private addObservationContentHashColumn(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(22) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
// Check actual schema first — cross-machine DB sync can leave schema_versions
|
||||
// claiming this migration ran while the column is actually missing (e.g. migration 21
|
||||
// recreated the table without content_hash on the synced machine).
|
||||
const tableInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
|
||||
const hasColumn = tableInfo.some(col => col.name === 'content_hash');
|
||||
|
||||
if (!hasColumn) {
|
||||
this.db.run('ALTER TABLE observations ADD COLUMN content_hash TEXT');
|
||||
// Backfill existing rows with unique random hashes
|
||||
this.db.run("UPDATE observations SET content_hash = substr(hex(randomblob(8)), 1, 16) WHERE content_hash IS NULL");
|
||||
// Index for fast dedup lookups
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_content_hash ON observations(content_hash, created_at_epoch)');
|
||||
logger.debug('DB', 'Added content_hash column to observations table with backfill and index');
|
||||
if (hasColumn) {
|
||||
// Column exists — just ensure version record is present
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(22, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.run('ALTER TABLE observations ADD COLUMN content_hash TEXT');
|
||||
// Backfill existing rows with unique random hashes
|
||||
this.db.run("UPDATE observations SET content_hash = substr(hex(randomblob(8)), 1, 16) WHERE content_hash IS NULL");
|
||||
// Index for fast dedup lookups
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_content_hash ON observations(content_hash, created_at_epoch)');
|
||||
logger.debug('DB', 'Added content_hash column to observations table with backfill and index');
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(22, new Date().toISOString());
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@ import fs from 'fs';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { sanitizeEnv } from '../../supervisor/env-sanitizer.js';
|
||||
import { getSupervisor } from '../../supervisor/index.js';
|
||||
|
||||
const CHROMA_MCP_CLIENT_NAME = 'claude-mem-chroma';
|
||||
const CHROMA_MCP_CLIENT_VERSION = '1.0.0';
|
||||
const MCP_CONNECTION_TIMEOUT_MS = 30_000;
|
||||
const RECONNECT_BACKOFF_MS = 10_000; // Don't retry connections faster than this after failure
|
||||
const DEFAULT_CHROMA_DATA_DIR = path.join(os.homedir(), '.claude-mem', 'chroma');
|
||||
const CHROMA_SUPERVISOR_ID = 'chroma-mcp';
|
||||
|
||||
export class ChromaMcpManager {
|
||||
private static instance: ChromaMcpManager | null = null;
|
||||
@@ -101,6 +104,7 @@ export class ChromaMcpManager {
|
||||
|
||||
const commandArgs = this.buildCommandArgs();
|
||||
const spawnEnvironment = this.getSpawnEnv();
|
||||
getSupervisor().assertCanSpawn('chroma mcp');
|
||||
|
||||
// On Windows, .cmd files require shell resolution. Since MCP SDK's
|
||||
// StdioClientTransport doesn't support `shell: true`, route through
|
||||
@@ -155,6 +159,7 @@ export class ChromaMcpManager {
|
||||
clearTimeout(timeoutId!);
|
||||
|
||||
this.connected = true;
|
||||
this.registerManagedProcess();
|
||||
|
||||
logger.info('CHROMA_MCP', 'Connected to chroma-mcp successfully');
|
||||
|
||||
@@ -169,6 +174,7 @@ export class ChromaMcpManager {
|
||||
}
|
||||
logger.warn('CHROMA_MCP', 'chroma-mcp subprocess closed unexpectedly, applying reconnect backoff');
|
||||
this.connected = false;
|
||||
getSupervisor().unregisterProcess(CHROMA_SUPERVISOR_ID);
|
||||
this.client = null;
|
||||
this.transport = null;
|
||||
this.lastConnectionFailureTimestamp = Date.now();
|
||||
@@ -201,9 +207,7 @@ export class ChromaMcpManager {
|
||||
'--port', chromaPort
|
||||
];
|
||||
|
||||
if (chromaSsl) {
|
||||
args.push('--ssl');
|
||||
}
|
||||
args.push('--ssl', chromaSsl ? 'true' : 'false');
|
||||
|
||||
if (chromaTenant !== 'default_tenant') {
|
||||
args.push('--tenant', chromaTenant);
|
||||
@@ -335,6 +339,7 @@ export class ChromaMcpManager {
|
||||
logger.debug('CHROMA_MCP', 'Error during client close (subprocess may already be dead)', {}, error as Error);
|
||||
}
|
||||
|
||||
getSupervisor().unregisterProcess(CHROMA_SUPERVISOR_ID);
|
||||
this.client = null;
|
||||
this.transport = null;
|
||||
this.connected = false;
|
||||
@@ -430,7 +435,7 @@ export class ChromaMcpManager {
|
||||
*/
|
||||
private getSpawnEnv(): Record<string, string> {
|
||||
const baseEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
for (const [key, value] of Object.entries(sanitizeEnv(process.env))) {
|
||||
if (value !== undefined) {
|
||||
baseEnv[key] = value;
|
||||
}
|
||||
@@ -453,4 +458,21 @@ export class ChromaMcpManager {
|
||||
NODE_EXTRA_CA_CERTS: combinedCertPath
|
||||
};
|
||||
}
|
||||
|
||||
private registerManagedProcess(): void {
|
||||
const chromaProcess = (this.transport as unknown as { _process?: import('child_process').ChildProcess })._process;
|
||||
if (!chromaProcess?.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
getSupervisor().registerProcess(CHROMA_SUPERVISOR_ID, {
|
||||
pid: chromaProcess.pid,
|
||||
type: 'chroma',
|
||||
startedAt: new Date().toISOString()
|
||||
}, chromaProcess);
|
||||
|
||||
chromaProcess.once('exit', () => {
|
||||
getSupervisor().unregisterProcess(CHROMA_SUPERVISOR_ID);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { ensureWorkerRunning, workerHttpRequest } 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';
|
||||
@@ -317,11 +317,10 @@ export class TranscriptEventProcessor {
|
||||
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`, {
|
||||
await workerHttpRequest('/api/sessions/summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -348,11 +347,10 @@ export class TranscriptEventProcessor {
|
||||
|
||||
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)}`
|
||||
const response = await workerHttpRequest(
|
||||
`/api/context/inject?projects=${encodeURIComponent(projectsParam)}`
|
||||
);
|
||||
if (!response.ok) return;
|
||||
|
||||
|
||||
+154
-96
@@ -20,6 +20,8 @@ import { getAuthMethodDescription } from '../shared/EnvManager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ChromaMcpManager } from './sync/ChromaMcpManager.js';
|
||||
import { ChromaSync } from './sync/ChromaSync.js';
|
||||
import { configureSupervisorSignalHandlers, getSupervisor, startSupervisor } from '../supervisor/index.js';
|
||||
import { sanitizeEnv } from '../supervisor/env-sanitizer.js';
|
||||
|
||||
// Windows: avoid repeated spawn popups when startup fails (issue #921)
|
||||
const WINDOWS_SPAWN_COOLDOWN_MS = 2 * 60 * 1000;
|
||||
@@ -78,7 +80,6 @@ import {
|
||||
cleanStalePidFile,
|
||||
isProcessAlive,
|
||||
spawnDaemon,
|
||||
createSignalHandler,
|
||||
isPidFileRecent,
|
||||
touchPidFile
|
||||
} from './infrastructure/ProcessManager.js';
|
||||
@@ -263,33 +264,10 @@ export class WorkerService {
|
||||
* Register signal handlers for graceful shutdown
|
||||
*/
|
||||
private registerSignalHandlers(): void {
|
||||
const shutdownRef = { value: this.isShuttingDown };
|
||||
const handler = createSignalHandler(() => this.shutdown(), shutdownRef);
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
this.isShuttingDown = shutdownRef.value;
|
||||
handler('SIGTERM');
|
||||
configureSupervisorSignalHandlers(async () => {
|
||||
this.isShuttingDown = true;
|
||||
await this.shutdown();
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,7 +329,9 @@ export class WorkerService {
|
||||
const port = getWorkerPort();
|
||||
const host = getWorkerHost();
|
||||
|
||||
// Start HTTP server FIRST - make port available immediately
|
||||
await startSupervisor();
|
||||
|
||||
// Start HTTP server FIRST - make it available immediately
|
||||
await this.server.listen(port, host);
|
||||
|
||||
// Worker writes its own PID - reliable on all platforms
|
||||
@@ -363,6 +343,12 @@ export class WorkerService {
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
getSupervisor().registerProcess('worker', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
logger.info('SYSTEM', 'Worker started', { host, port, pid: process.pid });
|
||||
|
||||
// Do slow initialization in background (non-blocking)
|
||||
@@ -446,19 +432,50 @@ export class WorkerService {
|
||||
|
||||
// Connect to MCP server
|
||||
const mcpServerPath = path.join(__dirname, 'mcp-server.cjs');
|
||||
getSupervisor().assertCanSpawn('mcp server');
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
env: process.env
|
||||
env: sanitizeEnv(process.env)
|
||||
});
|
||||
|
||||
const MCP_INIT_TIMEOUT_MS = 300000;
|
||||
const mcpConnectionPromise = this.mcpClient.connect(transport);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('MCP connection timeout after 5 minutes')), MCP_INIT_TIMEOUT_MS)
|
||||
);
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error('MCP connection timeout after 5 minutes')),
|
||||
MCP_INIT_TIMEOUT_MS
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.race([mcpConnectionPromise, timeoutPromise]);
|
||||
try {
|
||||
await Promise.race([mcpConnectionPromise, timeoutPromise]);
|
||||
} catch (connectionError) {
|
||||
clearTimeout(timeoutId!);
|
||||
logger.warn('WORKER', 'MCP server connection failed, cleaning up subprocess', {
|
||||
error: connectionError instanceof Error ? connectionError.message : String(connectionError)
|
||||
});
|
||||
try {
|
||||
await transport.close();
|
||||
} catch {
|
||||
// Best effort: the supervisor handles later process cleanup for survivors.
|
||||
}
|
||||
throw connectionError;
|
||||
}
|
||||
clearTimeout(timeoutId!);
|
||||
|
||||
const mcpProcess = (transport as unknown as { _process?: import('child_process').ChildProcess })._process;
|
||||
if (mcpProcess?.pid) {
|
||||
getSupervisor().registerProcess('mcp-server', {
|
||||
pid: mcpProcess.pid,
|
||||
type: 'mcp',
|
||||
startedAt: new Date().toISOString()
|
||||
}, mcpProcess);
|
||||
mcpProcess.once('exit', () => {
|
||||
getSupervisor().unregisterProcess('mcp-server');
|
||||
});
|
||||
}
|
||||
this.mcpReady = true;
|
||||
logger.success('WORKER', 'MCP server connected');
|
||||
|
||||
@@ -470,7 +487,7 @@ export class WorkerService {
|
||||
}
|
||||
return activeIds;
|
||||
});
|
||||
logger.info('SYSTEM', 'Started orphan reaper (runs every 5 minutes)');
|
||||
logger.info('SYSTEM', 'Started orphan reaper (runs every 30 seconds)');
|
||||
|
||||
// Reap stale sessions to unblock orphan process cleanup (Issue #1168)
|
||||
this.staleSessionReaperInterval = setInterval(async () => {
|
||||
@@ -561,6 +578,14 @@ export class WorkerService {
|
||||
'ENOENT',
|
||||
'spawn',
|
||||
'Invalid API key',
|
||||
'API_KEY_INVALID',
|
||||
'API key expired',
|
||||
'API key not valid',
|
||||
'PERMISSION_DENIED',
|
||||
'Gemini API error: 400',
|
||||
'Gemini API error: 401',
|
||||
'Gemini API error: 403',
|
||||
'FOREIGN KEY constraint failed',
|
||||
];
|
||||
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
|
||||
hadUnrecoverableError = true;
|
||||
@@ -618,7 +643,7 @@ export class WorkerService {
|
||||
.finally(async () => {
|
||||
// CRITICAL: Verify subprocess exit to prevent zombie accumulation (Issue #1168)
|
||||
const trackedProcess = getProcessBySession(session.sessionDbId);
|
||||
if (trackedProcess && !trackedProcess.process.killed && trackedProcess.process.exitCode === null) {
|
||||
if (trackedProcess && trackedProcess.process.exitCode === null) {
|
||||
await ensureProcessExit(trackedProcess, 5000);
|
||||
}
|
||||
|
||||
@@ -635,43 +660,59 @@ export class WorkerService {
|
||||
|
||||
// Do NOT restart after unrecoverable errors - prevents infinite loops
|
||||
if (hadUnrecoverableError) {
|
||||
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
this.broadcastProcessingStatus();
|
||||
this.terminateSession(session.sessionDbId, 'unrecoverable_error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store for pending-count check below
|
||||
const { PendingMessageStore } = require('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
|
||||
// Idle timeout means no new work arrived for 3 minutes - don't restart
|
||||
// No need to reset stale processing messages here — claimNextMessage() self-heals
|
||||
if (session.idleTimedOut) {
|
||||
logger.info('SYSTEM', 'Generator exited due to idle timeout, not restarting', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
session.idleTimedOut = false; // Reset flag
|
||||
this.broadcastProcessingStatus();
|
||||
return;
|
||||
}
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
|
||||
// Check if there's pending work that needs processing with a fresh AbortController
|
||||
const pendingCount = pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
// Idle timeout means no new work arrived for 3 minutes - don't restart
|
||||
// But check pendingCount first: a message may have arrived between idle
|
||||
// abort and .finally(), and we must not abandon it
|
||||
if (session.idleTimedOut) {
|
||||
session.idleTimedOut = false; // Reset flag
|
||||
if (pendingCount === 0) {
|
||||
this.terminateSession(session.sessionDbId, 'idle_timeout');
|
||||
return;
|
||||
}
|
||||
// Fall through to pending-work restart below
|
||||
}
|
||||
const MAX_PENDING_RESTARTS = 3;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
// Track consecutive pending-work restarts to prevent infinite loops (e.g. FK errors)
|
||||
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;
|
||||
|
||||
if (session.consecutiveRestarts > MAX_PENDING_RESTARTS) {
|
||||
logger.error('SYSTEM', 'Exceeded max pending-work restarts, stopping to prevent infinite loop', {
|
||||
sessionId: session.sessionDbId,
|
||||
pendingCount,
|
||||
consecutiveRestarts: session.consecutiveRestarts
|
||||
});
|
||||
session.consecutiveRestarts = 0;
|
||||
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Pending work remains after generator exit, restarting with fresh AbortController', {
|
||||
sessionId: session.sessionDbId,
|
||||
pendingCount
|
||||
pendingCount,
|
||||
attempt: session.consecutiveRestarts
|
||||
});
|
||||
// Reset AbortController for restart
|
||||
session.abortController = new AbortController();
|
||||
// Restart processor
|
||||
this.startSessionProcessor(session, 'pending-work-restart');
|
||||
this.broadcastProcessingStatus();
|
||||
} else {
|
||||
// Successful completion with no pending work — clean up session
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
session.consecutiveRestarts = 0;
|
||||
this.sessionManager.removeSessionImmediate(session.sessionDbId);
|
||||
}
|
||||
|
||||
this.broadcastProcessingStatus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -747,6 +788,30 @@ export class WorkerService {
|
||||
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a session that will not restart.
|
||||
* Enforces the restart-or-terminate invariant: every generator exit
|
||||
* must either call startSessionProcessor() or terminateSession().
|
||||
* No zombie sessions allowed.
|
||||
*
|
||||
* GENERATOR EXIT INVARIANT:
|
||||
* .finally() → restart? → startSessionProcessor()
|
||||
* no? → terminateSession()
|
||||
*/
|
||||
private terminateSession(sessionDbId: number, reason: string): void {
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
|
||||
|
||||
logger.info('SYSTEM', 'Session terminated', {
|
||||
sessionId: sessionDbId,
|
||||
reason,
|
||||
abandonedMessages: abandoned
|
||||
});
|
||||
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending session queues
|
||||
*/
|
||||
@@ -870,8 +935,8 @@ export class WorkerService {
|
||||
* Broadcast processing status change to SSE clients
|
||||
*/
|
||||
broadcastProcessingStatus(): void {
|
||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = this.sessionManager.getTotalActiveWork();
|
||||
const isProcessing = queueDepth > 0;
|
||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||
|
||||
logger.info('WORKER', 'Broadcasting processing status', {
|
||||
@@ -896,12 +961,22 @@ export class WorkerService {
|
||||
* Ensures the worker is started and healthy.
|
||||
* This function can be called by both 'start' and 'hook' commands.
|
||||
*
|
||||
* @param port - The port the worker should run on
|
||||
* @param port - The TCP port (used for port-in-use checks and daemon spawn)
|
||||
* @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();
|
||||
const pidFileStatus = cleanStalePidFile();
|
||||
if (pidFileStatus === 'alive') {
|
||||
logger.info('SYSTEM', 'Worker PID file points to a live process, skipping duplicate spawn');
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(HOOK_TIMEOUTS.PORT_IN_USE_WAIT));
|
||||
if (healthy) {
|
||||
logger.info('SYSTEM', 'Worker became healthy while waiting on live PID');
|
||||
return true;
|
||||
}
|
||||
logger.warn('SYSTEM', 'Live PID detected but worker did not become healthy before timeout');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if worker is already running and healthy
|
||||
if (await waitForHealth(port, 1000)) {
|
||||
@@ -1045,11 +1120,9 @@ async function main() {
|
||||
case 'restart': {
|
||||
logger.info('SYSTEM', 'Restarting worker');
|
||||
await httpShutdown(port);
|
||||
const freed = await waitForPortFree(port, getPlatformTimeout(15000));
|
||||
if (!freed) {
|
||||
const restartFreed = await waitForPortFree(port, getPlatformTimeout(15000));
|
||||
if (!restartFreed) {
|
||||
logger.error('SYSTEM', 'Port did not free up after shutdown, aborting restart', { port });
|
||||
// Exit gracefully: Windows Terminal won't keep tab open on exit 0
|
||||
// The wrapper/plugin will handle restart logic if needed
|
||||
process.exit(0);
|
||||
}
|
||||
removePidFile();
|
||||
@@ -1080,9 +1153,9 @@ async function main() {
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const running = await isPortInUse(port);
|
||||
const portInUse = await isPortInUse(port);
|
||||
const pidInfo = readPidFile();
|
||||
if (running && pidInfo) {
|
||||
if (portInUse && pidInfo) {
|
||||
console.log('Worker is running');
|
||||
console.log(` PID: ${pidInfo.pid}`);
|
||||
console.log(` Port: ${pidInfo.port}`);
|
||||
@@ -1102,13 +1175,7 @@ async function main() {
|
||||
}
|
||||
|
||||
case 'hook': {
|
||||
// Auto-start worker if not running
|
||||
const workerReady = await ensureWorkerStarted(port);
|
||||
if (!workerReady) {
|
||||
logger.warn('SYSTEM', 'Worker failed to start before hook, handler will retry');
|
||||
}
|
||||
|
||||
// Existing logic unchanged
|
||||
// Validate CLI args first (before any I/O)
|
||||
const platform = process.argv[3];
|
||||
const event = process.argv[4];
|
||||
if (!platform || !event) {
|
||||
@@ -1118,32 +1185,20 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if worker is already running on port
|
||||
const portInUse = await isPortInUse(port);
|
||||
let startedWorkerInProcess = false;
|
||||
|
||||
if (!portInUse) {
|
||||
// Port free - start worker IN THIS PROCESS (no spawn!)
|
||||
// This process becomes the worker and stays alive
|
||||
try {
|
||||
logger.info('SYSTEM', 'Starting worker in-process for hook', { event });
|
||||
const worker = new WorkerService();
|
||||
await worker.start();
|
||||
startedWorkerInProcess = true;
|
||||
// Worker is now running in this process on the port
|
||||
} catch (error) {
|
||||
logger.failure('SYSTEM', 'Worker failed to start in hook', {}, error as Error);
|
||||
removePidFile();
|
||||
process.exit(0);
|
||||
}
|
||||
// Ensure worker is running as a detached daemon (#1249).
|
||||
//
|
||||
// IMPORTANT: The hook process MUST NOT become the worker. Starting the
|
||||
// worker in-process makes it a grandchild of Claude Code, which the
|
||||
// sandbox kills. Instead, ensureWorkerStarted() spawns a fully detached
|
||||
// daemon (detached: true, stdio: 'ignore', child.unref()) that survives
|
||||
// the hook process's exit and is invisible to Claude Code's sandbox.
|
||||
const workerReady = await ensureWorkerStarted(port);
|
||||
if (!workerReady) {
|
||||
logger.warn('SYSTEM', 'Worker failed to start before hook, handler will proceed gracefully');
|
||||
}
|
||||
// If port in use, we'll use HTTP to the existing worker
|
||||
|
||||
const { hookCommand } = await import('../cli/hook-command.js');
|
||||
// If we started the worker in this process, skip process.exit() so we stay alive as the worker
|
||||
await hookCommand(platform, event, { skipExit: startedWorkerInProcess });
|
||||
// Note: if we started worker in-process, this process stays alive as the worker
|
||||
// The break allows the event loop to continue serving requests
|
||||
await hookCommand(platform, event);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1214,7 +1269,10 @@ async function main() {
|
||||
// Check if running as main module in both ESM and CommonJS
|
||||
const isMainModule = typeof require !== 'undefined' && typeof module !== 'undefined'
|
||||
? require.main === module || !module.parent
|
||||
: import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('worker-service');
|
||||
: import.meta.url === `file://${process.argv[1]}`
|
||||
|| process.argv[1]?.endsWith('worker-service')
|
||||
|| process.argv[1]?.endsWith('worker-service.cjs')
|
||||
|| process.argv[1]?.replaceAll('\\', '/') === __filename?.replaceAll('\\', '/');
|
||||
|
||||
if (isMainModule) {
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { sanitizeEnv } from '../../supervisor/env-sanitizer.js';
|
||||
import { getSupervisor } from '../../supervisor/index.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -29,14 +31,36 @@ interface TrackedProcess {
|
||||
process: ChildProcess;
|
||||
}
|
||||
|
||||
// PID Registry - tracks spawned Claude subprocesses
|
||||
const processRegistry = new Map<number, TrackedProcess>();
|
||||
function getTrackedProcesses(): TrackedProcess[] {
|
||||
return getSupervisor().getRegistry()
|
||||
.getAll()
|
||||
.filter(record => record.type === 'sdk')
|
||||
.map((record) => {
|
||||
const processRef = getSupervisor().getRegistry().getRuntimeProcess(record.id);
|
||||
if (!processRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pid: record.pid,
|
||||
sessionDbId: Number(record.sessionId),
|
||||
spawnedAt: Date.parse(record.startedAt),
|
||||
process: processRef
|
||||
};
|
||||
})
|
||||
.filter((value): value is TrackedProcess => value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a spawned process in the registry
|
||||
*/
|
||||
export function registerProcess(pid: number, sessionDbId: number, process: ChildProcess): void {
|
||||
processRegistry.set(pid, { pid, sessionDbId, spawnedAt: Date.now(), process });
|
||||
getSupervisor().registerProcess(`sdk:${sessionDbId}:${pid}`, {
|
||||
pid,
|
||||
type: 'sdk',
|
||||
sessionId: sessionDbId,
|
||||
startedAt: new Date().toISOString()
|
||||
}, process);
|
||||
logger.info('PROCESS', `Registered PID ${pid} for session ${sessionDbId}`, { pid, sessionDbId });
|
||||
}
|
||||
|
||||
@@ -44,7 +68,11 @@ export function registerProcess(pid: number, sessionDbId: number, process: Child
|
||||
* Unregister a process from the registry and notify pool waiters
|
||||
*/
|
||||
export function unregisterProcess(pid: number): void {
|
||||
processRegistry.delete(pid);
|
||||
for (const record of getSupervisor().getRegistry().getByPid(pid)) {
|
||||
if (record.type === 'sdk') {
|
||||
getSupervisor().unregisterProcess(record.id);
|
||||
}
|
||||
}
|
||||
logger.debug('PROCESS', `Unregistered PID ${pid}`, { pid });
|
||||
// Notify waiters that a pool slot may be available
|
||||
notifySlotAvailable();
|
||||
@@ -55,10 +83,7 @@ export function unregisterProcess(pid: number): void {
|
||||
* Warns if multiple processes found (indicates race condition)
|
||||
*/
|
||||
export function getProcessBySession(sessionDbId: number): TrackedProcess | undefined {
|
||||
const matches: TrackedProcess[] = [];
|
||||
for (const [, info] of processRegistry) {
|
||||
if (info.sessionDbId === sessionDbId) matches.push(info);
|
||||
}
|
||||
const matches = getTrackedProcesses().filter(info => info.sessionDbId === sessionDbId);
|
||||
if (matches.length > 1) {
|
||||
logger.warn('PROCESS', `Multiple processes found for session ${sessionDbId}`, {
|
||||
count: matches.length,
|
||||
@@ -72,7 +97,7 @@ export function getProcessBySession(sessionDbId: number): TrackedProcess | undef
|
||||
* Get count of active processes in the registry
|
||||
*/
|
||||
export function getActiveCount(): number {
|
||||
return processRegistry.size;
|
||||
return getSupervisor().getRegistry().getAll().filter(record => record.type === 'sdk').length;
|
||||
}
|
||||
|
||||
// Waiters for pool slots - resolved when a process exits and frees a slot
|
||||
@@ -91,10 +116,18 @@ function notifySlotAvailable(): void {
|
||||
* @param maxConcurrent Max number of concurrent agents
|
||||
* @param timeoutMs Max time to wait before giving up
|
||||
*/
|
||||
export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_000): Promise<void> {
|
||||
if (processRegistry.size < maxConcurrent) return;
|
||||
const TOTAL_PROCESS_HARD_CAP = 10;
|
||||
|
||||
logger.info('PROCESS', `Pool limit reached (${processRegistry.size}/${maxConcurrent}), waiting for slot...`);
|
||||
export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_000): Promise<void> {
|
||||
// Hard cap: refuse to spawn if too many processes exist regardless of pool accounting
|
||||
const activeCount = getActiveCount();
|
||||
if (activeCount >= TOTAL_PROCESS_HARD_CAP) {
|
||||
throw new Error(`Hard cap exceeded: ${activeCount} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`);
|
||||
}
|
||||
|
||||
if (activeCount < maxConcurrent) return;
|
||||
|
||||
logger.info('PROCESS', `Pool limit reached (${activeCount}/${maxConcurrent}), waiting for slot...`);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -105,7 +138,7 @@ export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_
|
||||
|
||||
const onSlot = () => {
|
||||
clearTimeout(timeout);
|
||||
if (processRegistry.size < maxConcurrent) {
|
||||
if (getActiveCount() < maxConcurrent) {
|
||||
resolve();
|
||||
} else {
|
||||
// Still full, re-queue
|
||||
@@ -122,7 +155,7 @@ export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_
|
||||
*/
|
||||
export function getActiveProcesses(): Array<{ pid: number; sessionDbId: number; ageMs: number }> {
|
||||
const now = Date.now();
|
||||
return Array.from(processRegistry.values()).map(info => ({
|
||||
return getTrackedProcesses().map(info => ({
|
||||
pid: info.pid,
|
||||
sessionDbId: info.sessionDbId,
|
||||
ageMs: now - info.spawnedAt
|
||||
@@ -136,8 +169,9 @@ export function getActiveProcesses(): Array<{ pid: number; sessionDbId: number;
|
||||
export async function ensureProcessExit(tracked: TrackedProcess, timeoutMs: number = 5000): Promise<void> {
|
||||
const { pid, process: proc } = tracked;
|
||||
|
||||
// Already exited?
|
||||
if (proc.killed || proc.exitCode !== null) {
|
||||
// Already exited? Only trust exitCode, NOT proc.killed
|
||||
// proc.killed only means Node sent a signal — the process can still be alive
|
||||
if (proc.exitCode !== null) {
|
||||
unregisterProcess(pid);
|
||||
return;
|
||||
}
|
||||
@@ -153,8 +187,8 @@ export async function ensureProcessExit(tracked: TrackedProcess, timeoutMs: numb
|
||||
|
||||
await Promise.race([exitPromise, timeoutPromise]);
|
||||
|
||||
// Check if exited gracefully
|
||||
if (proc.killed || proc.exitCode !== null) {
|
||||
// Check if exited gracefully — only trust exitCode
|
||||
if (proc.exitCode !== null) {
|
||||
unregisterProcess(pid);
|
||||
return;
|
||||
}
|
||||
@@ -167,8 +201,14 @@ export async function ensureProcessExit(tracked: TrackedProcess, timeoutMs: numb
|
||||
// Already dead
|
||||
}
|
||||
|
||||
// Brief wait for SIGKILL to take effect
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
// Wait for SIGKILL to take effect — use exit event with 1s timeout instead of blind sleep
|
||||
const sigkillExitPromise = new Promise<void>((resolve) => {
|
||||
proc.once('exit', () => resolve());
|
||||
});
|
||||
const sigkillTimeout = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
await Promise.race([sigkillExitPromise, sigkillTimeout]);
|
||||
unregisterProcess(pid);
|
||||
}
|
||||
|
||||
@@ -234,8 +274,8 @@ async function killIdleDaemonChildren(): Promise<number> {
|
||||
minutes = parseInt(minMatch[1], 10);
|
||||
}
|
||||
|
||||
// Kill if idle for more than 2 minutes
|
||||
if (minutes >= 2) {
|
||||
// Kill if idle for more than 1 minute
|
||||
if (minutes >= 1) {
|
||||
logger.info('PROCESS', `Killing idle daemon child PID ${pid} (idle ${minutes}m)`, { pid, minutes });
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
@@ -294,17 +334,26 @@ export async function reapOrphanedProcesses(activeSessionIds: Set<number>): Prom
|
||||
let killed = 0;
|
||||
|
||||
// Registry-based: kill processes for dead sessions
|
||||
for (const [pid, info] of processRegistry) {
|
||||
if (activeSessionIds.has(info.sessionDbId)) continue; // Active = safe
|
||||
for (const record of getSupervisor().getRegistry().getAll().filter(entry => entry.type === 'sdk')) {
|
||||
const pid = record.pid;
|
||||
const sessionDbId = Number(record.sessionId);
|
||||
const processRef = getSupervisor().getRegistry().getRuntimeProcess(record.id);
|
||||
|
||||
logger.warn('PROCESS', `Killing orphan PID ${pid} (session ${info.sessionDbId} gone)`, { pid, sessionDbId: info.sessionDbId });
|
||||
if (activeSessionIds.has(sessionDbId)) continue; // Active = safe
|
||||
|
||||
logger.warn('PROCESS', `Killing orphan PID ${pid} (session ${sessionDbId} gone)`, { pid, sessionDbId });
|
||||
try {
|
||||
info.process.kill('SIGKILL');
|
||||
if (processRef) {
|
||||
processRef.kill('SIGKILL');
|
||||
} else {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
}
|
||||
killed++;
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
unregisterProcess(pid);
|
||||
getSupervisor().unregisterProcess(record.id);
|
||||
notifySlotAvailable();
|
||||
}
|
||||
|
||||
// System-level: find ppid=1 orphans
|
||||
@@ -333,20 +382,23 @@ export function createPidCapturingSpawn(sessionDbId: number) {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
}) => {
|
||||
getSupervisor().assertCanSpawn('claude sdk');
|
||||
|
||||
// On Windows, use cmd.exe wrapper for .cmd files to properly handle paths with spaces
|
||||
const useCmdWrapper = process.platform === 'win32' && spawnOptions.command.endsWith('.cmd');
|
||||
const env = sanitizeEnv(spawnOptions.env ?? process.env);
|
||||
|
||||
const child = useCmdWrapper
|
||||
? spawn('cmd.exe', ['/d', '/c', spawnOptions.command, ...spawnOptions.args], {
|
||||
cwd: spawnOptions.cwd,
|
||||
env: spawnOptions.env,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
signal: spawnOptions.signal,
|
||||
windowsHide: true
|
||||
})
|
||||
: spawn(spawnOptions.command, spawnOptions.args, {
|
||||
cwd: spawnOptions.cwd,
|
||||
env: spawnOptions.env,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
signal: spawnOptions.signal, // CRITICAL: Pass signal for AbortController integration
|
||||
windowsHide: true
|
||||
@@ -393,7 +445,7 @@ export function createPidCapturingSpawn(sessionDbId: number) {
|
||||
* Start the orphan reaper interval
|
||||
* Returns cleanup function to stop the interval
|
||||
*/
|
||||
export function startOrphanReaper(getActiveSessionIds: () => Set<number>, intervalMs: number = 5 * 60 * 1000): () => void {
|
||||
export function startOrphanReaper(getActiveSessionIds: () => Set<number>, intervalMs: number = 30 * 1000): () => void {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const activeIds = getActiveSessionIds();
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { ActiveSession, SDKUserMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { processAgentResponse, type WorkerRef } from './agents/index.js';
|
||||
import { createPidCapturingSpawn, getProcessBySession, ensureProcessExit, waitForSlot } from './ProcessRegistry.js';
|
||||
import { sanitizeEnv } from '../../supervisor/env-sanitizer.js';
|
||||
|
||||
// Import Agent SDK (assumes it's installed)
|
||||
// @ts-ignore - Agent SDK types may not be available
|
||||
@@ -96,7 +97,7 @@ export class SDKAgent {
|
||||
// Build isolated environment from ~/.claude-mem/.env
|
||||
// This prevents Issue #733: random ANTHROPIC_API_KEY from project .env files
|
||||
// being used instead of the configured auth method (CLI subscription or explicit API key)
|
||||
const isolatedEnv = buildIsolatedEnv();
|
||||
const isolatedEnv = sanitizeEnv(buildIsolatedEnv());
|
||||
const authMethod = getAuthMethodDescription();
|
||||
|
||||
logger.info('SDK', 'Starting SDK query', {
|
||||
@@ -281,7 +282,7 @@ export class SDKAgent {
|
||||
} 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) {
|
||||
if (tracked && tracked.process.exitCode === null) {
|
||||
await ensureProcessExit(tracked, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ export class SearchManager {
|
||||
limit: number,
|
||||
whereFilter?: Record<string, any>
|
||||
): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> {
|
||||
if (!this.chromaSync) {
|
||||
return { ids: [], distances: [], metadatas: [] };
|
||||
}
|
||||
return await this.chromaSync.queryChroma(query, limit, whereFilter);
|
||||
}
|
||||
|
||||
@@ -180,15 +183,37 @@ export class SearchManager {
|
||||
logger.debug('SEARCH', 'ChromaDB returned semantic matches', { matchCount: chromaResults.ids.length });
|
||||
|
||||
if (chromaResults.ids.length > 0) {
|
||||
// Step 2: Filter by recency (90 days)
|
||||
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
|
||||
// Step 2: Filter by date range
|
||||
// Use user-provided dateRange if available, otherwise fall back to 90-day recency window
|
||||
const { dateRange } = options;
|
||||
let startEpoch: number | undefined;
|
||||
let endEpoch: number | undefined;
|
||||
|
||||
if (dateRange) {
|
||||
if (dateRange.start) {
|
||||
startEpoch = typeof dateRange.start === 'number'
|
||||
? dateRange.start
|
||||
: new Date(dateRange.start).getTime();
|
||||
}
|
||||
if (dateRange.end) {
|
||||
endEpoch = typeof dateRange.end === 'number'
|
||||
? dateRange.end
|
||||
: new Date(dateRange.end).getTime();
|
||||
}
|
||||
} else {
|
||||
// Default: 90-day recency window
|
||||
startEpoch = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
|
||||
}
|
||||
|
||||
const recentMetadata = chromaResults.metadatas.map((meta, idx) => ({
|
||||
id: chromaResults.ids[idx],
|
||||
meta,
|
||||
isRecent: meta && meta.created_at_epoch > ninetyDaysAgo
|
||||
isRecent: meta && meta.created_at_epoch != null
|
||||
&& (!startEpoch || meta.created_at_epoch >= startEpoch)
|
||||
&& (!endEpoch || meta.created_at_epoch <= endEpoch)
|
||||
})).filter(item => item.isRecent);
|
||||
|
||||
logger.debug('SEARCH', 'Results within 90-day window', { count: recentMetadata.length });
|
||||
logger.debug('SEARCH', dateRange ? 'Results within user date range' : 'Results within 90-day window', { count: recentMetadata.length });
|
||||
|
||||
// Step 3: Categorize IDs by document type
|
||||
const obsIds: number[] = [];
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ActiveSession, PendingMessage, PendingMessageWithId, ObservationDa
|
||||
import { PendingMessageStore } from '../sqlite/PendingMessageStore.js';
|
||||
import { SessionQueueProcessor } from '../queue/SessionQueueProcessor.js';
|
||||
import { getProcessBySession, ensureProcessExit } from './ProcessRegistry.js';
|
||||
import { getSupervisor } from '../../supervisor/index.js';
|
||||
|
||||
export class SessionManager {
|
||||
private dbManager: DatabaseManager;
|
||||
@@ -302,7 +303,7 @@ export class SessionManager {
|
||||
|
||||
// 3. Verify subprocess exit with 5s timeout (Issue #737 fix)
|
||||
const tracked = getProcessBySession(sessionDbId);
|
||||
if (tracked && !tracked.process.killed && tracked.process.exitCode === null) {
|
||||
if (tracked && tracked.process.exitCode === null) {
|
||||
logger.debug('SESSION', `Waiting for subprocess PID ${tracked.pid} to exit`, {
|
||||
sessionId: sessionDbId,
|
||||
pid: tracked.pid
|
||||
@@ -310,6 +311,17 @@ export class SessionManager {
|
||||
await ensureProcessExit(tracked, 5000);
|
||||
}
|
||||
|
||||
// 3b. Reap all supervisor-tracked processes for this session (#1351)
|
||||
// This catches MCP servers and other child processes not tracked by the
|
||||
// in-memory ProcessRegistry (e.g. processes registered only in supervisor.json).
|
||||
try {
|
||||
await getSupervisor().getRegistry().reapSession(sessionDbId);
|
||||
} catch (error) {
|
||||
logger.warn('SESSION', 'Supervisor reapSession failed (non-blocking)', {
|
||||
sessionId: sessionDbId
|
||||
}, error as Error);
|
||||
}
|
||||
|
||||
// 4. Cleanup
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
@@ -338,7 +350,7 @@ export class SessionManager {
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Session removed (orphaned after SDK termination)', {
|
||||
logger.info('SESSION', 'Session removed from active sessions', {
|
||||
sessionId: sessionDbId,
|
||||
project: session.project
|
||||
});
|
||||
@@ -390,10 +402,11 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any session has pending messages (for spinner tracking)
|
||||
* Check if any active session has pending messages (for spinner tracking).
|
||||
* Scoped to in-memory sessions only.
|
||||
*/
|
||||
hasPendingMessages(): boolean {
|
||||
return this.getPendingStore().hasAnyPendingWork();
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,12 +438,12 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any session is actively processing (has pending messages OR active generator)
|
||||
* Used for activity indicator to prevent spinner from stopping while SDK is processing
|
||||
* Check if any active session has pending work.
|
||||
* Scoped to in-memory sessions only — orphaned DB messages from dead
|
||||
* sessions must not keep the spinner spinning forever.
|
||||
*/
|
||||
isAnySessionProcessing(): boolean {
|
||||
// hasAnyPendingWork checks for 'pending' OR 'processing'
|
||||
return this.getPendingStore().hasAnyPendingWork();
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,12 +33,6 @@ export class SessionEventBroadcaster {
|
||||
prompt
|
||||
});
|
||||
|
||||
// Start activity indicator (work is about to begin)
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'processing_status',
|
||||
isProcessing: true
|
||||
});
|
||||
|
||||
// Update processing status based on queue depth
|
||||
this.workerService.broadcastProcessingStatus();
|
||||
}
|
||||
|
||||
@@ -57,13 +57,13 @@ export function createMiddleware(
|
||||
|
||||
// Log incoming request with body summary
|
||||
const bodySummary = summarizeRequestBody(req.method, req.path, req.body);
|
||||
logger.info('HTTP', `→ ${req.method} ${req.path}`, { requestId }, bodySummary);
|
||||
logger.debug('HTTP', `→ ${req.method} ${req.path}`, { requestId }, bodySummary);
|
||||
|
||||
// Capture response
|
||||
const originalSend = res.send.bind(res);
|
||||
res.send = function(body: any) {
|
||||
const duration = Date.now() - start;
|
||||
logger.info('HTTP', `← ${res.statusCode} ${req.path}`, { requestId, duration: `${duration}ms` });
|
||||
logger.debug('HTTP', `← ${res.statusCode} ${req.path}`, { requestId, duration: `${duration}ms` });
|
||||
return originalSend(body);
|
||||
};
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
// Support both legacy `project` and new `projects` parameter
|
||||
const projectsParam = (req.query.projects as string) || (req.query.project as string);
|
||||
const useColors = req.query.colors === 'true';
|
||||
const full = req.query.full === 'true';
|
||||
|
||||
if (!projectsParam) {
|
||||
this.badRequest(res, 'Project(s) parameter is required');
|
||||
@@ -234,7 +235,8 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
{
|
||||
session_id: 'context-inject-' + Date.now(),
|
||||
cwd: cwd,
|
||||
projects: projects
|
||||
projects: projects,
|
||||
full
|
||||
},
|
||||
useColors
|
||||
);
|
||||
|
||||
@@ -356,7 +356,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
// Sync user prompt to Chroma
|
||||
const chromaStart = Date.now();
|
||||
const promptText = latestPrompt.prompt_text;
|
||||
this.dbManager.getChromaSync().syncUserPrompt(
|
||||
this.dbManager.getChromaSync()?.syncUserPrompt(
|
||||
latestPrompt.id,
|
||||
latestPrompt.memory_session_id,
|
||||
latestPrompt.project,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { DEFAULT_OBSERVATION_TYPES_STRING, DEFAULT_OBSERVATION_CONCEPTS_STRING } from '../constants/observation-metadata.js';
|
||||
// NOTE: Do NOT import logger here - it creates a circular dependency
|
||||
// logger.ts depends on SettingsDefaultsManager for its initialization
|
||||
|
||||
@@ -41,9 +40,6 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: string;
|
||||
// Observation Filtering
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: string;
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: string;
|
||||
// Display Configuration
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: string;
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: string;
|
||||
@@ -103,9 +99,6 @@ export class SettingsDefaultsManager {
|
||||
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: '0',
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
|
||||
@@ -140,10 +133,15 @@ export class SettingsDefaultsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default value from defaults (no environment variable override)
|
||||
* Get a setting value with environment variable override.
|
||||
* Priority: process.env > hardcoded default
|
||||
*
|
||||
* For full priority (env > settings file > default), use loadFromFile().
|
||||
* This method is safe to call at module-load time (no file I/O) and still
|
||||
* respects environment variable overrides that were previously ignored.
|
||||
*/
|
||||
static get(key: keyof SettingsDefaults): string {
|
||||
return this.DEFAULTS[key];
|
||||
return process.env[key] ?? this.DEFAULTS[key];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+31
-1
@@ -24,7 +24,37 @@ const _dirname = getDirname();
|
||||
*/
|
||||
|
||||
// Base directories
|
||||
export const DATA_DIR = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
|
||||
// Resolve DATA_DIR with full priority: env var > settings.json > default.
|
||||
// SettingsDefaultsManager.get() handles env > default. For settings file
|
||||
// support, we do a one-time synchronous read of the default settings path
|
||||
// to check if the user configured a custom DATA_DIR there.
|
||||
function resolveDataDir(): string {
|
||||
// 1. Environment variable (highest priority) — already handled by get()
|
||||
if (process.env.CLAUDE_MEM_DATA_DIR) {
|
||||
return process.env.CLAUDE_MEM_DATA_DIR;
|
||||
}
|
||||
|
||||
// 2. Settings file at the default location
|
||||
const defaultDataDir = join(homedir(), '.claude-mem');
|
||||
const settingsPath = join(defaultDataDir, 'settings.json');
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const { readFileSync } = require('fs');
|
||||
const raw = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
const settings = raw.env ?? raw; // handle legacy nested schema
|
||||
if (settings.CLAUDE_MEM_DATA_DIR) {
|
||||
return settings.CLAUDE_MEM_DATA_DIR;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// settings file missing or corrupt — fall through to default
|
||||
}
|
||||
|
||||
// 3. Hardcoded default
|
||||
return defaultDataDir;
|
||||
}
|
||||
|
||||
export const DATA_DIR = resolveDataDir();
|
||||
// Note: CLAUDE_CONFIG_DIR is a Claude Code setting, not claude-mem, so leave as env var
|
||||
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@ export function extractLastMessage(
|
||||
stripSystemReminders: boolean = false
|
||||
): string {
|
||||
if (!transcriptPath || !existsSync(transcriptPath)) {
|
||||
throw new Error(`Transcript path missing or file does not exist: ${transcriptPath}`);
|
||||
logger.warn('PARSER', `Transcript path missing or file does not exist: ${transcriptPath}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const content = readFileSync(transcriptPath, 'utf-8').trim();
|
||||
if (!content) {
|
||||
throw new Error(`Transcript file exists but is empty: ${transcriptPath}`);
|
||||
logger.warn('PARSER', `Transcript file exists but is empty: ${transcriptPath}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
+44
-11
@@ -78,8 +78,8 @@ export function getWorkerHost(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached port and host values
|
||||
* Call this when settings are updated to force re-reading from file
|
||||
* Clear the cached port and host values.
|
||||
* Call this when settings are updated to force re-reading from file.
|
||||
*/
|
||||
export function clearPortCache(): void {
|
||||
cachedPort = null;
|
||||
@@ -87,7 +87,46 @@ export function clearPortCache(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worker HTTP server is responsive
|
||||
* Build a full URL for a given API path.
|
||||
*/
|
||||
export function buildWorkerUrl(apiPath: string): string {
|
||||
return `http://${getWorkerHost()}:${getWorkerPort()}${apiPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the worker over TCP.
|
||||
*
|
||||
* This is the preferred way for hooks to communicate with the worker.
|
||||
*/
|
||||
export function workerHttpRequest(
|
||||
apiPath: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
} = {}
|
||||
): Promise<Response> {
|
||||
const method = options.method ?? 'GET';
|
||||
const timeoutMs = options.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
|
||||
|
||||
const url = buildWorkerUrl(apiPath);
|
||||
const init: RequestInit = { method };
|
||||
if (options.headers) {
|
||||
init.headers = options.headers;
|
||||
}
|
||||
if (options.body) {
|
||||
init.body = options.body;
|
||||
}
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
return fetchWithTimeout(url, init, timeoutMs);
|
||||
}
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worker HTTP server is responsive.
|
||||
* Uses /api/health (liveness) instead of /api/readiness because:
|
||||
* - Hooks have 15-second timeout, but full initialization can take 5+ minutes (MCP connection)
|
||||
* - /api/health returns 200 as soon as HTTP server is up (sufficient for hook communication)
|
||||
@@ -95,10 +134,7 @@ export function clearPortCache(): void {
|
||||
* See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
*/
|
||||
async function isWorkerHealthy(): Promise<boolean> {
|
||||
const port = getWorkerPort();
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/health`, {}, HEALTH_CHECK_TIMEOUT_MS
|
||||
);
|
||||
const response = await workerHttpRequest('/api/health', { timeoutMs: HEALTH_CHECK_TIMEOUT_MS });
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
@@ -125,10 +161,7 @@ function getPluginVersion(): string {
|
||||
* Get the running worker's version from the API
|
||||
*/
|
||||
async function getWorkerVersion(): Promise<string> {
|
||||
const port = getWorkerPort();
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/version`, {}, HEALTH_CHECK_TIMEOUT_MS
|
||||
);
|
||||
const response = await workerHttpRequest('/api/version', { timeoutMs: HEALTH_CHECK_TIMEOUT_MS });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get worker version: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const ENV_PREFIXES = ['CLAUDECODE_', 'CLAUDE_CODE_'];
|
||||
export const ENV_EXACT_MATCHES = new Set([
|
||||
'CLAUDECODE',
|
||||
'CLAUDE_CODE_SESSION',
|
||||
'CLAUDE_CODE_ENTRYPOINT',
|
||||
'MCP_SESSION_ID',
|
||||
]);
|
||||
|
||||
/** Vars that start with CLAUDE_CODE_ but must be preserved for subprocess auth/tooling */
|
||||
export const ENV_PRESERVE = new Set([
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_CODE_GIT_BASH_PATH',
|
||||
]);
|
||||
|
||||
export function sanitizeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const sanitized: NodeJS.ProcessEnv = {};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) continue;
|
||||
if (ENV_PRESERVE.has(key)) { sanitized[key] = value; continue; }
|
||||
if (ENV_EXACT_MATCHES.has(key)) continue;
|
||||
if (ENV_PREFIXES.some(prefix => key.startsWith(prefix))) continue;
|
||||
sanitized[key] = value;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Health Checker - Periodic background cleanup of dead processes
|
||||
*
|
||||
* Runs every 30 seconds to prune dead processes from the supervisor registry.
|
||||
* The interval is unref'd so it does not keep the process alive.
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getProcessRegistry } from './process-registry.js';
|
||||
|
||||
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function runHealthCheck(): void {
|
||||
const registry = getProcessRegistry();
|
||||
|
||||
const removedProcessCount = registry.pruneDeadEntries();
|
||||
if (removedProcessCount > 0) {
|
||||
logger.info('SYSTEM', `Health check: pruned ${removedProcessCount} dead process(es) from registry`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startHealthChecker(): void {
|
||||
if (healthCheckInterval !== null) return;
|
||||
|
||||
healthCheckInterval = setInterval(runHealthCheck, HEALTH_CHECK_INTERVAL_MS);
|
||||
healthCheckInterval.unref();
|
||||
|
||||
logger.debug('SYSTEM', 'Health checker started', { intervalMs: HEALTH_CHECK_INTERVAL_MS });
|
||||
}
|
||||
|
||||
export function stopHealthChecker(): void {
|
||||
if (healthCheckInterval === null) return;
|
||||
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
|
||||
logger.debug('SYSTEM', 'Health checker stopped');
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { existsSync, readFileSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getProcessRegistry, isPidAlive, type ManagedProcessInfo, type ProcessRegistry } from './process-registry.js';
|
||||
import { runShutdownCascade } from './shutdown.js';
|
||||
import { startHealthChecker, stopHealthChecker } from './health-checker.js';
|
||||
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
interface PidInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
interface ValidateWorkerPidOptions {
|
||||
logAlive?: boolean;
|
||||
pidFilePath?: string;
|
||||
}
|
||||
|
||||
export type ValidateWorkerPidStatus = 'missing' | 'alive' | 'stale' | 'invalid';
|
||||
|
||||
class Supervisor {
|
||||
private readonly registry: ProcessRegistry;
|
||||
private started = false;
|
||||
private stopPromise: Promise<void> | null = null;
|
||||
private signalHandlersRegistered = false;
|
||||
private shutdownInitiated = false;
|
||||
private shutdownHandler: (() => Promise<void>) | null = null;
|
||||
|
||||
constructor(registry: ProcessRegistry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
|
||||
this.registry.initialize();
|
||||
const pidStatus = validateWorkerPidFile({ logAlive: false });
|
||||
if (pidStatus === 'alive') {
|
||||
throw new Error('Worker already running');
|
||||
}
|
||||
|
||||
this.started = true;
|
||||
|
||||
startHealthChecker();
|
||||
}
|
||||
|
||||
configureSignalHandlers(shutdownHandler: () => Promise<void>): void {
|
||||
this.shutdownHandler = shutdownHandler;
|
||||
|
||||
if (this.signalHandlersRegistered) return;
|
||||
this.signalHandlersRegistered = true;
|
||||
|
||||
const handleSignal = async (signal: string): Promise<void> => {
|
||||
if (this.shutdownInitiated) {
|
||||
logger.warn('SYSTEM', `Received ${signal} but shutdown already in progress`);
|
||||
return;
|
||||
}
|
||||
this.shutdownInitiated = true;
|
||||
|
||||
logger.info('SYSTEM', `Received ${signal}, shutting down...`);
|
||||
|
||||
try {
|
||||
if (this.shutdownHandler) {
|
||||
await this.shutdownHandler();
|
||||
} else {
|
||||
await this.stop();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SYSTEM', 'Error during shutdown', {}, error as Error);
|
||||
try {
|
||||
await this.stop();
|
||||
} catch (stopError) {
|
||||
logger.debug('SYSTEM', 'Supervisor shutdown fallback failed', {}, stopError as Error);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => void handleSignal('SIGTERM'));
|
||||
process.on('SIGINT', () => void handleSignal('SIGINT'));
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.argv.includes('--daemon')) {
|
||||
process.on('SIGHUP', () => {
|
||||
logger.debug('SYSTEM', 'Ignoring SIGHUP in daemon mode');
|
||||
});
|
||||
} else {
|
||||
process.on('SIGHUP', () => void handleSignal('SIGHUP'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.stopPromise) {
|
||||
await this.stopPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
stopHealthChecker();
|
||||
this.stopPromise = runShutdownCascade({
|
||||
registry: this.registry,
|
||||
currentPid: process.pid
|
||||
}).finally(() => {
|
||||
this.started = false;
|
||||
this.stopPromise = null;
|
||||
});
|
||||
|
||||
await this.stopPromise;
|
||||
}
|
||||
|
||||
assertCanSpawn(type: string): void {
|
||||
if (this.stopPromise !== null) {
|
||||
throw new Error(`Supervisor is shutting down, refusing to spawn ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcess(id: string, processInfo: ManagedProcessInfo, processRef?: Parameters<ProcessRegistry['register']>[2]): void {
|
||||
this.registry.register(id, processInfo, processRef);
|
||||
}
|
||||
|
||||
unregisterProcess(id: string): void {
|
||||
this.registry.unregister(id);
|
||||
}
|
||||
|
||||
getRegistry(): ProcessRegistry {
|
||||
return this.registry;
|
||||
}
|
||||
}
|
||||
|
||||
const supervisorSingleton = new Supervisor(getProcessRegistry());
|
||||
|
||||
export async function startSupervisor(): Promise<void> {
|
||||
await supervisorSingleton.start();
|
||||
}
|
||||
|
||||
export async function stopSupervisor(): Promise<void> {
|
||||
await supervisorSingleton.stop();
|
||||
}
|
||||
|
||||
export function getSupervisor(): Supervisor {
|
||||
return supervisorSingleton;
|
||||
}
|
||||
|
||||
export function configureSupervisorSignalHandlers(shutdownHandler: () => Promise<void>): void {
|
||||
supervisorSingleton.configureSignalHandlers(shutdownHandler);
|
||||
}
|
||||
|
||||
export function validateWorkerPidFile(options: ValidateWorkerPidOptions = {}): ValidateWorkerPidStatus {
|
||||
const pidFilePath = options.pidFilePath ?? PID_FILE;
|
||||
|
||||
if (!existsSync(pidFilePath)) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
let pidInfo: PidInfo | null = null;
|
||||
|
||||
try {
|
||||
pidInfo = JSON.parse(readFileSync(pidFilePath, 'utf-8')) as PidInfo;
|
||||
} catch (error) {
|
||||
logger.warn('SYSTEM', 'Failed to parse worker PID file, removing it', { path: pidFilePath }, error as Error);
|
||||
rmSync(pidFilePath, { force: true });
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
if (isPidAlive(pidInfo.pid)) {
|
||||
if (options.logAlive ?? true) {
|
||||
logger.info('SYSTEM', 'Worker already running (PID alive)', {
|
||||
existingPid: pidInfo.pid,
|
||||
existingPort: pidInfo.port,
|
||||
startedAt: pidInfo.startedAt
|
||||
});
|
||||
}
|
||||
return 'alive';
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Removing stale PID file (worker process is dead)', {
|
||||
pid: pidInfo.pid,
|
||||
port: pidInfo.port,
|
||||
startedAt: pidInfo.startedAt
|
||||
});
|
||||
rmSync(pidFilePath, { force: true });
|
||||
return 'stale';
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const REAP_SESSION_SIGTERM_TIMEOUT_MS = 5_000;
|
||||
const REAP_SESSION_SIGKILL_TIMEOUT_MS = 1_000;
|
||||
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const DEFAULT_REGISTRY_PATH = path.join(DATA_DIR, 'supervisor.json');
|
||||
|
||||
export interface ManagedProcessInfo {
|
||||
pid: number;
|
||||
type: string;
|
||||
sessionId?: string | number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface ManagedProcessRecord extends ManagedProcessInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface PersistedRegistry {
|
||||
processes: Record<string, ManagedProcessInfo>;
|
||||
}
|
||||
|
||||
export function isPidAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid < 0) return false;
|
||||
if (pid === 0) return false;
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProcessRegistry {
|
||||
private readonly registryPath: string;
|
||||
private readonly entries = new Map<string, ManagedProcessInfo>();
|
||||
private readonly runtimeProcesses = new Map<string, ChildProcess>();
|
||||
private initialized = false;
|
||||
|
||||
constructor(registryPath: string = DEFAULT_REGISTRY_PATH) {
|
||||
this.registryPath = registryPath;
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
mkdirSync(path.dirname(this.registryPath), { recursive: true });
|
||||
|
||||
if (!existsSync(this.registryPath)) {
|
||||
this.persist();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as PersistedRegistry;
|
||||
const processes = raw.processes ?? {};
|
||||
for (const [id, info] of Object.entries(processes)) {
|
||||
this.entries.set(id, info);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', {
|
||||
path: this.registryPath
|
||||
}, error as Error);
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
const removed = this.pruneDeadEntries();
|
||||
if (removed > 0) {
|
||||
logger.info('SYSTEM', 'Removed dead processes from supervisor registry', { removed });
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
register(id: string, processInfo: ManagedProcessInfo, processRef?: ChildProcess): void {
|
||||
this.initialize();
|
||||
this.entries.set(id, processInfo);
|
||||
if (processRef) {
|
||||
this.runtimeProcesses.set(id, processRef);
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
unregister(id: string): void {
|
||||
this.initialize();
|
||||
this.entries.delete(id);
|
||||
this.runtimeProcesses.delete(id);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.runtimeProcesses.clear();
|
||||
this.persist();
|
||||
}
|
||||
|
||||
getAll(): ManagedProcessRecord[] {
|
||||
this.initialize();
|
||||
return Array.from(this.entries.entries())
|
||||
.map(([id, info]) => ({ id, ...info }))
|
||||
.sort((a, b) => {
|
||||
const left = Date.parse(a.startedAt);
|
||||
const right = Date.parse(b.startedAt);
|
||||
return (Number.isNaN(left) ? 0 : left) - (Number.isNaN(right) ? 0 : right);
|
||||
});
|
||||
}
|
||||
|
||||
getBySession(sessionId: string | number): ManagedProcessRecord[] {
|
||||
const normalized = String(sessionId);
|
||||
return this.getAll().filter(record => record.sessionId !== undefined && String(record.sessionId) === normalized);
|
||||
}
|
||||
|
||||
getRuntimeProcess(id: string): ChildProcess | undefined {
|
||||
return this.runtimeProcesses.get(id);
|
||||
}
|
||||
|
||||
getByPid(pid: number): ManagedProcessRecord[] {
|
||||
return this.getAll().filter(record => record.pid === pid);
|
||||
}
|
||||
|
||||
pruneDeadEntries(): number {
|
||||
this.initialize();
|
||||
|
||||
let removed = 0;
|
||||
for (const [id, info] of this.entries) {
|
||||
if (isPidAlive(info.pid)) continue;
|
||||
this.entries.delete(id);
|
||||
this.runtimeProcesses.delete(id);
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill and unregister all processes tagged with the given sessionId.
|
||||
* Sends SIGTERM first, waits up to 5s, then SIGKILL for survivors.
|
||||
* Called when a session is deleted to prevent leaked child processes (#1351).
|
||||
*/
|
||||
async reapSession(sessionId: string | number): Promise<number> {
|
||||
this.initialize();
|
||||
|
||||
const sessionRecords = this.getBySession(sessionId);
|
||||
if (sessionRecords.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sessionIdNum = typeof sessionId === 'number' ? sessionId : Number(sessionId) || undefined;
|
||||
logger.info('SYSTEM', `Reaping ${sessionRecords.length} process(es) for session ${sessionId}`, {
|
||||
sessionId: sessionIdNum,
|
||||
pids: sessionRecords.map(r => r.pid)
|
||||
});
|
||||
|
||||
// Phase 1: SIGTERM all alive processes
|
||||
const aliveRecords = sessionRecords.filter(r => isPidAlive(r.pid));
|
||||
for (const record of aliveRecords) {
|
||||
try {
|
||||
process.kill(record.pid, 'SIGTERM');
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ESRCH') {
|
||||
logger.debug('SYSTEM', `Failed to SIGTERM session process PID ${record.pid}`, {
|
||||
pid: record.pid
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Wait for processes to exit
|
||||
const deadline = Date.now() + REAP_SESSION_SIGTERM_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const survivors = aliveRecords.filter(r => isPidAlive(r.pid));
|
||||
if (survivors.length === 0) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Phase 3: SIGKILL any survivors
|
||||
const survivors = aliveRecords.filter(r => isPidAlive(r.pid));
|
||||
for (const record of survivors) {
|
||||
logger.warn('SYSTEM', `Session process PID ${record.pid} did not exit after SIGTERM, sending SIGKILL`, {
|
||||
pid: record.pid,
|
||||
sessionId: sessionIdNum
|
||||
});
|
||||
try {
|
||||
process.kill(record.pid, 'SIGKILL');
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ESRCH') {
|
||||
logger.debug('SYSTEM', `Failed to SIGKILL session process PID ${record.pid}`, {
|
||||
pid: record.pid
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brief wait for SIGKILL to take effect
|
||||
if (survivors.length > 0) {
|
||||
const sigkillDeadline = Date.now() + REAP_SESSION_SIGKILL_TIMEOUT_MS;
|
||||
while (Date.now() < sigkillDeadline) {
|
||||
const remaining = survivors.filter(r => isPidAlive(r.pid));
|
||||
if (remaining.length === 0) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Unregister all session records
|
||||
for (const record of sessionRecords) {
|
||||
this.entries.delete(record.id);
|
||||
this.runtimeProcesses.delete(record.id);
|
||||
}
|
||||
this.persist();
|
||||
|
||||
logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, {
|
||||
sessionId: sessionIdNum,
|
||||
reaped: sessionRecords.length
|
||||
});
|
||||
|
||||
return sessionRecords.length;
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
const payload: PersistedRegistry = {
|
||||
processes: Object.fromEntries(this.entries.entries())
|
||||
};
|
||||
|
||||
mkdirSync(path.dirname(this.registryPath), { recursive: true });
|
||||
writeFileSync(this.registryPath, JSON.stringify(payload, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
let registrySingleton: ProcessRegistry | null = null;
|
||||
|
||||
export function getProcessRegistry(): ProcessRegistry {
|
||||
if (!registrySingleton) {
|
||||
registrySingleton = new ProcessRegistry();
|
||||
}
|
||||
return registrySingleton;
|
||||
}
|
||||
|
||||
export function createProcessRegistry(registryPath: string): ProcessRegistry {
|
||||
return new ProcessRegistry(registryPath);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
|
||||
import { isPidAlive, type ManagedProcessRecord, type ProcessRegistry } from './process-registry.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
type TreeKillFn = (pid: number, signal?: string, callback?: (error?: Error | null) => void) => void;
|
||||
|
||||
export interface ShutdownCascadeOptions {
|
||||
registry: ProcessRegistry;
|
||||
currentPid?: number;
|
||||
pidFilePath?: string;
|
||||
}
|
||||
|
||||
export async function runShutdownCascade(options: ShutdownCascadeOptions): Promise<void> {
|
||||
const currentPid = options.currentPid ?? process.pid;
|
||||
const pidFilePath = options.pidFilePath ?? PID_FILE;
|
||||
const allRecords = options.registry.getAll();
|
||||
const childRecords = [...allRecords]
|
||||
.filter(record => record.pid !== currentPid)
|
||||
.sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt));
|
||||
|
||||
for (const record of childRecords) {
|
||||
if (!isPidAlive(record.pid)) {
|
||||
options.registry.unregister(record.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await signalProcess(record.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
logger.debug('SYSTEM', 'Failed to send SIGTERM to child process', {
|
||||
pid: record.pid,
|
||||
type: record.type
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
await waitForExit(childRecords, 5000);
|
||||
|
||||
const survivors = childRecords.filter(record => isPidAlive(record.pid));
|
||||
for (const record of survivors) {
|
||||
try {
|
||||
await signalProcess(record.pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
logger.debug('SYSTEM', 'Failed to force kill child process', {
|
||||
pid: record.pid,
|
||||
type: record.type
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
await waitForExit(survivors, 1000);
|
||||
|
||||
for (const record of childRecords) {
|
||||
options.registry.unregister(record.id);
|
||||
}
|
||||
for (const record of allRecords.filter(record => record.pid === currentPid)) {
|
||||
options.registry.unregister(record.id);
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(pidFilePath, { force: true });
|
||||
} catch (error) {
|
||||
logger.debug('SYSTEM', 'Failed to remove PID file during shutdown', { pidFilePath }, error as Error);
|
||||
}
|
||||
|
||||
options.registry.pruneDeadEntries();
|
||||
}
|
||||
|
||||
async function waitForExit(records: ManagedProcessRecord[], timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const survivors = records.filter(record => isPidAlive(record.pid));
|
||||
if (survivors.length === 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
async function signalProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise<void> {
|
||||
if (signal === 'SIGTERM') {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
if (errno === 'ESRCH') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const treeKill = await loadTreeKill();
|
||||
if (treeKill) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
treeKill(pid, signal, (error) => {
|
||||
if (!error) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
if (errno === 'ESRCH') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ['/PID', String(pid), '/T'];
|
||||
if (signal === 'SIGKILL') {
|
||||
args.push('/F');
|
||||
}
|
||||
|
||||
await execFileAsync('taskkill', args, {
|
||||
timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND,
|
||||
windowsHide: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
if (errno === 'ESRCH') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTreeKill(): Promise<TreeKillFn | null> {
|
||||
const moduleName = 'tree-kill';
|
||||
|
||||
try {
|
||||
const treeKillModule = await import(moduleName);
|
||||
return (treeKillModule.default ?? treeKillModule) as TreeKillFn;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare module 'tree-kill' {
|
||||
export default function treeKill(
|
||||
pid: number,
|
||||
signal?: string,
|
||||
callback?: (error?: Error | null) => void
|
||||
): void;
|
||||
}
|
||||
+13
-16
@@ -25,29 +25,26 @@ export function App() {
|
||||
const { preference, resolvedTheme, setThemePreference } = useTheme();
|
||||
const pagination = usePagination(currentFilter);
|
||||
|
||||
// When filtering by project: ONLY use paginated data (API-filtered)
|
||||
// When showing all projects: merge SSE live data with paginated data
|
||||
// Merge SSE live data with paginated data, filtering by project when active
|
||||
const allObservations = useMemo(() => {
|
||||
if (currentFilter) {
|
||||
// Project filter active: API handles filtering, ignore SSE items
|
||||
return paginatedObservations;
|
||||
}
|
||||
// No filter: merge SSE + paginated, deduplicate by ID
|
||||
return mergeAndDeduplicateByProject(observations, paginatedObservations);
|
||||
const live = currentFilter
|
||||
? observations.filter(o => o.project === currentFilter)
|
||||
: observations;
|
||||
return mergeAndDeduplicateByProject(live, paginatedObservations);
|
||||
}, [observations, paginatedObservations, currentFilter]);
|
||||
|
||||
const allSummaries = useMemo(() => {
|
||||
if (currentFilter) {
|
||||
return paginatedSummaries;
|
||||
}
|
||||
return mergeAndDeduplicateByProject(summaries, paginatedSummaries);
|
||||
const live = currentFilter
|
||||
? summaries.filter(s => s.project === currentFilter)
|
||||
: summaries;
|
||||
return mergeAndDeduplicateByProject(live, paginatedSummaries);
|
||||
}, [summaries, paginatedSummaries, currentFilter]);
|
||||
|
||||
const allPrompts = useMemo(() => {
|
||||
if (currentFilter) {
|
||||
return paginatedPrompts;
|
||||
}
|
||||
return mergeAndDeduplicateByProject(prompts, paginatedPrompts);
|
||||
const live = currentFilter
|
||||
? prompts.filter(p => p.project === currentFilter)
|
||||
: prompts;
|
||||
return mergeAndDeduplicateByProject(live, paginatedPrompts);
|
||||
}, [prompts, paginatedPrompts, currentFilter]);
|
||||
|
||||
// Toggle context preview modal
|
||||
|
||||
@@ -54,62 +54,6 @@ function CollapsibleSection({
|
||||
);
|
||||
}
|
||||
|
||||
// Chip group with select all/none
|
||||
function ChipGroup({
|
||||
label,
|
||||
options,
|
||||
selectedValues,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onSelectNone
|
||||
}: {
|
||||
label: string;
|
||||
options: string[];
|
||||
selectedValues: string[];
|
||||
onToggle: (value: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
}) {
|
||||
const allSelected = options.every(opt => selectedValues.includes(opt));
|
||||
const noneSelected = options.every(opt => !selectedValues.includes(opt));
|
||||
|
||||
return (
|
||||
<div className="chip-group">
|
||||
<div className="chip-group-header">
|
||||
<span className="chip-group-label">{label}</span>
|
||||
<div className="chip-group-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`chip-action ${allSelected ? 'active' : ''}`}
|
||||
onClick={onSelectAll}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip-action ${noneSelected ? 'active' : ''}`}
|
||||
onClick={onSelectNone}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chips-container">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`chip ${selectedValues.includes(option) ? 'selected' : ''}`}
|
||||
onClick={() => onToggle(option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form field with optional tooltip
|
||||
function FormField({
|
||||
label,
|
||||
@@ -209,24 +153,6 @@ export function ContextSettingsModal({
|
||||
updateSetting(key, newValue);
|
||||
}, [formState, updateSetting]);
|
||||
|
||||
const toggleArrayValue = useCallback((key: keyof Settings, value: string) => {
|
||||
const currentValue = formState[key] || '';
|
||||
const currentArray = currentValue ? currentValue.split(',') : [];
|
||||
const newArray = currentArray.includes(value)
|
||||
? currentArray.filter(v => v !== value)
|
||||
: [...currentArray, value];
|
||||
updateSetting(key, newArray.join(','));
|
||||
}, [formState, updateSetting]);
|
||||
|
||||
const getArrayValues = useCallback((key: keyof Settings): string[] => {
|
||||
const currentValue = formState[key] || '';
|
||||
return currentValue ? currentValue.split(',') : [];
|
||||
}, [formState]);
|
||||
|
||||
const setAllArrayValues = useCallback((key: keyof Settings, values: string[]) => {
|
||||
updateSetting(key, values.join(','));
|
||||
}, [updateSetting]);
|
||||
|
||||
// Handle ESC key
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
@@ -240,9 +166,6 @@ export function ContextSettingsModal({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const observationTypes = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change'];
|
||||
const observationConcepts = ['how-it-works', 'why-it-exists', 'what-changed', 'problem-solution', 'gotcha', 'pattern', 'trade-off'];
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="context-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -322,30 +245,7 @@ export function ContextSettingsModal({
|
||||
</FormField>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Section 2: Filters */}
|
||||
<CollapsibleSection
|
||||
title="Filters"
|
||||
description="Which observation types to include"
|
||||
>
|
||||
<ChipGroup
|
||||
label="Type"
|
||||
options={observationTypes}
|
||||
selectedValues={getArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES')}
|
||||
onToggle={(value) => toggleArrayValue('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', value)}
|
||||
onSelectAll={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', observationTypes)}
|
||||
onSelectNone={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', [])}
|
||||
/>
|
||||
<ChipGroup
|
||||
label="Concept"
|
||||
options={observationConcepts}
|
||||
selectedValues={getArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS')}
|
||||
onToggle={(value) => toggleArrayValue('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', value)}
|
||||
onSelectAll={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', observationConcepts)}
|
||||
onSelectNone={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', [])}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Section 3: Display */}
|
||||
{/* Section 2: Display */}
|
||||
<CollapsibleSection
|
||||
title="Display"
|
||||
description="What to show in context tables"
|
||||
|
||||
@@ -18,18 +18,14 @@ export const DEFAULT_SETTINGS = {
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem',
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true',
|
||||
|
||||
// Token Economics (all true for backwards compatibility)
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'true',
|
||||
// Token Economics — match SettingsDefaultsManager defaults (off by default to keep context lean)
|
||||
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 (all types and concepts)
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: 'bugfix,feature,refactor,discovery,decision,change',
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: 'how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off',
|
||||
|
||||
// Display Configuration
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: '5',
|
||||
// Display Configuration — match SettingsDefaultsManager defaults
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: '0',
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
|
||||
CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10',
|
||||
|
||||
|
||||
@@ -14,42 +14,41 @@ export function useSettings() {
|
||||
fetch(API_ENDPOINTS.SETTINGS)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Use ?? (nullish coalescing) instead of || so that falsy values
|
||||
// like '0', 'false', and '' from the backend are preserved.
|
||||
// Using || would silently replace them with the UI defaults.
|
||||
setSettings({
|
||||
CLAUDE_MEM_MODEL: data.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL,
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: data.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS,
|
||||
CLAUDE_MEM_WORKER_PORT: data.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT,
|
||||
CLAUDE_MEM_WORKER_HOST: data.CLAUDE_MEM_WORKER_HOST || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_HOST,
|
||||
CLAUDE_MEM_MODEL: data.CLAUDE_MEM_MODEL ?? DEFAULT_SETTINGS.CLAUDE_MEM_MODEL,
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: data.CLAUDE_MEM_CONTEXT_OBSERVATIONS ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS,
|
||||
CLAUDE_MEM_WORKER_PORT: data.CLAUDE_MEM_WORKER_PORT ?? DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT,
|
||||
CLAUDE_MEM_WORKER_HOST: data.CLAUDE_MEM_WORKER_HOST ?? DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_HOST,
|
||||
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: data.CLAUDE_MEM_PROVIDER || DEFAULT_SETTINGS.CLAUDE_MEM_PROVIDER,
|
||||
CLAUDE_MEM_GEMINI_API_KEY: data.CLAUDE_MEM_GEMINI_API_KEY || DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_API_KEY,
|
||||
CLAUDE_MEM_GEMINI_MODEL: data.CLAUDE_MEM_GEMINI_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_MODEL,
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: data.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED || DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED,
|
||||
CLAUDE_MEM_PROVIDER: data.CLAUDE_MEM_PROVIDER ?? DEFAULT_SETTINGS.CLAUDE_MEM_PROVIDER,
|
||||
CLAUDE_MEM_GEMINI_API_KEY: data.CLAUDE_MEM_GEMINI_API_KEY ?? DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_API_KEY,
|
||||
CLAUDE_MEM_GEMINI_MODEL: data.CLAUDE_MEM_GEMINI_MODEL ?? DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_MODEL,
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: data.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED ?? DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED,
|
||||
|
||||
// OpenRouter Configuration
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: data.CLAUDE_MEM_OPENROUTER_API_KEY || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_API_KEY,
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: data.CLAUDE_MEM_OPENROUTER_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_MODEL,
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL: data.CLAUDE_MEM_OPENROUTER_SITE_URL || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_SITE_URL,
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: data.CLAUDE_MEM_OPENROUTER_APP_NAME || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_APP_NAME,
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: data.CLAUDE_MEM_OPENROUTER_API_KEY ?? DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_API_KEY,
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: data.CLAUDE_MEM_OPENROUTER_MODEL ?? DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_MODEL,
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL: data.CLAUDE_MEM_OPENROUTER_SITE_URL ?? DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_SITE_URL,
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: data.CLAUDE_MEM_OPENROUTER_APP_NAME ?? DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_APP_NAME,
|
||||
|
||||
// Token Economics Display
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: data.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: data.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: data.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: data.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT,
|
||||
|
||||
// Observation Filtering
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: data.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES,
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: data.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: data.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: data.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: data.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: data.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT,
|
||||
|
||||
// Display Configuration
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: data.CLAUDE_MEM_CONTEXT_FULL_COUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_COUNT,
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: data.CLAUDE_MEM_CONTEXT_FULL_FIELD || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_FIELD,
|
||||
CLAUDE_MEM_CONTEXT_SESSION_COUNT: data.CLAUDE_MEM_CONTEXT_SESSION_COUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SESSION_COUNT,
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: data.CLAUDE_MEM_CONTEXT_FULL_COUNT ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_COUNT,
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: data.CLAUDE_MEM_CONTEXT_FULL_FIELD ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_FIELD,
|
||||
CLAUDE_MEM_CONTEXT_SESSION_COUNT: data.CLAUDE_MEM_CONTEXT_SESSION_COUNT ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SESSION_COUNT,
|
||||
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: data.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: data.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: data.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY,
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: data.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE ?? DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -76,10 +76,6 @@ export interface Settings {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT?: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT?: string;
|
||||
|
||||
// Observation Filtering
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES?: string;
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS?: string;
|
||||
|
||||
// Display Configuration
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT?: string;
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD?: string;
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
|
||||
/**
|
||||
* Merge real-time SSE items with paginated items, removing duplicates by ID
|
||||
* NOTE: This should ONLY be used when no project filter is active.
|
||||
* When filtering, use ONLY paginated data (API-filtered).
|
||||
* Callers should pre-filter liveItems by project when a filter is active.
|
||||
*
|
||||
* @param liveItems - Items from SSE stream (unfiltered)
|
||||
* @param liveItems - Items from SSE stream (pre-filtered if needed)
|
||||
* @param paginatedItems - Items from pagination API
|
||||
* @returns Merged and deduplicated array
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,7 @@ import os from 'os';
|
||||
import { logger } from './logger.js';
|
||||
import { formatDate, groupByDate } from '../shared/timeline-formatting.js';
|
||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||
import { getWorkerHost } from '../shared/worker-utils.js';
|
||||
import { workerHttpRequest } from '../shared/worker-utils.js';
|
||||
|
||||
const SETTINGS_PATH = path.join(os.homedir(), '.claude-mem', 'settings.json');
|
||||
|
||||
@@ -321,12 +321,12 @@ function isExcludedFolder(folderPath: string, excludePaths: string[]): boolean {
|
||||
*
|
||||
* @param filePaths - Array of absolute file paths (modified or read)
|
||||
* @param project - Project identifier for API query
|
||||
* @param port - Worker API port
|
||||
* @param _port - Worker API port (legacy, now resolved automatically via socket/TCP)
|
||||
*/
|
||||
export async function updateFolderClaudeMdFiles(
|
||||
filePaths: string[],
|
||||
project: string,
|
||||
port: number,
|
||||
_port: number,
|
||||
projectRoot?: string
|
||||
): Promise<void> {
|
||||
// Load settings to get configurable observation limit and exclude list
|
||||
@@ -417,10 +417,9 @@ export async function updateFolderClaudeMdFiles(
|
||||
// Process each folder
|
||||
for (const folderPath of folderPaths) {
|
||||
try {
|
||||
// Fetch timeline via existing API
|
||||
const host = getWorkerHost();
|
||||
const response = await fetch(
|
||||
`http://${host}:${port}/api/search/by-file?filePath=${encodeURIComponent(folderPath)}&limit=${limit}&project=${encodeURIComponent(project)}&isFolder=true`
|
||||
// Fetch timeline via existing API (uses socket or TCP automatically)
|
||||
const response = await workerHttpRequest(
|
||||
`/api/search/by-file?filePath=${encodeURIComponent(folderPath)}&limit=${limit}&project=${encodeURIComponent(project)}&isFolder=true`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Tag Stripping Utilities
|
||||
*
|
||||
* Implements the dual-tag system for meta-observation control:
|
||||
* Implements the tag system for meta-observation control:
|
||||
* 1. <claude-mem-context> - System-level tag for auto-injected observations
|
||||
* (prevents recursive storage when context injection is active)
|
||||
* 2. <private> - User-level tag for manual privacy control
|
||||
* (allows users to mark content they don't want persisted)
|
||||
* 3. <system_instruction> / <system-instruction> - Conductor-injected system instructions
|
||||
* (should not be persisted to memory)
|
||||
*
|
||||
* EDGE PROCESSING PATTERN: Filter at hook layer before sending to worker/storage.
|
||||
* This keeps the worker service simple and follows one-way data stream.
|
||||
@@ -27,7 +29,9 @@ const MAX_TAG_COUNT = 100;
|
||||
function countTags(content: string): number {
|
||||
const privateCount = (content.match(/<private>/g) || []).length;
|
||||
const contextCount = (content.match(/<claude-mem-context>/g) || []).length;
|
||||
return privateCount + contextCount;
|
||||
const systemInstructionCount = (content.match(/<system_instruction>/g) || []).length;
|
||||
const systemInstructionHyphenCount = (content.match(/<system-instruction>/g) || []).length;
|
||||
return privateCount + contextCount + systemInstructionCount + systemInstructionHyphenCount;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +53,8 @@ function stripTagsInternal(content: string): string {
|
||||
return content
|
||||
.replace(/<claude-mem-context>[\s\S]*?<\/claude-mem-context>/g, '')
|
||||
.replace(/<private>[\s\S]*?<\/private>/g, '')
|
||||
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/g, '')
|
||||
.replace(/<system-instruction>[\s\S]*?<\/system-instruction>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -256,41 +256,74 @@ describe('Cursor IDE Compatibility (#838, #1049)', () => {
|
||||
// --- Platform Adapter Tests ---
|
||||
|
||||
describe('Hook Lifecycle - Claude Code Adapter', () => {
|
||||
it('should default suppressOutput to true when not explicitly set', async () => {
|
||||
const fmt = async (input: any) => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
return claudeCodeAdapter.formatOutput(input);
|
||||
};
|
||||
|
||||
// Result with no suppressOutput field
|
||||
const output = claudeCodeAdapter.formatOutput({ continue: true });
|
||||
expect(output).toEqual({ continue: true, suppressOutput: true });
|
||||
// --- Happy paths ---
|
||||
|
||||
it('should return empty object for empty result', async () => {
|
||||
expect(await fmt({})).toEqual({});
|
||||
});
|
||||
|
||||
it('should default both continue and suppressOutput to true for empty result', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
|
||||
const output = claudeCodeAdapter.formatOutput({});
|
||||
expect(output).toEqual({ continue: true, suppressOutput: true });
|
||||
it('should include systemMessage when present', async () => {
|
||||
expect(await fmt({ systemMessage: 'test message' })).toEqual({ systemMessage: 'test message' });
|
||||
});
|
||||
|
||||
it('should respect explicit suppressOutput: false', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
|
||||
const output = claudeCodeAdapter.formatOutput({ continue: true, suppressOutput: false });
|
||||
expect(output).toEqual({ continue: true, suppressOutput: false });
|
||||
});
|
||||
|
||||
it('should use hookSpecificOutput format for context injection', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
|
||||
const result = {
|
||||
it('should use hookSpecificOutput format with systemMessage', async () => {
|
||||
const output = await fmt({
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'test context' },
|
||||
systemMessage: 'test message'
|
||||
};
|
||||
const output = claudeCodeAdapter.formatOutput(result) as Record<string, unknown>;
|
||||
}) as Record<string, unknown>;
|
||||
expect(output.hookSpecificOutput).toEqual({ hookEventName: 'SessionStart', additionalContext: 'test context' });
|
||||
expect(output.systemMessage).toBe('test message');
|
||||
// Should NOT have continue/suppressOutput when using hookSpecificOutput
|
||||
expect(output.continue).toBeUndefined();
|
||||
expect(output.suppressOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return hookSpecificOutput without systemMessage when absent', async () => {
|
||||
expect(await fmt({
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'ctx' },
|
||||
})).toEqual({
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'ctx' },
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge cases / unhappy paths (addresses PR #1291 review) ---
|
||||
|
||||
it('should return empty object for malformed input (undefined/null)', async () => {
|
||||
expect(await fmt(undefined)).toEqual({});
|
||||
expect(await fmt(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('should exclude falsy systemMessage values', async () => {
|
||||
expect(await fmt({ systemMessage: '' })).toEqual({});
|
||||
expect(await fmt({ systemMessage: null })).toEqual({});
|
||||
expect(await fmt({ systemMessage: 0 })).toEqual({});
|
||||
});
|
||||
|
||||
it('should strip all non-contract fields', async () => {
|
||||
expect(await fmt({
|
||||
continue: false,
|
||||
suppressOutput: false,
|
||||
systemMessage: 'msg',
|
||||
exitCode: 2,
|
||||
hookSpecificOutput: undefined,
|
||||
})).toEqual({ systemMessage: 'msg' });
|
||||
});
|
||||
|
||||
it('should only emit keys from the Claude Code hook contract', async () => {
|
||||
const allowedKeys = new Set(['hookSpecificOutput', 'systemMessage', 'decision', 'reason']);
|
||||
const cases = [
|
||||
{},
|
||||
{ systemMessage: 'x' },
|
||||
{ continue: true, suppressOutput: true, systemMessage: 'x', exitCode: 1 },
|
||||
{ hookSpecificOutput: { hookEventName: 'E', additionalContext: 'C' }, systemMessage: 'x' },
|
||||
];
|
||||
for (const input of cases) {
|
||||
for (const key of Object.keys(await fmt(input) as object)) {
|
||||
expect(allowedKeys.has(key)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@ mock.module('../../src/shared/SettingsDefaultsManager.js', () => ({
|
||||
mock.module('../../src/shared/worker-utils.js', () => ({
|
||||
ensureWorkerRunning: () => Promise.resolve(true),
|
||||
getWorkerPort: () => 37777,
|
||||
workerHttpRequest: (apiPath: string, options?: any) => {
|
||||
// Delegate to global fetch so tests can mock fetch behavior
|
||||
const url = `http://127.0.0.1:37777${apiPath}`;
|
||||
return globalThis.fetch(url, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.headers,
|
||||
body: options?.body,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../src/utils/project-name.js', () => ({
|
||||
|
||||
@@ -59,7 +59,11 @@ describe('HealthMonitor', () => {
|
||||
|
||||
describe('waitForHealth', () => {
|
||||
it('should succeed immediately when server responds', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForHealth(37777, 5000);
|
||||
@@ -91,7 +95,11 @@ describe('HealthMonitor', () => {
|
||||
if (callCount < 3) {
|
||||
return Promise.reject(new Error('ECONNREFUSED'));
|
||||
}
|
||||
return Promise.resolve({ ok: true } as Response);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
const result = await waitForHealth(37777, 5000);
|
||||
@@ -101,7 +109,11 @@ describe('HealthMonitor', () => {
|
||||
});
|
||||
|
||||
it('should check health endpoint for liveness', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
const fetchMock = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await waitForHealth(37777, 1000);
|
||||
@@ -115,7 +127,11 @@ describe('HealthMonitor', () => {
|
||||
});
|
||||
|
||||
it('should use default timeout when not specified', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
|
||||
// Just verify it doesn't throw and returns quickly
|
||||
const result = await waitForHealth(37777);
|
||||
@@ -154,8 +170,9 @@ describe('HealthMonitor', () => {
|
||||
it('should detect version mismatch', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ version: '0.0.0-definitely-wrong' })
|
||||
} as Response));
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ version: '0.0.0-definitely-wrong' }))
|
||||
} as unknown as Response));
|
||||
|
||||
const result = await checkVersionMatch(37777);
|
||||
|
||||
@@ -172,8 +189,9 @@ describe('HealthMonitor', () => {
|
||||
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ version: pluginVersion })
|
||||
} as Response));
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ version: pluginVersion }))
|
||||
} as unknown as Response));
|
||||
|
||||
const result = await checkVersionMatch(37777);
|
||||
|
||||
|
||||
@@ -67,11 +67,14 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
|
||||
expect(parsed.hooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
|
||||
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands (except inline hooks)', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
// SessionEnd uses a lightweight inline node -e command (no plugin root needed)
|
||||
const inlineHookEvents = new Set(['SessionEnd']);
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
if (inlineHookEvents.has(eventName)) continue;
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
@@ -82,12 +85,14 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
|
||||
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands except inline hooks (#1215)', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
|
||||
const inlineHookEvents = new Set(['SessionEnd']);
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
if (inlineHookEvents.has(eventName)) continue;
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
@@ -97,6 +102,18 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should use lightweight inline node command for SessionEnd hook', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
const sessionEndHooks = parsed.hooks.SessionEnd;
|
||||
expect(sessionEndHooks).toBeDefined();
|
||||
expect(sessionEndHooks.length).toBe(1);
|
||||
const command = sessionEndHooks[0].hooks[0].command;
|
||||
expect(command).toContain('node -e');
|
||||
expect(command).toContain('/api/sessions/complete');
|
||||
expect(sessionEndHooks[0].hooks[0].timeout).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - package.json Files Field', () => {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Tests for malformed schema repair in Database.ts
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with temp file — tests actual schema repair logic
|
||||
* - Uses Python sqlite3 to simulate cross-version schema corruption
|
||||
* (bun:sqlite doesn't allow writable_schema modifications)
|
||||
* - Covers the cross-machine sync scenario from issue #1307
|
||||
*
|
||||
* Value: Prevents the silent 503 failure loop when a DB is synced between
|
||||
* machines running different claude-mem versions
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
import { MigrationRunner } from '../../../src/services/sqlite/migrations/runner.js';
|
||||
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
|
||||
function tempDbPath(): string {
|
||||
return join(tmpdir(), `claude-mem-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
}
|
||||
|
||||
function cleanup(path: string): void {
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const p = path + suffix;
|
||||
if (existsSync(p)) unlinkSync(p);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPython(): boolean {
|
||||
try {
|
||||
execSync('python3 --version', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Python's sqlite3 to corrupt a DB by removing the content_hash column
|
||||
* from the observations table definition while leaving the index intact.
|
||||
* This simulates what happens when a DB from a newer version is synced.
|
||||
*/
|
||||
function corruptDbViaPython(dbPath: string): void {
|
||||
const script = join(tmpdir(), `corrupt-${Date.now()}.py`);
|
||||
writeFileSync(script, `
|
||||
import sqlite3, re, sys
|
||||
c = sqlite3.connect(sys.argv[1])
|
||||
c.execute("PRAGMA writable_schema = ON")
|
||||
row = c.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='observations'").fetchone()
|
||||
if row:
|
||||
new_sql = re.sub(r',\\s*content_hash\\s+TEXT', '', row[0])
|
||||
c.execute("UPDATE sqlite_master SET sql = ? WHERE type='table' AND name='observations'", (new_sql,))
|
||||
c.execute("PRAGMA writable_schema = OFF")
|
||||
c.commit()
|
||||
c.close()
|
||||
`);
|
||||
try {
|
||||
execSync(`python3 "${script}" "${dbPath}"`, { timeout: 10000 });
|
||||
} finally {
|
||||
if (existsSync(script)) unlinkSync(script);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Schema repair on malformed database', () => {
|
||||
it('should repair a database with an orphaned index referencing a non-existent column', () => {
|
||||
if (!hasPython()) {
|
||||
console.log('Python3 not available, skipping test');
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
// Step 1: Create a valid database with all migrations
|
||||
const db = new Database(dbPath, { create: true, readwrite: true });
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Verify content_hash column and index exist
|
||||
const hasContentHash = db.prepare('PRAGMA table_info(observations)').all()
|
||||
.some((col: any) => col.name === 'content_hash');
|
||||
expect(hasContentHash).toBe(true);
|
||||
|
||||
// Checkpoint WAL so all data is in the main file
|
||||
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.close();
|
||||
|
||||
// Step 2: Corrupt the DB
|
||||
corruptDbViaPython(dbPath);
|
||||
|
||||
// Step 3: Verify the DB is actually corrupted
|
||||
const corruptDb = new Database(dbPath, { readwrite: true });
|
||||
let threw = false;
|
||||
try {
|
||||
corruptDb.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
||||
} catch (e: any) {
|
||||
threw = true;
|
||||
expect(e.message).toContain('malformed database schema');
|
||||
expect(e.message).toContain('idx_observations_content_hash');
|
||||
}
|
||||
corruptDb.close();
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// Step 4: Open via ClaudeMemDatabase — it should auto-repair
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
|
||||
// Verify the DB is functional
|
||||
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('observations');
|
||||
expect(tableNames).toContain('sdk_sessions');
|
||||
|
||||
// Verify the index was recreated by the migration runner
|
||||
const indexes = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_observations_content_hash'")
|
||||
.all() as { name: string }[];
|
||||
expect(indexes.length).toBe(1);
|
||||
|
||||
// Verify the content_hash column was re-added by the migration
|
||||
const columns = repaired.db.prepare('PRAGMA table_info(observations)').all() as { name: string }[];
|
||||
expect(columns.some(c => c.name === 'content_hash')).toBe(true);
|
||||
|
||||
repaired.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle a fresh database without triggering repair', () => {
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
const db = new ClaudeMemDatabase(dbPath);
|
||||
const tables = db.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
expect(tables.length).toBeGreaterThan(0);
|
||||
db.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should repair a corrupted DB that has no schema_versions table', () => {
|
||||
if (!hasPython()) {
|
||||
console.log('Python3 not available, skipping test');
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
const scriptPath = join(tmpdir(), `corrupt-nosv-${Date.now()}.py`);
|
||||
try {
|
||||
// Build a minimal DB with only a malformed observations table and orphaned index
|
||||
// — no schema_versions table. This simulates a partially-initialized DB that was
|
||||
// synced before migrations ever ran.
|
||||
writeFileSync(scriptPath, `
|
||||
import sqlite3, sys
|
||||
c = sqlite3.connect(sys.argv[1])
|
||||
c.execute('PRAGMA writable_schema = ON')
|
||||
# Inject an orphaned index into sqlite_master without any backing table.
|
||||
# This simulates a partially-synced DB where index metadata arrived but
|
||||
# the table schema is incomplete or missing columns.
|
||||
idx_sql = 'CREATE INDEX idx_observations_content_hash ON observations(content_hash, created_at_epoch)'
|
||||
c.execute(
|
||||
"INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql) VALUES ('index', 'idx_observations_content_hash', 'observations', 0, ?)",
|
||||
(idx_sql,)
|
||||
)
|
||||
c.execute('PRAGMA writable_schema = OFF')
|
||||
c.commit()
|
||||
c.close()
|
||||
`);
|
||||
execFileSync('python3', [scriptPath, dbPath], { timeout: 10000 });
|
||||
|
||||
// Verify it's corrupted
|
||||
const corruptDb = new Database(dbPath, { readwrite: true });
|
||||
let threw = false;
|
||||
try {
|
||||
corruptDb.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
||||
} catch (e: any) {
|
||||
threw = true;
|
||||
expect(e.message).toContain('malformed database schema');
|
||||
}
|
||||
corruptDb.close();
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// ClaudeMemDatabase must repair and fully initialize despite missing schema_versions
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('schema_versions');
|
||||
expect(tableNames).toContain('observations');
|
||||
expect(tableNames).toContain('sdk_sessions');
|
||||
repaired.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
if (existsSync(scriptPath)) unlinkSync(scriptPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve existing data through repair and re-migration', () => {
|
||||
if (!hasPython()) {
|
||||
console.log('Python3 not available, skipping test');
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
// Step 1: Create a fully migrated DB and insert a session + observation
|
||||
const db = new Database(dbPath, { create: true, readwrite: true });
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const epoch = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('test-content-1', 'test-memory-1', 'test-project', now, epoch, 'active');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO observations (memory_session_id, project, type, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('test-memory-1', 'test-project', 'discovery', now, epoch);
|
||||
|
||||
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.close();
|
||||
|
||||
// Step 2: Corrupt the DB
|
||||
corruptDbViaPython(dbPath);
|
||||
|
||||
// Step 3: Repair via ClaudeMemDatabase
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
|
||||
// Data must survive the repair + re-migration
|
||||
const sessions = repaired.db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
|
||||
const observations = repaired.db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
||||
expect(sessions.count).toBe(1);
|
||||
expect(observations.count).toBe(1);
|
||||
|
||||
repaired.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Regression tests for ChromaMcpManager SSL flag handling (PR #1286)
|
||||
*
|
||||
* Validates that buildCommandArgs() always emits the correct `--ssl` flag
|
||||
* based on CLAUDE_MEM_CHROMA_SSL, and omits it entirely in local mode.
|
||||
*
|
||||
* Strategy: mock StdioClientTransport to capture the spawned args without
|
||||
* actually launching a subprocess, then inspect the captured args array.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
||||
|
||||
// ── Mutable settings closure (updated per test) ────────────────────────
|
||||
let currentSettings: Record<string, string> = {};
|
||||
|
||||
// ── Mock modules BEFORE importing the module under test ────────────────
|
||||
// Capture the args passed to StdioClientTransport constructor
|
||||
let capturedTransportOpts: { command: string; args: string[] } | null = null;
|
||||
|
||||
mock.module('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: class FakeTransport {
|
||||
// Required: ChromaMcpManager assigns transport.onclose after connect()
|
||||
onclose: (() => void) | null = null;
|
||||
constructor(opts: { command: string; args: string[] }) {
|
||||
capturedTransportOpts = { command: opts.command, args: opts.args };
|
||||
}
|
||||
async close() {}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: class FakeClient {
|
||||
constructor() {}
|
||||
async connect() {}
|
||||
async callTool() {
|
||||
return { content: [{ type: 'text', text: '{}' }] };
|
||||
}
|
||||
async close() {}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/shared/SettingsDefaultsManager.js', () => ({
|
||||
SettingsDefaultsManager: {
|
||||
get: (key: string) => currentSettings[key] ?? '',
|
||||
getInt: () => 0,
|
||||
loadFromFile: () => currentSettings,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/shared/paths.js', () => ({
|
||||
USER_SETTINGS_PATH: '/tmp/fake-settings.json',
|
||||
}));
|
||||
|
||||
mock.module('../../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
failure: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Now import the module under test ───────────────────────────────────
|
||||
import { ChromaMcpManager } from '../../../src/services/sync/ChromaMcpManager.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
async function assertSslFlag(sslSetting: string | undefined, expectedValue: string) {
|
||||
currentSettings = { CLAUDE_MEM_CHROMA_MODE: 'remote' };
|
||||
if (sslSetting !== undefined) currentSettings.CLAUDE_MEM_CHROMA_SSL = sslSetting;
|
||||
|
||||
await mgr.callTool('chroma_list_collections', {});
|
||||
|
||||
expect(capturedTransportOpts).not.toBeNull();
|
||||
const sslIdx = capturedTransportOpts!.args.indexOf('--ssl');
|
||||
expect(sslIdx).not.toBe(-1);
|
||||
expect(capturedTransportOpts!.args[sslIdx + 1]).toBe(expectedValue);
|
||||
}
|
||||
|
||||
let mgr: ChromaMcpManager;
|
||||
|
||||
// ── Test suite ─────────────────────────────────────────────────────────
|
||||
describe('ChromaMcpManager SSL flag regression (#1286)', () => {
|
||||
beforeEach(async () => {
|
||||
await ChromaMcpManager.reset();
|
||||
capturedTransportOpts = null;
|
||||
currentSettings = {};
|
||||
mgr = ChromaMcpManager.getInstance();
|
||||
});
|
||||
|
||||
it('emits --ssl false when CLAUDE_MEM_CHROMA_SSL=false', async () => {
|
||||
await assertSslFlag('false', 'false');
|
||||
});
|
||||
|
||||
it('emits --ssl true when CLAUDE_MEM_CHROMA_SSL=true', async () => {
|
||||
await assertSslFlag('true', 'true');
|
||||
});
|
||||
|
||||
it('defaults --ssl false when CLAUDE_MEM_CHROMA_SSL is not set', async () => {
|
||||
await assertSslFlag(undefined, 'false');
|
||||
});
|
||||
|
||||
it('omits --ssl entirely in local mode', async () => {
|
||||
currentSettings = {
|
||||
CLAUDE_MEM_CHROMA_MODE: 'local',
|
||||
};
|
||||
|
||||
await mgr.callTool('chroma_list_collections', {});
|
||||
|
||||
expect(capturedTransportOpts).not.toBeNull();
|
||||
const args = capturedTransportOpts!.args;
|
||||
expect(args).not.toContain('--ssl');
|
||||
expect(args).toContain('--client-type');
|
||||
expect(args[args.indexOf('--client-type') + 1]).toBe('persistent');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Smart Install Script Tests
|
||||
@@ -163,3 +164,76 @@ describe('smart-install verifyCriticalModules logic', () => {
|
||||
expect(missing).toEqual(['@chroma-core/other-pkg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smart-install stdout JSON output (#1253)', () => {
|
||||
const SCRIPT_PATH = join(__dirname, '..', 'plugin', 'scripts', 'smart-install.js');
|
||||
|
||||
it('should not have any execSync with stdio: inherit (prevents stdout leak)', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// stdio: 'inherit' would leak non-JSON output to stdout, breaking Claude Code hooks
|
||||
expect(content).not.toContain("stdio: 'inherit'");
|
||||
expect(content).not.toContain('stdio: "inherit"');
|
||||
});
|
||||
|
||||
it('should output valid JSON to stdout on success path', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// The script must print JSON to stdout for the Claude Code hook contract
|
||||
expect(content).toContain('console.log(JSON.stringify(');
|
||||
expect(content).toContain('continue');
|
||||
expect(content).toContain('suppressOutput');
|
||||
});
|
||||
|
||||
it('should output valid JSON to stdout even in error catch block', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// Find the catch block and verify it also outputs JSON
|
||||
const catchIndex = content.lastIndexOf('catch (e)');
|
||||
expect(catchIndex).toBeGreaterThan(0);
|
||||
const catchBlock = content.slice(catchIndex, catchIndex + 300);
|
||||
expect(catchBlock).toContain('console.log(JSON.stringify(');
|
||||
});
|
||||
|
||||
it('should use piped stdout for all execSync calls', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// All execSync calls should pipe stdout to prevent leaking to the hook output.
|
||||
// Match execSync calls that have a stdio option — they should all use array form.
|
||||
// All execSync calls should either use 'ignore', array form, or the installStdio variable
|
||||
// — never bare 'inherit' which leaks non-JSON output to stdout
|
||||
expect(content).not.toContain("stdio: 'inherit'");
|
||||
expect(content).not.toContain('stdio: "inherit"');
|
||||
// Verify the installStdio variable is defined with the correct pipe config
|
||||
expect(content).toContain("const installStdio = ['pipe', 'pipe', 'inherit']");
|
||||
});
|
||||
|
||||
it('should produce valid JSON when run with plugin disabled', () => {
|
||||
// Run the actual script with the plugin forcefully disabled via settings
|
||||
// This exercises the early exit path
|
||||
const settingsDir = join(tmpdir(), `claude-mem-test-settings-${process.pid}`);
|
||||
const settingsFile = join(settingsDir, 'settings.json');
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
writeFileSync(settingsFile, JSON.stringify({
|
||||
enabledPlugins: { 'claude-mem@thedotmack': false }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = spawnSync('node', [SCRIPT_PATH], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: settingsDir,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// When plugin is disabled, script exits with 0 and produces no stdout
|
||||
// (the early exit at line 31-33 calls process.exit(0) before any output)
|
||||
expect(result.status).toBe(0);
|
||||
// stdout should be empty or valid JSON (not plain text install messages)
|
||||
const stdout = (result.stdout || '').trim();
|
||||
if (stdout.length > 0) {
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
}
|
||||
} finally {
|
||||
rmSync(settingsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { sanitizeEnv } from '../../src/supervisor/env-sanitizer.js';
|
||||
|
||||
describe('sanitizeEnv', () => {
|
||||
it('strips variables with CLAUDECODE_ prefix', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDECODE_FOO: 'bar',
|
||||
CLAUDECODE_SOMETHING: 'value',
|
||||
PATH: '/usr/bin'
|
||||
});
|
||||
|
||||
expect(result.CLAUDECODE_FOO).toBeUndefined();
|
||||
expect(result.CLAUDECODE_SOMETHING).toBeUndefined();
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('strips variables with CLAUDE_CODE_ prefix but preserves allowed ones', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDE_CODE_BAR: 'baz',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token',
|
||||
HOME: '/home/user'
|
||||
});
|
||||
|
||||
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('token');
|
||||
expect(result.HOME).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('strips exact-match variables (CLAUDECODE, CLAUDE_CODE_SESSION, CLAUDE_CODE_ENTRYPOINT, MCP_SESSION_ID)', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDECODE: '1',
|
||||
CLAUDE_CODE_SESSION: 'session-123',
|
||||
CLAUDE_CODE_ENTRYPOINT: 'hook',
|
||||
MCP_SESSION_ID: 'mcp-abc',
|
||||
NODE_PATH: '/usr/local/lib'
|
||||
});
|
||||
|
||||
expect(result.CLAUDECODE).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_SESSION).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
expect(result.MCP_SESSION_ID).toBeUndefined();
|
||||
expect(result.NODE_PATH).toBe('/usr/local/lib');
|
||||
});
|
||||
|
||||
it('preserves allowed variables like PATH, HOME, NODE_PATH', () => {
|
||||
const result = sanitizeEnv({
|
||||
PATH: '/usr/bin:/usr/local/bin',
|
||||
HOME: '/home/user',
|
||||
NODE_PATH: '/usr/local/lib/node_modules',
|
||||
SHELL: '/bin/zsh',
|
||||
USER: 'developer',
|
||||
LANG: 'en_US.UTF-8'
|
||||
});
|
||||
|
||||
expect(result.PATH).toBe('/usr/bin:/usr/local/bin');
|
||||
expect(result.HOME).toBe('/home/user');
|
||||
expect(result.NODE_PATH).toBe('/usr/local/lib/node_modules');
|
||||
expect(result.SHELL).toBe('/bin/zsh');
|
||||
expect(result.USER).toBe('developer');
|
||||
expect(result.LANG).toBe('en_US.UTF-8');
|
||||
});
|
||||
|
||||
it('returns a new object and does not mutate the original', () => {
|
||||
const original: NodeJS.ProcessEnv = {
|
||||
PATH: '/usr/bin',
|
||||
CLAUDECODE_FOO: 'bar',
|
||||
KEEP: 'yes'
|
||||
};
|
||||
const originalCopy = { ...original };
|
||||
|
||||
const result = sanitizeEnv(original);
|
||||
|
||||
// Result should be a different object
|
||||
expect(result).not.toBe(original);
|
||||
|
||||
// Original should be unchanged
|
||||
expect(original).toEqual(originalCopy);
|
||||
|
||||
// Result should not contain stripped vars
|
||||
expect(result.CLAUDECODE_FOO).toBeUndefined();
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('handles empty env gracefully', () => {
|
||||
const result = sanitizeEnv({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('skips entries with undefined values', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
DEFINED: 'value',
|
||||
UNDEFINED_KEY: undefined
|
||||
};
|
||||
|
||||
const result = sanitizeEnv(env);
|
||||
expect(result.DEFINED).toBe('value');
|
||||
expect('UNDEFINED_KEY' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('combines prefix and exact match removal in a single pass', () => {
|
||||
const result = sanitizeEnv({
|
||||
PATH: '/usr/bin',
|
||||
CLAUDECODE: '1',
|
||||
CLAUDECODE_FOO: 'bar',
|
||||
CLAUDE_CODE_BAR: 'baz',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token',
|
||||
CLAUDE_CODE_SESSION: 'session',
|
||||
CLAUDE_CODE_ENTRYPOINT: 'entry',
|
||||
MCP_SESSION_ID: 'mcp',
|
||||
KEEP_ME: 'yes'
|
||||
});
|
||||
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
expect(result.KEEP_ME).toBe('yes');
|
||||
expect(result.CLAUDECODE).toBeUndefined();
|
||||
expect(result.CLAUDECODE_FOO).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
|
||||
expect(result.CLAUDE_CODE_SESSION).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
expect(result.MCP_SESSION_ID).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves CLAUDE_CODE_GIT_BASH_PATH through sanitization', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDE_CODE_GIT_BASH_PATH: 'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user'
|
||||
});
|
||||
|
||||
expect(result.CLAUDE_CODE_GIT_BASH_PATH).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
expect(result.HOME).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('selectively preserves only allowed CLAUDE_CODE_* vars while stripping others', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'my-oauth-token',
|
||||
CLAUDE_CODE_GIT_BASH_PATH: '/usr/bin/bash',
|
||||
CLAUDE_CODE_RANDOM_OTHER: 'should-be-stripped',
|
||||
CLAUDE_CODE_INTERNAL_FLAG: 'should-be-stripped',
|
||||
PATH: '/usr/bin'
|
||||
});
|
||||
|
||||
// Preserved: explicitly allowed CLAUDE_CODE_* vars
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('my-oauth-token');
|
||||
expect(result.CLAUDE_CODE_GIT_BASH_PATH).toBe('/usr/bin/bash');
|
||||
|
||||
// Stripped: all other CLAUDE_CODE_* vars
|
||||
expect(result.CLAUDE_CODE_RANDOM_OTHER).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_INTERNAL_FLAG).toBeUndefined();
|
||||
|
||||
// Preserved: normal env vars
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { startHealthChecker, stopHealthChecker } from '../../src/supervisor/health-checker.js';
|
||||
|
||||
describe('health-checker', () => {
|
||||
afterEach(() => {
|
||||
// Always stop the checker to avoid leaking intervals between tests
|
||||
stopHealthChecker();
|
||||
});
|
||||
|
||||
it('startHealthChecker sets up an interval without throwing', () => {
|
||||
expect(() => startHealthChecker()).not.toThrow();
|
||||
});
|
||||
|
||||
it('stopHealthChecker clears the interval without throwing', () => {
|
||||
startHealthChecker();
|
||||
expect(() => stopHealthChecker()).not.toThrow();
|
||||
});
|
||||
|
||||
it('stopHealthChecker is safe to call when no checker is running', () => {
|
||||
expect(() => stopHealthChecker()).not.toThrow();
|
||||
});
|
||||
|
||||
it('multiple startHealthChecker calls do not create multiple intervals', () => {
|
||||
// Track setInterval calls
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
let setIntervalCallCount = 0;
|
||||
|
||||
globalThis.setInterval = ((...args: Parameters<typeof setInterval>) => {
|
||||
setIntervalCallCount++;
|
||||
return originalSetInterval(...args);
|
||||
}) as typeof setInterval;
|
||||
|
||||
try {
|
||||
// Stop any existing checker first to ensure clean state
|
||||
stopHealthChecker();
|
||||
setIntervalCallCount = 0;
|
||||
|
||||
startHealthChecker();
|
||||
startHealthChecker();
|
||||
startHealthChecker();
|
||||
|
||||
// Only one interval should have been created due to the guard
|
||||
expect(setIntervalCallCount).toBe(1);
|
||||
} finally {
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
}
|
||||
});
|
||||
|
||||
it('stopHealthChecker after start allows restarting', () => {
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
let setIntervalCallCount = 0;
|
||||
|
||||
globalThis.setInterval = ((...args: Parameters<typeof setInterval>) => {
|
||||
setIntervalCallCount++;
|
||||
return originalSetInterval(...args);
|
||||
}) as typeof setInterval;
|
||||
|
||||
try {
|
||||
stopHealthChecker();
|
||||
setIntervalCallCount = 0;
|
||||
|
||||
startHealthChecker();
|
||||
expect(setIntervalCallCount).toBe(1);
|
||||
|
||||
stopHealthChecker();
|
||||
|
||||
startHealthChecker();
|
||||
expect(setIntervalCallCount).toBe(2);
|
||||
} finally {
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import { validateWorkerPidFile, type ValidateWorkerPidStatus } from '../../src/supervisor/index.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = path.join(tmpdir(), `claude-mem-index-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('validateWorkerPidFile', () => {
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns "missing" when PID file does not exist', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('missing');
|
||||
});
|
||||
|
||||
it('returns "invalid" when PID file contains bad JSON', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
writeFileSync(pidFilePath, 'not-json!!!');
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('invalid');
|
||||
});
|
||||
|
||||
it('returns "stale" when PID file references a dead process', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
writeFileSync(pidFilePath, JSON.stringify({
|
||||
pid: 2147483647,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('stale');
|
||||
});
|
||||
|
||||
it('returns "alive" when PID file references the current process', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
writeFileSync(pidFilePath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('alive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supervisor assertCanSpawn behavior', () => {
|
||||
it('assertCanSpawn throws when stopPromise is active (shutdown in progress)', () => {
|
||||
const { getSupervisor } = require('../../src/supervisor/index.js');
|
||||
const supervisor = getSupervisor();
|
||||
|
||||
// When not shutting down, assertCanSpawn should not throw
|
||||
expect(() => supervisor.assertCanSpawn('test')).not.toThrow();
|
||||
});
|
||||
|
||||
it('registerProcess and unregisterProcess delegate to the registry', () => {
|
||||
const { getSupervisor } = require('../../src/supervisor/index.js');
|
||||
const supervisor = getSupervisor();
|
||||
const registry = supervisor.getRegistry();
|
||||
|
||||
const testId = `test-${Date.now()}`;
|
||||
supervisor.registerProcess(testId, {
|
||||
pid: process.pid,
|
||||
type: 'test',
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const found = registry.getAll().find((r: { id: string }) => r.id === testId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.type).toBe('test');
|
||||
|
||||
supervisor.unregisterProcess(testId);
|
||||
const afterUnregister = registry.getAll().find((r: { id: string }) => r.id === testId);
|
||||
expect(afterUnregister).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supervisor start idempotency', () => {
|
||||
it('getSupervisor returns the same instance', () => {
|
||||
const { getSupervisor } = require('../../src/supervisor/index.js');
|
||||
const s1 = getSupervisor();
|
||||
const s2 = getSupervisor();
|
||||
expect(s1).toBe(s2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import { createProcessRegistry, isPidAlive } from '../../src/supervisor/process-registry.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return path.join(tmpdir(), `claude-mem-supervisor-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('supervisor ProcessRegistry', () => {
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('isPidAlive', () => {
|
||||
it('treats current process as alive', () => {
|
||||
expect(isPidAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an impossibly high PID as dead', () => {
|
||||
expect(isPidAlive(2147483647)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats negative PID as dead', () => {
|
||||
expect(isPidAlive(-1)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats non-integer PID as dead', () => {
|
||||
expect(isPidAlive(3.14)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('persists entries to disk and reloads them on initialize', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
|
||||
// Create a registry, register an entry, and let it persist
|
||||
const registry1 = createProcessRegistry(registryPath);
|
||||
registry1.register('worker:1', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// Verify file exists on disk
|
||||
expect(existsSync(registryPath)).toBe(true);
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(diskData.processes['worker:1']).toBeDefined();
|
||||
|
||||
// Create a second registry from the same path — it should load the persisted entry
|
||||
const registry2 = createProcessRegistry(registryPath);
|
||||
registry2.initialize();
|
||||
const records = registry2.getAll();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('worker:1');
|
||||
expect(records[0]?.pid).toBe(process.pid);
|
||||
});
|
||||
|
||||
it('prunes dead processes on initialize', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
|
||||
writeFileSync(registryPath, JSON.stringify({
|
||||
processes: {
|
||||
alive: {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
},
|
||||
dead: {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
registry.initialize();
|
||||
|
||||
const records = registry.getAll();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('alive');
|
||||
expect(existsSync(registryPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles corrupted registry file gracefully', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
|
||||
writeFileSync(registryPath, '{ not valid json!!!');
|
||||
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
registry.initialize();
|
||||
|
||||
// Should recover with an empty registry
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register and unregister', () => {
|
||||
it('register adds an entry retrievable by getAll', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
const records = registry.getAll();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('sdk:1');
|
||||
expect(records[0]?.type).toBe('sdk');
|
||||
});
|
||||
|
||||
it('unregister removes an entry', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
|
||||
registry.unregister('sdk:1');
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unregister is a no-op for unknown IDs', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
registry.unregister('nonexistent');
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('returns records sorted by startedAt ascending', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('newest', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
registry.register('oldest', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('middle', {
|
||||
pid: process.pid,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
const records = registry.getAll();
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0]?.id).toBe('oldest');
|
||||
expect(records[1]?.id).toBe('middle');
|
||||
expect(records[2]?.id).toBe('newest');
|
||||
});
|
||||
|
||||
it('returns empty array when no entries exist', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
expect(registry.getAll()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBySession', () => {
|
||||
it('filters records by session id', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 42,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('sdk:2', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 'other',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
const records = registry.getBySession(42);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('sdk:1');
|
||||
});
|
||||
|
||||
it('returns empty array when no processes match the session', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 42,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
expect(registry.getBySession(999)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches string and numeric session IDs by string comparison', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: '42',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// Querying with number should find string "42"
|
||||
expect(registry.getBySession(42)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneDeadEntries', () => {
|
||||
it('removes entries with dead PIDs and preserves live ones', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('alive', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('dead', {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
const removed = registry.pruneDeadEntries();
|
||||
expect(removed).toBe(1);
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
expect(registry.getAll()[0]?.id).toBe('alive');
|
||||
});
|
||||
|
||||
it('returns 0 when all entries are alive', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('alive', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
const removed = registry.pruneDeadEntries();
|
||||
expect(removed).toBe(0);
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('persists changes to disk after pruning', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('dead', {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
registry.pruneDeadEntries();
|
||||
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(diskData.processes)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('sdk:2', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
|
||||
registry.clear();
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
|
||||
// Verify persisted to disk
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(diskData.processes)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProcessRegistry', () => {
|
||||
it('creates an isolated instance with a custom path', () => {
|
||||
const tempDir1 = makeTempDir();
|
||||
const tempDir2 = makeTempDir();
|
||||
tempDirs.push(tempDir1, tempDir2);
|
||||
|
||||
const registry1 = createProcessRegistry(path.join(tempDir1, 'supervisor.json'));
|
||||
const registry2 = createProcessRegistry(path.join(tempDir2, 'supervisor.json'));
|
||||
|
||||
registry1.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// registry2 should be independent
|
||||
expect(registry1.getAll()).toHaveLength(1);
|
||||
expect(registry2.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reapSession', () => {
|
||||
it('unregisters dead processes for the given session', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:99:50001', {
|
||||
pid: 2147483640,
|
||||
type: 'sdk',
|
||||
sessionId: 99,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('mcp:99:50002', {
|
||||
pid: 2147483641,
|
||||
type: 'mcp',
|
||||
sessionId: 99,
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
// Register a process for a different session (should survive)
|
||||
registry.register('sdk:100:50003', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 100,
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
|
||||
const reaped = await registry.reapSession(99);
|
||||
expect(reaped).toBe(2);
|
||||
|
||||
expect(registry.getBySession(99)).toHaveLength(0);
|
||||
expect(registry.getBySession(100)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 0 when no processes match the session', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 42,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
const reaped = await registry.reapSession(999);
|
||||
expect(reaped).toBe(0);
|
||||
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import { createProcessRegistry } from '../../src/supervisor/process-registry.js';
|
||||
import { runShutdownCascade } from '../../src/supervisor/shutdown.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return path.join(tmpdir(), `claude-mem-shutdown-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('supervisor shutdown cascade', () => {
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('removes child records and pid file', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
|
||||
writeFileSync(pidFilePath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
registry.register('worker', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('dead-child', {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(persisted.processes)).toHaveLength(0);
|
||||
expect(() => readFileSync(pidFilePath, 'utf-8')).toThrow();
|
||||
});
|
||||
|
||||
it('terminates tracked children in reverse spawn order', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
registry.register('oldest', {
|
||||
pid: 41001,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('middle', {
|
||||
pid: 41002,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
registry.register('newest', {
|
||||
pid: 41003,
|
||||
type: 'chroma',
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
|
||||
const originalKill = process.kill;
|
||||
const alive = new Set([41001, 41002, 41003]);
|
||||
const calls: Array<{ pid: number; signal: NodeJS.Signals | number }> = [];
|
||||
|
||||
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
|
||||
const normalizedSignal = signal ?? 'SIGTERM';
|
||||
if (normalizedSignal === 0) {
|
||||
if (!alive.has(pid)) {
|
||||
const error = new Error(`kill ESRCH ${pid}`) as NodeJS.ErrnoException;
|
||||
error.code = 'ESRCH';
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
calls.push({ pid, signal: normalizedSignal });
|
||||
alive.delete(pid);
|
||||
return true;
|
||||
}) as typeof process.kill;
|
||||
|
||||
try {
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
} finally {
|
||||
process.kill = originalKill;
|
||||
}
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ pid: 41003, signal: 'SIGTERM' },
|
||||
{ pid: 41002, signal: 'SIGTERM' },
|
||||
{ pid: 41001, signal: 'SIGTERM' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles already-dead processes gracefully without throwing', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
// Register processes with PIDs that are definitely dead
|
||||
registry.register('dead:1', {
|
||||
pid: 2147483640,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('dead:2', {
|
||||
pid: 2147483641,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
|
||||
// All entries should be unregistered
|
||||
const persisted = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(persisted.processes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unregisters all children from registry after cascade', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('worker', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('child:1', {
|
||||
pid: 2147483640,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
registry.register('child:2', {
|
||||
pid: 2147483641,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
|
||||
// All records (including the current process one) should be removed
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,24 @@ mock.module('../../src/utils/logger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock worker-utils to delegate workerHttpRequest to global.fetch
|
||||
mock.module('../../src/shared/worker-utils.js', () => ({
|
||||
getWorkerPort: () => 37777,
|
||||
getWorkerHost: () => '127.0.0.1',
|
||||
workerHttpRequest: (apiPath: string, options?: any) => {
|
||||
const url = `http://127.0.0.1:37777${apiPath}`;
|
||||
return globalThis.fetch(url, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.headers,
|
||||
body: options?.body,
|
||||
});
|
||||
},
|
||||
clearPortCache: () => {},
|
||||
ensureWorkerRunning: () => Promise.resolve(true),
|
||||
fetchWithTimeout: (url: string, init: any, timeoutMs: number) => globalThis.fetch(url, init),
|
||||
buildWorkerUrl: (apiPath: string) => `http://127.0.0.1:37777${apiPath}`,
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import {
|
||||
replaceTaggedContent,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tag Stripping Utility Tests
|
||||
*
|
||||
* Tests the dual-tag privacy system for <private> and <claude-mem-context> tags.
|
||||
* Tests the tag privacy system for <private>, <claude-mem-context>, and <system_instruction> tags.
|
||||
* These tags enable users and the system to exclude content from memory storage.
|
||||
*
|
||||
* Sources:
|
||||
@@ -257,6 +257,74 @@ finish`;
|
||||
});
|
||||
});
|
||||
|
||||
describe('system_instruction tag stripping', () => {
|
||||
describe('basic system_instruction removal', () => {
|
||||
it('should strip single <system_instruction> tag from prompt', () => {
|
||||
const input = 'user content <system_instruction>injected instructions</system_instruction> more content';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('user content more content');
|
||||
});
|
||||
|
||||
it('should strip <system_instruction> mixed with <private> tags', () => {
|
||||
const input = '<system_instruction>instructions</system_instruction> public <private>secret</private> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('public end');
|
||||
});
|
||||
|
||||
it('should return empty string for entirely <system_instruction> content', () => {
|
||||
const input = '<system_instruction>entire prompt is system instructions</system_instruction>';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should strip <system_instruction> tags from JSON content', () => {
|
||||
const jsonContent = JSON.stringify({
|
||||
data: '<system_instruction>injected</system_instruction> real data'
|
||||
});
|
||||
const result = stripMemoryTagsFromJson(jsonContent);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.data).toBe(' real data');
|
||||
});
|
||||
|
||||
it('should strip multiline content within <system_instruction> tags', () => {
|
||||
const input = `before
|
||||
<system_instruction>
|
||||
line one
|
||||
line two
|
||||
line three
|
||||
</system_instruction>
|
||||
after`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('before\n\nafter');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('system-instruction (hyphen variant) tag stripping', () => {
|
||||
it('should strip single <system-instruction> tag from prompt', () => {
|
||||
const input = 'user content <system-instruction>injected instructions</system-instruction> more content';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('user content more content');
|
||||
});
|
||||
|
||||
it('should strip both underscore and hyphen variants in same prompt', () => {
|
||||
const input = '<system_instruction>underscore</system_instruction> middle <system-instruction>hyphen</system-instruction> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('middle end');
|
||||
});
|
||||
|
||||
it('should strip multiline <system-instruction> content', () => {
|
||||
const input = `before
|
||||
<system-instruction>
|
||||
line one
|
||||
line two
|
||||
</system-instruction>
|
||||
after`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('before\n\nafter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacy enforcement integration', () => {
|
||||
it('should allow empty result to trigger privacy skip', () => {
|
||||
// Simulates what SessionRoutes does with private-only prompts
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
registerProcess,
|
||||
unregisterProcess,
|
||||
getProcessBySession,
|
||||
getActiveCount,
|
||||
getActiveProcesses,
|
||||
waitForSlot,
|
||||
ensureProcessExit,
|
||||
} from '../../src/services/worker/ProcessRegistry.js';
|
||||
|
||||
/**
|
||||
* Create a mock ChildProcess that behaves like a real one for testing.
|
||||
* Supports exitCode, killed, kill(), and event emission.
|
||||
*/
|
||||
function createMockProcess(overrides: { exitCode?: number | null; killed?: boolean } = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const mock = Object.assign(emitter, {
|
||||
pid: Math.floor(Math.random() * 100000) + 1000,
|
||||
exitCode: overrides.exitCode ?? null,
|
||||
killed: overrides.killed ?? false,
|
||||
kill(signal?: string) {
|
||||
mock.killed = true;
|
||||
// Simulate async exit after kill
|
||||
setTimeout(() => {
|
||||
mock.exitCode = signal === 'SIGKILL' ? null : 0;
|
||||
mock.emit('exit', mock.exitCode, signal || 'SIGTERM');
|
||||
}, 10);
|
||||
return true;
|
||||
},
|
||||
stdin: null,
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
// Helper to clear registry between tests by unregistering all
|
||||
function clearRegistry() {
|
||||
for (const p of getActiveProcesses()) {
|
||||
unregisterProcess(p.pid);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProcessRegistry', () => {
|
||||
beforeEach(() => {
|
||||
clearRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRegistry();
|
||||
});
|
||||
|
||||
describe('registerProcess / unregisterProcess', () => {
|
||||
it('should register and track a process', () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
expect(getActiveCount()).toBe(1);
|
||||
expect(getProcessBySession(1)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should unregister a process and free the slot', () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
unregisterProcess(proc.pid);
|
||||
expect(getActiveCount()).toBe(0);
|
||||
expect(getProcessBySession(1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProcessBySession', () => {
|
||||
it('should return undefined for unknown session', () => {
|
||||
expect(getProcessBySession(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find process by session ID', () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 42, proc as any);
|
||||
const found = getProcessBySession(42);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.pid).toBe(proc.pid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForSlot', () => {
|
||||
it('should resolve immediately when under limit', async () => {
|
||||
await waitForSlot(2); // 0 processes, limit 2
|
||||
});
|
||||
|
||||
it('should wait until a slot opens', async () => {
|
||||
const proc1 = createMockProcess();
|
||||
const proc2 = createMockProcess();
|
||||
registerProcess(proc1.pid, 1, proc1 as any);
|
||||
registerProcess(proc2.pid, 2, proc2 as any);
|
||||
|
||||
// Start waiting for slot (limit=2, both slots full)
|
||||
const waitPromise = waitForSlot(2, 5000);
|
||||
|
||||
// Free a slot after 50ms
|
||||
setTimeout(() => unregisterProcess(proc1.pid), 50);
|
||||
|
||||
await waitPromise; // Should resolve once slot freed
|
||||
expect(getActiveCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw on timeout when no slot opens', async () => {
|
||||
const proc1 = createMockProcess();
|
||||
const proc2 = createMockProcess();
|
||||
registerProcess(proc1.pid, 1, proc1 as any);
|
||||
registerProcess(proc2.pid, 2, proc2 as any);
|
||||
|
||||
await expect(waitForSlot(2, 100)).rejects.toThrow('Timed out waiting for agent pool slot');
|
||||
});
|
||||
|
||||
it('should throw when hard cap (10) is exceeded', async () => {
|
||||
// Register 10 processes to hit the hard cap
|
||||
const procs = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, i + 100, proc as any);
|
||||
procs.push(proc);
|
||||
}
|
||||
|
||||
await expect(waitForSlot(20)).rejects.toThrow('Hard cap exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureProcessExit', () => {
|
||||
it('should unregister immediately if exitCode is set', async () => {
|
||||
const proc = createMockProcess({ exitCode: 0 });
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any });
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should NOT treat proc.killed as exited — must wait for actual exit', async () => {
|
||||
// This is the core bug fix: proc.killed=true but exitCode=null means NOT dead
|
||||
const proc = createMockProcess({ killed: true, exitCode: null });
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
// Override kill to simulate SIGKILL + delayed exit
|
||||
proc.kill = (signal?: string) => {
|
||||
proc.killed = true;
|
||||
setTimeout(() => {
|
||||
proc.exitCode = 0;
|
||||
proc.emit('exit', 0, signal);
|
||||
}, 20);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ensureProcessExit should NOT short-circuit on proc.killed
|
||||
// It should wait for exit event or timeout, then escalate to SIGKILL
|
||||
const start = Date.now();
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any }, 100);
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should escalate to SIGKILL after timeout', async () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
// Override kill: only respond to SIGKILL
|
||||
let sigkillSent = false;
|
||||
proc.kill = (signal?: string) => {
|
||||
proc.killed = true;
|
||||
if (signal === 'SIGKILL') {
|
||||
sigkillSent = true;
|
||||
setTimeout(() => {
|
||||
proc.exitCode = -1;
|
||||
proc.emit('exit', -1, 'SIGKILL');
|
||||
}, 10);
|
||||
}
|
||||
// Don't emit exit for non-SIGKILL signals (simulates stuck process)
|
||||
return true;
|
||||
};
|
||||
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any }, 100);
|
||||
expect(sigkillSent).toBe(true);
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should unregister even if process ignores SIGKILL (after 1s timeout)', async () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
// Override kill to never emit exit (completely stuck process)
|
||||
proc.kill = () => {
|
||||
proc.killed = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any }, 100);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Should have waited ~100ms for graceful + ~1000ms for SIGKILL timeout
|
||||
expect(elapsed).toBeGreaterThan(90);
|
||||
// Process is unregistered regardless (safety net)
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -326,4 +326,152 @@ describe('Zombie Agent Prevention', () => {
|
||||
session.generatorPromise = null;
|
||||
expect(session.generatorPromise).toBeNull();
|
||||
});
|
||||
|
||||
describe('Session Termination Invariant', () => {
|
||||
// Tests the restart-or-terminate invariant:
|
||||
// When a generator exits without restarting, its messages must be
|
||||
// marked abandoned and the session removed from the active Map.
|
||||
|
||||
test('should mark messages abandoned when session is terminated', () => {
|
||||
const sessionId = createDbSession('content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
|
||||
// Verify messages exist
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// Terminate: mark abandoned (same as terminateSession does)
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(2);
|
||||
|
||||
// Spinner should stop: no pending work remains
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle terminate with zero pending messages', () => {
|
||||
const sessionId = createDbSession('content-terminate-empty');
|
||||
|
||||
// No messages enqueued
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
|
||||
// Terminate with nothing to abandon
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(0);
|
||||
|
||||
// Still no pending work
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should be idempotent — double terminate marks zero on second call', () => {
|
||||
const sessionId = createDbSession('content-terminate-idempotent');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-idempotent');
|
||||
|
||||
// First terminate
|
||||
const first = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(first).toBe(1);
|
||||
|
||||
// Second terminate — already failed, nothing to mark
|
||||
const second = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(second).toBe(0);
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should remove session from Map via removeSessionImmediate', () => {
|
||||
const sessionId = createDbSession('content-terminate-map');
|
||||
const session = createMockSession(sessionId, {
|
||||
contentSessionId: 'content-terminate-map',
|
||||
});
|
||||
|
||||
// Simulate the in-memory sessions Map
|
||||
const sessions = new Map<number, ActiveSession>();
|
||||
sessions.set(sessionId, session);
|
||||
expect(sessions.has(sessionId)).toBe(true);
|
||||
|
||||
// Simulate removeSessionImmediate behavior
|
||||
sessions.delete(sessionId);
|
||||
expect(sessions.has(sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return hasAnyPendingWork false after all sessions terminated', () => {
|
||||
// Create multiple sessions with messages
|
||||
const sid1 = createDbSession('content-multi-term-1');
|
||||
const sid2 = createDbSession('content-multi-term-2');
|
||||
const sid3 = createDbSession('content-multi-term-3');
|
||||
|
||||
enqueueTestMessage(sid1, 'content-multi-term-1');
|
||||
enqueueTestMessage(sid1, 'content-multi-term-1');
|
||||
enqueueTestMessage(sid2, 'content-multi-term-2');
|
||||
enqueueTestMessage(sid3, 'content-multi-term-3');
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// Terminate all sessions
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid1);
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid2);
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid3);
|
||||
|
||||
// Spinner must stop
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should not affect other sessions when terminating one', () => {
|
||||
const sid1 = createDbSession('content-isolate-1');
|
||||
const sid2 = createDbSession('content-isolate-2');
|
||||
|
||||
enqueueTestMessage(sid1, 'content-isolate-1');
|
||||
enqueueTestMessage(sid2, 'content-isolate-2');
|
||||
|
||||
// Terminate only session 1
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid1);
|
||||
|
||||
// Session 2 still has work
|
||||
expect(pendingStore.getPendingCount(sid1)).toBe(0);
|
||||
expect(pendingStore.getPendingCount(sid2)).toBe(1);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test('should mark both pending and processing messages as abandoned', () => {
|
||||
const sessionId = createDbSession('content-mixed-status');
|
||||
|
||||
// Enqueue two messages
|
||||
const msgId1 = enqueueTestMessage(sessionId, 'content-mixed-status');
|
||||
enqueueTestMessage(sessionId, 'content-mixed-status');
|
||||
|
||||
// Claim first message (transitions to 'processing')
|
||||
const claimed = pendingStore.claimNextMessage(sessionId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId1);
|
||||
|
||||
// Now we have 1 processing + 1 pending
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
// Terminate should mark BOTH as failed
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should enforce invariant: no pending work after terminate regardless of initial state', () => {
|
||||
const sessionId = createDbSession('content-invariant');
|
||||
|
||||
// Create a complex initial state: some pending, some processing, some with stale timestamps
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
|
||||
// Claim one (processing)
|
||||
pendingStore.claimNextMessage(sessionId);
|
||||
|
||||
// Verify complex state
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
// THE INVARIANT: after terminate, hasAnyPendingWork MUST be false
|
||||
pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user