Compare commits

..

14 Commits

Author SHA1 Message Date
Alex Newman dd1b812443 chore: bump version to 10.4.3
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:34:28 -05:00
Alex Newman ad3d236cec fix: resolve hook crashes and CLAUDE_PLUGIN_ROOT fallback (#1215, #1220) (#1229)
* fix: resolve PostToolUse hook crashes and 5s latency (#1220)

Three compounding bugs caused hook failures:

1. Missing break statements in worker-service.ts switch — if async
   code threw before process.exit(), execution fell through to
   subsequent cases. Added break to all 7 cases missing them.

2. Unhandled promise rejection on main() — added .catch() that logs
   the error and exits 0 (per project exit code strategy: don't block
   Claude Code or leave Windows Terminal tabs open).

3. Redundant start commands in hooks.json — PostToolUse,
   UserPromptSubmit, and Stop groups each had a standalone start
   command that was redundant (the hook case already calls
   ensureWorkerStarted internally). The redundant start also caused
   5s latency via bun-runner.js collectStdin() timeout since Claude
   Code never closes stdin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add CLAUDE_PLUGIN_ROOT fallback for Stop hooks (#1215)

Upstream Claude Code bug (anthropics/claude-code#24529) leaves
CLAUDE_PLUGIN_ROOT unset for Stop hooks on macOS and ALL hooks
on Linux. Two-layer defense:

1. Shell-level: hooks.json commands now use inline fallback
   _R="${CLAUDE_PLUGIN_ROOT}"; [ -z "$_R" ] && _R="$HOME/...";
   falling back to the known marketplace install path.

2. Script-level: bun-runner.js self-resolves plugin root from
   its own filesystem location via import.meta.url, and fixes
   broken /scripts/... paths that result from empty expansion.

Added test to verify all hook commands include the fallback path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:31:26 -05:00
Alex Newman 494f681cbf docs: update CHANGELOG.md for v10.4.1 2026-02-23 22:13:35 -05:00
Alex Newman 4144010264 chore: bump version to 10.4.1
Publish to npm / publish (push) Has been cancelled
2026-02-23 22:13:11 -05:00
Alex Newman d482f3ed76 chore: bump version to 10.4.1 2026-02-23 22:10:38 -05:00
Alex Newman 3c4486e69e feat: convert make-plan and do commands to skills (#1216) 2026-02-23 22:08:21 -05:00
alan e0fec4bad7 feat: add terminal output control for SessionStart context (#1143)
* feat: add terminal output control for SessionStart context

Add CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT setting to control whether
context is displayed in the terminal at SessionStart.

When set to "false", the terminal remains clean at startup while
context is still injected into Claude's system prompt. This allows
users who find the context output verbose to disable it without
losing the automatic context injection.

Defaults to "true" for backward compatibility.

Changes:
- Add CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT to SettingsDefaultsManager
- Check setting in context handler before setting systemMessage
- Update settings file format to include new option

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: use USER_SETTINGS_PATH and skip color fetch when disabled

Address PR feedback from automated review:

1. Use shared USER_SETTINGS_PATH constant instead of hardcoded path
   - Respects custom CLAUDE_MEM_DATA_DIR override
   - Consistent with other handlers (session-init, observation)

2. Skip color fetch when terminal output disabled
   - Check setting before making HTTP requests
   - Saves network round-trip on every session start

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Alan Dong <adong@Alans-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 21:05:05 -05:00
Alex Newman f5a873ffdc docs: update CHANGELOG.md for v10.4.0 2026-02-23 19:39:59 -05:00
Alex Newman 23f30d35b9 chore: bump version to 10.4.0
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:39:06 -05:00
Alex Newman c6f932988a Fix 30+ root-cause bugs across 10 triage phases (#1214)
* MAESTRO: fix ChromaDB core issues — Python pinning, Windows paths, disable toggle, metadata sanitization, transport errors

- Add --python version pinning to uvx args in both local and remote mode (fixes #1196, #1206, #1208)
- Convert backslash paths to forward slashes for --data-dir on Windows (fixes #1199)
- Add CLAUDE_MEM_CHROMA_ENABLED setting for SQLite-only fallback mode (fixes #707)
- Sanitize metadata in addDocuments() to filter null/undefined/empty values (fixes #1183, #1188)
- Wrap callTool() in try/catch for transport errors with auto-reconnect (fixes #1162)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix data integrity — content-hash deduplication, project name collision, empty project guard, stuck isProcessing

- Add SHA-256 content-hash deduplication to observations INSERT (store.ts, transactions.ts, SessionStore.ts)
- Add content_hash column via migration 22 with backfill and index
- Fix project name collision: getCurrentProjectName() now returns parent/basename
- Guard against empty project string with cwd-derived fallback
- Fix stuck isProcessing: hasAnyPendingWork() resets processing messages older than 5 minutes
- Add 12 new tests covering all four fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix hook lifecycle — stderr suppression, output isolation, conversation pollution prevention

- Suppress process.stderr.write in hookCommand() to prevent Claude Code showing diagnostic
  output as error UI (#1181). Restores stderr in finally block for worker-continues case.
- Convert console.error() to logger.warn()/error() in hook-command.ts and handlers/index.ts
  so all diagnostics route to log file instead of stderr.
- Verified all 7 handlers return suppressOutput: true (prevents conversation pollution #598, #784).
- Verified session-complete is a recognized event type (fixes #984).
- Verified unknown event types return no-op handler with exit 0 (graceful degradation).
- Added 10 new tests in tests/hook-lifecycle.test.ts covering event dispatch, adapter defaults,
  stderr suppression, and standard response constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix worker lifecycle — restart loop coordination, stale transport retry, ENOENT shutdown race

- Add PID file mtime guard to prevent concurrent restart storms (#1145):
  isPidFileRecent() + touchPidFile() coordinate across sessions
- Add transparent retry in ChromaMcpManager.callTool() on transport
  error — reconnects and retries once instead of failing (#1131)
- Wrap getInstalledPluginVersion() with ENOENT/EBUSY handling (#1042)
- Verified ChromaMcpManager.stop() already called on all shutdown paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix Windows platform support — uvx.cmd spawn, PowerShell $_ elimination, windowsHide, FTS5 fallback

- Route uvx spawn through cmd.exe /c on Windows since MCP SDK lacks shell:true (#1190, #1192, #1199)
- Replace all PowerShell Where-Object {$_} pipelines with WQL -Filter server-side filtering (#1024, #1062)
- Add windowsHide: true to all exec/spawn calls missing it to prevent console popups (#1048)
- Add FTS5 runtime probe with graceful fallback when unavailable on Windows (#791)
- Guard FTS5 table creation in migrations, SessionSearch, and SessionStore with try/catch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix skills/ distribution — build-time verification and regression tests (#1187)

Add post-build verification in build-hooks.js that fails if critical
distribution files (skills, hooks, plugin manifest) are missing. Add
10 regression tests covering skill file presence, YAML frontmatter,
hooks.json integrity, and package.json files field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix MigrationRunner schema initialization (#979) — version conflict between parallel migration systems

Root cause: old DatabaseManager migrations 1-7 shared schema_versions table with
MigrationRunner's 4-22, causing version number collisions (5=drop tables vs add column,
6=FTS5 vs prompt tracking, 7=discovery_tokens vs remove UNIQUE).  initializeSchema()
was gated behind maxApplied===0, so core tables were never created when old versions
were present.

Fixes:
- initializeSchema() always creates core tables via CREATE TABLE IF NOT EXISTS
- Migrations 5-7 check actual DB state (columns/constraints) not just version tracking
- Crash-safe temp table rebuilds (DROP IF EXISTS _new before CREATE)
- Added missing migration 21 (ON UPDATE CASCADE) to MigrationRunner
- Added ON UPDATE CASCADE to FK definitions in initializeSchema()
- All changes applied to both runner.ts and SessionStore.ts

Tests: 13 new tests in migration-runner.test.ts covering fresh DB, idempotency,
version conflicts, crash recovery, FK constraints, and data integrity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix 21 test failures — stale mocks, outdated assertions, missing OpenClaw guards

Server tests (12): Added missing workerPath and getAiStatus to ServerOptions
mocks after interface expansion. ChromaSync tests (3): Updated to verify
transport cleanup in ChromaMcpManager after architecture refactor. OpenClaw (2):
Added memory_ tool skipping and response truncation to prevent recursive loops
and oversized payloads. MarkdownFormatter (2): Updated assertions to match
current output. SettingsDefaultsManager (1): Used correct default key for
getBool test. Logger standards (1): Excluded CLI transcript command from
background service check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix Codex CLI compatibility (#744) — session_id fallbacks, unknown platform tolerance, undefined guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix Cursor IDE integration (#838, #1049) — adapter field fallbacks, tolerant session-init validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix /api/logs OOM (#1203) — tail-read replaces full-file readFileSync

Replace readFileSync (loads entire file into memory) with readLastLines()
that reads only from the end of the file in expanding chunks (64KB → 10MB cap).
Prevents OOM on large log files while preserving the same API response shape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix Settings CORS error (#1029) — explicit methods and allowedHeaders in CORS config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: add session custom_title for agent attribution (#1213) — migration 23, endpoint + store support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: prevent CLAUDE.md/AGENTS.md writes inside .git/ directories (#1165)

Add .git path guard to all 4 write sites to prevent ref corruption when
paths resolve inside .git internals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix plugin disabled state not respected (#781) — early exit check in all hook entry points

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix UserPromptSubmit context re-injection on every turn (#1079) — contextInjected session flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* MAESTRO: fix stale AbortController queue stall (#1099) — lastGeneratorActivity tracking + 30s timeout

Three-layer fix:
1. Added lastGeneratorActivity timestamp to ActiveSession, updated by
   processAgentResponse (all agents), getMessageIterator (queue yields),
   and startGeneratorWithProvider (generator launch)
2. Added stale generator detection in ensureGeneratorRunning — if no
   activity for >30s, aborts stale controller, resets state, restarts
3. Added AbortSignal.timeout(30000) in deleteSession to prevent
   indefinite hang when awaiting a stuck generator promise

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:34:35 -05:00
Alex Newman d9a30cc7d4 MAESTRO: exclude transcript CLI from logger-usage-standards test
src/services/transcripts/cli.ts is a CLI command with user-visible
console output, not a background service — console.log is intentional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:55:50 -05:00
Alex Newman 50eeed97e7 MAESTRO: fix cache-based install — resolveRoot, post-install verification, CLI path
Replace hardcoded marketplace path in plugin/scripts/smart-install.js with
resolveRoot() that uses CLAUDE_PLUGIN_ROOT env var (set by Claude Code for
all hooks), with fallback to script location and legacy paths. Fixes #1128,
#1166 where cache installs couldn't find or install node_modules.

Also fixes installCLI() path (ROOT/plugin/scripts/ → ROOT/scripts/) and adds
verifyCriticalModules() post-install check with npm fallback on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:28:51 -05:00
Alex Newman 5f28550551 MAESTRO: fix MCP type coercion for batch endpoints, add defensive observation error handling
Add string-to-array coercion for ids and memorySessionIds in DataRoutes.ts
batch endpoints so MCP clients sending "[1,2,3]" or "1,2,3" instead of
native arrays no longer get 400 errors. Wrap observation storage path in
SessionRoutes.ts with try/catch returning 200 on recoverable errors instead
of 500, preventing hook breakage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:41:28 -05:00
Alex Newman a6a843f871 docs: update CHANGELOG.md for v10.3.3 2026-02-23 03:48:22 -05:00
84 changed files with 4648 additions and 1375 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "10.3.3",
"version": "10.4.3",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "10.2.5",
"version": "10.4.1",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+84 -133
View File
@@ -2,6 +2,90 @@
All notable changes to claude-mem.
## [v10.4.1] - 2026-02-24
### Refactor
- **Skills Conversion**: Converted `/make-plan` and `/do` commands into first-class skills in `plugin/skills/`.
- **Organization**: Centralized planning and execution instructions alongside `mem-search`.
- **Compatibility**: Added symlinks for `openclaw/skills/` to ensure seamless integration with OpenClaw.
### Chore
- **Version Bump**: Aligned all package and plugin manifests to v10.4.1.
## [v10.4.0] - 2026-02-24
## v10.4.0 — Stability & Platform Hardening
Massive reliability release: 30+ root-cause bug fixes across 10 triage phases, plus new features for agent attribution, Chroma control, and broader platform support.
### New Features
- **Session custom titles** — Agents can now set `custom_title` on sessions for attribution (migration 23, new endpoint)
- **Chroma toggle** — `CLAUDE_MEM_CHROMA_ENABLED` setting allows SQLite-only fallback mode (#707)
- **Plugin disabled state** — Early exit check in all hook entry points when plugin is disabled (#781)
- **Context re-injection guard** — `contextInjected` session flag prevents re-injecting context on every UserPromptSubmit turn (#1079)
### Bug Fixes
#### Data Integrity
- SHA-256 content-hash deduplication on observation INSERT (migration 22 with backfill + index)
- Project name collision fix: `getCurrentProjectName()` now returns `parent/basename`
- Empty project string guard with cwd-derived fallback
- Stuck `isProcessing` reset: pending work older than 5 minutes auto-clears
#### ChromaDB
- Python version pinning in uvx args for both local and remote mode (#1196, #1206, #1208)
- Windows backslash-to-forward-slash path conversion for `--data-dir` (#1199)
- Metadata sanitization: filter null/undefined/empty values in `addDocuments()` (#1183, #1188)
- Transport error auto-reconnect in `callTool()` (#1162)
- Stale transport retry with transparent reconnect (#1131)
#### Hook Lifecycle
- Suppress `process.stderr.write` in `hookCommand()` to prevent diagnostic output showing as error UI (#1181)
- Route all `console.error()` through logger instead of stderr
- Verified all 7 handlers return `suppressOutput: true` (#598, #784)
#### Worker Lifecycle
- PID file mtime guard prevents concurrent restart storms (#1145)
- `getInstalledPluginVersion()` ENOENT/EBUSY handling (#1042)
#### SQLite Migrations
- Schema initialization always creates core tables via `CREATE TABLE IF NOT EXISTS`
- Migrations 5-7 check actual DB state instead of version tracking (fixes version collision between old/new migration systems, #979)
- Crash-safe temp table rebuilds
#### Platform Support
- **Windows**: `cmd.exe /c` uvx spawn, PowerShell `$_` elimination with WQL filtering, `windowsHide: true`, FTS5 runtime probe with fallback (#1190, #1192, #1199, #1024, #1062, #1048, #791)
- **Cursor IDE**: Adapter field fallbacks, tolerant session-init validation (#838, #1049)
- **Codex CLI**: `session_id` fallbacks, unknown platform tolerance, undefined guard (#744)
#### API & Infrastructure
- `/api/logs` OOM fix: tail-read replaces full-file `readFileSync` (64KB expanding chunks, 10MB cap, #1203)
- CORS: explicit methods and allowedHeaders (#1029)
- MCP type coercion for batch endpoints: string-to-array for `ids` and `memorySessionIds`
- Defensive observation error handling returns 200 on recoverable errors instead of 500
- `.git/` directory write guard on all 4 CLAUDE.md/AGENTS.md write sites (#1165)
#### Stale AbortController Fix
- `lastGeneratorActivity` timestamp tracking with 30s timeout (#1099)
- Stale generator detection + abort + restart in `ensureGeneratorRunning`
- `AbortSignal.timeout(30000)` in `deleteSession` prevents indefinite hang
### Installation
- `resolveRoot()` replaces hardcoded marketplace path using `CLAUDE_PLUGIN_ROOT` env var (#1128, #1166)
- `installCLI()` path correction and `verifyCriticalModules()` post-install check
- Build-time distribution verification for skills, hooks, and plugin manifest (#1187)
### Testing
- 50+ new tests across hook lifecycle, context re-injection, plugin distribution, migration runner, data integrity, stale abort controller, logs tail-read, CORS, MCP type coercion, and smart-install
- 68 files changed, ~4200 insertions, ~900 deletions
## [v10.3.3] - 2026-02-23
### Bug Fixes
- Fixed session context footer to reference the claude-mem skill instead of MCP search tools for accessing memories
## [v10.3.2] - 2026-02-23
## Bug Fixes
@@ -1313,136 +1397,3 @@ Fixed a critical memory leak where Claude SDK child processes were never termina
Thanks to @yonnock for the detailed bug report and investigation in #499!
## [v8.5.1] - 2025-12-30
## Bug Fix
**Fixed**: Migration 17 column rename failing for databases in intermediate states (#481)
### Problem
Migration 17 renamed session ID columns but used a single check to determine if ALL tables were migrated. This caused errors for databases in partial migration states:
- `no such column: sdk_session_id` (when columns already renamed)
- `table observations has no column named memory_session_id` (when not renamed)
### Solution
- Rewrote migration 17 to check **each table individually** before renaming
- Added `safeRenameColumn()` helper that handles all edge cases gracefully
- Handles all database states: fresh, old, and partially migrated
### Who was affected
- Users upgrading from pre-v8.2.6 versions
- Users whose migration was interrupted (crash, restart, etc.)
- Users who restored database from backup
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## [v8.5.0] - 2025-12-30
# Cursor Support Now Available 🎉
This is a major release introducing **full Cursor IDE support**. Claude-mem now works with Cursor, bringing persistent AI memory to Cursor users with or without a Claude Code subscription.
## Highlights
**Give Cursor persistent memory.** Every Cursor session starts fresh - your AI doesn't remember what it worked on yesterday. Claude-mem changes that. Your agent builds cumulative knowledge about your codebase, decisions, and patterns over time.
### Works Without Claude Code
You can now use claude-mem with Cursor using free AI providers:
- **Gemini** (recommended): 1,500 free requests/day, no credit card required
- **OpenRouter**: Access to 100+ models including free options
- **Claude SDK**: For Claude Code subscribers
### Cross-Platform Support
Full support for all major platforms:
- **macOS**: Bash scripts with `jq` and `curl`
- **Linux**: Same toolchain as macOS
- **Windows**: Native PowerShell scripts, no WSL required
## New Features
### Interactive Setup Wizard (`bun run cursor:setup`)
A guided installer that:
- Detects your environment (Claude Code present or not)
- Helps you choose and configure an AI provider
- Installs Cursor hooks automatically
- Starts the worker service
- Verifies everything is working
### Cursor Lifecycle Hooks
Complete hook integration with Cursor's native hook system:
- `session-init.sh/.ps1` - Session start with context injection
- `user-message.sh/.ps1` - User prompt capture
- `save-observation.sh/.ps1` - Tool usage logging
- `save-file-edit.sh/.ps1` - File edit tracking
- `session-summary.sh/.ps1` - Session end summary
- `context-inject.sh/.ps1` - Load relevant history
### Context Injection via `.cursor/rules`
Relevant past context is automatically injected into Cursor sessions via the `.cursor/rules/claude-mem-context.mdc` file, giving your AI immediate awareness of prior work.
### Project Registry
Multi-project support with automatic project detection:
- Projects registered in `~/.claude-mem/cursor-projects.json`
- Context automatically scoped to current project
- Works across multiple workspaces simultaneously
### MCP Search Tools
Full MCP server integration for Cursor:
- `search` - Find observations by query, date, type
- `timeline` - Get context around specific observations
- `get_observations` - Fetch full details for filtered IDs
## New Commands
| Command | Description |
|---------|-------------|
| `bun run cursor:setup` | Interactive setup wizard |
| `bun run cursor:install` | Install Cursor hooks |
| `bun run cursor:uninstall` | Remove Cursor hooks |
| `bun run cursor:status` | Check hook installation status |
## Documentation
Full documentation available at [docs.claude-mem.ai/cursor](https://docs.claude-mem.ai/cursor):
- Cursor Integration Overview
- Gemini Setup Guide (free tier)
- OpenRouter Setup Guide
- Troubleshooting
## Getting Started
### For Cursor-Only Users (No Claude Code)
```bash
git clone https://github.com/thedotmack/claude-mem.git
cd claude-mem && bun install && bun run build
bun run cursor:setup
```
### For Claude Code Users
```bash
/plugin marketplace add thedotmack/claude-mem
/plugin install claude-mem
claude-mem cursor install
```
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.2.10...v8.5.0
## [v8.2.10] - 2025-12-30
## Bug Fixes
- **Auto-restart worker on version mismatch** (#484): When the plugin updates but the worker was already running on the old version, the worker now automatically restarts instead of failing with 400 errors.
### Changes
- `/api/version` endpoint now returns the built-in version (compiled at build time) instead of reading from disk
- `worker-service start` command checks for version mismatch and auto-restarts if needed
- Downgraded hook version mismatch warning to debug logging (now handled by auto-restart)
Thanks @yungweng for the detailed bug report!
+4
View File
@@ -14,6 +14,10 @@ Claude-mem is a Claude Code plugin providing persistent memory across sessions.
**Search Skill** (`plugin/skills/mem-search/SKILL.md`) - HTTP API for searching past work, auto-invoked when users ask about history
**Planning Skill** (`plugin/skills/make-plan/SKILL.md`) - Orchestrator instructions for creating phased implementation plans with documentation discovery
**Execution Skill** (`plugin/skills/do/SKILL.md`) - Orchestrator instructions for executing phased plans using subagents
**Chroma** (`src/services/sync/ChromaSync.ts`) - Vector embeddings for semantic search
**Viewer UI** (`src/ui/viewer/`) - React interface at http://localhost:37777, built to `plugin/ui/viewer.html`
+2 -2
View File
@@ -3,10 +3,10 @@
"name": "Claude-Mem (Persistent Memory)",
"description": "Official OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.",
"kind": "memory",
"version": "1.0.0",
"version": "10.4.1",
"author": "thedotmack",
"homepage": "https://claude-mem.com",
"skills": ["skills/make-plan", "skills/do-plan"],
"skills": ["skills/make-plan", "skills/do"],
"configSchema": {
"type": "object",
"additionalProperties": false,
+1
View File
@@ -0,0 +1 @@
../../../plugin/skills/do/SKILL.md
-63
View File
@@ -1,63 +0,0 @@
---
name: make-plan
description: Create a detailed, phased implementation plan with documentation discovery. Use when asked to plan a feature, task, or multi-step implementation — especially before executing with do-plan.
---
# Make Plan
You are an ORCHESTRATOR. Create an LLM-friendly plan in phases that can be executed consecutively in new chat contexts.
## Delegation Model
Use subagents for *fact gathering and extraction* (docs, examples, signatures, grep results). Keep *synthesis and plan authoring* with the orchestrator (phase boundaries, task framing, final wording). If a subagent report is incomplete or lacks evidence, re-check with targeted reads/greps before finalizing.
### Subagent Reporting Contract (MANDATORY)
Each subagent response must include:
1. Sources consulted (files/URLs) and what was read
2. Concrete findings (exact API names/signatures; exact file paths/locations)
3. Copy-ready snippet locations (example files/sections to copy)
4. "Confidence" note + known gaps (what might still be missing)
Reject and redeploy the subagent if it reports conclusions without sources.
## Plan Structure
### Phase 0: Documentation Discovery (ALWAYS FIRST)
Before planning implementation, deploy "Documentation Discovery" subagents to:
1. Search for and read relevant documentation, examples, and existing patterns
2. Identify the actual APIs, methods, and signatures available (not assumed)
3. Create a brief "Allowed APIs" list citing specific documentation sources
4. Note any anti-patterns to avoid (methods that DON'T exist, deprecated parameters)
The orchestrator consolidates findings into a single Phase 0 output.
### Each Implementation Phase Must Include
1. **What to implement** — Frame tasks to COPY from docs, not transform existing code
- Good: "Copy the V2 session pattern from docs/examples.ts:45-60"
- Bad: "Migrate the existing code to V2"
2. **Documentation references** — Cite specific files/lines for patterns to follow
3. **Verification checklist** — How to prove this phase worked (tests, grep checks)
4. **Anti-pattern guards** — What NOT to do (invented APIs, undocumented params)
### Final Phase: Verification
1. Verify all implementations match documentation
2. Check for anti-patterns (grep for known bad patterns)
3. Run tests to confirm functionality
## Key Principles
- Documentation Availability ≠ Usage: Explicitly require reading docs
- Task Framing Matters: Direct agents to docs, not just outcomes
- Verify > Assume: Require proof, not assumptions about APIs
- Session Boundaries: Each phase should be self-contained with its own doc references
## Anti-Patterns to Prevent
- Inventing API methods that "should" exist
- Adding parameters not in documentation
- Skipping verification steps
- Assuming structure without checking examples
+1
View File
@@ -0,0 +1 @@
../../../plugin/skills/make-plan/SKILL.md
+9
View File
@@ -642,6 +642,9 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
const toolName = event.toolName;
if (!toolName) return;
// Skip memory_ tools to prevent recursive observation loops
if (toolName.startsWith("memory_")) return;
const contentSessionId = getContentSessionId(ctx.sessionKey);
// Extract result text from all content blocks
@@ -654,6 +657,12 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
.join("\n");
}
// Truncate long responses to prevent oversized payloads
const MAX_TOOL_RESPONSE_LENGTH = 1000;
if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) {
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
}
// Fire-and-forget: send observation + sync MEMORY.md in parallel
workerPostFireAndForget(workerPort, "/api/sessions/observations", {
contentSessionId,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "10.3.3",
"version": "10.4.3",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "10.3.3",
"version": "10.4.3",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
-43
View File
@@ -1,43 +0,0 @@
---
description: "Execute a plan using subagents for implementation"
argument-hint: "[task or plan reference]"
---
You are an ORCHESTRATOR.
Primary instruction: deploy subagents to execute *all* work for #$ARGUMENTS.
Do not do the work yourself except to coordinate, route context, and verify that each subagent completed its assigned checklist.
Deploy subagents to execute each phase of #$ARGUMENTS independently and consecutively. For every checklist item below, explicitly deploy (or reuse) a subagent responsible for that item and record its outcome before proceeding.
## Execution Protocol (Orchestrator-Driven)
Orchestrator rules:
- Each phase uses fresh subagents where noted (or when context is large/unclear).
- The orchestrator assigns one clear objective per subagent and requires evidence (commands run, outputs, files changed).
- Do not advance to the next step until the assigned subagent reports completion and the orchestrator confirms it matches the plan.
### During Each Phase:
Deploy an "Implementation" subagent to:
1. Execute the implementation as specified
2. COPY patterns from documentation, don't invent
3. Cite documentation sources in code comments when using unfamiliar APIs
4. If an API seems missing, STOP and verify - don't assume it exists
### After Each Phase:
Deploy subagents for each post-phase responsibility:
1. **Run verification checklist** - Deploy a "Verification" subagent to prove the phase worked
2. **Anti-pattern check** - Deploy an "Anti-pattern" subagent to grep for known bad patterns from the plan
3. **Code quality review** - Deploy a "Code Quality" subagent to review changes
4. **Commit only if verified** - Deploy a "Commit" subagent *only after* verification passes; otherwise, do not commit
### Between Phases:
Deploy a "Branch/Sync" subagent to:
- Push to working branch after each verified phase
- Prepare the next phase handoff so the next phase's subagents start fresh but have plan context
## Failure Modes to Prevent
- Don't invent APIs that "should" exist - verify against docs
- Don't add undocumented parameters - copy exact signatures
- Don't skip verification - deploy a verification subagent and run the checklist
- Don't commit before verification passes (or without explicit orchestrator approval)
-66
View File
@@ -1,66 +0,0 @@
---
description: "Create an implementation plan with documentation discovery"
argument-hint: "[feature or task description]"
---
You are an ORCHESTRATOR.
Create an LLM-friendly plan in phases that can be executed consecutively in new chat contexts.
Delegation model (because subagents can under-report):
- Use subagents for *fact gathering and extraction* (docs, examples, signatures, grep results).
- Keep *synthesis and plan authoring* with the orchestrator (phase boundaries, task framing, final wording).
- If a subagent report is incomplete or lacks evidence, the orchestrator must re-check with targeted reads/greps before finalizing the plan.
Subagent reporting contract (MANDATORY):
- Each subagent response must include:
1) Sources consulted (files/URLs) and what was read
2) Concrete findings (exact API names/signatures; exact file paths/locations)
3) Copy-ready snippet locations (example files/sections to copy)
4) "Confidence" note + known gaps (what might still be missing)
- Reject and redeploy the subagent if it reports conclusions without sources.
## Plan Structure Requirements
### Phase 0: Documentation Discovery (ALWAYS FIRST)
Before planning implementation, you MUST:
Deploy one or more "Documentation Discovery" subagents to:
1. Search for and read relevant documentation, examples, and existing patterns
2. Identify the actual APIs, methods, and signatures available (not assumed)
3. Create a brief "Allowed APIs" list citing specific documentation sources
4. Note any anti-patterns to avoid (methods that DON'T exist, deprecated parameters)
Then the orchestrator consolidates their findings into a single Phase 0 output.
### Each Implementation Phase Must Include:
1. **What to implement** - Frame tasks to COPY from docs, not transform existing code
- Good: "Copy the V2 session pattern from docs/examples.ts:45-60"
- Bad: "Migrate the existing code to V2"
2. **Documentation references** - Cite specific files/lines for patterns to follow
3. **Verification checklist** - How to prove this phase worked (tests, grep checks)
4. **Anti-pattern guards** - What NOT to do (invented APIs, undocumented params)
Subagent-friendly split:
- Subagents can propose candidate doc references and verification commands.
- The orchestrator must write the final phase text, ensuring tasks are copy-based, scoped, and independently executable.
### Final Phase: Verification
1. Verify all implementations match documentation
2. Check for anti-patterns (grep for known bad patterns)
3. Run tests to confirm functionality
Delegation guidance:
- Deploy a "Verification" subagent to draft the checklist and commands.
- The orchestrator must review the checklist for completeness and ensure it maps to earlier phase outputs.
## Key Principles
- Documentation Availability ≠ Usage: Explicitly require reading docs
- Task Framing Matters: Direct agents to docs, not just outcomes
- Verify > Assume: Require proof, not assumptions about APIs
- Session Boundaries: Each phase should be self-contained with its own doc references
## Anti-Patterns to Prevent
- Inventing API methods that "should" exist
- Adding parameters not in documentation
- Skipping verification steps
- Assuming structure without checking examples
+8 -23
View File
@@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
"timeout": 300
}
]
@@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/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
}
]
@@ -29,12 +29,12 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
"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": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/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\" hook claude-code context",
"timeout": 60
}
]
@@ -45,12 +45,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
"timeout": 60
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/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
}
]
@@ -62,12 +57,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
"timeout": 60
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/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
}
]
@@ -78,17 +68,12 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
"timeout": 60
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/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
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code session-complete",
"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-complete",
"timeout": 30
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem-plugin",
"version": "10.3.3",
"version": "10.4.3",
"private": true,
"description": "Runtime dependencies for claude-mem bundled hooks",
"type": "module",
+48 -2
View File
@@ -12,12 +12,37 @@
* Fixes #818: Worker fails to start on fresh install
*/
import { spawnSync, spawn } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { existsSync, readFileSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const IS_WINDOWS = process.platform === 'win32';
// Self-resolve plugin root when CLAUDE_PLUGIN_ROOT is not set by Claude Code.
// Upstream bug: anthropics/claude-code#24529 — Stop hooks (and on Linux, all hooks)
// don't receive CLAUDE_PLUGIN_ROOT, causing script paths to resolve to /scripts/...
// which doesn't exist. This fallback derives the plugin root from bun-runner.js's
// own filesystem location (this file lives in <plugin-root>/scripts/).
const __bun_runner_dirname = dirname(fileURLToPath(import.meta.url));
const RESOLVED_PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || resolve(__bun_runner_dirname, '..');
/**
* Fix script path arguments that were broken by empty CLAUDE_PLUGIN_ROOT.
* When CLAUDE_PLUGIN_ROOT is empty, "${CLAUDE_PLUGIN_ROOT}/scripts/foo.cjs"
* expands to "/scripts/foo.cjs" which doesn't exist. Detect this and rewrite
* the path using our self-resolved plugin root.
*/
function fixBrokenScriptPath(argPath) {
if (argPath.startsWith('/scripts/') && !existsSync(argPath)) {
const fixedPath = join(RESOLVED_PLUGIN_ROOT, argPath);
if (existsSync(fixedPath)) {
return fixedPath;
}
}
return argPath;
}
/**
* Find Bun executable - checks PATH first, then common install locations
*/
@@ -54,6 +79,24 @@ function findBun() {
return null;
}
// Early exit if plugin is disabled in Claude Code settings (#781).
// Sync read + JSON parse — fastest possible check before spawning Bun.
function isPluginDisabledInClaudeSettings() {
try {
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
const settingsPath = join(configDir, 'settings.json');
if (!existsSync(settingsPath)) return false;
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
return settings?.enabledPlugins?.['claude-mem@thedotmack'] === false;
} catch {
return false;
}
}
if (isPluginDisabledInClaudeSettings()) {
process.exit(0);
}
// Get args: node bun-runner.js <script> [args...]
const args = process.argv.slice(2);
@@ -62,6 +105,9 @@ if (args.length === 0) {
process.exit(1);
}
// Fix broken script paths caused by empty CLAUDE_PLUGIN_ROOT (#1215)
args[0] = fixBrokenScriptPath(args[0]);
const bunPath = findBun();
if (!bunPath) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+100 -4
View File
@@ -4,16 +4,72 @@
*
* Ensures Bun runtime and uv (Python package manager) are installed
* (auto-installs if missing) and handles dependency installation when needed.
*
* Resolves the install directory from CLAUDE_PLUGIN_ROOT (set by Claude Code
* for both cache and marketplace installs), falling back to script location
* and legacy paths.
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { execSync, spawnSync } from 'child_process';
import { join } from 'path';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const ROOT = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
const MARKER = join(ROOT, '.install-version');
// Early exit if plugin is disabled in Claude Code settings (#781)
function isPluginDisabledInClaudeSettings() {
try {
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
const settingsPath = join(configDir, 'settings.json');
if (!existsSync(settingsPath)) return false;
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
return settings?.enabledPlugins?.['claude-mem@thedotmack'] === false;
} catch {
return false;
}
}
if (isPluginDisabledInClaudeSettings()) {
process.exit(0);
}
const IS_WINDOWS = process.platform === 'win32';
/**
* Resolve the plugin root directory where dependencies should be installed.
*
* Priority:
* 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks works for
* both cache-based and marketplace installs)
* 2. Script location (dirname of this file, up one level from scripts/)
* 3. XDG path (~/.config/claude/plugins/marketplaces/thedotmack)
* 4. Legacy path (~/.claude/plugins/marketplaces/thedotmack)
*/
function resolveRoot() {
// CLAUDE_PLUGIN_ROOT is the authoritative location set by Claude Code
if (process.env.CLAUDE_PLUGIN_ROOT) {
const root = process.env.CLAUDE_PLUGIN_ROOT;
if (existsSync(join(root, 'package.json'))) return root;
}
// Derive from script location (this file is in <root>/scripts/)
try {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const candidate = dirname(scriptDir);
if (existsSync(join(candidate, 'package.json'))) return candidate;
} catch {
// import.meta.url not available
}
// Probe XDG path, then legacy
const marketplaceRel = join('plugins', 'marketplaces', 'thedotmack');
const xdg = join(homedir(), '.config', 'claude', marketplaceRel);
if (existsSync(join(xdg, 'package.json'))) return xdg;
return join(homedir(), '.claude', marketplaceRel);
}
const ROOT = resolveRoot();
const MARKER = join(ROOT, '.install-version');
/**
* Check if Bun is installed and accessible
*/
@@ -287,7 +343,7 @@ function installUv() {
* Add shell alias for claude-mem command
*/
function installCLI() {
const WORKER_CLI = join(ROOT, 'plugin', 'scripts', 'worker-service.cjs');
const WORKER_CLI = join(ROOT, 'scripts', 'worker-service.cjs');
const bunPath = getBunPath() || 'bun';
const aliasLine = `alias claude-mem='${bunPath} "${WORKER_CLI}"'`;
const markerPath = join(ROOT, '.cli-installed');
@@ -405,6 +461,31 @@ function installDeps() {
}));
}
/**
* Verify that critical runtime modules are resolvable from the install directory.
* Returns true if all critical modules exist, false otherwise.
*/
function verifyCriticalModules() {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const dependencies = Object.keys(pkg.dependencies || {});
const missing = [];
for (const dep of dependencies) {
// Check that the module directory exists in node_modules
const modulePath = join(ROOT, 'node_modules', ...dep.split('/'));
if (!existsSync(modulePath)) {
missing.push(dep);
}
}
if (missing.length > 0) {
console.error(`❌ Post-install check failed: missing modules: ${missing.join(', ')}`);
return false;
}
return true;
}
// Main execution
try {
// Step 1: Ensure Bun is installed and meets minimum version (REQUIRED)
@@ -456,6 +537,21 @@ try {
const newVersion = pkg.version;
installDeps();
// Verify critical modules are resolvable
if (!verifyCriticalModules()) {
console.error('⚠️ Retrying install with npm...');
try {
execSync('npm install --production', { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
} catch {
// npm also failed
}
if (!verifyCriticalModules()) {
console.error('❌ Dependencies could not be installed. Plugin may not work correctly.');
process.exit(1);
}
}
console.error('✅ Dependencies installed');
// Auto-restart worker to pick up new code
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
---
name: do-plan
name: do
description: Execute a phased implementation plan using subagents. Use when asked to execute, run, or carry out a plan — especially one created by make-plan.
---
+63
View File
@@ -0,0 +1,63 @@
---
name: make-plan
description: Create a detailed, phased implementation plan with documentation discovery. Use when asked to plan a feature, task, or multi-step implementation — especially before executing with do.
---
# Make Plan
You are an ORCHESTRATOR. Create an LLM-friendly plan in phases that can be executed consecutively in new chat contexts.
## Delegation Model
Use subagents for *fact gathering and extraction* (docs, examples, signatures, grep results). Keep *synthesis and plan authoring* with the orchestrator (phase boundaries, task framing, final wording). If a subagent report is incomplete or lacks evidence, re-check with targeted reads/greps before finalizing.
### Subagent Reporting Contract (MANDATORY)
Each subagent response must include:
1. Sources consulted (files/URLs) and what was read
2. Concrete findings (exact API names/signatures; exact file paths/locations)
3. Copy-ready snippet locations (example files/sections to copy)
4. "Confidence" note + known gaps (what might still be missing)
Reject and redeploy the subagent if it reports conclusions without sources.
## Plan Structure
### Phase 0: Documentation Discovery (ALWAYS FIRST)
Before planning implementation, deploy "Documentation Discovery" subagents to:
1. Search for and read relevant documentation, examples, and existing patterns
2. Identify the actual APIs, methods, and signatures available (not assumed)
3. Create a brief "Allowed APIs" list citing specific documentation sources
4. Note any anti-patterns to avoid (methods that DON'T exist, deprecated parameters)
The orchestrator consolidates findings into a single Phase 0 output.
### Each Implementation Phase Must Include
1. **What to implement** — Frame tasks to COPY from docs, not transform existing code
- Good: "Copy the V2 session pattern from docs/examples.ts:45-60"
- Bad: "Migrate the existing code to V2"
2. **Documentation references** — Cite specific files/lines for patterns to follow
3. **Verification checklist** — How to prove this phase worked (tests, grep checks)
4. **Anti-pattern guards** — What NOT to do (invented APIs, undocumented params)
### Final Phase: Verification
1. Verify all implementations match documentation
2. Check for anti-patterns (grep for known bad patterns)
3. Run tests to confirm functionality
## Key Principles
- Documentation Availability ≠ Usage: Explicitly require reading docs
- Task Framing Matters: Direct agents to docs, not just outcomes
- Verify > Assume: Require proof, not assumptions about APIs
- Session Boundaries: Each phase should be self-contained with its own doc references
## Anti-Patterns to Prevent
- Inventing API methods that "should" exist
- Adding parameters not in documentation
- Skipping verification steps
- Assuming structure without checking examples
+14
View File
@@ -162,6 +162,20 @@ async function buildHooks() {
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
// Verify critical distribution files exist (skills are source files, not build outputs)
console.log('\n📋 Verifying distribution files...');
const requiredDistributionFiles = [
'plugin/skills/mem-search/SKILL.md',
'plugin/hooks/hooks.json',
'plugin/.claude-plugin/plugin.json',
];
for (const filePath of requiredDistributionFiles) {
if (!fs.existsSync(filePath)) {
throw new Error(`Missing required distribution file: ${filePath}`);
}
}
console.log('✓ All required distribution files present');
console.log('\n✅ Worker service, MCP server, and context generator built successfully!');
console.log(` Output: ${hooksDir}/`);
console.log(` - Worker: worker-service.cjs`);
+5
View File
@@ -279,6 +279,11 @@ function formatObservationsForClaudeMd(observations: ObservationRow[], folderPat
* which only writes to existing folders.
*/
function writeClaudeMdToFolderForRegenerate(folderPath: string, newContent: string): void {
const resolvedPath = path.resolve(folderPath);
// Never write inside .git directories — corrupts refs (#1165)
if (resolvedPath.includes('/.git/') || resolvedPath.includes('\\.git\\') || resolvedPath.endsWith('/.git') || resolvedPath.endsWith('\\.git')) return;
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
const tempFile = `${claudeMdPath}.tmp`;
+53 -17
View File
@@ -7,34 +7,40 @@
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { execSync, spawnSync } from 'child_process';
import { join } from 'path';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const IS_WINDOWS = process.platform === 'win32';
/**
* Resolve the marketplace root directory.
* Resolve the plugin root directory where dependencies should be installed.
*
* Claude Code may store plugins under either `~/.claude/plugins/` (legacy) or
* `~/.config/claude/plugins/` (XDG-compliant, e.g. Nix-managed installs).
* When `CLAUDE_PLUGIN_ROOT` is set we derive the base from it; otherwise we
* probe both candidate paths and fall back to the legacy location.
* Priority:
* 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks works for
* both cache-based and marketplace installs)
* 2. Script location (dirname of this file, up one level from scripts/)
* 3. XDG path (~/.config/claude/plugins/marketplaces/thedotmack)
* 4. Legacy path (~/.claude/plugins/marketplaces/thedotmack)
*/
function resolveRoot() {
const marketplaceRel = join('plugins', 'marketplaces', 'thedotmack');
// Derive from CLAUDE_PLUGIN_ROOT (e.g. .../plugins/cache/thedotmack/claude-mem/<ver>)
// CLAUDE_PLUGIN_ROOT is the authoritative location set by Claude Code
if (process.env.CLAUDE_PLUGIN_ROOT) {
let dir = process.env.CLAUDE_PLUGIN_ROOT;
const cacheIndex = dir.indexOf(join('plugins', 'cache'));
if (cacheIndex !== -1) {
const base = dir.substring(0, cacheIndex);
const candidate = join(base, marketplaceRel);
if (existsSync(join(candidate, 'package.json'))) return candidate;
}
const root = process.env.CLAUDE_PLUGIN_ROOT;
if (existsSync(join(root, 'package.json'))) return root;
}
// Probe XDG path first, then legacy
// Derive from script location (this file is in <root>/scripts/)
try {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const candidate = dirname(scriptDir);
if (existsSync(join(candidate, 'package.json'))) return candidate;
} catch {
// import.meta.url not available
}
// Probe XDG path, then legacy
const marketplaceRel = join('plugins', 'marketplaces', 'thedotmack');
const xdg = join(homedir(), '.config', 'claude', marketplaceRel);
if (existsSync(join(xdg, 'package.json'))) return xdg;
@@ -275,12 +281,42 @@ function installDeps() {
}));
}
/**
* Verify that critical runtime modules are resolvable from the install directory.
* Returns true if all critical modules exist, false otherwise.
*/
function verifyCriticalModules() {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const dependencies = Object.keys(pkg.dependencies || {});
const missing = [];
for (const dep of dependencies) {
const modulePath = join(ROOT, 'node_modules', ...dep.split('/'));
if (!existsSync(modulePath)) {
missing.push(dep);
}
}
if (missing.length > 0) {
console.error(`❌ Post-install check failed: missing modules: ${missing.join(', ')}`);
return false;
}
return true;
}
// Main execution
try {
if (!isBunInstalled()) installBun();
if (!isUvInstalled()) installUv();
if (needsInstall()) {
installDeps();
if (!verifyCriticalModules()) {
console.error('❌ Dependencies could not be installed. Plugin may not work correctly.');
process.exit(1);
}
console.error('✅ Dependencies installed');
}
} catch (e) {
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
/**
* Wipes the Chroma data directory so backfillAllProjects rebuilds it on next worker start.
* Chroma is always rebuildable from SQLite this is safe.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const chromaDir = path.join(os.homedir(), '.claude-mem', 'chroma');
if (fs.existsSync(chromaDir)) {
const before = fs.readdirSync(chromaDir);
console.log(`Wiping ${chromaDir} (${before.length} items)...`);
fs.rmSync(chromaDir, { recursive: true, force: true });
console.log('Done. Chroma will rebuild from SQLite on next worker restart.');
} else {
console.log('Chroma directory does not exist, nothing to wipe.');
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const claudeCodeAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = (raw ?? {}) as any;
return {
sessionId: r.session_id,
sessionId: r.session_id ?? r.id ?? r.sessionId,
cwd: r.cwd ?? process.cwd(),
prompt: r.prompt,
toolName: r.tool_name,
+8 -3
View File
@@ -3,15 +3,20 @@ import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.
// Maps Cursor stdin format - field names differ from Claude Code
// Cursor uses: conversation_id, workspace_roots[], result_json, command/output
// Handle undefined input gracefully for hooks that don't receive stdin
//
// Cursor payload variations (#838, #1049):
// Session ID: conversation_id, generation_id, or id
// Prompt: prompt, query, input, or message (varies by Cursor version/hook type)
// CWD: workspace_roots[0] or cwd
export const cursorAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = (raw ?? {}) as any;
// Cursor-specific: shell commands come as command/output instead of tool_name/input/response
const isShellCommand = !!r.command && !r.tool_name;
return {
sessionId: r.conversation_id || r.generation_id, // conversation_id preferred
cwd: r.workspace_roots?.[0] ?? process.cwd(), // First workspace root
prompt: r.prompt,
sessionId: r.conversation_id || r.generation_id || r.id,
cwd: r.workspace_roots?.[0] ?? r.cwd ?? process.cwd(),
prompt: r.prompt ?? r.query ?? r.input ?? r.message,
toolName: isShellCommand ? 'Bash' : r.tool_name,
toolInput: isShellCommand ? { command: r.command } : r.tool_input,
toolResponse: isShellCommand ? { output: r.output } : r.result_json, // result_json not tool_response
+2 -1
View File
@@ -8,7 +8,8 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
case 'claude-code': return claudeCodeAdapter;
case 'cursor': return cursorAdapter;
case 'raw': return rawAdapter;
default: throw new Error(`Unknown platform: ${platform}`);
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
default: return rawAdapter;
}
}
+5
View File
@@ -264,6 +264,11 @@ function formatObservationsForClaudeMd(observations: ObservationRow[], folderPat
* Only writes to folders that exist never creates directories.
*/
function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
const resolvedPath = path.resolve(folderPath);
// Never write inside .git directories — corrupts refs (#1165)
if (resolvedPath.includes('/.git/') || resolvedPath.includes('\\.git\\') || resolvedPath.endsWith('/.git') || resolvedPath.endsWith('\\.git')) return;
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
const tempFile = `${claudeMdPath}.tmp`;
+10 -3
View File
@@ -10,6 +10,8 @@ import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js
import { getProjectContext } from '../../utils/project-name.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
export const contextHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -30,6 +32,10 @@ export const contextHandler: EventHandler = {
const context = getProjectContext(cwd);
const port = getWorkerPort();
// Check if terminal output should be shown (load settings early)
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const showTerminalOutput = settings.CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT === 'true';
// 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)}`;
@@ -37,11 +43,11 @@ export const contextHandler: EventHandler = {
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
// Worker service has its own timeouts, so client-side timeout is redundant
try {
// Fetch both markdown (for Claude context) and colored (for user display) truly in parallel
// Fetch markdown (for Claude context) and optionally colored (for user display)
const colorUrl = `${url}&colors=true`;
const [response, colorResponse] = await Promise.all([
fetch(url),
fetch(colorUrl).catch(() => null)
showTerminalOutput ? fetch(colorUrl).catch(() => null) : Promise.resolve(null)
]);
if (!response.ok) {
@@ -60,7 +66,8 @@ export const contextHandler: EventHandler = {
const additionalContext = contextResult.trim();
const coloredTimeline = colorResult.trim();
const systemMessage = coloredTimeline
const systemMessage = showTerminalOutput && coloredTimeline
? `${coloredTimeline}\n\nView Observations Live @ http://localhost:${port}`
: undefined;
+2 -1
View File
@@ -6,6 +6,7 @@
import type { EventHandler } from '../types.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { logger } from '../../utils/logger.js';
import { contextHandler } from './context.js';
import { sessionInitHandler } from './session-init.js';
import { observationHandler } from './observation.js';
@@ -46,7 +47,7 @@ const handlers: Record<EventType, EventHandler> = {
export function getEventHandler(eventType: string): EventHandler {
const handler = handlers[eventType as EventType];
if (!handler) {
console.error(`[claude-mem] Unknown event type: ${eventType}, returning no-op`);
logger.warn('HOOK', `Unknown event type: ${eventType}, returning no-op`);
return {
async execute() {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
+18 -1
View File
@@ -24,6 +24,12 @@ export const sessionInitHandler: EventHandler = {
const { sessionId, cwd, prompt: rawPrompt } = input;
// Guard: Codex CLI and other platforms may not provide a session_id (#744)
if (!sessionId) {
logger.warn('HOOK', 'session-init: No sessionId provided, skipping (Codex CLI or unknown platform)');
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
// Check if project is excluded from tracking
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (cwd && isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
@@ -63,11 +69,12 @@ export const sessionInitHandler: EventHandler = {
promptNumber: number;
skipped?: boolean;
reason?: string;
contextInjected?: boolean;
};
const sessionDbId = initResult.sessionDbId;
const promptNumber = initResult.promptNumber;
logger.debug('HOOK', 'session-init: Received from /api/sessions/init', { sessionDbId, promptNumber, skipped: initResult.skipped });
logger.debug('HOOK', 'session-init: Received from /api/sessions/init', { sessionDbId, promptNumber, skipped: initResult.skipped, contextInjected: initResult.contextInjected });
// Debug-level alignment log for detailed tracing
logger.debug('HOOK', `[ALIGNMENT] Hook Entry | contentSessionId=${sessionId} | prompt#=${promptNumber} | sessionDbId=${sessionDbId}`);
@@ -80,6 +87,16 @@ export const sessionInitHandler: EventHandler = {
return { continue: true, suppressOutput: true };
}
// Skip SDK agent re-initialization if context was already injected for this session (#1079)
// The prompt was already saved to the database by /api/sessions/init above —
// no need to re-start the SDK agent on every turn
if (initResult.contextInjected) {
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped_agent_init=true | reason=context_already_injected`, {
sessionId: sessionDbId
});
return { continue: true, suppressOutput: true };
}
// Only initialize SDK agent for Claude Code (not Cursor)
// Cursor doesn't use the SDK agent - it only needs session/observation storage
if (input.platform !== 'cursor' && sessionDbId) {
+14 -3
View File
@@ -2,6 +2,7 @@ import { readJsonFromStdin } from './stdin-reader.js';
import { getPlatformAdapter } from './adapters/index.js';
import { getEventHandler } from './handlers/index.js';
import { HOOK_EXIT_CODES } from '../shared/hook-constants.js';
import { logger } from '../utils/logger.js';
export interface HookCommandOptions {
/** If true, don't call process.exit() - let caller handle process lifecycle */
@@ -65,6 +66,12 @@ export function isWorkerUnavailableError(error: unknown): boolean {
}
export async function hookCommand(platform: string, event: string, options: HookCommandOptions = {}): Promise<number> {
// Suppress stderr in hook context — Claude Code shows stderr as error UI (#1181)
// Exit 1: stderr shown to user. Exit 2: stderr fed to Claude for processing.
// All diagnostics go to log file via logger; stderr must stay clean.
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (() => true) as typeof process.stderr.write;
try {
const adapter = getPlatformAdapter(platform);
const handler = getEventHandler(event);
@@ -84,18 +91,22 @@ export async function hookCommand(platform: string, event: string, options: Hook
} catch (error) {
if (isWorkerUnavailableError(error)) {
// Worker unavailable — degrade gracefully, don't block the user
console.error(`[claude-mem] Worker unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
// Log to file instead of stderr (#1181)
logger.warn('HOOK', `Worker unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
if (!options.skipExit) {
process.exit(HOOK_EXIT_CODES.SUCCESS); // = 0 (graceful)
}
return HOOK_EXIT_CODES.SUCCESS;
}
// Handler/client bug — show as blocking error so developers see it
console.error(`Hook error: ${error}`);
// Handler/client bug — log to file instead of stderr (#1181)
logger.error('HOOK', `Hook error: ${error instanceof Error ? error.message : error}`, {}, error instanceof Error ? error : undefined);
if (!options.skipExit) {
process.exit(HOOK_EXIT_CODES.BLOCKING_ERROR); // = 2
}
return HOOK_EXIT_CODES.BLOCKING_ERROR;
} finally {
// Restore stderr for non-hook code paths (e.g., when skipExit is true and process continues as worker)
process.stderr.write = originalStderrWrite;
}
}
+16 -6
View File
@@ -115,12 +115,22 @@ export async function httpShutdown(port: number): Promise<boolean> {
/**
* Get the plugin version from the installed marketplace package.json
* This is the "expected" version that should be running
* This is the "expected" version that should be running.
* Returns 'unknown' on ENOENT/EBUSY (shutdown race condition, fix #1042).
*/
export function getInstalledPluginVersion(): string {
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version;
try {
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version;
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'EBUSY') {
logger.debug('SYSTEM', 'Could not read plugin version (shutdown race)', { code });
return 'unknown';
}
throw error;
}
}
/**
@@ -155,8 +165,8 @@ export async function checkVersionMatch(port: number): Promise<VersionCheckResul
const pluginVersion = getInstalledPluginVersion();
const workerVersion = await getRunningWorkerVersion(port);
// If we can't get worker version, assume it matches (graceful degradation)
if (!workerVersion) {
// If either version is unknown/null, assume match (graceful degradation, fix #1042)
if (!workerVersion || pluginVersion === 'unknown') {
return { matches: true, pluginVersion, workerVersion };
}
+59 -22
View File
@@ -10,7 +10,7 @@
import path from 'path';
import { homedir } from 'os';
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, rmSync } from 'fs';
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, rmSync, statSync, utimesSync } from 'fs';
import { exec, execSync, spawn } from 'child_process';
import { promisify } from 'util';
import { logger } from '../../utils/logger.js';
@@ -54,7 +54,8 @@ function lookupBinaryInPath(binaryName: string, platform: NodeJS.Platform): stri
try {
const output = execSync(command, {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf-8'
encoding: 'utf-8',
windowsHide: true
});
const firstMatch = output
@@ -191,10 +192,10 @@ export async function getChildProcesses(parentPid: number): Promise<number[]> {
}
try {
// PowerShell Get-Process instead of WMIC (deprecated in Windows 11)
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty Id"`;
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
// PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format
// Use WQL -Filter to avoid $_ pipeline syntax that breaks in Git Bash (#1062, #1024).
// Get-CimInstance with server-side filtering is also more efficient than piping through Where-Object.
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${parentPid}' | Select-Object -ExpandProperty ProcessId"`;
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true });
return stdout
.split('\n')
.map(line => line.trim())
@@ -223,7 +224,7 @@ export async function forceKillProcess(pid: number): Promise<void> {
try {
if (process.platform === 'win32') {
// /T kills entire process tree, /F forces termination
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true });
} else {
process.kill(pid, 'SIGKILL');
}
@@ -315,13 +316,14 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
try {
if (isWindows) {
// Windows: Use PowerShell Get-CimInstance with JSON output for age filtering
const patternConditions = ORPHAN_PROCESS_PATTERNS
.map(p => `$_.CommandLine -like '*${p}*'`)
.join(' -or ');
// Windows: Use WQL -Filter for server-side filtering (no $_ pipeline syntax).
// Avoids Git Bash $_ interpretation (#1062) and PowerShell syntax errors (#1024).
const wqlPatternConditions = ORPHAN_PROCESS_PATTERNS
.map(p => `CommandLine LIKE '%${p}%'`)
.join(' OR ');
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { (${patternConditions}) -and $_.ProcessId -ne ${currentPid} } | Select-Object ProcessId, CreationDate | ConvertTo-Json"`;
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter '(${wqlPatternConditions}) AND ProcessId != ${currentPid}' | Select-Object ProcessId, CreationDate | ConvertTo-Json"`;
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true });
if (!stdout.trim() || stdout.trim() === 'null') {
logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Windows)');
@@ -406,7 +408,7 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
continue;
}
try {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, stdio: 'ignore' });
execSync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, stdio: 'ignore', windowsHide: true });
} catch (error) {
// [ANTI-PATTERN IGNORED]: Cleanup loop - process may have exited, continue to next PID
logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, error as Error);
@@ -451,12 +453,14 @@ export async function aggressiveStartupCleanup(): Promise<void> {
try {
if (isWindows) {
const patternConditions = allPatterns
.map(p => `$_.CommandLine -like '*${p}*'`)
.join(' -or ');
// Use WQL -Filter for server-side filtering (no $_ pipeline syntax).
// Avoids Git Bash $_ interpretation (#1062) and PowerShell syntax errors (#1024).
const wqlPatternConditions = allPatterns
.map(p => `CommandLine LIKE '%${p}%'`)
.join(' OR ');
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { (${patternConditions}) -and $_.ProcessId -ne ${currentPid} } | Select-Object ProcessId, CommandLine, CreationDate | ConvertTo-Json"`;
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter '(${wqlPatternConditions}) AND ProcessId != ${currentPid}' | Select-Object ProcessId, CommandLine, CreationDate | ConvertTo-Json"`;
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true });
if (!stdout.trim() || stdout.trim() === 'null') {
logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Windows)');
@@ -549,7 +553,7 @@ export async function aggressiveStartupCleanup(): Promise<void> {
for (const pid of pidsToKill) {
if (!Number.isInteger(pid) || pid <= 0) continue;
try {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, stdio: 'ignore' });
execSync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, stdio: 'ignore', windowsHide: true });
} catch (error) {
logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, error as Error);
}
@@ -699,10 +703,10 @@ export function spawnDaemon(
*
* EPERM is treated as "alive" because it means the process exists but
* belongs to a different user/session (common in multi-user setups).
* PID 0 (Windows WMIC sentinel for unknown PID) is treated as alive.
* PID 0 (Windows sentinel for unknown PID) is treated as alive.
*/
export function isProcessAlive(pid: number): boolean {
// PID 0 is the Windows WMIC sentinel value — process was spawned but PID unknown
// PID 0 is the Windows sentinel value — process was spawned but PID unknown
if (pid === 0) return true;
// Invalid PIDs are not alive
@@ -720,6 +724,39 @@ export function isProcessAlive(pid: number): boolean {
}
}
/**
* Check if the PID file was written recently (within thresholdMs).
*
* Used to coordinate restarts across concurrent sessions: if the PID file
* was recently written, another session likely just restarted the worker.
* Callers should poll /api/health instead of attempting their own restart.
*
* @param thresholdMs - Maximum age in ms to consider "recent" (default: 15000)
* @returns true if the PID file exists and was modified within thresholdMs
*/
export function isPidFileRecent(thresholdMs: number = 15000): boolean {
try {
const stats = statSync(PID_FILE);
return (Date.now() - stats.mtimeMs) < thresholdMs;
} catch {
return false;
}
}
/**
* Touch the PID file to update its mtime without changing contents.
* Used after a restart to signal other sessions that a restart just completed.
*/
export function touchPidFile(): void {
try {
if (!existsSync(PID_FILE)) return;
const now = new Date();
utimesSync(PID_FILE, now, now);
} catch {
// Best-effort — failure to touch doesn't affect correctness
}
}
/**
* Read the PID file and remove it if the recorded process is dead (stale).
*
+14 -1
View File
@@ -398,9 +398,22 @@ export class PendingMessageStore {
}
/**
* Check if any session has pending work
* Check if any session has pending work.
* Excludes 'processing' messages stuck for >5 minutes (resets them to 'pending' as a side effect).
*/
hasAnyPendingWork(): boolean {
// Reset stuck 'processing' messages older than 5 minutes before checking
const stuckCutoff = Date.now() - (5 * 60 * 1000);
const resetStmt = this.db.prepare(`
UPDATE pending_messages
SET status = 'pending', started_processing_at_epoch = NULL
WHERE status = 'processing' AND started_processing_at_epoch < ?
`);
const resetResult = resetStmt.run(stuckCutoff);
if (resetResult.changes > 0) {
logger.info('QUEUE', `STUCK_RESET | hasAnyPendingWork reset ${resetResult.changes} stuck processing message(s) older than 5 minutes`);
}
const stmt = this.db.prepare(`
SELECT COUNT(*) as count FROM pending_messages
WHERE status IN ('pending', 'processing')
+102 -72
View File
@@ -46,6 +46,10 @@ export class SessionSearch {
* - Tables maintained but search paths removed
* - Triggers still fire to keep tables synchronized
*
* FTS5 may be unavailable on some platforms (e.g., Bun on Windows #791).
* When unavailable, we skip FTS table creation search falls back to
* ChromaDB (vector) and LIKE queries (structured filters) which are unaffected.
*
* TODO: Remove FTS5 infrastructure in future major version (v7.0.0)
*/
private ensureFTSTables(): void {
@@ -58,91 +62,117 @@ export class SessionSearch {
return;
}
// Runtime check: verify FTS5 is available before attempting to create tables.
// bun:sqlite on Windows may not include the FTS5 extension (#791).
if (!this.isFts5Available()) {
logger.warn('DB', 'FTS5 not available on this platform — skipping FTS table creation (search uses ChromaDB)');
return;
}
logger.info('DB', 'Creating FTS5 tables');
// Create observations_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
title,
subtitle,
narrative,
text,
facts,
concepts,
content='observations',
content_rowid='id'
);
`);
try {
// Create observations_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
title,
subtitle,
narrative,
text,
facts,
concepts,
content='observations',
content_rowid='id'
);
`);
// Populate with existing data
this.db.run(`
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
SELECT id, title, subtitle, narrative, text, facts, concepts
FROM observations;
`);
// Create triggers for observations
this.db.run(`
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
// Populate with existing data
this.db.run(`
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
SELECT id, title, subtitle, narrative, text, facts, concepts
FROM observations;
`);
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
END;
// Create triggers for observations
this.db.run(`
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
`);
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
END;
// Create session_summaries_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
request,
investigated,
learned,
completed,
next_steps,
notes,
content='session_summaries',
content_rowid='id'
);
`);
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
`);
// Populate with existing data
this.db.run(`
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
SELECT id, request, investigated, learned, completed, next_steps, notes
FROM session_summaries;
`);
// Create session_summaries_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
request,
investigated,
learned,
completed,
next_steps,
notes,
content='session_summaries',
content_rowid='id'
);
`);
// Create triggers for session_summaries
this.db.run(`
CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
// Populate with existing data
this.db.run(`
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
SELECT id, request, investigated, learned, completed, next_steps, notes
FROM session_summaries;
`);
CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
END;
// Create triggers for session_summaries
this.db.run(`
CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
`);
CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
END;
logger.info('DB', 'FTS5 tables created successfully');
CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
`);
logger.info('DB', 'FTS5 tables created successfully');
} catch (error) {
// FTS5 creation failed at runtime despite probe succeeding — degrade gracefully
logger.warn('DB', 'FTS5 table creation failed — search will use ChromaDB and LIKE queries', {}, error as Error);
}
}
/**
* Probe whether the FTS5 extension is available in the current SQLite build.
* Creates and immediately drops a temporary FTS5 table.
*/
private isFts5Available(): boolean {
try {
this.db.run('CREATE VIRTUAL TABLE _fts5_probe USING fts5(test_column)');
this.db.run('DROP TABLE _fts5_probe');
return true;
} catch {
return false;
}
}
+183 -117
View File
@@ -13,6 +13,7 @@ import {
LatestPromptResult
} from '../../types/database.js';
import type { PendingMessageStore } from './PendingMessageStore.js';
import { computeObservationContentHash, findDuplicateObservation } from './observations/store.js';
/**
* Session data store for SDK sessions, observations, and summaries
@@ -48,11 +49,17 @@ export class SessionStore {
this.repairSessionIdColumnRename();
this.addFailedAtEpochColumn();
this.addOnUpdateCascadeToForeignKeys();
this.addObservationContentHashColumn();
this.addSessionCustomTitleColumn();
}
/**
* Initialize database schema using migrations (migration004)
* This runs the core SDK tables migration if no tables exist
* Initialize database schema (migration004)
*
* ALWAYS creates core tables using CREATE TABLE IF NOT EXISTS safe to run
* regardless of schema_versions state. This fixes issue #979 where the old
* DatabaseManager migration system (versions 1-7) shared the schema_versions
* table, causing maxApplied > 0 and skipping core table creation entirely.
*/
private initializeSchema(): void {
// Create schema_versions table if it doesn't exist
@@ -64,90 +71,77 @@ export class SessionStore {
)
`);
// Get applied migrations
const appliedVersions = this.db.prepare('SELECT version FROM schema_versions ORDER BY version').all() as SchemaVersion[];
const maxApplied = appliedVersions.length > 0 ? Math.max(...appliedVersions.map(v => v.version)) : 0;
// Always create core tables — IF NOT EXISTS makes this idempotent
this.db.run(`
CREATE TABLE IF NOT EXISTS sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active'
);
// Only run migration004 if no migrations have been applied
// This creates the sdk_sessions, observations, and session_summaries tables
if (maxApplied === 0) {
logger.info('DB', 'Initializing fresh database with migration004');
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(content_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
// Migration004: SDK agent architecture tables
this.db.run(`
CREATE TABLE IF NOT EXISTS sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(content_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project);
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT UNIQUE NOT NULL,
project TEXT NOT NULL,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
files_read TEXT,
files_edited TEXT,
notes TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project);
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
`);
CREATE TABLE IF NOT EXISTS session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT UNIQUE NOT NULL,
project TEXT NOT NULL,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
files_read TEXT,
files_edited TEXT,
notes TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
`);
// Record migration004 as applied
this.db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString());
logger.info('DB', 'Migration004 applied successfully');
}
// Record migration004 as applied (OR IGNORE handles re-runs safely)
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString());
}
/**
* Ensure worker_port column exists (migration 5)
*
* NOTE: Version 5 conflicts with old DatabaseManager migration005 (which drops orphaned tables).
* We check actual column state rather than relying solely on version tracking.
*/
private ensureWorkerPortColumn(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(5) as SchemaVersion | undefined;
if (applied) return;
// Check if column exists
// Check actual column existence — don't rely on version tracking alone (issue #979)
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasWorkerPort = tableInfo.some(col => col.name === 'worker_port');
@@ -162,12 +156,12 @@ export class SessionStore {
/**
* Ensure prompt tracking columns exist (migration 6)
*
* NOTE: Version 6 conflicts with old DatabaseManager migration006 (which creates FTS5 tables).
* We check actual column state rather than relying solely on version tracking.
*/
private ensurePromptTrackingColumns(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(6) as SchemaVersion | undefined;
if (applied) return;
// Check actual column existence — don't rely on version tracking alone (issue #979)
// Check sdk_sessions for prompt_counter
const sessionsInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasPromptCounter = sessionsInfo.some(col => col.name === 'prompt_counter');
@@ -201,13 +195,12 @@ export class SessionStore {
/**
* Remove UNIQUE constraint from session_summaries.memory_session_id (migration 7)
*
* NOTE: Version 7 conflicts with old DatabaseManager migration007 (which adds discovery_tokens).
* We check actual constraint state rather than relying solely on version tracking.
*/
private removeSessionSummariesUniqueConstraint(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(7) as SchemaVersion | undefined;
if (applied) return;
// Check if UNIQUE constraint exists
// Check actual constraint state — don't rely on version tracking alone (issue #979)
const summariesIndexes = this.db.query('PRAGMA index_list(session_summaries)').all() as IndexInfo[];
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1);
@@ -222,6 +215,9 @@ export class SessionStore {
// Begin transaction
this.db.run('BEGIN TRANSACTION');
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS session_summaries_new');
// Create new table without UNIQUE constraint
this.db.run(`
CREATE TABLE session_summaries_new (
@@ -335,6 +331,9 @@ export class SessionStore {
// Begin transaction
this.db.run('BEGIN TRANSACTION');
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS observations_new');
// Create new table with text as nullable
this.db.run(`
CREATE TABLE observations_new (
@@ -428,34 +427,39 @@ export class SessionStore {
CREATE INDEX idx_user_prompts_lookup ON user_prompts(content_session_id, prompt_number);
`);
// Create FTS5 virtual table
this.db.run(`
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
prompt_text,
content='user_prompts',
content_rowid='id'
);
`);
// Create FTS5 virtual table — skip if FTS5 is unavailable (e.g., Bun on Windows #791).
// The user_prompts table itself is still created; only FTS indexing is skipped.
try {
this.db.run(`
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
prompt_text,
content='user_prompts',
content_rowid='id'
);
`);
// Create triggers to sync FTS5
this.db.run(`
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
// Create triggers to sync FTS5
this.db.run(`
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
END;
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
END;
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`);
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`);
} catch (ftsError) {
logger.warn('DB', 'FTS5 not available — user_prompts_fts skipped (search uses ChromaDB)', {}, ftsError as Error);
}
// Commit transaction
this.db.run('COMMIT');
@@ -463,7 +467,7 @@ export class SessionStore {
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString());
logger.debug('DB', 'Successfully created user_prompts table with FTS5 support');
logger.debug('DB', 'Successfully created user_prompts table');
}
/**
@@ -675,6 +679,9 @@ export class SessionStore {
this.db.run('DROP TRIGGER IF EXISTS observations_ad');
this.db.run('DROP TRIGGER IF EXISTS observations_au');
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS observations_new');
this.db.run(`
CREATE TABLE observations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -744,6 +751,9 @@ export class SessionStore {
// 2. Recreate session_summaries table
// ==========================================
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS session_summaries_new');
this.db.run(`
CREATE TABLE session_summaries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -825,6 +835,44 @@ 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;
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');
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(22, new Date().toISOString());
}
/**
* Add custom_title column to sdk_sessions for agent attribution (migration 23)
*/
private addSessionCustomTitleColumn(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(23) as SchemaVersion | undefined;
if (applied) return;
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasColumn = tableInfo.some(col => col.name === 'custom_title');
if (!hasColumn) {
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN custom_title TEXT');
logger.debug('DB', 'Added custom_title column to sdk_sessions table');
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
}
/**
* Update the memory session ID for a session
* Called by SDKAgent when it captures the session ID from the first SDK message
@@ -1290,9 +1338,10 @@ export class SessionStore {
memory_session_id: string | null;
project: string;
user_prompt: string;
custom_title: string | null;
} | null {
const stmt = this.db.prepare(`
SELECT id, content_session_id, memory_session_id, project, user_prompt
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title
FROM sdk_sessions
WHERE id = ?
LIMIT 1
@@ -1311,6 +1360,7 @@ export class SessionStore {
memory_session_id: string;
project: string;
user_prompt: string;
custom_title: string | null;
started_at: string;
started_at_epoch: number;
completed_at: string | null;
@@ -1321,7 +1371,7 @@ export class SessionStore {
const placeholders = memorySessionIds.map(() => '?').join(',');
const stmt = this.db.prepare(`
SELECT id, content_session_id, memory_session_id, project, user_prompt,
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title,
started_at, started_at_epoch, completed_at, completed_at_epoch, status
FROM sdk_sessions
WHERE memory_session_id IN (${placeholders})
@@ -1366,7 +1416,7 @@ export class SessionStore {
* Pure get-or-create: never modifies memory_session_id.
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
*/
createSDKSession(contentSessionId: string, project: string, userPrompt: string): number {
createSDKSession(contentSessionId: string, project: string, userPrompt: string, customTitle?: string): number {
const now = new Date();
const nowEpoch = now.getTime();
@@ -1383,6 +1433,13 @@ export class SessionStore {
WHERE content_session_id = ? AND (project IS NULL OR project = '')
`).run(project, contentSessionId);
}
// Backfill custom_title if provided and not yet set
if (customTitle) {
this.db.prepare(`
UPDATE sdk_sessions SET custom_title = ?
WHERE content_session_id = ? AND custom_title IS NULL
`).run(customTitle, contentSessionId);
}
return existing.id;
}
@@ -1392,9 +1449,9 @@ export class SessionStore {
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
this.db.prepare(`
INSERT INTO sdk_sessions
(content_session_id, memory_session_id, project, user_prompt, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, userPrompt, now.toISOString(), nowEpoch);
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
// Return new ID
const row = this.db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
@@ -1441,6 +1498,7 @@ export class SessionStore {
/**
* Store an observation (from SDK parsing)
* Assumes session already exists (created by hook)
* Performs content-hash deduplication: skips INSERT if an identical observation exists within 30s
*/
storeObservation(
memorySessionId: string,
@@ -1463,11 +1521,18 @@ export class SessionStore {
const timestampEpoch = overrideTimestampEpoch ?? Date.now();
const timestampIso = new Date(timestampEpoch).toISOString();
// Content-hash deduplication
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
const existing = findDuplicateObservation(this.db, contentHash, timestampEpoch);
if (existing) {
return { id: existing.id, createdAtEpoch: existing.created_at_epoch };
}
const stmt = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -1483,6 +1548,7 @@ export class SessionStore {
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
contentHash,
timestampIso,
timestampEpoch
);
+10
View File
@@ -372,6 +372,16 @@ export const migration005: Migration = {
export const migration006: Migration = {
version: 6,
up: (db: Database) => {
// FTS5 may be unavailable on some platforms (e.g., Bun on Windows #791).
// Probe before creating tables — search falls back to ChromaDB when unavailable.
try {
db.run('CREATE VIRTUAL TABLE _fts5_probe USING fts5(test_column)');
db.run('DROP TABLE _fts5_probe');
} catch {
console.log('⚠️ FTS5 not available on this platform — skipping FTS migration (search uses ChromaDB)');
return;
}
// FTS5 virtual table for observations
// Note: This assumes the hierarchical fields (title, subtitle, etc.) already exist
// from the inline migrations in SessionStore constructor
+340 -109
View File
@@ -31,11 +31,18 @@ export class MigrationRunner {
this.renameSessionIdColumns();
this.repairSessionIdColumnRename();
this.addFailedAtEpochColumn();
this.addOnUpdateCascadeToForeignKeys();
this.addObservationContentHashColumn();
this.addSessionCustomTitleColumn();
}
/**
* Initialize database schema using migrations (migration004)
* This runs the core SDK tables migration if no tables exist
* Initialize database schema (migration004)
*
* ALWAYS creates core tables using CREATE TABLE IF NOT EXISTS safe to run
* regardless of schema_versions state. This fixes issue #979 where the old
* DatabaseManager migration system (versions 1-7) shared the schema_versions
* table, causing maxApplied > 0 and skipping core table creation entirely.
*/
private initializeSchema(): void {
// Create schema_versions table if it doesn't exist
@@ -47,90 +54,77 @@ export class MigrationRunner {
)
`);
// Get applied migrations
const appliedVersions = this.db.prepare('SELECT version FROM schema_versions ORDER BY version').all() as SchemaVersion[];
const maxApplied = appliedVersions.length > 0 ? Math.max(...appliedVersions.map(v => v.version)) : 0;
// Always create core tables — IF NOT EXISTS makes this idempotent
this.db.run(`
CREATE TABLE IF NOT EXISTS sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active'
);
// Only run migration004 if no migrations have been applied
// This creates the sdk_sessions, observations, and session_summaries tables
if (maxApplied === 0) {
logger.info('DB', 'Initializing fresh database with migration004');
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(content_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
// Migration004: SDK agent architecture tables
this.db.run(`
CREATE TABLE IF NOT EXISTS sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(content_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project);
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT UNIQUE NOT NULL,
project TEXT NOT NULL,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
files_read TEXT,
files_edited TEXT,
notes TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project);
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
`);
CREATE TABLE IF NOT EXISTS session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT UNIQUE NOT NULL,
project TEXT NOT NULL,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
files_read TEXT,
files_edited TEXT,
notes TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
`);
// Record migration004 as applied
this.db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString());
logger.info('DB', 'Migration004 applied successfully');
}
// Record migration004 as applied (OR IGNORE handles re-runs safely)
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString());
}
/**
* Ensure worker_port column exists (migration 5)
*
* NOTE: Version 5 conflicts with old DatabaseManager migration005 (which drops orphaned tables).
* We check actual column state rather than relying solely on version tracking.
*/
private ensureWorkerPortColumn(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(5) as SchemaVersion | undefined;
if (applied) return;
// Check if column exists
// Check actual column existence — don't rely on version tracking alone (issue #979)
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasWorkerPort = tableInfo.some(col => col.name === 'worker_port');
@@ -145,12 +139,12 @@ export class MigrationRunner {
/**
* Ensure prompt tracking columns exist (migration 6)
*
* NOTE: Version 6 conflicts with old DatabaseManager migration006 (which creates FTS5 tables).
* We check actual column state rather than relying solely on version tracking.
*/
private ensurePromptTrackingColumns(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(6) as SchemaVersion | undefined;
if (applied) return;
// Check actual column existence — don't rely on version tracking alone (issue #979)
// Check sdk_sessions for prompt_counter
const sessionsInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasPromptCounter = sessionsInfo.some(col => col.name === 'prompt_counter');
@@ -184,13 +178,12 @@ export class MigrationRunner {
/**
* Remove UNIQUE constraint from session_summaries.memory_session_id (migration 7)
*
* NOTE: Version 7 conflicts with old DatabaseManager migration007 (which adds discovery_tokens).
* We check actual constraint state rather than relying solely on version tracking.
*/
private removeSessionSummariesUniqueConstraint(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(7) as SchemaVersion | undefined;
if (applied) return;
// Check if UNIQUE constraint exists
// Check actual constraint state — don't rely on version tracking alone (issue #979)
const summariesIndexes = this.db.query('PRAGMA index_list(session_summaries)').all() as IndexInfo[];
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1);
@@ -205,6 +198,9 @@ export class MigrationRunner {
// Begin transaction
this.db.run('BEGIN TRANSACTION');
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS session_summaries_new');
// Create new table without UNIQUE constraint
this.db.run(`
CREATE TABLE session_summaries_new (
@@ -318,6 +314,9 @@ export class MigrationRunner {
// Begin transaction
this.db.run('BEGIN TRANSACTION');
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS observations_new');
// Create new table with text as nullable
this.db.run(`
CREATE TABLE observations_new (
@@ -411,34 +410,39 @@ export class MigrationRunner {
CREATE INDEX idx_user_prompts_lookup ON user_prompts(content_session_id, prompt_number);
`);
// Create FTS5 virtual table
this.db.run(`
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
prompt_text,
content='user_prompts',
content_rowid='id'
);
`);
// Create FTS5 virtual table — skip if FTS5 is unavailable (e.g., Bun on Windows #791).
// The user_prompts table itself is still created; only FTS indexing is skipped.
try {
this.db.run(`
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
prompt_text,
content='user_prompts',
content_rowid='id'
);
`);
// Create triggers to sync FTS5
this.db.run(`
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
// Create triggers to sync FTS5
this.db.run(`
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
END;
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
END;
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`);
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`);
} catch (ftsError) {
logger.warn('DB', 'FTS5 not available — user_prompts_fts skipped (search uses ChromaDB)', {}, ftsError as Error);
}
// Commit transaction
this.db.run('COMMIT');
@@ -446,7 +450,7 @@ export class MigrationRunner {
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString());
logger.debug('DB', 'Successfully created user_prompts table with FTS5 support');
logger.debug('DB', 'Successfully created user_prompts table');
}
/**
@@ -628,4 +632,231 @@ export class MigrationRunner {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(20, new Date().toISOString());
}
/**
* Add ON UPDATE CASCADE to FK constraints on observations and session_summaries (migration 21)
*
* Both tables have FK(memory_session_id) -> sdk_sessions(memory_session_id) with ON DELETE CASCADE
* but missing ON UPDATE CASCADE. This causes FK constraint violations when code updates
* sdk_sessions.memory_session_id while child rows still reference the old value.
*
* SQLite doesn't support ALTER TABLE for FK changes, so we recreate both tables.
*/
private addOnUpdateCascadeToForeignKeys(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(21) as SchemaVersion | undefined;
if (applied) return;
logger.debug('DB', 'Adding ON UPDATE CASCADE to FK constraints on observations and session_summaries');
// PRAGMA foreign_keys must be set outside a transaction
this.db.run('PRAGMA foreign_keys = OFF');
this.db.run('BEGIN TRANSACTION');
try {
// ==========================================
// 1. Recreate observations table
// ==========================================
// Drop FTS triggers first (they reference the observations table)
this.db.run('DROP TRIGGER IF EXISTS observations_ai');
this.db.run('DROP TRIGGER IF EXISTS observations_ad');
this.db.run('DROP TRIGGER IF EXISTS observations_au');
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS observations_new');
this.db.run(`
CREATE TABLE observations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT,
type TEXT NOT NULL,
title TEXT,
subtitle TEXT,
facts TEXT,
narrative TEXT,
concepts TEXT,
files_read TEXT,
files_modified TEXT,
prompt_number INTEGER,
discovery_tokens INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
)
`);
this.db.run(`
INSERT INTO observations_new
SELECT 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
FROM observations
`);
this.db.run('DROP TABLE observations');
this.db.run('ALTER TABLE observations_new RENAME TO observations');
// Recreate indexes
this.db.run(`
CREATE INDEX idx_observations_sdk_session ON observations(memory_session_id);
CREATE INDEX idx_observations_project ON observations(project);
CREATE INDEX idx_observations_type ON observations(type);
CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC);
`);
// Recreate FTS triggers only if observations_fts exists
const hasFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'").all() as { name: string }[]).length > 0;
if (hasFTS) {
this.db.run(`
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
END;
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
`);
}
// ==========================================
// 2. Recreate session_summaries table
// ==========================================
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS session_summaries_new');
this.db.run(`
CREATE TABLE session_summaries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_session_id TEXT NOT NULL,
project TEXT NOT NULL,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
files_read TEXT,
files_edited TEXT,
notes TEXT,
prompt_number INTEGER,
discovery_tokens INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
)
`);
this.db.run(`
INSERT INTO session_summaries_new
SELECT id, memory_session_id, project, request, investigated, learned,
completed, next_steps, files_read, files_edited, notes,
prompt_number, discovery_tokens, created_at, created_at_epoch
FROM session_summaries
`);
// Drop session_summaries FTS triggers before dropping the table
this.db.run('DROP TRIGGER IF EXISTS session_summaries_ai');
this.db.run('DROP TRIGGER IF EXISTS session_summaries_ad');
this.db.run('DROP TRIGGER IF EXISTS session_summaries_au');
this.db.run('DROP TABLE session_summaries');
this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries');
// Recreate indexes
this.db.run(`
CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
CREATE INDEX idx_session_summaries_project ON session_summaries(project);
CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
`);
// Recreate session_summaries FTS triggers if FTS table exists
const hasSummariesFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_summaries_fts'").all() as { name: string }[]).length > 0;
if (hasSummariesFTS) {
this.db.run(`
CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
END;
CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
`);
}
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(21, new Date().toISOString());
this.db.run('COMMIT');
this.db.run('PRAGMA foreign_keys = ON');
logger.debug('DB', 'Successfully added ON UPDATE CASCADE to FK constraints');
} catch (error) {
this.db.run('ROLLBACK');
this.db.run('PRAGMA foreign_keys = ON');
throw error;
}
}
/**
* Add content_hash column to observations for deduplication (migration 22)
* Prevents duplicate observations from being stored when the same content is processed multiple times.
* 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;
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');
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(22, new Date().toISOString());
}
/**
* Add custom_title column to sdk_sessions for agent attribution (migration 23)
* Allows callers (e.g. Maestro agents) to label sessions with a human-readable name.
*/
private addSessionCustomTitleColumn(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(23) as SchemaVersion | undefined;
if (applied) return;
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasColumn = tableInfo.some(col => col.name === 'custom_title');
if (!hasColumn) {
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN custom_title TEXT');
logger.debug('DB', 'Added custom_title column to sdk_sessions table');
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
}
}
+52 -3
View File
@@ -3,13 +3,50 @@
* Extracted from SessionStore.ts for modular organization
*/
import { createHash } from 'crypto';
import { Database } from 'bun:sqlite';
import { logger } from '../../../utils/logger.js';
import { getCurrentProjectName } from '../../../shared/paths.js';
import type { ObservationInput, StoreObservationResult } from './types.js';
/** Deduplication window: observations with the same content hash within this window are skipped */
const DEDUP_WINDOW_MS = 30_000;
/**
* Compute a short content hash for deduplication.
* Uses (memory_session_id, title, narrative) as the semantic identity of an observation.
*/
export function computeObservationContentHash(
memorySessionId: string,
title: string | null,
narrative: string | null
): string {
return createHash('sha256')
.update((memorySessionId || '') + (title || '') + (narrative || ''))
.digest('hex')
.slice(0, 16);
}
/**
* Check if a duplicate observation exists within the dedup window.
* Returns the existing observation's id and timestamp if found, null otherwise.
*/
export function findDuplicateObservation(
db: Database,
contentHash: string,
timestampEpoch: number
): { id: number; created_at_epoch: number } | null {
const windowStart = timestampEpoch - DEDUP_WINDOW_MS;
const stmt = db.prepare(
'SELECT id, created_at_epoch FROM observations WHERE content_hash = ? AND created_at_epoch > ?'
);
return (stmt.get(contentHash, windowStart) as { id: number; created_at_epoch: number } | null);
}
/**
* Store an observation (from SDK parsing)
* Assumes session already exists (created by hook)
* Performs content-hash deduplication: skips INSERT if an identical observation exists within 30s
*/
export function storeObservation(
db: Database,
@@ -24,16 +61,27 @@ export function storeObservation(
const timestampEpoch = overrideTimestampEpoch ?? Date.now();
const timestampIso = new Date(timestampEpoch).toISOString();
// Guard against empty project string (race condition where project isn't set yet)
const resolvedProject = project || getCurrentProjectName();
// Content-hash deduplication
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
const existing = findDuplicateObservation(db, contentHash, timestampEpoch);
if (existing) {
logger.debug('DEDUP', `Skipped duplicate observation | contentHash=${contentHash} | existingId=${existing.id}`);
return { id: existing.id, createdAtEpoch: existing.created_at_epoch };
}
const stmt = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
memorySessionId,
project,
resolvedProject,
observation.type,
observation.title,
observation.subtitle,
@@ -44,6 +92,7 @@ export function storeObservation(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
contentHash,
timestampIso,
timestampEpoch
);
+12 -4
View File
@@ -21,7 +21,8 @@ export function createSDKSession(
db: Database,
contentSessionId: string,
project: string,
userPrompt: string
userPrompt: string,
customTitle?: string
): number {
const now = new Date();
const nowEpoch = now.getTime();
@@ -39,6 +40,13 @@ export function createSDKSession(
WHERE content_session_id = ? AND (project IS NULL OR project = '')
`).run(project, contentSessionId);
}
// Backfill custom_title if provided and not yet set
if (customTitle) {
db.prepare(`
UPDATE sdk_sessions SET custom_title = ?
WHERE content_session_id = ? AND custom_title IS NULL
`).run(customTitle, contentSessionId);
}
return existing.id;
}
@@ -48,9 +56,9 @@ export function createSDKSession(
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
db.prepare(`
INSERT INTO sdk_sessions
(content_session_id, memory_session_id, project, user_prompt, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, userPrompt, now.toISOString(), nowEpoch);
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
// Return new ID
const row = db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
+2 -2
View File
@@ -17,7 +17,7 @@ import type {
*/
export function getSessionById(db: Database, id: number): SessionBasic | null {
const stmt = db.prepare(`
SELECT id, content_session_id, memory_session_id, project, user_prompt
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title
FROM sdk_sessions
WHERE id = ?
LIMIT 1
@@ -38,7 +38,7 @@ export function getSdkSessionsBySessionIds(
const placeholders = memorySessionIds.map(() => '?').join(',');
const stmt = db.prepare(`
SELECT id, content_session_id, memory_session_id, project, user_prompt,
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title,
started_at, started_at_epoch, completed_at, completed_at_epoch, status
FROM sdk_sessions
WHERE memory_session_id IN (${placeholders})
+2
View File
@@ -13,6 +13,7 @@ export interface SessionBasic {
memory_session_id: string | null;
project: string;
user_prompt: string;
custom_title: string | null;
}
/**
@@ -24,6 +25,7 @@ export interface SessionFull {
memory_session_id: string;
project: string;
user_prompt: string;
custom_title: string | null;
started_at: string;
started_at_epoch: number;
completed_at: string | null;
+23 -6
View File
@@ -10,6 +10,7 @@ import { Database } from 'bun:sqlite';
import { logger } from '../../utils/logger.js';
import type { ObservationInput } from './observations/types.js';
import type { SummaryInput } from './summaries/types.js';
import { computeObservationContentHash, findDuplicateObservation } from './observations/store.js';
/**
* Result from storeObservations / storeObservationsAndMarkComplete transaction
@@ -63,15 +64,22 @@ export function storeObservationsAndMarkComplete(
const storeAndMarkTx = db.transaction(() => {
const observationIds: number[] = [];
// 1. Store all observations
// 1. Store all observations (with content-hash deduplication)
const obsStmt = 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) {
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
const existing = findDuplicateObservation(db, contentHash, timestampEpoch);
if (existing) {
observationIds.push(existing.id);
continue;
}
const result = obsStmt.run(
memorySessionId,
project,
@@ -85,6 +93,7 @@ export function storeObservationsAndMarkComplete(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
contentHash,
timestampIso,
timestampEpoch
);
@@ -174,15 +183,22 @@ export function storeObservations(
const storeTx = db.transaction(() => {
const observationIds: number[] = [];
// 1. Store all observations
// 1. Store all observations (with content-hash deduplication)
const obsStmt = 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) {
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
const existing = findDuplicateObservation(db, contentHash, timestampEpoch);
if (existing) {
observationIds.push(existing.id);
continue;
}
const result = obsStmt.run(
memorySessionId,
project,
@@ -196,6 +212,7 @@ export function storeObservations(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
contentHash,
timestampIso,
timestampEpoch
);
+44 -10
View File
@@ -102,17 +102,23 @@ export class ChromaMcpManager {
const commandArgs = this.buildCommandArgs();
const spawnEnvironment = this.getSpawnEnv();
// On Windows, .cmd files require shell resolution. Since MCP SDK's
// StdioClientTransport doesn't support `shell: true`, route through
// cmd.exe which resolves .cmd/.bat extensions and PATH automatically.
// This also fixes Git Bash compatibility (#1062) since cmd.exe handles
// Windows-native command resolution regardless of the calling shell.
const isWindows = process.platform === 'win32';
const uvxCommand = isWindows ? 'uvx.cmd' : 'uvx';
const uvxSpawnCommand = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'uvx';
const uvxSpawnArgs = isWindows ? ['/c', 'uvx', ...commandArgs] : commandArgs;
logger.info('CHROMA_MCP', 'Connecting to chroma-mcp via MCP stdio', {
command: uvxCommand,
args: commandArgs.join(' ')
command: uvxSpawnCommand,
args: uvxSpawnArgs.join(' ')
});
this.transport = new StdioClientTransport({
command: uvxCommand,
args: commandArgs,
command: uvxSpawnCommand,
args: uvxSpawnArgs,
env: spawnEnvironment,
stderr: 'pipe'
});
@@ -177,6 +183,7 @@ export class ChromaMcpManager {
private buildCommandArgs(): string[] {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const chromaMode = settings.CLAUDE_MEM_CHROMA_MODE || 'local';
const pythonVersion = process.env.CLAUDE_MEM_PYTHON_VERSION || settings.CLAUDE_MEM_PYTHON_VERSION || '3.13';
if (chromaMode === 'remote') {
const chromaHost = settings.CLAUDE_MEM_CHROMA_HOST || '127.0.0.1';
@@ -187,6 +194,7 @@ export class ChromaMcpManager {
const chromaApiKey = settings.CLAUDE_MEM_CHROMA_API_KEY || '';
const args = [
'--python', pythonVersion,
'chroma-mcp',
'--client-type', 'http',
'--host', chromaHost,
@@ -214,9 +222,10 @@ export class ChromaMcpManager {
// Local mode: persistent client with data directory
return [
'--python', pythonVersion,
'chroma-mcp',
'--client-type', 'persistent',
'--data-dir', DEFAULT_CHROMA_DATA_DIR
'--data-dir', DEFAULT_CHROMA_DATA_DIR.replace(/\\/g, '/')
];
}
@@ -235,10 +244,35 @@ export class ChromaMcpManager {
arguments: JSON.stringify(toolArguments).slice(0, 200)
});
const result = await this.client!.callTool({
name: toolName,
arguments: toolArguments
});
let result;
try {
result = await this.client!.callTool({
name: toolName,
arguments: toolArguments
});
} catch (transportError) {
// Transport error: chroma-mcp subprocess likely died (e.g., killed by orphan reaper,
// HNSW index corruption). Mark connection dead and retry once after reconnect (#1131).
// Without this retry, callers see a one-shot error even though reconnect would succeed.
this.connected = false;
this.client = null;
this.transport = null;
logger.warn('CHROMA_MCP', `Transport error during "${toolName}", reconnecting and retrying once`, {
error: transportError instanceof Error ? transportError.message : String(transportError)
});
try {
await this.ensureConnected();
result = await this.client!.callTool({
name: toolName,
arguments: toolArguments
});
} catch (retryError) {
this.connected = false;
throw new Error(`chroma-mcp transport error during "${toolName}" (retry failed): ${retryError instanceof Error ? retryError.message : String(retryError)}`);
}
}
// MCP tools signal errors via isError flag on the CallToolResult
if (result.isError) {
+22 -6
View File
@@ -267,12 +267,28 @@ export class ChromaSync {
for (let i = 0; i < documents.length; i += this.BATCH_SIZE) {
const batch = documents.slice(i, i + this.BATCH_SIZE);
await chromaMcp.callTool('chroma_add_documents', {
collection_name: this.collectionName,
ids: batch.map(d => d.id),
documents: batch.map(d => d.document),
metadatas: batch.map(d => d.metadata)
});
// Sanitize metadata: filter out null, undefined, and empty string values
// that chroma-mcp may reject (e.g., null subtitle from raw SQLite rows)
const cleanMetadatas = batch.map(d =>
Object.fromEntries(
Object.entries(d.metadata).filter(([_, v]) => v !== null && v !== undefined && v !== '')
)
);
try {
await chromaMcp.callTool('chroma_add_documents', {
collection_name: this.collectionName,
ids: batch.map(d => d.id),
documents: batch.map(d => d.document),
metadatas: cleanMetadatas
});
} catch (error) {
logger.error('CHROMA_SYNC', 'Batch add failed, continuing with remaining batches', {
collection: this.collectionName,
batchStart: i,
batchSize: batch.length
}, error as Error);
}
}
logger.debug('CHROMA_SYNC', 'Documents added', {
+57 -5
View File
@@ -59,6 +59,10 @@ function clearWorkerSpawnAttempted(): void {
}
}
// Re-export for backward compatibility — canonical implementation in shared/plugin-state.ts
export { isPluginDisabledInClaudeSettings } from '../shared/plugin-state.js';
import { isPluginDisabledInClaudeSettings } from '../shared/plugin-state.js';
// Version injected at build time by esbuild define
declare const __DEFAULT_PACKAGE_VERSION__: string;
const packageVersion = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev';
@@ -74,7 +78,9 @@ import {
cleanStalePidFile,
isProcessAlive,
spawnDaemon,
createSignalHandler
createSignalHandler,
isPidFileRecent,
touchPidFile
} from './infrastructure/ProcessManager.js';
import {
isPortInUse,
@@ -385,9 +391,14 @@ export class WorkerService {
runOneTimeChromaMigration();
}
// Initialize ChromaMcpManager (lazy - connects on first use via ChromaSync)
this.chromaMcpManager = ChromaMcpManager.getInstance();
logger.info('SYSTEM', 'ChromaMcpManager initialized (lazy - connects on first use)');
// Initialize ChromaMcpManager only if Chroma is enabled
const chromaEnabled = settings.CLAUDE_MEM_CHROMA_ENABLED !== 'false';
if (chromaEnabled) {
this.chromaMcpManager = ChromaMcpManager.getInstance();
logger.info('SYSTEM', 'ChromaMcpManager initialized (lazy - connects on first use)');
} else {
logger.info('SYSTEM', 'Chroma disabled via CLAUDE_MEM_CHROMA_ENABLED=false, skipping ChromaMcpManager');
}
const modeId = settings.CLAUDE_MEM_MODE;
ModeManager.getInstance().loadMode(modeId);
@@ -535,6 +546,9 @@ export class WorkerService {
logger.info('SYSTEM', `Starting generator (${source}) using ${providerName}`, { sessionId: sid });
// Track generator activity for stale detection (Issue #1099)
session.lastGeneratorActivity = Date.now();
session.generatorPromise = agent.startSession(session, this)
.catch(async (error: unknown) => {
const errorMessage = (error as Error)?.message || '';
@@ -893,6 +907,23 @@ async function ensureWorkerStarted(port: number): Promise<boolean> {
if (await waitForHealth(port, 1000)) {
const versionCheck = await checkVersionMatch(port);
if (!versionCheck.matches) {
// Guard: If PID file was written recently, another session is likely already
// restarting the worker. Poll health instead of starting a concurrent restart.
// This prevents the "100 sessions all restart simultaneously" storm (#1145).
const RESTART_COORDINATION_THRESHOLD_MS = 15000;
if (isPidFileRecent(RESTART_COORDINATION_THRESHOLD_MS)) {
logger.info('SYSTEM', 'Version mismatch detected but PID file is recent — another restart likely in progress, polling health', {
pluginVersion: versionCheck.pluginVersion,
workerVersion: versionCheck.workerVersion
});
const healthy = await waitForHealth(port, RESTART_COORDINATION_THRESHOLD_MS);
if (healthy) {
logger.info('SYSTEM', 'Worker became healthy after waiting for concurrent restart');
return true;
}
logger.warn('SYSTEM', 'Worker did not become healthy after waiting — proceeding with own restart');
}
logger.info('SYSTEM', 'Worker version mismatch detected - auto-restarting', {
pluginVersion: versionCheck.pluginVersion,
workerVersion: versionCheck.workerVersion
@@ -957,6 +988,9 @@ async function ensureWorkerStarted(port: number): Promise<boolean> {
}
clearWorkerSpawnAttempted();
// Touch PID file to signal other sessions that a restart just completed.
// Other sessions checking isPidFileRecent() will see this and skip their own restart.
touchPidFile();
logger.info('SYSTEM', 'Worker started successfully');
return true;
}
@@ -967,6 +1001,14 @@ async function ensureWorkerStarted(port: number): Promise<boolean> {
async function main() {
const command = process.argv[2];
// Early exit if plugin is disabled in Claude Code settings (#781).
// Only gate hook-initiated commands; CLI management (stop/status) still works.
const hookInitiatedCommands = ['start', 'hook', 'restart', '--daemon'];
if ((hookInitiatedCommands.includes(command) || command === undefined) && isPluginDisabledInClaudeSettings()) {
process.exit(0);
}
const port = getWorkerPort();
// Helper for JSON status output in 'start' command
@@ -985,6 +1027,7 @@ async function main() {
} else {
exitWithStatus('error', 'Failed to start worker');
}
break;
}
case 'stop': {
@@ -996,6 +1039,7 @@ async function main() {
removePidFile();
logger.info('SYSTEM', 'Worker stopped successfully');
process.exit(0);
break;
}
case 'restart': {
@@ -1032,6 +1076,7 @@ async function main() {
logger.info('SYSTEM', 'Worker restarted successfully');
process.exit(0);
break;
}
case 'status': {
@@ -1046,12 +1091,14 @@ async function main() {
console.log('Worker is not running');
}
process.exit(0);
break;
}
case 'cursor': {
const subcommand = process.argv[3];
const cursorResult = await handleCursorCommand(subcommand, process.argv.slice(4));
process.exit(cursorResult);
break;
}
case 'hook': {
@@ -1105,6 +1152,7 @@ async function main() {
const { generateClaudeMd } = await import('../cli/claude-md-commands.js');
const result = await generateClaudeMd(dryRun);
process.exit(result);
break;
}
case 'clean': {
@@ -1112,6 +1160,7 @@ async function main() {
const { cleanClaudeMd } = await import('../cli/claude-md-commands.js');
const result = await cleanClaudeMd(dryRun);
process.exit(result);
break;
}
case '--daemon':
@@ -1168,5 +1217,8 @@ const isMainModule = typeof require !== 'undefined' && typeof module !== 'undefi
: import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('worker-service');
if (isMainModule) {
main();
main().catch((error) => {
logger.error('SYSTEM', 'Fatal error in main', {}, error instanceof Error ? error : undefined);
process.exit(0); // Exit 0: don't block Claude Code, don't leave Windows Terminal tabs open
});
}
+1
View File
@@ -36,6 +36,7 @@ export interface ActiveSession {
consecutiveRestarts: number; // Track consecutive restart attempts to prevent infinite loops
forceInit?: boolean; // Force fresh SDK session (skip resume)
idleTimedOut?: boolean; // Set when session exits due to idle timeout (prevents restart loop)
lastGeneratorActivity: number; // Timestamp of last generator progress (for stale detection, Issue #1099)
// CLAIM-CONFIRM FIX: Track IDs of messages currently being processed
// These IDs will be confirmed (deleted) after successful storage
processingMessageIds: number[];
+12 -7
View File
@@ -11,6 +11,8 @@
import { SessionStore } from '../sqlite/SessionStore.js';
import { SessionSearch } from '../sqlite/SessionSearch.js';
import { ChromaSync } from '../sync/ChromaSync.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
import type { DBSession } from '../worker-types.js';
@@ -27,8 +29,14 @@ export class DatabaseManager {
this.sessionStore = new SessionStore();
this.sessionSearch = new SessionSearch();
// Initialize ChromaSync (lazy - connects on first search, not at startup)
this.chromaSync = new ChromaSync('claude-mem');
// Initialize ChromaSync only if Chroma is enabled (SQLite-only fallback when disabled)
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const chromaEnabled = settings.CLAUDE_MEM_CHROMA_ENABLED !== 'false';
if (chromaEnabled) {
this.chromaSync = new ChromaSync('claude-mem');
} else {
logger.info('DB', 'Chroma disabled via CLAUDE_MEM_CHROMA_ENABLED=false, using SQLite-only search');
}
logger.info('DB', 'Database initialized');
}
@@ -75,12 +83,9 @@ export class DatabaseManager {
}
/**
* Get ChromaSync instance (throws if not initialized)
* Get ChromaSync instance (returns null if Chroma is disabled)
*/
getChromaSync(): ChromaSync {
if (!this.chromaSync) {
throw new Error('ChromaSync not initialized');
}
getChromaSync(): ChromaSync | null {
return this.chromaSync;
}
+1 -1
View File
@@ -39,7 +39,7 @@ export class SearchManager {
constructor(
private sessionSearch: SessionSearch,
private sessionStore: SessionStore,
private chromaSync: ChromaSync,
private chromaSync: ChromaSync | null,
private formatter: FormattingService,
private timelineService: TimelineService
) {
+13 -3
View File
@@ -155,7 +155,8 @@ export class SessionManager {
conversationHistory: [], // Initialize empty - will be populated by agents
currentProvider: null, // Will be set when generator starts
consecutiveRestarts: 0, // Track consecutive restart attempts to prevent infinite loops
processingMessageIds: [] // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
processingMessageIds: [], // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
lastGeneratorActivity: Date.now() // Initialize for stale detection (Issue #1099)
};
logger.debug('SESSION', 'Creating new session object (memorySessionId cleared to prevent stale resume)', {
@@ -286,11 +287,17 @@ export class SessionManager {
// 1. Abort the SDK agent
session.abortController.abort();
// 2. Wait for generator to finish
// 2. Wait for generator to finish (with 30s timeout to prevent stale stall, Issue #1099)
if (session.generatorPromise) {
await session.generatorPromise.catch(() => {
const generatorDone = session.generatorPromise.catch(() => {
logger.debug('SYSTEM', 'Generator already failed, cleaning up', { sessionId: session.sessionDbId });
});
const timeoutDone = new Promise<void>(resolve => {
AbortSignal.timeout(30_000).addEventListener('abort', () => resolve(), { once: true });
});
await Promise.race([generatorDone, timeoutDone]).then(() => {}, () => {
logger.warn('SESSION', 'Generator did not exit within 30s after abort, forcing cleanup (#1099)', { sessionDbId });
});
}
// 3. Verify subprocess exit with 5s timeout (Issue #737 fix)
@@ -468,6 +475,9 @@ export class SessionManager {
session.earliestPendingTimestamp = Math.min(session.earliestPendingTimestamp, message._originalTimestamp);
}
// Update generator activity for stale detection (Issue #1099)
session.lastGeneratorActivity = Date.now();
yield message;
}
}
@@ -56,6 +56,9 @@ export async function processAgentResponse(
agentName: string,
projectRoot?: string
): Promise<void> {
// Track generator activity for stale detection (Issue #1099)
session.lastGeneratorActivity = Date.now();
// Add assistant response to shared conversation history for provider interop
if (text) {
session.conversationHistory.push({ role: 'assistant', content: text });
@@ -189,8 +192,8 @@ async function syncAndBroadcastObservations(
const obs = observations[i];
const chromaStart = Date.now();
// Sync to Chroma (fire-and-forget)
dbManager.getChromaSync().syncObservation(
// Sync to Chroma (fire-and-forget, skipped if Chroma is disabled)
dbManager.getChromaSync()?.syncObservation(
obsId,
session.contentSessionId,
session.project,
@@ -282,8 +285,8 @@ async function syncAndBroadcastSummary(
const chromaStart = Date.now();
// Sync to Chroma (fire-and-forget)
dbManager.getChromaSync().syncSummary(
// Sync to Chroma (fire-and-forget, skipped if Chroma is disabled)
dbManager.getChromaSync()?.syncSummary(
result.summaryId,
session.contentSessionId,
session.project,
+2
View File
@@ -37,6 +37,8 @@ export function createMiddleware(
callback(new Error('CORS not allowed'));
}
},
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: false
}));
+12 -2
View File
@@ -114,7 +114,12 @@ export class DataRoutes extends BaseRouteHandler {
* Body: { ids: number[], orderBy?: 'date_desc' | 'date_asc', limit?: number, project?: string }
*/
private handleGetObservationsByIds = this.wrapHandler((req: Request, res: Response): void => {
const { ids, orderBy, limit, project } = req.body;
let { ids, orderBy, limit, project } = req.body;
// Coerce string-encoded arrays from MCP clients (e.g. "[1,2,3]" or "1,2,3")
if (typeof ids === 'string') {
try { ids = JSON.parse(ids); } catch { ids = ids.split(',').map(Number); }
}
if (!ids || !Array.isArray(ids)) {
this.badRequest(res, 'ids must be an array of numbers');
@@ -163,7 +168,12 @@ export class DataRoutes extends BaseRouteHandler {
* Body: { memorySessionIds: string[] }
*/
private handleGetSdkSessionsByIds = this.wrapHandler((req: Request, res: Response): void => {
const { memorySessionIds } = req.body;
let { memorySessionIds } = req.body;
// Coerce string-encoded arrays from MCP clients (e.g. '["a","b"]' or "a,b")
if (typeof memorySessionIds === 'string') {
try { memorySessionIds = JSON.parse(memorySessionIds); } catch { memorySessionIds = memorySessionIds.split(',').map((s: string) => s.trim()); }
}
if (!Array.isArray(memorySessionIds)) {
this.badRequest(res, 'memorySessionIds must be an array');
+78 -9
View File
@@ -5,12 +5,85 @@
*/
import express, { Request, Response } from 'express';
import { readFileSync, existsSync, writeFileSync, readdirSync } from 'fs';
import { openSync, fstatSync, readSync, closeSync, existsSync, writeFileSync } from 'fs';
import { join } from 'path';
import { logger } from '../../../../utils/logger.js';
import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
/**
* Read the last N lines from a file without loading the entire file into memory.
* Reads backwards from the end of the file in chunks until enough lines are found.
*/
export function readLastLines(filePath: string, lineCount: number): { lines: string; totalEstimate: number } {
const fd = openSync(filePath, 'r');
try {
const stat = fstatSync(fd);
const fileSize = stat.size;
if (fileSize === 0) {
return { lines: '', totalEstimate: 0 };
}
// Start with a reasonable chunk size, expand if needed
const INITIAL_CHUNK_SIZE = 64 * 1024; // 64KB
const MAX_READ_SIZE = 10 * 1024 * 1024; // 10MB cap to prevent OOM on huge single-line files
let readSize = Math.min(INITIAL_CHUNK_SIZE, fileSize);
let content = '';
let newlineCount = 0;
while (readSize <= fileSize && readSize <= MAX_READ_SIZE) {
const startPosition = Math.max(0, fileSize - readSize);
const bytesToRead = fileSize - startPosition;
const buffer = Buffer.alloc(bytesToRead);
readSync(fd, buffer, 0, bytesToRead, startPosition);
content = buffer.toString('utf-8');
// Count newlines to see if we have enough
newlineCount = 0;
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') newlineCount++;
}
// We need lineCount newlines to get lineCount full lines (trailing newline)
if (newlineCount >= lineCount || startPosition === 0) {
break;
}
// Double the read size for next attempt
readSize = Math.min(readSize * 2, fileSize, MAX_READ_SIZE);
}
// Split and take the last N lines
const allLines = content.split('\n');
// Remove trailing empty element from final newline
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
allLines.pop();
}
const startIndex = Math.max(0, allLines.length - lineCount);
const resultLines = allLines.slice(startIndex);
// Estimate total lines: if we read the whole file, we know exactly; otherwise estimate
let totalEstimate: number;
if (fileSize <= readSize) {
totalEstimate = allLines.length;
} else {
// Rough estimate based on average line length in the chunk we read
const avgLineLength = content.length / Math.max(newlineCount, 1);
totalEstimate = Math.round(fileSize / avgLineLength);
}
return {
lines: resultLines.join('\n'),
totalEstimate,
};
} finally {
closeSync(fd);
}
}
export class LogsRoutes extends BaseRouteHandler {
private getLogFilePath(): string {
const dataDir = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
@@ -50,19 +123,15 @@ export class LogsRoutes extends BaseRouteHandler {
const requestedLines = parseInt(req.query.lines as string || '1000', 10);
const maxLines = Math.min(requestedLines, 10000); // Cap at 10k lines
const content = readFileSync(logFilePath, 'utf-8');
const lines = content.split('\n');
// Return the last N lines
const startIndex = Math.max(0, lines.length - maxLines);
const recentLines = lines.slice(startIndex).join('\n');
const { lines: recentLines, totalEstimate } = readLastLines(logFilePath, maxLines);
const returnedLines = recentLines === '' ? 0 : recentLines.split('\n').length;
res.json({
logs: recentLines,
path: logFilePath,
exists: true,
totalLines: lines.length,
returnedLines: lines.length - startIndex
totalLines: totalEstimate,
returnedLines,
});
});
@@ -90,6 +90,8 @@ export class SessionRoutes extends BaseRouteHandler {
* we let the current generator finish naturally (max 5s linger timeout).
* The next generator will use the new provider with shared conversationHistory.
*/
private static readonly STALE_GENERATOR_THRESHOLD_MS = 30_000; // 30 seconds (#1099)
private ensureGeneratorRunning(sessionDbId: number, source: string): void {
const session = this.sessionManager.getSession(sessionDbId);
if (!session) return;
@@ -109,6 +111,26 @@ export class SessionRoutes extends BaseRouteHandler {
return;
}
// Generator is running - check if stale (no activity for 30s) to prevent queue stall (#1099)
const timeSinceActivity = Date.now() - session.lastGeneratorActivity;
if (timeSinceActivity > SessionRoutes.STALE_GENERATOR_THRESHOLD_MS) {
logger.warn('SESSION', 'Stale generator detected, aborting to prevent queue stall (#1099)', {
sessionId: sessionDbId,
timeSinceActivityMs: timeSinceActivity,
thresholdMs: SessionRoutes.STALE_GENERATOR_THRESHOLD_MS,
source
});
// Abort the stale generator and reset state
session.abortController.abort();
session.generatorPromise = null;
session.abortController = new AbortController();
session.lastGeneratorActivity = Date.now();
// Start a fresh generator
this.spawnInProgress.set(sessionDbId, true);
this.startGeneratorWithProvider(session, selectedProvider, 'stale-recovery');
return;
}
// Generator is running - check if provider changed
if (session.currentProvider && session.currentProvider !== selectedProvider) {
logger.info('SESSION', `Provider changed, will switch after current generator finishes`, {
@@ -155,8 +177,9 @@ export class SessionRoutes extends BaseRouteHandler {
historyLength: session.conversationHistory.length
});
// Track which provider is running
// Track which provider is running and mark activity for stale detection (#1099)
session.currentProvider = provider;
session.lastGeneratorActivity = Date.now();
session.generatorPromise = agent.startSession(session, this.workerService)
.catch(error => {
@@ -504,57 +527,63 @@ export class SessionRoutes extends BaseRouteHandler {
}
}
const store = this.dbManager.getSessionStore();
try {
const store = this.dbManager.getSessionStore();
// Get or create session
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
// Get or create session
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
// Privacy check: skip if user prompt was entirely private
const userPrompt = PrivacyCheckValidator.checkUserPromptPrivacy(
store,
contentSessionId,
promptNumber,
'observation',
sessionDbId,
{ tool_name }
);
if (!userPrompt) {
res.json({ status: 'skipped', reason: 'private' });
return;
// Privacy check: skip if user prompt was entirely private
const userPrompt = PrivacyCheckValidator.checkUserPromptPrivacy(
store,
contentSessionId,
promptNumber,
'observation',
sessionDbId,
{ tool_name }
);
if (!userPrompt) {
res.json({ status: 'skipped', reason: 'private' });
return;
}
// Strip memory tags from tool_input and tool_response
const cleanedToolInput = tool_input !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
: '{}';
const cleanedToolResponse = tool_response !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
: '{}';
// Queue observation
this.sessionManager.queueObservation(sessionDbId, {
tool_name,
tool_input: cleanedToolInput,
tool_response: cleanedToolResponse,
prompt_number: promptNumber,
cwd: cwd || (() => {
logger.error('SESSION', 'Missing cwd when queueing observation in SessionRoutes', {
sessionId: sessionDbId,
tool_name
});
return '';
})()
});
// Ensure SDK agent is running
this.ensureGeneratorRunning(sessionDbId, 'observation');
// Broadcast observation queued event
this.eventBroadcaster.broadcastObservationQueued(sessionDbId);
res.json({ status: 'queued' });
} catch (error) {
// Return 200 on recoverable errors so the hook doesn't break
logger.error('SESSION', 'Observation storage failed', { contentSessionId, tool_name }, error as Error);
res.json({ stored: false, reason: (error as Error).message });
}
// Strip memory tags from tool_input and tool_response
const cleanedToolInput = tool_input !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
: '{}';
const cleanedToolResponse = tool_response !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
: '{}';
// Queue observation
this.sessionManager.queueObservation(sessionDbId, {
tool_name,
tool_input: cleanedToolInput,
tool_response: cleanedToolResponse,
prompt_number: promptNumber,
cwd: cwd || (() => {
logger.error('SESSION', 'Missing cwd when queueing observation in SessionRoutes', {
sessionId: sessionDbId,
tool_name
});
return '';
})()
});
// Ensure SDK agent is running
this.ensureGeneratorRunning(sessionDbId, 'observation');
// Broadcast observation queued event
this.eventBroadcaster.broadcastObservationQueued(sessionDbId);
res.json({ status: 'queued' });
});
/**
@@ -663,23 +692,30 @@ export class SessionRoutes extends BaseRouteHandler {
* Returns: { sessionDbId, promptNumber, skipped: boolean, reason?: string }
*/
private handleSessionInitByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, project, prompt } = req.body;
const { contentSessionId } = req.body;
// Only contentSessionId is truly required — Cursor and other platforms
// may omit prompt/project in their payload (#838, #1049)
const project = req.body.project || 'unknown';
const prompt = req.body.prompt || '[media prompt]';
const customTitle = req.body.customTitle || undefined;
logger.info('HTTP', 'SessionRoutes: handleSessionInitByClaudeId called', {
contentSessionId,
project,
prompt_length: prompt?.length
prompt_length: prompt?.length,
customTitle
});
// Validate required parameters
if (!this.validateRequired(req, res, ['contentSessionId', 'project', 'prompt'])) {
if (!this.validateRequired(req, res, ['contentSessionId'])) {
return;
}
const store = this.dbManager.getSessionStore();
// Step 1: Create/get SDK session (idempotent INSERT OR IGNORE)
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt);
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt, customTitle);
// Verify session creation with DB lookup
const dbSession = store.getSessionById(sessionDbId);
@@ -723,16 +759,22 @@ export class SessionRoutes extends BaseRouteHandler {
// Step 5: Save cleaned user prompt
store.saveUserPrompt(contentSessionId, promptNumber, cleanedPrompt);
// Step 6: Check if SDK agent is already running for this session (#1079)
// If contextInjected is true, the hook should skip re-initializing the SDK agent
const contextInjected = this.sessionManager.getSession(sessionDbId) !== undefined;
// Debug-level log since CREATED already logged the key info
logger.debug('SESSION', 'User prompt saved', {
sessionId: sessionDbId,
promptNumber
promptNumber,
contextInjected
});
res.json({
sessionDbId,
promptNumber,
skipped: false
skipped: false,
contextInjected
});
});
}
+4
View File
@@ -51,6 +51,7 @@ export interface SettingsDefaults {
// Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string;
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: string;
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
// Process Management
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; // Max concurrent Claude SDK agent subprocesses (default: 2)
@@ -58,6 +59,7 @@ export interface SettingsDefaults {
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
// Chroma Vector Database Configuration
CLAUDE_MEM_CHROMA_ENABLED: string; // 'true' | 'false' - set to 'false' for SQLite-only mode
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
CLAUDE_MEM_CHROMA_HOST: string;
CLAUDE_MEM_CHROMA_PORT: string;
@@ -111,6 +113,7 @@ export class SettingsDefaultsManager {
// Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: 'true',
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
// Process Management
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
@@ -118,6 +121,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
// Chroma Vector Database Configuration
CLAUDE_MEM_CHROMA_ENABLED: 'true', // Set to 'false' to disable Chroma and use SQLite-only search
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' uses persistent chroma-mcp via uvx, 'remote' connects to existing server
CLAUDE_MEM_CHROMA_HOST: '127.0.0.1',
CLAUDE_MEM_CHROMA_PORT: '8000',
+6 -3
View File
@@ -99,7 +99,9 @@ export function ensureAllClaudeDirs(): void {
}
/**
* Get current project name from git root or cwd
* Get current project name from git root or cwd.
* Includes parent directory to avoid collisions when repos share a folder name
* (e.g., ~/work/monorepo "work/monorepo" vs ~/personal/monorepo "personal/monorepo").
*/
export function getCurrentProjectName(): string {
try {
@@ -109,12 +111,13 @@ export function getCurrentProjectName(): string {
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true
}).trim();
return basename(gitRoot);
return basename(dirname(gitRoot)) + '/' + basename(gitRoot);
} catch (error) {
logger.debug('SYSTEM', 'Git root detection failed, using cwd basename', {
cwd: process.cwd()
}, error as Error);
return basename(process.cwd());
const cwd = process.cwd();
return basename(dirname(cwd)) + '/' + basename(cwd);
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Plugin state utilities for checking Claude Code's plugin settings.
* Kept minimal no heavy dependencies so hooks can check quickly.
*/
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const PLUGIN_SETTINGS_KEY = 'claude-mem@thedotmack';
/**
* Check if claude-mem is disabled in Claude Code's settings (#781).
* Sync read + JSON parse for speed called before any async work.
* Returns true only if the plugin is explicitly disabled (enabledPlugins[key] === false).
*/
export function isPluginDisabledInClaudeSettings(): boolean {
try {
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
const settingsPath = join(claudeConfigDir, 'settings.json');
if (!existsSync(settingsPath)) return false;
const raw = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(raw);
return settings?.enabledPlugins?.[PLUGIN_SETTINGS_KEY] === false;
} catch {
// If settings can't be read/parsed, assume not disabled
return false;
}
}
+5 -1
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { dirname, resolve } from 'path';
import { replaceTaggedContent } from './claude-md-utils.js';
import { logger } from './logger.js';
@@ -10,6 +10,10 @@ import { logger } from './logger.js';
export function writeAgentsMd(agentsPath: string, context: string): void {
if (!agentsPath) return;
// Never write inside .git directories — corrupts refs (#1165)
const resolvedPath = resolve(agentsPath);
if (resolvedPath.includes('/.git/') || resolvedPath.includes('\\.git\\') || resolvedPath.endsWith('/.git') || resolvedPath.endsWith('\\.git')) return;
const dir = dirname(agentsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
+5
View File
@@ -114,6 +114,11 @@ export function replaceTaggedContent(existingContent: string, newContent: string
* @param newContent - Content to write inside tags
*/
export function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
const resolvedPath = path.resolve(folderPath);
// Never write inside .git directories — corrupts refs (#1165)
if (resolvedPath.includes('/.git/') || resolvedPath.includes('\\.git\\') || resolvedPath.endsWith('/.git') || resolvedPath.endsWith('\\.git')) return;
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
const tempFile = `${claudeMdPath}.tmp`;
@@ -169,11 +169,11 @@ describe('MarkdownFormatter', () => {
expect(result[0]).toContain('**Context Index:**');
});
it('should mention MCP tools', () => {
it('should mention mem-search skill', () => {
const result = renderMarkdownContextIndex();
const joined = result.join('\n');
expect(joined).toContain('MCP tools');
expect(joined).toContain('mem-search');
});
});
@@ -488,11 +488,11 @@ describe('MarkdownFormatter', () => {
expect(joined).toContain('500');
});
it('should mention MCP', () => {
it('should mention claude-mem skill', () => {
const result = renderMarkdownFooter(5000, 100);
const joined = result.join('\n');
expect(joined).toContain('MCP');
expect(joined).toContain('claude-mem');
});
it('should round work tokens to nearest thousand', () => {
+374
View File
@@ -0,0 +1,374 @@
/**
* Tests for Hook Lifecycle Fixes (TRIAGE-04)
*
* Validates:
* - Stop hook returns suppressOutput: true (prevents infinite loop #987)
* - All handlers return suppressOutput: true (prevents conversation pollution #598, #784)
* - Unknown event types handled gracefully (fixes #984)
* - stderr suppressed in hook context (fixes #1181)
* - Claude Code adapter defaults suppressOutput to true
*/
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
// --- Event Handler Tests ---
describe('Hook Lifecycle - Event Handlers', () => {
describe('getEventHandler', () => {
it('should return handler for all recognized event types', async () => {
const { getEventHandler } = await import('../src/cli/handlers/index.js');
const recognizedTypes = [
'context', 'session-init', 'observation',
'summarize', 'session-complete', 'user-message', 'file-edit'
];
for (const type of recognizedTypes) {
const handler = getEventHandler(type);
expect(handler).toBeDefined();
expect(handler.execute).toBeDefined();
}
});
it('should return no-op handler for unknown event types (#984)', async () => {
const { getEventHandler } = await import('../src/cli/handlers/index.js');
const handler = getEventHandler('nonexistent-event');
expect(handler).toBeDefined();
expect(handler.execute).toBeDefined();
const result = await handler.execute({
sessionId: 'test-session',
cwd: '/tmp'
});
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
expect(result.exitCode).toBe(0);
});
it('should include session-complete as a recognized event type (#984)', async () => {
const { getEventHandler } = await import('../src/cli/handlers/index.js');
const handler = getEventHandler('session-complete');
// session-complete should NOT be the no-op handler
// We can verify this by checking it's not the same as an unknown type handler
expect(handler).toBeDefined();
// The real handler has different behavior than the no-op
// (it tries to call the worker, while no-op just returns immediately)
});
});
});
// --- Codex CLI Compatibility Tests (#744) ---
describe('Codex CLI Compatibility (#744)', () => {
describe('getPlatformAdapter', () => {
it('should return rawAdapter for unknown platforms like codex', async () => {
const { getPlatformAdapter, rawAdapter } = await import('../src/cli/adapters/index.js');
// Should not throw for unknown platforms — falls back to rawAdapter
const adapter = getPlatformAdapter('codex');
expect(adapter).toBe(rawAdapter);
});
it('should return rawAdapter for any unrecognized platform string', async () => {
const { getPlatformAdapter, rawAdapter } = await import('../src/cli/adapters/index.js');
const adapter = getPlatformAdapter('some-future-cli');
expect(adapter).toBe(rawAdapter);
});
});
describe('claudeCodeAdapter session_id fallbacks', () => {
it('should use session_id when present', async () => {
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
const input = claudeCodeAdapter.normalizeInput({ session_id: 'claude-123', cwd: '/tmp' });
expect(input.sessionId).toBe('claude-123');
});
it('should fall back to id field (Codex CLI format)', async () => {
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
const input = claudeCodeAdapter.normalizeInput({ id: 'codex-456', cwd: '/tmp' });
expect(input.sessionId).toBe('codex-456');
});
it('should fall back to sessionId field (camelCase format)', async () => {
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
const input = claudeCodeAdapter.normalizeInput({ sessionId: 'camel-789', cwd: '/tmp' });
expect(input.sessionId).toBe('camel-789');
});
it('should return undefined when no session ID field is present', async () => {
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
const input = claudeCodeAdapter.normalizeInput({ cwd: '/tmp' });
expect(input.sessionId).toBeUndefined();
});
it('should handle undefined input gracefully', async () => {
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
const input = claudeCodeAdapter.normalizeInput(undefined);
expect(input.sessionId).toBeUndefined();
expect(input.cwd).toBe(process.cwd());
});
});
describe('session-init handler undefined prompt', () => {
it('should not throw when prompt is undefined', () => {
// Verify the short-circuit logic works for undefined
const rawPrompt: string | undefined = undefined;
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
expect(prompt).toBe('[media prompt]');
});
it('should not throw when prompt is empty string', () => {
const rawPrompt = '';
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
expect(prompt).toBe('[media prompt]');
});
it('should not throw when prompt is whitespace-only', () => {
const rawPrompt = ' ';
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
expect(prompt).toBe('[media prompt]');
});
it('should preserve valid prompts', () => {
const rawPrompt = 'fix the bug';
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
expect(prompt).toBe('fix the bug');
});
});
});
// --- Cursor IDE Compatibility Tests (#838, #1049) ---
describe('Cursor IDE Compatibility (#838, #1049)', () => {
describe('cursorAdapter session ID fallbacks', () => {
it('should use conversation_id when present', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'conv-123', workspace_roots: ['/project'] });
expect(input.sessionId).toBe('conv-123');
});
it('should fall back to generation_id', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ generation_id: 'gen-456', workspace_roots: ['/project'] });
expect(input.sessionId).toBe('gen-456');
});
it('should fall back to id field', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ id: 'id-789', workspace_roots: ['/project'] });
expect(input.sessionId).toBe('id-789');
});
it('should return undefined when no session ID field is present', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ workspace_roots: ['/project'] });
expect(input.sessionId).toBeUndefined();
});
});
describe('cursorAdapter prompt field fallbacks', () => {
it('should use prompt when present', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', prompt: 'fix the bug' });
expect(input.prompt).toBe('fix the bug');
});
it('should fall back to query field', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', query: 'search for files' });
expect(input.prompt).toBe('search for files');
});
it('should fall back to input field', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', input: 'user typed this' });
expect(input.prompt).toBe('user typed this');
});
it('should fall back to message field', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', message: 'hello cursor' });
expect(input.prompt).toBe('hello cursor');
});
it('should return undefined when no prompt field is present', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1' });
expect(input.prompt).toBeUndefined();
});
it('should prefer prompt over query', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', prompt: 'primary', query: 'secondary' });
expect(input.prompt).toBe('primary');
});
});
describe('cursorAdapter cwd fallbacks', () => {
it('should use workspace_roots[0] when present', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', workspace_roots: ['/my/project'] });
expect(input.cwd).toBe('/my/project');
});
it('should fall back to cwd field', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', cwd: '/fallback/dir' });
expect(input.cwd).toBe('/fallback/dir');
});
it('should fall back to process.cwd() when nothing provided', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1' });
expect(input.cwd).toBe(process.cwd());
});
});
describe('cursorAdapter undefined input handling', () => {
it('should handle undefined input gracefully', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput(undefined);
expect(input.sessionId).toBeUndefined();
expect(input.prompt).toBeUndefined();
expect(input.cwd).toBe(process.cwd());
});
it('should handle null input gracefully', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const input = cursorAdapter.normalizeInput(null);
expect(input.sessionId).toBeUndefined();
expect(input.prompt).toBeUndefined();
expect(input.cwd).toBe(process.cwd());
});
});
describe('cursorAdapter formatOutput', () => {
it('should return simple continue flag', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const output = cursorAdapter.formatOutput({ continue: true, suppressOutput: true });
expect(output).toEqual({ continue: true });
});
it('should default continue to true', async () => {
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
const output = cursorAdapter.formatOutput({});
expect(output).toEqual({ continue: true });
});
});
});
// --- Platform Adapter Tests ---
describe('Hook Lifecycle - Claude Code Adapter', () => {
it('should default suppressOutput to true when not explicitly set', async () => {
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
// Result with no suppressOutput field
const output = claudeCodeAdapter.formatOutput({ continue: true });
expect(output).toEqual({ continue: true, suppressOutput: true });
});
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 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 = {
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'test context' },
systemMessage: 'test message'
};
const output = claudeCodeAdapter.formatOutput(result) 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();
});
});
// --- stderr Suppression Tests ---
describe('Hook Lifecycle - stderr Suppression (#1181)', () => {
let originalStderrWrite: typeof process.stderr.write;
let stderrOutput: string[];
beforeEach(() => {
originalStderrWrite = process.stderr.write.bind(process.stderr);
stderrOutput = [];
// Capture stderr writes
process.stderr.write = ((chunk: any) => {
stderrOutput.push(String(chunk));
return true;
}) as typeof process.stderr.write;
});
afterEach(() => {
process.stderr.write = originalStderrWrite;
});
it('should not use console.error in handlers/index.ts for unknown events', async () => {
// Re-import to get fresh module
const { getEventHandler } = await import('../src/cli/handlers/index.js');
// Clear any stderr from import
stderrOutput.length = 0;
// Call with unknown event — should use logger (writes to file), not console.error (writes to stderr)
const handler = getEventHandler('unknown-event-type');
await handler.execute({ sessionId: 'test', cwd: '/tmp' });
// No stderr output should have leaked from the handler dispatcher itself
// (logger may write to stderr as fallback if log file unavailable, but that's
// the logger's responsibility, not the dispatcher's)
const dispatcherStderr = stderrOutput.filter(s => s.includes('[claude-mem] Unknown event'));
expect(dispatcherStderr).toHaveLength(0);
});
});
// --- Hook Response Constants ---
describe('Hook Lifecycle - Standard Response', () => {
it('should define standard hook response with suppressOutput: true', async () => {
const { STANDARD_HOOK_RESPONSE } = await import('../src/hooks/hook-response.js');
const parsed = JSON.parse(STANDARD_HOOK_RESPONSE);
expect(parsed.continue).toBe(true);
expect(parsed.suppressOutput).toBe(true);
});
});
// --- hookCommand stderr suppression ---
describe('hookCommand - stderr suppression', () => {
it('should not use console.error for worker unavailable errors', async () => {
// The hookCommand function should use logger.warn instead of console.error
// for worker unavailable errors, so stderr stays clean (#1181)
const { hookCommand } = await import('../src/cli/hook-command.js');
// Verify the import includes logger
const hookCommandSource = await Bun.file(
new URL('../src/cli/hook-command.ts', import.meta.url).pathname
).text();
// Should import logger
expect(hookCommandSource).toContain("import { logger }");
// Should use logger.warn for worker unavailable
expect(hookCommandSource).toContain("logger.warn('HOOK'");
// Should use logger.error for hook errors
expect(hookCommandSource).toContain("logger.error('HOOK'");
// Should suppress stderr
expect(hookCommandSource).toContain("process.stderr.write = (() => true)");
// Should restore stderr in finally block
expect(hookCommandSource).toContain("process.stderr.write = originalStderrWrite");
// Should NOT have console.error for error reporting
expect(hookCommandSource).not.toContain("console.error(`[claude-mem]");
expect(hookCommandSource).not.toContain("console.error(`Hook error:");
});
});
@@ -0,0 +1,310 @@
/**
* Tests for Context Re-Injection Guard (#1079)
*
* Validates:
* - session-init handler skips SDK agent init when contextInjected=true
* - session-init handler proceeds with SDK agent init when contextInjected=false
* - SessionManager.getSession returns undefined for uninitialized sessions
* - SessionManager.getSession returns session after initialization
*/
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
import { homedir } from 'os';
import { join } from 'path';
// Mock modules that cause import chain issues - MUST be before handler imports
// paths.ts calls SettingsDefaultsManager.get() at module load time
mock.module('../../src/shared/SettingsDefaultsManager.js', () => ({
SettingsDefaultsManager: {
get: (key: string) => {
if (key === 'CLAUDE_MEM_DATA_DIR') return join(homedir(), '.claude-mem');
return '';
},
getInt: () => 0,
loadFromFile: () => ({ CLAUDE_MEM_EXCLUDED_PROJECTS: [] }),
},
}));
mock.module('../../src/shared/worker-utils.js', () => ({
ensureWorkerRunning: () => Promise.resolve(true),
getWorkerPort: () => 37777,
}));
mock.module('../../src/utils/project-name.js', () => ({
getProjectName: () => 'test-project',
}));
mock.module('../../src/utils/project-filter.js', () => ({
isProjectExcluded: () => false,
}));
// Now import after mocks
import { logger } from '../../src/utils/logger.js';
// Suppress logger output during tests
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(() => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'failure').mockImplementation(() => {}),
];
});
afterEach(() => {
loggerSpies.forEach(spy => spy.mockRestore());
});
describe('Context Re-Injection Guard (#1079)', () => {
describe('session-init handler - contextInjected flag behavior', () => {
it('should skip SDK agent init when contextInjected is true', async () => {
const fetchedUrls: string[] = [];
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = typeof url === 'string' ? url : url.toString();
fetchedUrls.push(urlStr);
if (urlStr.includes('/api/sessions/init')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
sessionDbId: 42,
promptNumber: 2,
skipped: false,
contextInjected: true // SDK agent already running
})
});
}
// The /sessions/42/init call — should NOT be reached
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'initialized' })
});
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch as any;
try {
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
const result = await sessionInitHandler.execute({
sessionId: 'test-session-123',
cwd: '/test/project',
prompt: 'second prompt in this session',
platform: 'claude-code',
});
// Should return success without making the second /sessions/42/init call
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
// Only the /api/sessions/init call should have been made
const apiInitCalls = fetchedUrls.filter(u => u.includes('/api/sessions/init'));
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
expect(apiInitCalls.length).toBe(1);
expect(sdkInitCalls.length).toBe(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should proceed with SDK agent init when contextInjected is false', async () => {
const fetchedUrls: string[] = [];
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = typeof url === 'string' ? url : url.toString();
fetchedUrls.push(urlStr);
if (urlStr.includes('/api/sessions/init')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
sessionDbId: 42,
promptNumber: 1,
skipped: false,
contextInjected: false // First prompt — SDK agent not yet started
})
});
}
// The /sessions/42/init call — SHOULD be reached
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'initialized' })
});
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch as any;
try {
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
const result = await sessionInitHandler.execute({
sessionId: 'test-session-456',
cwd: '/test/project',
prompt: 'first prompt in session',
platform: 'claude-code',
});
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
// Both calls should have been made
const apiInitCalls = fetchedUrls.filter(u => u.includes('/api/sessions/init'));
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
expect(apiInitCalls.length).toBe(1);
expect(sdkInitCalls.length).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should proceed with SDK agent init when contextInjected is undefined (backward compat)', async () => {
const fetchedUrls: string[] = [];
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = typeof url === 'string' ? url : url.toString();
fetchedUrls.push(urlStr);
if (urlStr.includes('/api/sessions/init')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
sessionDbId: 42,
promptNumber: 1,
skipped: false
// contextInjected not present (older worker version)
})
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'initialized' })
});
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch as any;
try {
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
const result = await sessionInitHandler.execute({
sessionId: 'test-session-789',
cwd: '/test/project',
prompt: 'test prompt',
platform: 'claude-code',
});
expect(result.continue).toBe(true);
// When contextInjected is undefined/missing, should still make the SDK init call
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
expect(sdkInitCalls.length).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('SessionManager contextInjected logic', () => {
it('should return undefined for getSession when no active session exists', async () => {
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
const mockDbManager = {
getSessionById: () => ({
id: 1,
content_session_id: 'test-session',
project: 'test',
user_prompt: 'test prompt',
memory_session_id: null,
status: 'active',
started_at: new Date().toISOString(),
completed_at: null,
}),
getSessionStore: () => ({ db: {} }),
} as any;
const sessionManager = new SessionManager(mockDbManager);
// Session 42 has not been initialized in memory
const session = sessionManager.getSession(42);
expect(session).toBeUndefined();
});
it('should return active session after initializeSession is called', async () => {
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
const mockDbManager = {
getSessionById: () => ({
id: 42,
content_session_id: 'test-session',
project: 'test',
user_prompt: 'test prompt',
memory_session_id: null,
status: 'active',
started_at: new Date().toISOString(),
completed_at: null,
}),
getSessionStore: () => ({
db: {},
clearMemorySessionId: () => {},
}),
} as any;
const sessionManager = new SessionManager(mockDbManager);
// Initialize session (simulates first SDK agent init)
sessionManager.initializeSession(42, 'first prompt', 1);
// Now getSession should return the active session
const session = sessionManager.getSession(42);
expect(session).toBeDefined();
expect(session!.contentSessionId).toBe('test-session');
});
it('should return contextInjected=true pattern for subsequent prompts', async () => {
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
const mockDbManager = {
getSessionById: () => ({
id: 42,
content_session_id: 'test-session',
project: 'test',
user_prompt: 'test prompt',
memory_session_id: 'sdk-session-abc',
status: 'active',
started_at: new Date().toISOString(),
completed_at: null,
}),
getSessionStore: () => ({
db: {},
clearMemorySessionId: () => {},
}),
} as any;
const sessionManager = new SessionManager(mockDbManager);
// Before initialization: contextInjected would be false
expect(sessionManager.getSession(42)).toBeUndefined();
// After initialization: contextInjected would be true
sessionManager.initializeSession(42, 'first prompt', 1);
expect(sessionManager.getSession(42)).toBeDefined();
// Second call to initializeSession returns existing session (idempotent)
const session2 = sessionManager.initializeSession(42, 'second prompt', 2);
expect(session2.contentSessionId).toBe('test-session');
expect(session2.userPrompt).toBe('second prompt');
expect(session2.lastPromptNumber).toBe(2);
});
});
});
+62 -1
View File
@@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import {
isPortInUse,
waitForHealth,
waitForPortFree
waitForPortFree,
getInstalledPluginVersion,
checkVersionMatch
} from '../../src/services/infrastructure/index.js';
describe('HealthMonitor', () => {
@@ -122,6 +124,65 @@ describe('HealthMonitor', () => {
});
});
describe('getInstalledPluginVersion', () => {
it('should return a valid semver string', () => {
const version = getInstalledPluginVersion();
// Should be a string matching semver pattern or 'unknown'
if (version !== 'unknown') {
expect(version).toMatch(/^\d+\.\d+\.\d+/);
}
});
it('should not throw on ENOENT (graceful degradation)', () => {
// The function handles ENOENT internally — should not throw
// If package.json exists, it returns the version; if not, 'unknown'
expect(() => getInstalledPluginVersion()).not.toThrow();
});
});
describe('checkVersionMatch', () => {
it('should assume match when worker version is unavailable', async () => {
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
const result = await checkVersionMatch(39999);
expect(result.matches).toBe(true);
expect(result.workerVersion).toBeNull();
});
it('should detect version mismatch', async () => {
global.fetch = mock(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: '0.0.0-definitely-wrong' })
} as Response));
const result = await checkVersionMatch(37777);
// Unless the plugin version is also '0.0.0-definitely-wrong', this should be a mismatch
const pluginVersion = getInstalledPluginVersion();
if (pluginVersion !== 'unknown' && pluginVersion !== '0.0.0-definitely-wrong') {
expect(result.matches).toBe(false);
}
});
it('should detect version match', async () => {
const pluginVersion = getInstalledPluginVersion();
if (pluginVersion === 'unknown') return; // Skip if can't read plugin version
global.fetch = mock(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: pluginVersion })
} as Response));
const result = await checkVersionMatch(37777);
expect(result.matches).toBe(true);
expect(result.pluginVersion).toBe(pluginVersion);
expect(result.workerVersion).toBe(pluginVersion);
});
});
describe('waitForPortFree', () => {
it('should return true immediately when port is already free', async () => {
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { isPluginDisabledInClaudeSettings } from '../../src/shared/plugin-state.js';
/**
* Tests for isPluginDisabledInClaudeSettings() (#781).
*
* The function reads CLAUDE_CONFIG_DIR/settings.json and checks if
* enabledPlugins["claude-mem@thedotmack"] === false.
*
* We test by setting CLAUDE_CONFIG_DIR to a temp directory with mock settings.
*/
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `plugin-disabled-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
});
afterEach(() => {
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('isPluginDisabledInClaudeSettings (#781)', () => {
it('should return false when settings.json does not exist', () => {
expect(isPluginDisabledInClaudeSettings()).toBe(false);
});
it('should return false when plugin is explicitly enabled', () => {
const settings = {
enabledPlugins: {
'claude-mem@thedotmack': true
}
};
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
expect(isPluginDisabledInClaudeSettings()).toBe(false);
});
it('should return true when plugin is explicitly disabled', () => {
const settings = {
enabledPlugins: {
'claude-mem@thedotmack': false
}
};
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
expect(isPluginDisabledInClaudeSettings()).toBe(true);
});
it('should return false when enabledPlugins key is missing', () => {
const settings = {
permissions: { allow: [] }
};
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
expect(isPluginDisabledInClaudeSettings()).toBe(false);
});
it('should return false when plugin key is absent from enabledPlugins', () => {
const settings = {
enabledPlugins: {
'other-plugin@marketplace': true
}
};
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
expect(isPluginDisabledInClaudeSettings()).toBe(false);
});
it('should return false when settings.json contains invalid JSON', () => {
writeFileSync(join(tempDir, 'settings.json'), '{ invalid json }}}');
expect(isPluginDisabledInClaudeSettings()).toBe(false);
});
it('should return false when settings.json is empty', () => {
writeFileSync(join(tempDir, 'settings.json'), '');
expect(isPluginDisabledInClaudeSettings()).toBe(false);
});
});
@@ -0,0 +1,121 @@
import { describe, it, expect } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '../..');
/**
* Regression tests for plugin distribution completeness.
* Ensures all required files (skills, hooks, manifests) are present
* and correctly structured for end-user installs.
*
* Prevents issue #1187 (missing skills/ directory after install).
*/
describe('Plugin Distribution - Skills', () => {
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
it('should include plugin/skills/mem-search/SKILL.md', () => {
expect(existsSync(skillPath)).toBe(true);
});
it('should have valid YAML frontmatter with name and description', () => {
const content = readFileSync(skillPath, 'utf-8');
// Must start with YAML frontmatter
expect(content.startsWith('---\n')).toBe(true);
// Extract frontmatter
const frontmatterEnd = content.indexOf('\n---\n', 4);
expect(frontmatterEnd).toBeGreaterThan(0);
const frontmatter = content.slice(4, frontmatterEnd);
expect(frontmatter).toContain('name:');
expect(frontmatter).toContain('description:');
});
it('should reference the 3-layer search workflow', () => {
const content = readFileSync(skillPath, 'utf-8');
// The skill must document the search → timeline → get_observations workflow
expect(content).toContain('search');
expect(content).toContain('timeline');
expect(content).toContain('get_observations');
});
});
describe('Plugin Distribution - Required Files', () => {
const requiredFiles = [
'plugin/hooks/hooks.json',
'plugin/.claude-plugin/plugin.json',
'plugin/skills/mem-search/SKILL.md',
];
for (const filePath of requiredFiles) {
it(`should include ${filePath}`, () => {
const fullPath = path.join(projectRoot, filePath);
expect(existsSync(fullPath)).toBe(true);
});
}
});
describe('Plugin Distribution - hooks.json Integrity', () => {
it('should have valid JSON in hooks.json', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const content = readFileSync(hooksPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.hooks).toBeDefined();
});
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain('${CLAUDE_PLUGIN_ROOT}');
}
}
}
}
});
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#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';
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain(expectedFallbackPath);
}
}
}
}
});
});
describe('Plugin Distribution - package.json Files Field', () => {
it('should include "plugin" in root package.json files field', () => {
const packageJsonPath = path.join(projectRoot, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
expect(packageJson.files).toBeDefined();
expect(packageJson.files).toContain('plugin');
});
});
describe('Plugin Distribution - Build Script Verification', () => {
it('should verify distribution files in build-hooks.js', () => {
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
const content = readFileSync(buildScriptPath, 'utf-8');
// Build script must check for critical distribution files
expect(content).toContain('plugin/skills/mem-search/SKILL.md');
expect(content).toContain('plugin/hooks/hooks.json');
expect(content).toContain('plugin/.claude-plugin/plugin.json');
});
});
@@ -11,6 +11,8 @@ import {
parseElapsedTime,
isProcessAlive,
cleanStalePidFile,
isPidFileRecent,
touchPidFile,
spawnDaemon,
resolveWorkerRuntimePath,
runOneTimeChromaMigration,
@@ -347,6 +349,58 @@ describe('ProcessManager', () => {
});
});
describe('isPidFileRecent', () => {
it('should return true for a recently written PID file', () => {
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
// File was just written, should be very recent
expect(isPidFileRecent(15000)).toBe(true);
});
it('should return false when PID file does not exist', () => {
removePidFile();
expect(isPidFileRecent(15000)).toBe(false);
});
it('should return false for a very short threshold on a real file', () => {
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
// With a 0ms threshold, even a just-written file should be "too old"
// (mtime is at least 1ms in the past by the time we check)
// Use a negative threshold to guarantee false
expect(isPidFileRecent(-1)).toBe(false);
});
});
describe('touchPidFile', () => {
it('should update mtime of existing PID file', async () => {
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
// Wait a bit to ensure measurable mtime difference
await new Promise(r => setTimeout(r, 50));
const statsBefore = require('fs').statSync(PID_FILE);
const mtimeBefore = statsBefore.mtimeMs;
// Wait again to ensure mtime advances
await new Promise(r => setTimeout(r, 50));
touchPidFile();
const statsAfter = require('fs').statSync(PID_FILE);
const mtimeAfter = statsAfter.mtimeMs;
expect(mtimeAfter).toBeGreaterThanOrEqual(mtimeBefore);
});
it('should not throw when PID file does not exist', () => {
removePidFile();
expect(() => touchPidFile()).not.toThrow();
});
});
describe('spawnDaemon', () => {
it('should use setsid on Linux when available', () => {
// setsid should exist at /usr/bin/setsid on Linux
+34 -62
View File
@@ -316,80 +316,52 @@ describe('ChromaSync Vector Sync Integration', () => {
/**
* Regression test for GitHub Issue #761:
* "Feature Request: Option to disable Chroma (RAM usage / zombie processes)"
*
*
* Root cause: When connection errors occur (MCP error -32000, Connection closed),
* the code was resetting `connected` and `client` but NOT closing the transport,
* leaving the chroma-mcp subprocess alive. Each reconnection attempt spawned
* a NEW process while old ones accumulated as zombies.
*
* Fix: Close transport before resetting state in error handlers at:
* - ensureCollection() error handling (~line 180)
* - queryChroma() error handling (~line 840)
*
* Fix: Transport lifecycle is now managed by ChromaMcpManager (singleton),
* which handles connect/disconnect/cleanup. ChromaSync delegates to it.
*/
it('should have transport cleanup in connection error handlers', async () => {
// This test verifies the fix exists by checking the source code pattern
// The actual runtime behavior depends on uvx/chroma availability
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
const sync = new ChromaSync(testProject);
// Verify the class has the expected structure
const syncAny = sync as any;
// Initial state should be null/false
expect(syncAny.client).toBeNull();
expect(syncAny.transport).toBeNull();
expect(syncAny.connected).toBe(false);
// The close() method should properly clean up all state
// This is the reference implementation that error handlers should mirror
await sync.close();
expect(syncAny.client).toBeNull();
expect(syncAny.transport).toBeNull();
expect(syncAny.connected).toBe(false);
});
it('should reset state after close regardless of connection status', async () => {
if (!chromaAvailable) {
console.log(`Skipping: ${skipReason}`);
return;
}
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
const sync = new ChromaSync(testProject);
const syncAny = sync as any;
// Try to establish connection (may succeed or fail depending on environment)
try {
await sync.queryChroma('test', 5);
} catch {
// Connection or query may fail - that's OK
}
// Regardless of whether connection succeeded, close() must clean up everything
await sync.close();
// After close(), ALL state must be null/false - this prevents zombie processes
expect(syncAny.connected).toBe(false);
expect(syncAny.client).toBeNull();
expect(syncAny.transport).toBeNull();
});
it('should clean up transport in close() method', async () => {
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
// Read the source to verify transport.close() is called
// This is a static analysis test - verifies the fix exists
it('should have transport cleanup in ChromaMcpManager error handlers', async () => {
// ChromaSync now delegates connection management to ChromaMcpManager.
// Verify that ChromaMcpManager source includes transport cleanup.
const sourceFile = await Bun.file(
new URL('../../src/services/sync/ChromaSync.ts', import.meta.url)
new URL('../../src/services/sync/ChromaMcpManager.ts', import.meta.url)
).text();
// Verify that error handlers include transport cleanup
// The fix adds: if (this.transport) { await this.transport.close(); }
expect(sourceFile).toContain('this.transport.close()');
// Verify transport is set to null after close
expect(sourceFile).toContain('this.transport = null');
// Verify connected is set to false after close
expect(sourceFile).toContain('this.connected = false');
});
it('should reset state after close regardless of connection status', async () => {
// ChromaSync.close() is now a lightweight method that logs and returns.
// Connection state is managed by ChromaMcpManager singleton.
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
const sync = new ChromaSync(testProject);
// close() should complete without error regardless of state
await expect(sync.close()).resolves.toBeUndefined();
});
it('should clean up transport in ChromaMcpManager close() method', async () => {
// Read the ChromaMcpManager source to verify transport.close() is in the close path
const sourceFile = await Bun.file(
new URL('../../src/services/sync/ChromaMcpManager.ts', import.meta.url)
).text();
// Verify the close/disconnect method properly cleans up transport
expect(sourceFile).toContain('await this.transport.close()');
expect(sourceFile).toContain('this.transport = null');
expect(sourceFile).toContain('this.connected = false');
});
});
});
@@ -45,6 +45,12 @@ describe('Hook Execution E2E', () => {
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({
provider: 'claude',
authMethod: 'cli',
lastInteraction: null,
}),
};
testPort = 40000 + Math.floor(Math.random() * 10000);
@@ -96,6 +102,8 @@ describe('Hook Execution E2E', () => {
getMcpReady: () => false,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(uninitializedOptions);
@@ -157,6 +165,8 @@ describe('Hook Execution E2E', () => {
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(dynamicOptions);
@@ -45,6 +45,12 @@ describe('Worker API Endpoints Integration', () => {
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({
provider: 'claude',
authMethod: 'cli',
lastInteraction: null,
}),
};
testPort = 40000 + Math.floor(Math.random() * 10000);
@@ -88,6 +94,8 @@ describe('Worker API Endpoints Integration', () => {
getMcpReady: () => false,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(uninitOptions);
@@ -121,6 +129,8 @@ describe('Worker API Endpoints Integration', () => {
getMcpReady: () => false,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(uninitOptions);
@@ -236,6 +246,8 @@ describe('Worker API Endpoints Integration', () => {
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(dynamicOptions);
@@ -260,6 +272,8 @@ describe('Worker API Endpoints Integration', () => {
getMcpReady: () => mcpReady,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(dynamicOptions);
+1
View File
@@ -37,6 +37,7 @@ const EXCLUDED_PATTERNS = [
/user-message-hook\.ts$/, // Deprecated - kept for reference only, not registered in hooks.json
/cli\/hook-command\.ts$/, // CLI hook command uses console.log/error for hook protocol output
/cli\/handlers\/user-message\.ts$/, // User message handler uses console.error for user-visible context
/services\/transcripts\/cli\.ts$/, // CLI transcript subcommands use console.log for user-visible interactive output
];
// Files that should always use logger (core business logic)
+10
View File
@@ -32,6 +32,12 @@ describe('Server', () => {
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({
provider: 'claude',
authMethod: 'cli',
lastInteraction: null,
}),
};
});
@@ -269,6 +275,8 @@ describe('Server', () => {
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(dynamicOptions);
@@ -326,6 +334,8 @@ describe('Server', () => {
getMcpReady: () => false,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker-service.cjs',
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
};
server = new Server(uninitializedOptions);
@@ -0,0 +1,128 @@
/**
* Tests for readLastLines() tail-read function for /api/logs endpoint (#1203)
*
* Verifies that log files are read from the end without loading the entire
* file into memory, preventing OOM on large log files.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { readLastLines } from '../../src/services/worker/http/routes/LogsRoutes.js';
describe('readLastLines (#1203 OOM fix)', () => {
const testDir = join(tmpdir(), `claude-mem-logs-test-${Date.now()}`);
const testFile = join(testDir, 'test.log');
beforeEach(() => {
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
it('should return empty string for empty file', () => {
writeFileSync(testFile, '', 'utf-8');
const result = readLastLines(testFile, 10);
expect(result.lines).toBe('');
expect(result.totalEstimate).toBe(0);
});
it('should return all lines when file has fewer lines than requested', () => {
writeFileSync(testFile, 'line1\nline2\nline3\n', 'utf-8');
const result = readLastLines(testFile, 10);
expect(result.lines).toBe('line1\nline2\nline3');
expect(result.totalEstimate).toBe(3);
});
it('should return exactly the last N lines', () => {
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
const result = readLastLines(testFile, 5);
expect(result.lines).toBe('line16\nline17\nline18\nline19\nline20');
});
it('should return single line when requested', () => {
writeFileSync(testFile, 'first\nsecond\nthird\n', 'utf-8');
const result = readLastLines(testFile, 1);
expect(result.lines).toBe('third');
});
it('should handle file without trailing newline', () => {
writeFileSync(testFile, 'line1\nline2\nline3', 'utf-8');
const result = readLastLines(testFile, 2);
expect(result.lines).toBe('line2\nline3');
});
it('should handle single line file', () => {
writeFileSync(testFile, 'only line\n', 'utf-8');
const result = readLastLines(testFile, 5);
expect(result.lines).toBe('only line');
expect(result.totalEstimate).toBe(1);
});
it('should handle file with exactly requested number of lines', () => {
writeFileSync(testFile, 'a\nb\nc\n', 'utf-8');
const result = readLastLines(testFile, 3);
expect(result.lines).toBe('a\nb\nc');
});
it('should work with lines larger than initial chunk size', () => {
// Create a file where lines are long enough to exceed the 64KB initial chunk
const longLine = 'X'.repeat(10000);
const lines = Array.from({ length: 20 }, (_, i) => `${i}:${longLine}`);
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
const result = readLastLines(testFile, 3);
const resultLines = result.lines.split('\n');
expect(resultLines.length).toBe(3);
expect(resultLines[0]).toStartWith('17:');
expect(resultLines[1]).toStartWith('18:');
expect(resultLines[2]).toStartWith('19:');
});
it('should provide accurate totalEstimate when entire file is read', () => {
const lines = Array.from({ length: 5 }, (_, i) => `line${i}`);
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
const result = readLastLines(testFile, 100);
// When file fits in one chunk, totalEstimate should be exact
expect(result.totalEstimate).toBe(5);
});
it('should handle requesting zero lines', () => {
writeFileSync(testFile, 'line1\nline2\n', 'utf-8');
const result = readLastLines(testFile, 0);
expect(result.lines).toBe('');
});
it('should handle file with only newlines', () => {
writeFileSync(testFile, '\n\n\n', 'utf-8');
const result = readLastLines(testFile, 2);
const resultLines = result.lines.split('\n');
// The last two "lines" before trailing newline are empty strings
expect(resultLines.length).toBe(2);
});
it('should not load entire large file for small tail request', () => {
// This test verifies the core fix: a file with many lines should
// not be fully loaded when only a few lines are requested.
// We create a file larger than the initial 64KB chunk.
const line = 'A'.repeat(100) + '\n'; // ~101 bytes per line
const lineCount = 1000; // ~101KB total
writeFileSync(testFile, line.repeat(lineCount), 'utf-8');
const result = readLastLines(testFile, 5);
const resultLines = result.lines.split('\n');
expect(resultLines.length).toBe(5);
// Each returned line should be our repeated 'A' pattern
for (const l of resultLines) {
expect(l).toBe('A'.repeat(100));
}
});
});
@@ -0,0 +1,315 @@
/**
* Tests for MigrationRunner idempotency and schema initialization (#979)
*
* Mock Justification: NONE (0% mock code)
* - Uses real SQLite with ':memory:' tests actual migration SQL
* - Validates idempotency by running migrations multiple times
* - Covers the version-conflict scenario from issue #979
*
* Value: Prevents regression where old DatabaseManager migrations mask core table creation
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { MigrationRunner } from '../../../src/services/sqlite/migrations/runner.js';
interface TableNameRow {
name: string;
}
interface TableColumnInfo {
name: string;
type: string;
notnull: number;
}
interface SchemaVersion {
version: number;
}
interface ForeignKeyInfo {
table: string;
on_update: string;
on_delete: string;
}
function getTableNames(db: Database): string[] {
const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as TableNameRow[];
return rows.map(r => r.name);
}
function getColumns(db: Database, table: string): TableColumnInfo[] {
return db.prepare(`PRAGMA table_info(${table})`).all() as TableColumnInfo[];
}
function getSchemaVersions(db: Database): number[] {
const rows = db.prepare('SELECT version FROM schema_versions ORDER BY version').all() as SchemaVersion[];
return rows.map(r => r.version);
}
describe('MigrationRunner', () => {
let db: Database;
beforeEach(() => {
db = new Database(':memory:');
db.run('PRAGMA journal_mode = WAL');
db.run('PRAGMA foreign_keys = ON');
});
afterEach(() => {
db.close();
});
describe('fresh database initialization', () => {
it('should create all core tables on a fresh database', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const tables = getTableNames(db);
expect(tables).toContain('schema_versions');
expect(tables).toContain('sdk_sessions');
expect(tables).toContain('observations');
expect(tables).toContain('session_summaries');
expect(tables).toContain('user_prompts');
expect(tables).toContain('pending_messages');
});
it('should create sdk_sessions with all expected columns', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const columns = getColumns(db, 'sdk_sessions');
const columnNames = columns.map(c => c.name);
expect(columnNames).toContain('id');
expect(columnNames).toContain('content_session_id');
expect(columnNames).toContain('memory_session_id');
expect(columnNames).toContain('project');
expect(columnNames).toContain('status');
expect(columnNames).toContain('worker_port');
expect(columnNames).toContain('prompt_counter');
});
it('should create observations with all expected columns including content_hash', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const columns = getColumns(db, 'observations');
const columnNames = columns.map(c => c.name);
expect(columnNames).toContain('id');
expect(columnNames).toContain('memory_session_id');
expect(columnNames).toContain('project');
expect(columnNames).toContain('type');
expect(columnNames).toContain('title');
expect(columnNames).toContain('narrative');
expect(columnNames).toContain('prompt_number');
expect(columnNames).toContain('discovery_tokens');
expect(columnNames).toContain('content_hash');
});
it('should record all migration versions', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const versions = getSchemaVersions(db);
// Core set of expected versions
expect(versions).toContain(4); // initializeSchema
expect(versions).toContain(5); // worker_port
expect(versions).toContain(6); // prompt tracking
expect(versions).toContain(7); // remove unique constraint
expect(versions).toContain(8); // hierarchical fields
expect(versions).toContain(9); // text nullable
expect(versions).toContain(10); // user_prompts
expect(versions).toContain(11); // discovery_tokens
expect(versions).toContain(16); // pending_messages
expect(versions).toContain(17); // rename columns
expect(versions).toContain(19); // repair (noop)
expect(versions).toContain(20); // failed_at_epoch
expect(versions).toContain(21); // ON UPDATE CASCADE
expect(versions).toContain(22); // content_hash
});
});
describe('idempotency — running migrations twice', () => {
it('should succeed when run twice on the same database', () => {
const runner = new MigrationRunner(db);
// First run
runner.runAllMigrations();
// Second run — must not throw
expect(() => runner.runAllMigrations()).not.toThrow();
});
it('should produce identical schema when run twice', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const tablesAfterFirst = getTableNames(db);
const versionsAfterFirst = getSchemaVersions(db);
runner.runAllMigrations();
const tablesAfterSecond = getTableNames(db);
const versionsAfterSecond = getSchemaVersions(db);
expect(tablesAfterSecond).toEqual(tablesAfterFirst);
expect(versionsAfterSecond).toEqual(versionsAfterFirst);
});
});
describe('issue #979 — old DatabaseManager version conflict', () => {
it('should create core tables even when old migration versions 1-7 are in schema_versions', () => {
// Simulate the old DatabaseManager having applied its migrations 1-7
// (which are completely different operations with the same version numbers)
db.run(`
CREATE TABLE IF NOT EXISTS schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
applied_at TEXT NOT NULL
)
`);
const now = new Date().toISOString();
for (let v = 1; v <= 7; v++) {
db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(v, now);
}
// Now run MigrationRunner — core tables MUST still be created
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const tables = getTableNames(db);
expect(tables).toContain('sdk_sessions');
expect(tables).toContain('observations');
expect(tables).toContain('session_summaries');
expect(tables).toContain('user_prompts');
expect(tables).toContain('pending_messages');
});
it('should handle version 5 conflict (old=drop tables, new=add column) correctly', () => {
// Old migration 5 drops streaming_sessions/observation_queue
// New migration 5 adds worker_port column to sdk_sessions
// With old version 5 already recorded, MigrationRunner must still add the column
db.run(`
CREATE TABLE IF NOT EXISTS schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
applied_at TEXT NOT NULL
)
`);
db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(5, new Date().toISOString());
const runner = new MigrationRunner(db);
runner.runAllMigrations();
// sdk_sessions should exist and have worker_port (added by later migrations even if v5 is skipped)
const columns = getColumns(db, 'sdk_sessions');
const columnNames = columns.map(c => c.name);
expect(columnNames).toContain('content_session_id');
});
});
describe('crash recovery — leftover temp tables', () => {
it('should handle leftover session_summaries_new table from crashed migration 7', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
// Simulate a leftover temp table from a crash
db.run(`
CREATE TABLE session_summaries_new (
id INTEGER PRIMARY KEY,
test TEXT
)
`);
// Remove version 7 so migration tries to re-run
db.prepare('DELETE FROM schema_versions WHERE version = 7').run();
// Re-run should handle the leftover table gracefully
expect(() => runner.runAllMigrations()).not.toThrow();
});
it('should handle leftover observations_new table from crashed migration 9', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
// Simulate a leftover temp table from a crash
db.run(`
CREATE TABLE observations_new (
id INTEGER PRIMARY KEY,
test TEXT
)
`);
// Remove version 9 so migration tries to re-run
db.prepare('DELETE FROM schema_versions WHERE version = 9').run();
// Re-run should handle the leftover table gracefully
expect(() => runner.runAllMigrations()).not.toThrow();
});
});
describe('ON UPDATE CASCADE FK constraints', () => {
it('should have ON UPDATE CASCADE on observations FK after migration 21', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const fks = db.prepare('PRAGMA foreign_key_list(observations)').all() as ForeignKeyInfo[];
const memorySessionFk = fks.find(fk => fk.table === 'sdk_sessions');
expect(memorySessionFk).toBeDefined();
expect(memorySessionFk!.on_update).toBe('CASCADE');
expect(memorySessionFk!.on_delete).toBe('CASCADE');
});
it('should have ON UPDATE CASCADE on session_summaries FK after migration 21', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
const fks = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as ForeignKeyInfo[];
const memorySessionFk = fks.find(fk => fk.table === 'sdk_sessions');
expect(memorySessionFk).toBeDefined();
expect(memorySessionFk!.on_update).toBe('CASCADE');
expect(memorySessionFk!.on_delete).toBe('CASCADE');
});
});
describe('data integrity during migration', () => {
it('should preserve existing data through all migrations', () => {
const runner = new MigrationRunner(db);
runner.runAllMigrations();
// Insert test data
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, text, type, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?)
`).run('test-memory-1', 'test-project', 'test observation', 'discovery', now, epoch);
db.prepare(`
INSERT INTO session_summaries (memory_session_id, project, request, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?)
`).run('test-memory-1', 'test-project', 'test request', now, epoch);
// Run migrations again — data should survive
runner.runAllMigrations();
const sessions = db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
const observations = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
const summaries = db.prepare('SELECT COUNT(*) as count FROM session_summaries').get() as { count: number };
expect(sessions.count).toBe(1);
expect(observations.count).toBe(1);
expect(summaries.count).toBe(1);
});
});
});
@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
/**
* Tests for Issue #1099: Stale AbortController queue stall prevention
*
* Validates that:
* 1. ActiveSession tracks lastGeneratorActivity timestamp
* 2. deleteSession uses a 30s timeout to prevent indefinite stalls
* 3. Stale generators (>30s no activity) are detected and aborted
* 4. processAgentResponse updates lastGeneratorActivity
*/
describe('Stale AbortController Guard (#1099)', () => {
describe('ActiveSession.lastGeneratorActivity', () => {
it('should be defined in ActiveSession type', () => {
// Verify the type includes lastGeneratorActivity
const session = {
sessionDbId: 1,
contentSessionId: 'test',
memorySessionId: null,
project: 'test',
userPrompt: 'test',
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
lastPromptNumber: 1,
startTime: Date.now(),
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
earliestPendingTimestamp: null,
conversationHistory: [],
currentProvider: null,
consecutiveRestarts: 0,
processingMessageIds: [],
lastGeneratorActivity: Date.now()
};
expect(session.lastGeneratorActivity).toBeGreaterThan(0);
});
it('should update when set to current time', () => {
const before = Date.now();
const activity = Date.now();
expect(activity).toBeGreaterThanOrEqual(before);
});
});
describe('Stale generator detection logic', () => {
const STALE_THRESHOLD_MS = 30_000;
it('should detect generator as stale when no activity for >30s', () => {
const lastActivity = Date.now() - 31_000; // 31 seconds ago
const timeSinceActivity = Date.now() - lastActivity;
expect(timeSinceActivity).toBeGreaterThan(STALE_THRESHOLD_MS);
});
it('should NOT detect generator as stale when activity within 30s', () => {
const lastActivity = Date.now() - 5_000; // 5 seconds ago
const timeSinceActivity = Date.now() - lastActivity;
expect(timeSinceActivity).toBeLessThan(STALE_THRESHOLD_MS);
});
it('should reset activity timestamp when generator restarts', () => {
const session = {
lastGeneratorActivity: Date.now() - 60_000, // 60 seconds ago (stale)
abortController: new AbortController(),
generatorPromise: Promise.resolve() as Promise<void> | null,
};
// Simulate stale recovery: abort, reset, restart
session.abortController.abort();
session.generatorPromise = null;
session.abortController = new AbortController();
session.lastGeneratorActivity = Date.now();
// After reset, should no longer be stale
const timeSinceActivity = Date.now() - session.lastGeneratorActivity;
expect(timeSinceActivity).toBeLessThan(STALE_THRESHOLD_MS);
expect(session.abortController.signal.aborted).toBe(false);
});
});
describe('AbortSignal.timeout for deleteSession', () => {
it('should resolve timeout signal after specified ms', async () => {
const start = Date.now();
const timeoutMs = 50; // Use short timeout for test
await new Promise<void>(resolve => {
AbortSignal.timeout(timeoutMs).addEventListener('abort', () => resolve(), { once: true });
});
const elapsed = Date.now() - start;
// Allow some margin for timing
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10);
});
it('should race generator promise against timeout', async () => {
// Simulate a hung generator (never resolves)
const hungGenerator = new Promise<void>(() => {});
const timeoutMs = 50;
const timeoutDone = new Promise<string>(resolve => {
AbortSignal.timeout(timeoutMs).addEventListener('abort', () => resolve('timeout'), { once: true });
});
const generatorDone = hungGenerator.then(() => 'generator');
const result = await Promise.race([generatorDone, timeoutDone]);
expect(result).toBe('timeout');
});
it('should prefer generator completion over timeout when fast', async () => {
// Simulate a generator that resolves quickly
const fastGenerator = Promise.resolve('generator');
const timeoutMs = 5000;
const timeoutDone = new Promise<string>(resolve => {
AbortSignal.timeout(timeoutMs).addEventListener('abort', () => resolve('timeout'), { once: true });
});
const result = await Promise.race([fastGenerator, timeoutDone]);
expect(result).toBe('generator');
});
});
describe('AbortController replacement on stale recovery', () => {
it('should create fresh AbortController that is not aborted', () => {
const oldController = new AbortController();
oldController.abort();
expect(oldController.signal.aborted).toBe(true);
const newController = new AbortController();
expect(newController.signal.aborted).toBe(false);
});
it('should not affect new controller when old is aborted', () => {
const oldController = new AbortController();
const newController = new AbortController();
oldController.abort();
expect(oldController.signal.aborted).toBe(true);
expect(newController.signal.aborted).toBe(false);
});
});
});
@@ -323,7 +323,7 @@ describe('SettingsDefaultsManager', () => {
describe('getBool', () => {
it('should return true for "true" string', () => {
expect(SettingsDefaultsManager.getBool('CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS')).toBe(true);
expect(SettingsDefaultsManager.getBool('CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT')).toBe(true);
});
it('should return false for non-"true" string', () => {
+165
View File
@@ -0,0 +1,165 @@
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';
/**
* Smart Install Script Tests
*
* Tests the resolveRoot() and verifyCriticalModules() logic used by
* plugin/scripts/smart-install.js to find the correct install directory
* for cache-based and marketplace installs.
*
* These are unit tests that exercise the resolution logic in isolation
* using temp directories, without running actual bun/npm install.
*/
const TEST_DIR = join(tmpdir(), `claude-mem-smart-install-test-${process.pid}`);
function createDir(relativePath: string): string {
const fullPath = join(TEST_DIR, relativePath);
mkdirSync(fullPath, { recursive: true });
return fullPath;
}
function createPackageJson(dir: string, version = '10.0.0', deps: Record<string, string> = {}): void {
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'claude-mem-plugin',
version,
dependencies: deps
}));
}
describe('smart-install resolveRoot logic', () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true });
});
it('should prefer CLAUDE_PLUGIN_ROOT when it contains package.json', () => {
const cacheDir = createDir('cache/thedotmack/claude-mem/10.0.0');
createPackageJson(cacheDir);
// Simulate what resolveRoot does
const root = cacheDir;
expect(existsSync(join(root, 'package.json'))).toBe(true);
});
it('should detect cache-based install paths', () => {
// Cache installs have paths like ~/.claude/plugins/cache/thedotmack/claude-mem/<version>/
const cacheDir = createDir('plugins/cache/thedotmack/claude-mem/10.3.0');
createPackageJson(cacheDir);
// Marketplace dir does NOT exist (fresh cache install, no marketplace)
const pluginRoot = cacheDir;
expect(existsSync(join(pluginRoot, 'package.json'))).toBe(true);
// The cache dir is valid — resolveRoot should use it, not try to navigate to marketplace
});
it('should fall back to script-relative path when CLAUDE_PLUGIN_ROOT is unset', () => {
// Simulate: scripts/smart-install.js lives in <root>/scripts/
const pluginRoot = createDir('marketplace-plugin');
createPackageJson(pluginRoot);
const scriptsDir = createDir('marketplace-plugin/scripts');
// dirname(scripts/) = marketplace-plugin/ which has package.json
const candidate = join(scriptsDir, '..');
expect(existsSync(join(candidate, 'package.json'))).toBe(true);
});
it('should handle missing package.json in CLAUDE_PLUGIN_ROOT gracefully', () => {
// CLAUDE_PLUGIN_ROOT points to a dir without package.json
const badDir = createDir('empty-cache-dir');
expect(existsSync(join(badDir, 'package.json'))).toBe(false);
// resolveRoot should fall through to next candidate
});
});
describe('smart-install verifyCriticalModules logic', () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true });
});
it('should pass when all dependencies exist in node_modules', () => {
const root = createDir('plugin-root');
createPackageJson(root, '10.0.0', {
'@chroma-core/default-embed': '^0.1.9'
});
// Create the module directory
mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true });
// Simulate verifyCriticalModules
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
const dependencies = Object.keys(pkg.dependencies || {});
const missing: string[] = [];
for (const dep of dependencies) {
const modulePath = join(root, 'node_modules', ...dep.split('/'));
if (!existsSync(modulePath)) {
missing.push(dep);
}
}
expect(missing).toEqual([]);
});
it('should detect missing dependencies in node_modules', () => {
const root = createDir('plugin-root-missing');
createPackageJson(root, '10.0.0', {
'@chroma-core/default-embed': '^0.1.9'
});
// Do NOT create node_modules — simulate a failed install
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
const dependencies = Object.keys(pkg.dependencies || {});
const missing: string[] = [];
for (const dep of dependencies) {
const modulePath = join(root, 'node_modules', ...dep.split('/'));
if (!existsSync(modulePath)) {
missing.push(dep);
}
}
expect(missing).toEqual(['@chroma-core/default-embed']);
});
it('should handle packages with no dependencies gracefully', () => {
const root = createDir('plugin-root-no-deps');
createPackageJson(root, '10.0.0', {});
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
const dependencies = Object.keys(pkg.dependencies || {});
expect(dependencies).toEqual([]);
});
it('should detect partially installed scoped packages', () => {
const root = createDir('plugin-root-partial');
createPackageJson(root, '10.0.0', {
'@chroma-core/default-embed': '^0.1.9',
'@chroma-core/other-pkg': '^1.0.0'
});
// Only install one of the two packages
mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true });
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
const dependencies = Object.keys(pkg.dependencies || {});
const missing: string[] = [];
for (const dep of dependencies) {
const modulePath = join(root, 'node_modules', ...dep.split('/'));
if (!existsSync(modulePath)) {
missing.push(dep);
}
}
expect(missing).toEqual(['@chroma-core/other-pkg']);
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Data integrity tests for TRIAGE-03
* Tests: content-hash deduplication, project name collision, empty project guard, stuck isProcessing
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
import {
storeObservation,
computeObservationContentHash,
findDuplicateObservation,
} from '../../src/services/sqlite/observations/store.js';
import {
createSDKSession,
updateMemorySessionId,
} from '../../src/services/sqlite/Sessions.js';
import { storeObservations } from '../../src/services/sqlite/transactions.js';
import { PendingMessageStore } from '../../src/services/sqlite/PendingMessageStore.js';
import type { ObservationInput } from '../../src/services/sqlite/observations/types.js';
import type { Database } from 'bun:sqlite';
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
return {
type: 'discovery',
title: 'Test Observation',
subtitle: 'Test Subtitle',
facts: ['fact1', 'fact2'],
narrative: 'Test narrative content',
concepts: ['concept1', 'concept2'],
files_read: ['/path/to/file1.ts'],
files_modified: ['/path/to/file2.ts'],
...overrides,
};
}
function createSessionWithMemoryId(db: Database, contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
updateMemorySessionId(db, sessionId, memorySessionId);
return memorySessionId;
}
describe('TRIAGE-03: Data Integrity', () => {
let db: Database;
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
});
afterEach(() => {
db.close();
});
describe('Content-hash deduplication', () => {
it('computeObservationContentHash produces consistent hashes', () => {
const hash1 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
const hash2 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
expect(hash1).toBe(hash2);
expect(hash1.length).toBe(16);
});
it('computeObservationContentHash produces different hashes for different content', () => {
const hash1 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
const hash2 = computeObservationContentHash('session-1', 'Title B', 'Narrative B');
expect(hash1).not.toBe(hash2);
});
it('computeObservationContentHash handles nulls', () => {
const hash = computeObservationContentHash('session-1', null, null);
expect(hash.length).toBe(16);
});
it('storeObservation deduplicates identical observations within 30s window', () => {
const memId = createSessionWithMemoryId(db, 'content-dedup-1', 'mem-dedup-1');
const obs = createObservationInput({ title: 'Same Title', narrative: 'Same Narrative' });
const now = Date.now();
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 1000);
// Second call should return the same id as the first (deduped)
expect(result2.id).toBe(result1.id);
});
it('storeObservation allows same content after dedup window expires', () => {
const memId = createSessionWithMemoryId(db, 'content-dedup-2', 'mem-dedup-2');
const obs = createObservationInput({ title: 'Same Title', narrative: 'Same Narrative' });
const now = Date.now();
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
// 31 seconds later — outside the 30s window
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 31_000);
expect(result2.id).not.toBe(result1.id);
});
it('storeObservation allows different content at same time', () => {
const memId = createSessionWithMemoryId(db, 'content-dedup-3', 'mem-dedup-3');
const obs1 = createObservationInput({ title: 'Title A', narrative: 'Narrative A' });
const obs2 = createObservationInput({ title: 'Title B', narrative: 'Narrative B' });
const now = Date.now();
const result1 = storeObservation(db, memId, 'test-project', obs1, 1, 0, now);
const result2 = storeObservation(db, memId, 'test-project', obs2, 1, 0, now);
expect(result2.id).not.toBe(result1.id);
});
it('content_hash column is populated on new observations', () => {
const memId = createSessionWithMemoryId(db, 'content-hash-col', 'mem-hash-col');
const obs = createObservationInput();
storeObservation(db, memId, 'test-project', obs);
const row = db.prepare('SELECT content_hash FROM observations LIMIT 1').get() as { content_hash: string };
expect(row.content_hash).toBeTruthy();
expect(row.content_hash.length).toBe(16);
});
});
describe('Transaction-level deduplication', () => {
it('storeObservations deduplicates within a batch', () => {
const memId = createSessionWithMemoryId(db, 'content-tx-1', 'mem-tx-1');
const obs = createObservationInput({ title: 'Duplicate', narrative: 'Same content' });
const result = storeObservations(db, memId, 'test-project', [obs, obs, obs], null);
// First is inserted, second and third are deduped to the first
expect(result.observationIds.length).toBe(3);
expect(result.observationIds[1]).toBe(result.observationIds[0]);
expect(result.observationIds[2]).toBe(result.observationIds[0]);
// Only 1 row in the database
const count = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
expect(count.count).toBe(1);
});
});
describe('Empty project string guard', () => {
it('storeObservation replaces empty project with cwd-derived name', () => {
const memId = createSessionWithMemoryId(db, 'content-empty-proj', 'mem-empty-proj');
const obs = createObservationInput();
const result = storeObservation(db, memId, '', obs);
const row = db.prepare('SELECT project FROM observations WHERE id = ?').get(result.id) as { project: string };
// Should not be empty — will be derived from cwd
expect(row.project).toBeTruthy();
expect(row.project.length).toBeGreaterThan(0);
});
});
describe('Stuck isProcessing flag', () => {
it('hasAnyPendingWork resets stuck processing messages older than 5 minutes', () => {
// Create a pending_messages table entry that's stuck in 'processing'
const sessionId = createSDKSession(db, 'content-stuck', 'stuck-project', 'test');
// Insert a processing message stuck for 6 minutes
const sixMinutesAgo = Date.now() - (6 * 60 * 1000);
db.prepare(`
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, retry_count, created_at_epoch, started_processing_at_epoch)
VALUES (?, 'content-stuck', 'observation', 'processing', 0, ?, ?)
`).run(sessionId, sixMinutesAgo, sixMinutesAgo);
const pendingStore = new PendingMessageStore(db);
// hasAnyPendingWork should reset the stuck message and still return true (it's now pending again)
const hasPending = pendingStore.hasAnyPendingWork();
expect(hasPending).toBe(true);
// Verify the message was reset to 'pending'
const msg = db.prepare('SELECT status FROM pending_messages WHERE content_session_id = ?').get('content-stuck') as { status: string };
expect(msg.status).toBe('pending');
});
it('hasAnyPendingWork does NOT reset recently-started processing messages', () => {
const sessionId = createSDKSession(db, 'content-recent', 'recent-project', 'test');
// Insert a processing message started 1 minute ago (well within 5-minute threshold)
const oneMinuteAgo = Date.now() - (1 * 60 * 1000);
db.prepare(`
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, retry_count, created_at_epoch, started_processing_at_epoch)
VALUES (?, 'content-recent', 'observation', 'processing', 0, ?, ?)
`).run(sessionId, oneMinuteAgo, oneMinuteAgo);
const pendingStore = new PendingMessageStore(db);
const hasPending = pendingStore.hasAnyPendingWork();
expect(hasPending).toBe(true);
// Verify the message is still 'processing' (not reset)
const msg = db.prepare('SELECT status FROM pending_messages WHERE content_session_id = ?').get('content-recent') as { status: string };
expect(msg.status).toBe('processing');
});
it('hasAnyPendingWork returns false when no pending or processing messages exist', () => {
const pendingStore = new PendingMessageStore(db);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
});
});
+46
View File
@@ -84,6 +84,52 @@ describe('Sessions Module', () => {
});
});
describe('custom_title', () => {
it('should store custom_title when provided at creation', () => {
const sessionId = createSDKSession(db, 'session-title-1', 'project', 'prompt', 'My Agent');
const session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('My Agent');
});
it('should default custom_title to null when not provided', () => {
const sessionId = createSDKSession(db, 'session-title-2', 'project', 'prompt');
const session = getSessionById(db, sessionId);
expect(session?.custom_title).toBeNull();
});
it('should backfill custom_title on idempotent call if not already set', () => {
const sessionId = createSDKSession(db, 'session-title-3', 'project', 'prompt');
let session = getSessionById(db, sessionId);
expect(session?.custom_title).toBeNull();
// Second call with custom_title should backfill
createSDKSession(db, 'session-title-3', 'project', 'prompt', 'Backfilled Title');
session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('Backfilled Title');
});
it('should not overwrite existing custom_title on idempotent call', () => {
const sessionId = createSDKSession(db, 'session-title-4', 'project', 'prompt', 'Original');
let session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('Original');
// Second call should NOT overwrite
createSDKSession(db, 'session-title-4', 'project', 'prompt', 'Attempted Override');
session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('Original');
});
it('should handle empty string custom_title as no title', () => {
const sessionId = createSDKSession(db, 'session-title-5', 'project', 'prompt', '');
const session = getSessionById(db, sessionId);
// Empty string becomes null via the || null conversion
expect(session?.custom_title).toBeNull();
});
});
describe('updateMemorySessionId', () => {
it('should update memory_session_id for existing session', () => {
const contentSessionId = 'content-session-update';
+42
View File
@@ -222,6 +222,48 @@ describe('writeClaudeMdToFolder', () => {
});
});
describe('issue #1165 - prevent CLAUDE.md inside .git directories', () => {
it('should not write CLAUDE.md when folder is inside .git/', () => {
const gitRefsFolder = join(tempDir, '.git', 'refs');
mkdirSync(gitRefsFolder, { recursive: true });
writeClaudeMdToFolder(gitRefsFolder, 'Should not be written');
const claudeMdPath = join(gitRefsFolder, 'CLAUDE.md');
expect(existsSync(claudeMdPath)).toBe(false);
});
it('should not write CLAUDE.md when folder is .git itself', () => {
const gitFolder = join(tempDir, '.git');
mkdirSync(gitFolder, { recursive: true });
writeClaudeMdToFolder(gitFolder, 'Should not be written');
const claudeMdPath = join(gitFolder, 'CLAUDE.md');
expect(existsSync(claudeMdPath)).toBe(false);
});
it('should not write CLAUDE.md to deeply nested .git path', () => {
const deepGitPath = join(tempDir, 'project', '.git', 'hooks');
mkdirSync(deepGitPath, { recursive: true });
writeClaudeMdToFolder(deepGitPath, 'Should not be written');
const claudeMdPath = join(deepGitPath, 'CLAUDE.md');
expect(existsSync(claudeMdPath)).toBe(false);
});
it('should still write CLAUDE.md to normal folders', () => {
const normalFolder = join(tempDir, 'src', 'git-utils');
mkdirSync(normalFolder, { recursive: true });
writeClaudeMdToFolder(normalFolder, 'Should be written');
const claudeMdPath = join(normalFolder, 'CLAUDE.md');
expect(existsSync(claudeMdPath)).toBe(true);
});
});
describe('updateFolderClaudeMdFiles', () => {
it('should skip when filePaths is empty', async () => {
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
@@ -0,0 +1,195 @@
/**
* DataRoutes Type Coercion Tests
*
* Tests that MCP clients sending string-encoded arrays for `ids` and
* `memorySessionIds` are properly coerced before validation.
*
* Mock Justification:
* - Express req/res mocks: Required because route handlers expect Express objects
* - DatabaseManager/SessionStore: Avoids database setup; we test coercion logic, not queries
* - Logger spies: Suppress console output during tests
*/
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
import type { Request, Response } from 'express';
import { logger } from '../../../../src/utils/logger.js';
// Mock dependencies before importing DataRoutes
mock.module('../../../../src/shared/paths.js', () => ({
getPackageRoot: () => '/tmp/test',
}));
mock.module('../../../../src/shared/worker-utils.js', () => ({
getWorkerPort: () => 37777,
}));
import { DataRoutes } from '../../../../src/services/worker/http/routes/DataRoutes.js';
let loggerSpies: ReturnType<typeof spyOn>[] = [];
// Helper to create mock req/res
function createMockReqRes(body: any): { req: Partial<Request>; res: Partial<Response>; jsonSpy: ReturnType<typeof mock>; statusSpy: ReturnType<typeof mock> } {
const jsonSpy = mock(() => {});
const statusSpy = mock(() => ({ json: jsonSpy }));
return {
req: { body, path: '/test', query: {} } as Partial<Request>,
res: { json: jsonSpy, status: statusSpy } as unknown as Partial<Response>,
jsonSpy,
statusSpy,
};
}
describe('DataRoutes Type Coercion', () => {
let routes: DataRoutes;
let mockGetObservationsByIds: ReturnType<typeof mock>;
let mockGetSdkSessionsBySessionIds: ReturnType<typeof mock>;
beforeEach(() => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'failure').mockImplementation(() => {}),
];
mockGetObservationsByIds = mock(() => [{ id: 1 }, { id: 2 }]);
mockGetSdkSessionsBySessionIds = mock(() => [{ id: 'abc' }]);
const mockDbManager = {
getSessionStore: () => ({
getObservationsByIds: mockGetObservationsByIds,
getSdkSessionsBySessionIds: mockGetSdkSessionsBySessionIds,
}),
};
routes = new DataRoutes(
{} as any, // paginationHelper
mockDbManager as any,
{} as any, // sessionManager
{} as any, // sseBroadcaster
{} as any, // workerService
Date.now()
);
});
afterEach(() => {
loggerSpies.forEach(spy => spy.mockRestore());
mock.restore();
});
describe('handleGetObservationsByIds — ids coercion', () => {
// Access the handler via setupRoutes
let handler: (req: Request, res: Response) => void;
beforeEach(() => {
const mockApp = {
get: mock(() => {}),
post: mock((path: string, fn: any) => {
if (path === '/api/observations/batch') handler = fn;
}),
delete: mock(() => {}),
};
routes.setupRoutes(mockApp as any);
});
it('should accept a native array of numbers', () => {
const { req, res, jsonSpy } = createMockReqRes({ ids: [1, 2, 3] });
handler(req as Request, res as Response);
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
expect(jsonSpy).toHaveBeenCalled();
});
it('should coerce a JSON-encoded string array "[1,2,3]" to native array', () => {
const { req, res, jsonSpy } = createMockReqRes({ ids: '[1,2,3]' });
handler(req as Request, res as Response);
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
expect(jsonSpy).toHaveBeenCalled();
});
it('should coerce a comma-separated string "1,2,3" to native array', () => {
const { req, res, jsonSpy } = createMockReqRes({ ids: '1,2,3' });
handler(req as Request, res as Response);
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
expect(jsonSpy).toHaveBeenCalled();
});
it('should reject non-integer values after coercion', () => {
const { req, res, statusSpy } = createMockReqRes({ ids: 'foo,bar' });
handler(req as Request, res as Response);
// NaN values should fail the Number.isInteger check
expect(statusSpy).toHaveBeenCalledWith(400);
});
it('should reject missing ids', () => {
const { req, res, statusSpy } = createMockReqRes({});
handler(req as Request, res as Response);
expect(statusSpy).toHaveBeenCalledWith(400);
});
it('should return empty array for empty ids array', () => {
const { req, res, jsonSpy } = createMockReqRes({ ids: [] });
handler(req as Request, res as Response);
expect(jsonSpy).toHaveBeenCalledWith([]);
});
});
describe('handleGetSdkSessionsByIds — memorySessionIds coercion', () => {
let handler: (req: Request, res: Response) => void;
beforeEach(() => {
const mockApp = {
get: mock(() => {}),
post: mock((path: string, fn: any) => {
if (path === '/api/sdk-sessions/batch') handler = fn;
}),
delete: mock(() => {}),
};
routes.setupRoutes(mockApp as any);
});
it('should accept a native array of strings', () => {
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: ['abc', 'def'] });
handler(req as Request, res as Response);
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
expect(jsonSpy).toHaveBeenCalled();
});
it('should coerce a JSON-encoded string array to native array', () => {
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: '["abc","def"]' });
handler(req as Request, res as Response);
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
expect(jsonSpy).toHaveBeenCalled();
});
it('should coerce a comma-separated string to native array', () => {
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: 'abc,def' });
handler(req as Request, res as Response);
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
expect(jsonSpy).toHaveBeenCalled();
});
it('should trim whitespace from comma-separated values', () => {
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: 'abc, def , ghi' });
handler(req as Request, res as Response);
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def', 'ghi']);
expect(jsonSpy).toHaveBeenCalled();
});
it('should reject non-array, non-string values', () => {
const { req, res, statusSpy } = createMockReqRes({ memorySessionIds: 42 });
handler(req as Request, res as Response);
expect(statusSpy).toHaveBeenCalledWith(400);
});
});
});
@@ -1,11 +1,14 @@
/**
* CORS Restriction Tests
*
* Verifies that CORS is properly restricted to localhost origins only.
* This prevents cross-origin attacks from malicious websites.
* Verifies that CORS is properly restricted to localhost origins only,
* and that preflight responses include the correct methods and headers (#1029).
*/
import { describe, it, expect } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import express from 'express';
import cors from 'cors';
import http from 'http';
// Test the CORS origin validation logic directly
function isAllowedOrigin(origin: string | undefined): boolean {
@@ -15,6 +18,27 @@ function isAllowedOrigin(origin: string | undefined): boolean {
return false;
}
/**
* Build the same CORS config used in production middleware.ts.
* Duplicated here to avoid module-mock interference from other test files.
*/
function buildProductionCorsMiddleware() {
return cors({
origin: (origin, callback) => {
if (!origin ||
origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:')) {
callback(null, true);
} else {
callback(new Error('CORS not allowed'));
}
},
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: false
});
}
describe('CORS Restriction', () => {
describe('allowed origins', () => {
it('allows requests without Origin header (hooks, curl, CLI)', () => {
@@ -59,4 +83,120 @@ describe('CORS Restriction', () => {
expect(isAllowedOrigin('null')).toBe(false);
});
});
describe('preflight CORS headers (#1029)', () => {
let app: express.Application;
let server: http.Server;
let testPort: number;
beforeEach(async () => {
app = express();
app.use(express.json());
app.use(buildProductionCorsMiddleware());
// Add a test endpoint that supports all methods
app.all('/api/settings', (_req, res) => {
res.json({ ok: true });
});
testPort = 41000 + Math.floor(Math.random() * 10000);
await new Promise<void>((resolve) => {
server = app.listen(testPort, '127.0.0.1', resolve);
});
});
afterEach(async () => {
if (server) {
await new Promise<void>((resolve, reject) => {
server.close(err => err ? reject(err) : resolve());
});
}
});
it('preflight response includes PUT in allowed methods', async () => {
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
method: 'OPTIONS',
headers: {
'Origin': 'http://localhost:37777',
'Access-Control-Request-Method': 'PUT',
},
});
expect(response.status).toBe(204);
const allowedMethods = response.headers.get('access-control-allow-methods');
expect(allowedMethods).toContain('PUT');
});
it('preflight response includes PATCH in allowed methods', async () => {
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
method: 'OPTIONS',
headers: {
'Origin': 'http://localhost:37777',
'Access-Control-Request-Method': 'PATCH',
},
});
expect(response.status).toBe(204);
const allowedMethods = response.headers.get('access-control-allow-methods');
expect(allowedMethods).toContain('PATCH');
});
it('preflight response includes DELETE in allowed methods', async () => {
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
method: 'OPTIONS',
headers: {
'Origin': 'http://localhost:37777',
'Access-Control-Request-Method': 'DELETE',
},
});
expect(response.status).toBe(204);
const allowedMethods = response.headers.get('access-control-allow-methods');
expect(allowedMethods).toContain('DELETE');
});
it('preflight response includes Content-Type in allowed headers', async () => {
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
method: 'OPTIONS',
headers: {
'Origin': 'http://localhost:37777',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type',
},
});
expect(response.status).toBe(204);
const allowedHeaders = response.headers.get('access-control-allow-headers');
expect(allowedHeaders).toContain('Content-Type');
});
it('preflight from localhost includes allow-origin header', async () => {
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
method: 'OPTIONS',
headers: {
'Origin': 'http://localhost:37777',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type',
},
});
expect(response.status).toBe(204);
const origin = response.headers.get('access-control-allow-origin');
expect(origin).toBe('http://localhost:37777');
});
it('preflight from external origin omits allow-origin header', async () => {
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
method: 'OPTIONS',
headers: {
'Origin': 'http://evil.com',
'Access-Control-Request-Method': 'POST',
},
});
// cors middleware rejects disallowed origins — browser enforces the block
const origin = response.headers.get('access-control-allow-origin');
expect(origin).toBeNull();
});
});
});