Compare commits
231 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11666e9ffb | |||
| fb8e526c55 | |||
| b8d63d949f | |||
| 7a66cb310f | |||
| d1601123fd | |||
| f6fda8fff4 | |||
| d24f3a7019 | |||
| 0a5f072aaf | |||
| bce4ce32ec | |||
| 5664fabce4 | |||
| 0b90495391 | |||
| 3e770df332 | |||
| a7c3c4af2d | |||
| 3d1dfcc26a | |||
| dc198d5677 | |||
| 193e7e0719 | |||
| 9d695f53ed | |||
| a65ab055ca | |||
| d589bc5f25 | |||
| 3869b083d0 | |||
| 148e1892df | |||
| 040729beef | |||
| 53622b59e9 | |||
| 69080dc291 | |||
| c76a439491 | |||
| 70a150db74 | |||
| d7b4610e27 | |||
| 88bb4e589e | |||
| ebefae864e | |||
| 0cd931bb06 | |||
| 4c792f026d | |||
| aa7cdb6d9f | |||
| 5db90f2ea0 | |||
| 4ddf57610a | |||
| d0fc68c630 | |||
| 1d7500604f | |||
| 05232ff091 | |||
| b411d91885 | |||
| 4538e686ad | |||
| f97c50bfb9 | |||
| 983be42998 | |||
| 544e9d39f5 | |||
| 16a0737dfc | |||
| 3d92684e04 | |||
| 471e1f62f9 | |||
| f44605658d | |||
| eeb6841033 | |||
| 2a2008bac2 | |||
| d64c252f4d | |||
| 59ce0fc553 | |||
| 31ee1024c5 | |||
| 7d5d4c5036 | |||
| 06b997e3d0 | |||
| a390a537c9 | |||
| 2357835942 | |||
| 77a22d30b2 | |||
| 40a25e0225 | |||
| 4c2ab98d90 | |||
| 7bcfd73985 | |||
| 7dd321f869 | |||
| 153ddb814b | |||
| 216d17879d | |||
| fa73dd483c | |||
| 9dd0ae10a3 | |||
| 9a91a1be2b | |||
| a5b2c26592 | |||
| fc9331fc39 | |||
| ff17609a81 | |||
| fe8737420d | |||
| 8275b3da3b | |||
| b7c23ca232 | |||
| edc8535ac1 | |||
| ad127bec40 | |||
| 2f19eab9c2 | |||
| e7bf2ac65a | |||
| 5ac54239d8 | |||
| 08cf2ba3bd | |||
| c7c4fd54d6 | |||
| f61eb2d162 | |||
| e9a234308a | |||
| e398212983 | |||
| 36a03f75b2 | |||
| 5676cab83f | |||
| 126129fbac | |||
| cde4faae2f | |||
| b701bf29e6 | |||
| c648d5d8d2 | |||
| 07be61cf91 | |||
| f7fd2221c8 | |||
| 6461d718f2 | |||
| 29f2d0bc02 | |||
| abd55977ca | |||
| 1fc66add67 | |||
| 25ccf46ac0 | |||
| 1a6a68cac8 | |||
| a0e895b53b | |||
| 753a993647 | |||
| d0676aa049 | |||
| 7996dfd5cd | |||
| 95889c7b4e | |||
| 25bb93a995 | |||
| c21e49d9fa | |||
| f4570f2a0a | |||
| cbb68ad9e1 | |||
| b8999c1181 | |||
| 052da384b2 | |||
| d8947473b8 | |||
| 53f98fad67 | |||
| 64062ac761 | |||
| 8cdabe6315 | |||
| e3475180cd | |||
| ef1b427a2a | |||
| 455aeaf654 | |||
| 31910fb265 | |||
| 6250a194dd | |||
| 3b935294bf | |||
| 58fcd85724 | |||
| 2d5480b5e4 | |||
| c1a3fc27ec | |||
| d570909bf1 | |||
| 5dd2a6f758 | |||
| c3cb8f81ed | |||
| 8d02271321 | |||
| 54289b34e6 | |||
| 5a52121216 | |||
| 5cffff7d40 | |||
| d63d73acc2 | |||
| 9a4afab4c2 | |||
| 832bd755ed | |||
| 995f69e4e9 | |||
| 842d614adb | |||
| b1da4c7e2c | |||
| 4d2bb1f13e | |||
| a9de029c02 | |||
| a4115d055a | |||
| 53c1fc9a70 | |||
| 79d3ca6aaa | |||
| 85f57e6440 | |||
| 36de44d661 | |||
| f32fda8b35 | |||
| 4509da1409 | |||
| b0f70b8302 | |||
| 1f808c0be7 | |||
| a28eddb925 | |||
| a60f79c44d | |||
| 5e696888d6 | |||
| 17fa383450 | |||
| 9f01228a2b | |||
| 18aa5dc4e7 | |||
| 6cb74c6183 | |||
| 0f9745535a | |||
| f81684c61c | |||
| 7def736f0a | |||
| d3262ae1f4 | |||
| 2b8fbcf50e | |||
| 0099a196c5 | |||
| 41010c527d | |||
| 753837bff3 | |||
| 76a27296f0 | |||
| e2d4babae8 | |||
| 00ab61b46e | |||
| a7ebc35ee0 | |||
| 9063c5d8a7 | |||
| 3b34feb779 | |||
| ad58fdf8fc | |||
| b385570884 | |||
| 29ef3f5603 | |||
| f7a088c6d9 | |||
| 538ada9ec4 | |||
| bedca129ac | |||
| 70a8edc5b1 | |||
| c3e5f3a79e | |||
| 6c0dcd9a4a | |||
| 811c94da36 | |||
| af6bfda2d8 | |||
| bf8b7dbd9f | |||
| 76207fb8d6 | |||
| 42cc863bf2 | |||
| 0fcc078873 | |||
| d11c0821bb | |||
| 876cc4d837 | |||
| 64cce2bf10 | |||
| 5a27420809 | |||
| 8958c3335d | |||
| 8c03704246 | |||
| 91f73a83bc | |||
| c74101b7f7 | |||
| 1b5d1a1234 | |||
| c4146cca67 | |||
| eea9c100ba | |||
| 16f79d6f71 | |||
| a74ff0034f | |||
| a66b98bcdd | |||
| bd47a919a8 | |||
| 4d4b0a2f24 | |||
| 472d302133 | |||
| 303aafa64b | |||
| 67645041fa | |||
| d8eb2fa9f9 | |||
| 93a30c5c8f | |||
| 2a304d59eb | |||
| 12501412b9 | |||
| fb8c9dbdbe | |||
| b81281fd6c | |||
| 247d287bdc | |||
| 2a6c9ea2b7 | |||
| 4589b34eab | |||
| 7fce21c145 | |||
| b0f1a458cf | |||
| 83f61177c7 | |||
| 88b47f9e9c | |||
| f86be1ef2b | |||
| a48bf89963 | |||
| 368daddd88 | |||
| ed444dfec7 | |||
| 4aa7119d7d | |||
| 9cfa57d498 | |||
| fe8c65a8cd | |||
| 4f6fb9e614 | |||
| 2b60dd2932 | |||
| b6f9950bb3 | |||
| 4324f6bbc1 | |||
| df1fb8bb89 | |||
| 5b041d6b49 | |||
| abb5940788 | |||
| d88ea71590 | |||
| c80763390b | |||
| 47d6d51030 | |||
| e07b13f7de | |||
| 1d48f63b99 | |||
| fb9d917f8a |
@@ -1,136 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Oct 25, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #2374 | 2:55 PM | ✅ | Marketplace metadata version synchronized to 4.2.11 | ~157 |
|
||||
|
||||
### Oct 27, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #2757 | 1:23 AM | 🟣 | Released v4.3.3 with Configurable Session Display and First-Time Setup UX | ~391 |
|
||||
|
||||
### Nov 4, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #3706 | 9:47 PM | ✅ | Marketplace Plugin Version Synchronized to 5.0.2 | ~162 |
|
||||
| #3655 | 3:43 PM | ✅ | Version bumped to 5.0.1 across project | ~354 |
|
||||
|
||||
### Nov 5, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4068 | 10:58 PM | ✅ | Committed v5.1.0 release with comprehensive release notes | ~486 |
|
||||
| #4066 | 10:57 PM | ✅ | Updated marketplace.json version to 5.1.0 | ~192 |
|
||||
| #3739 | 2:24 PM | ✅ | Updated version to 5.0.3 across project manifests | ~322 |
|
||||
|
||||
### Nov 6, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4099 | 1:13 PM | 🟣 | Theme Toggle for Light/Dark Mode | ~253 |
|
||||
| #4096 | " | ✅ | Marketplace Metadata Version Sync | ~179 |
|
||||
| #4092 | 1:12 PM | 🔵 | Marketplace Configuration for Claude-Mem Plugin | ~194 |
|
||||
| #4078 | 12:50 PM | 🔴 | Fixed PM2 ENOENT error on Windows systems | ~286 |
|
||||
| #4075 | 12:49 PM | ✅ | Marketplace plugin version synchronized to 5.1.1 | ~189 |
|
||||
|
||||
### Nov 7, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4612 | 6:33 PM | ✅ | Version Bumped to 5.2.0 Across All Package Metadata | ~359 |
|
||||
| #4598 | 6:31 PM | ✅ | PR #69 Merged: cleanup/worker Branch Integration | ~469 |
|
||||
| #4298 | 11:54 AM | 🔴 | Fixed PostToolUse Hook Schema Compliance | ~310 |
|
||||
| #4295 | 11:53 AM | ✅ | Synchronized Plugin Marketplace Version to 5.1.4 | ~188 |
|
||||
|
||||
### Nov 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5150 | 7:37 PM | 🟣 | Troubleshooting Skill Added to Claude-Mem Plugin | ~427 |
|
||||
| #5133 | 7:29 PM | ✅ | Version 5.2.3 Released with Build Process | ~487 |
|
||||
|
||||
### Nov 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5941 | 7:14 PM | ✅ | Marketplace Version Updated to 5.4.0 | ~157 |
|
||||
|
||||
### Nov 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6341 | 1:49 PM | ✅ | Version Bumped to 5.4.1 | ~239 |
|
||||
|
||||
### Nov 11, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6602 | 1:51 PM | ✅ | Version 5.4.5 Released to GitHub | ~279 |
|
||||
| #6601 | " | ✅ | Version Patch Bump 5.4.4 to 5.4.5 | ~233 |
|
||||
|
||||
### Nov 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #8212 | 3:06 PM | 🔵 | Version Consistency Verification Across Multiple Configuration Files | ~238 |
|
||||
|
||||
### Nov 25, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #14882 | 1:32 PM | 🔵 | Marketplace Configuration Defines Plugin Version and Source Directory | ~366 |
|
||||
|
||||
### Nov 30, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #18064 | 10:52 PM | ✅ | Bumped version to 6.3.7 in marketplace.json | ~179 |
|
||||
| #18060 | 10:51 PM | 🔵 | Read marketplace.json plugin manifest | ~190 |
|
||||
|
||||
### Dec 1, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #18428 | 3:33 PM | 🔵 | Version Conflict in Marketplace Configuration | ~191 |
|
||||
|
||||
### Dec 4, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #20049 | 3:23 PM | ✅ | Updated marketplace.json version to 6.5.2 | ~203 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22559 | 1:08 AM | ✅ | Version 7.0.3 committed to repository | ~261 |
|
||||
| #22551 | 1:07 AM | ✅ | Marketplace metadata updated to version 7.0.3 | ~179 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23440 | 2:25 PM | ✅ | Marketplace Configuration Updated to 7.0.8 | ~188 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26799 | 11:39 PM | ✅ | Marketplace Manifest Version Updated to 7.2.3 | ~248 |
|
||||
| #26796 | " | ✅ | Version Bumped to 7.2.3 in marketplace.json | ~259 |
|
||||
| #26792 | 11:38 PM | 🔵 | Current Version Confirmed as 7.2.2 Across All Configuration Files | ~291 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28306 | 10:08 PM | 🔵 | Marketplace Configuration Also Shows Version 7.3.3 | ~220 |
|
||||
| #27555 | 4:48 PM | ✅ | Version bump committed to main branch | ~242 |
|
||||
| #27553 | " | ✅ | Version consistency verified across all configuration files | ~195 |
|
||||
| #27551 | 4:47 PM | ✅ | Marketplace.json version updated to 7.3.1 | ~207 |
|
||||
</claude-mem-context>
|
||||
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.7.0",
|
||||
"version": "12.2.0",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.4.1",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"version": "12.2.0",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
},
|
||||
"repository": "https://github.com/thedotmack/claude-mem",
|
||||
"license": "AGPL-3.0",
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"claude-agent-sdk",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"memory",
|
||||
"context",
|
||||
"persistence",
|
||||
"hooks",
|
||||
"mcp"
|
||||
]
|
||||
"compression",
|
||||
"knowledge-graph",
|
||||
"transcript",
|
||||
"typescript",
|
||||
"nodejs"
|
||||
],
|
||||
"homepage": "https://github.com/thedotmack/claude-mem#readme"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"6a00de6e-282e-4cd8-98ec-b5afb73c468d","pid":50072,"acquiredAt":1775678989779}
|
||||
@@ -1,29 +0,0 @@
|
||||
# Project-Level Skills
|
||||
|
||||
This directory contains skills **for developing and maintaining the claude-mem project itself**, not skills that are released as part of the plugin.
|
||||
|
||||
## Distinction
|
||||
|
||||
**Project Skills** (`.claude/skills/`):
|
||||
- Used by developers working on claude-mem
|
||||
- Not included in the plugin distribution
|
||||
- Project-specific workflows (version bumps, release management, etc.)
|
||||
- Not synced to `~/.claude/plugins/marketplaces/thedotmack/`
|
||||
|
||||
**Plugin Skills** (`plugin/skills/`):
|
||||
- Released as part of the claude-mem plugin
|
||||
- Available to all users who install the plugin
|
||||
- General-purpose memory search functionality
|
||||
- Synced to user installations via `npm run sync-marketplace`
|
||||
|
||||
## Skills in This Directory
|
||||
|
||||
### version-bump
|
||||
Manages semantic versioning for the claude-mem project itself. Handles updating all three version files (package.json, marketplace.json, plugin.json), creating git tags, and GitHub releases.
|
||||
|
||||
**Usage**: Only for claude-mem maintainers releasing new versions.
|
||||
|
||||
## Adding New Skills
|
||||
|
||||
**For claude-mem development** → Add to `.claude/skills/`
|
||||
**For end users** → Add to `plugin/skills/` (gets distributed with plugin)
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.2.0",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman",
|
||||
"url": "https://github.com/thedotmack"
|
||||
},
|
||||
"homepage": "https://github.com/thedotmack/claude-mem#readme",
|
||||
"repository": "https://github.com/thedotmack/claude-mem",
|
||||
"license": "AGPL-3.0",
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"claude-agent-sdk",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"memory",
|
||||
"compression",
|
||||
"knowledge-graph",
|
||||
"transcript",
|
||||
"typescript",
|
||||
"nodejs"
|
||||
],
|
||||
"interface": {
|
||||
"displayName": "claude-mem",
|
||||
"shortDescription": "Persistent memory and context compression across coding sessions.",
|
||||
"longDescription": "claude-mem captures coding-session activity, compresses it into reusable observations, and injects relevant context back into future Claude Code and Codex-compatible sessions.",
|
||||
"developerName": "Alex Newman",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
"Interactive",
|
||||
"Write"
|
||||
],
|
||||
"websiteURL": "https://github.com/thedotmack/claude-mem",
|
||||
"defaultPrompt": [
|
||||
"Find what I already learned about this codebase before I start a new task.",
|
||||
"Show recent observations related to the files I am editing right now.",
|
||||
"Summarize the last session and inject the most relevant context into this one."
|
||||
],
|
||||
"brandColor": "#1F6FEB"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Normalize all text files to LF on commit and checkout.
|
||||
# This prevents CRLF shebang lines in bundled scripts from breaking
|
||||
# the MCP server on macOS/Linux when built on Windows. Fixes #1342.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Compiled plugin scripts must always be LF — CRLF in the shebang
|
||||
# causes "env: node\r: No such file or directory" on non-Windows hosts.
|
||||
plugin/scripts/*.cjs eol=lf
|
||||
plugin/scripts/*.js eol=lf
|
||||
|
||||
# Explicitly mark binary assets so git never modifies them.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.ico binary
|
||||
*.gif binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
- name: Comment with AI summary
|
||||
run: |
|
||||
gh issue comment $ISSUE_NUMBER --body '${{ steps.inference.outputs.response }}'
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$RESPONSE"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
+4
-1
@@ -34,4 +34,7 @@ src/ui/viewer.html
|
||||
.claude-octopus/
|
||||
.claude/session-intent.md
|
||||
.claude/session-plan.md
|
||||
.octo/
|
||||
.octo/
|
||||
|
||||
# Local contribution analysis (not part of upstream)
|
||||
CONTRIB_NOTES.md
|
||||
@@ -0,0 +1,570 @@
|
||||
# Merged-Worktree Adoption
|
||||
|
||||
**Goal**: When a worktree's branch is merged into its parent, the worktree's observations become part of the parent project's observation list — without data movement, destructive schema changes, or lost provenance.
|
||||
|
||||
**Approach**: Add a nullable `merged_into_project` column to observations and session_summaries, extend query predicates with `OR merged_into_project = :parent`, propagate the same metadata to Chroma embeddings for semantic-search consistency, detect merges via git (authoritative), run adoption automatically on worker startup, and offer a CLI escape hatch for squash-merges.
|
||||
|
||||
**Key design decisions**:
|
||||
- `observations.project` is **immutable provenance** — never overwritten.
|
||||
- Merged-status is a **virtual pointer**, not a data move.
|
||||
- **Chroma metadata stays in lockstep with SQLite** (full consistent sync, not lazy SQL expansion). Single source of truth per row.
|
||||
- Detection is **git-authoritative** (`git worktree list --porcelain` + `git branch --merged`), with a manual CLI override for squash-merges.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Documentation Discovery (COMPLETE)
|
||||
|
||||
Findings consolidated from three parallel discovery subagents. The following are the ONLY APIs/patterns to copy from. Do not invent alternatives.
|
||||
|
||||
### Allowed APIs (copy from these locations)
|
||||
|
||||
| Need | File | Lines | What to copy |
|
||||
|---|---|---|---|
|
||||
| Migration idempotency via marker file | `src/services/infrastructure/ProcessManager.ts` | 680–830 | `runOneTimeCwdRemap` structure, marker file pattern `.cwd-remap-applied-v1` |
|
||||
| Worker startup wiring | `src/services/worker-service.ts` | 363–365 | Call site inside `initializeBackground()`, invoked before `dbManager.initialize()` |
|
||||
| `ALTER TABLE ADD COLUMN` idempotency | `src/services/sqlite/migrations/runner.ts` | 131–141 | `PRAGMA table_info(<table>)` guard before `ALTER TABLE ... ADD COLUMN` |
|
||||
| Column addition example | `src/services/sqlite/migrations/runner.ts` | 495 | `db.run('ALTER TABLE observations ADD COLUMN discovery_tokens INTEGER DEFAULT 0')` |
|
||||
| Observations schema | `src/services/sqlite/migrations/runner.ts` | 82–96 | Existing columns + indices (do not duplicate) |
|
||||
| `schema_versions` marker table | `src/services/sqlite/migrations/runner.ts` | 51–58 | `INSERT OR IGNORE INTO schema_versions ...` — used only when numbered migration |
|
||||
| Logger | `src/utils/logger.ts` | 18 | Components: `SYSTEM`, `DB`, `CHROMA_SYNC`. Use `logger.info/warn/error('SYSTEM', ...)` |
|
||||
| Worktree detection | `src/utils/worktree.ts` | 1–84 | `detectWorktree(cwd): WorktreeInfo { isWorktree, worktreeName, parentRepoPath, parentProjectName }` |
|
||||
| Project-name derivation | `src/utils/project-name.ts` | 73–119 | `getProjectContext(cwd): ProjectContext { primary, parent, isWorktree, allProjects }` |
|
||||
| Multi-project read (WHERE to extend) | `src/services/context/ObservationCompiler.ts` | 111–160 | `queryObservationsMulti` — `WHERE o.project IN (${projectPlaceholders})` |
|
||||
| Same, for summaries | `src/services/context/ObservationCompiler.ts` | 168–196 | Parallel summary-fetching query with `ss.project IN (...)` |
|
||||
| Context injection endpoint | `src/services/worker/http/routes/SearchRoutes.ts` | 211–253 | `handleContextInject` wires `projects` comma-separated query param into `generateContext` |
|
||||
| Context entry point | `src/services/context/ContextBuilder.ts` | 126–183 | `generateContext()` picks `queryObservationsMulti` when `projects.length > 1` |
|
||||
| Chroma metadata attach (observations) | `src/services/sync/ChromaSync.ts` | 132–140 | `baseMetadata` object — includes `project`, `sqlite_id`, etc. This is where `merged_into_project` is added. |
|
||||
| Chroma collection architecture | `src/services/sync/ChromaSync.ts` | 806 (comment) | **Single shared collection `cm__claude-mem`**, scoped by metadata. Do NOT create a per-merged collection. |
|
||||
| Chroma filter build (read side) | `src/services/sync/SearchManager.ts` | 174–177 | `whereFilter = { project: options.project }` — extended with `$or` in Phase 3 |
|
||||
| Chroma update API | `src/services/sync/ChromaSync.ts` (grep) | — | `chroma_update_documents` via MCP — used by existing sync flows |
|
||||
| CLI entrypoint switch | `src/npx-cli/index.ts` | 28–169 | Plain `switch (command)`, dynamic `import()` of `./commands/<name>.ts`. No commander/cac. |
|
||||
| Admin-script template | `scripts/cwd-remap.ts` | 1–186 | Bun shebang, argv parsing, `--apply` gate, dry-run default |
|
||||
| UI observation card | `src/ui/viewer/components/ObservationCard.tsx` | 58 | `<span className="card-project">{observation.project}</span>` — where the merged badge is added |
|
||||
|
||||
### Anti-patterns (do NOT do these)
|
||||
|
||||
- Do NOT overwrite `observations.project` or `session_summaries.project`. These are immutable provenance.
|
||||
- Do NOT create a new Chroma collection for merged observations. Deployment uses a single shared `cm__claude-mem` collection.
|
||||
- Do NOT introduce a `gh` CLI dependency. Codebase has no `gh` usage outside `.github/workflows/`. Use `git` subprocesses only.
|
||||
- Do NOT use SQLite's unsupported `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` syntax. Use the `PRAGMA table_info` guard instead.
|
||||
- Do NOT use a CLI framework (commander, cac, yargs). The codebase uses hand-rolled `switch (command)` + `process.argv.slice(2)`.
|
||||
- Do NOT mutate `ProjectContext.allProjects` to inject merged children. The reverse lookup lives in the SQL/Chroma query predicates, not in `ProjectContext`.
|
||||
- Do NOT run the lazy "SQL-expand projects then filter Chroma" approach. We want Chroma metadata to be the authoritative filter for semantic search.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Schema migration
|
||||
|
||||
**What to implement**: One nullable column + one index on each of `observations` and `session_summaries`. Idempotent via `PRAGMA table_info` guard.
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/services/sqlite/migrations/runner.ts`
|
||||
|
||||
### Implementation
|
||||
|
||||
Add a new method `ensureMergedIntoProjectColumns()` on `MigrationRunner`, modeled on the pattern at lines 131–141:
|
||||
|
||||
```typescript
|
||||
private ensureMergedIntoProjectColumns(): void {
|
||||
const obsCols = this.db
|
||||
.query('PRAGMA table_info(observations)')
|
||||
.all() as TableColumnInfo[];
|
||||
if (!obsCols.some(c => c.name === 'merged_into_project')) {
|
||||
this.db.run('ALTER TABLE observations ADD COLUMN merged_into_project TEXT');
|
||||
this.db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_observations_merged_into ON observations(merged_into_project)'
|
||||
);
|
||||
}
|
||||
|
||||
const sumCols = this.db
|
||||
.query('PRAGMA table_info(session_summaries)')
|
||||
.all() as TableColumnInfo[];
|
||||
if (!sumCols.some(c => c.name === 'merged_into_project')) {
|
||||
this.db.run('ALTER TABLE session_summaries ADD COLUMN merged_into_project TEXT');
|
||||
this.db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_summaries_merged_into ON session_summaries(merged_into_project)'
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call from `runAllMigrations()` — append immediately after the last existing `ensure*` method so it runs on every worker startup. The `PRAGMA table_info` check is O(1) and makes re-runs cheap.
|
||||
|
||||
### Verification
|
||||
|
||||
- Start the worker. Migration logs show no error.
|
||||
- `sqlite3 ~/.claude-mem/claude-mem.db ".schema observations"` shows `merged_into_project TEXT`.
|
||||
- Same for `session_summaries`.
|
||||
- Restart worker → no ALTER TABLE error (guard worked).
|
||||
- `sqlite3 ~/.claude-mem/claude-mem.db ".indices observations"` lists `idx_observations_merged_into`.
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- Do NOT use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` — SQLite does not support it.
|
||||
- Do NOT bump `schema_versions` for this migration. That table is for numbered migration history; the column-existence check is self-idempotent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Adoption engine (SQLite + Chroma consistent)
|
||||
|
||||
**What to implement**: A single function that, given a parent repo path, detects all merged-worktree branches and stamps `merged_into_project` on both SQLite rows AND Chroma metadata in the same logical operation. Reused by worker startup (Phase 4) and CLI (Phase 5).
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/services/infrastructure/WorktreeAdoption.ts` (new)
|
||||
- `src/services/sync/ChromaSync.ts` — add `updateMergedIntoProject(sqliteIds: number[], mergedIntoProject: string): Promise<void>`
|
||||
|
||||
### Public API
|
||||
|
||||
```typescript
|
||||
export interface AdoptionResult {
|
||||
repoPath: string;
|
||||
parentProject: string;
|
||||
scannedWorktrees: number;
|
||||
mergedBranches: string[]; // branches classified as merged
|
||||
adoptedObservations: number; // SQLite rows stamped
|
||||
adoptedSummaries: number;
|
||||
chromaUpdates: number; // Chroma docs patched
|
||||
chromaFailed: number;
|
||||
dryRun: boolean;
|
||||
errors: Array<{ worktree: string; error: string }>;
|
||||
}
|
||||
|
||||
export async function adoptMergedWorktrees(opts: {
|
||||
repoPath?: string; // defaults to process.cwd()
|
||||
dataDirectory?: string; // defaults to DATA_DIR
|
||||
dryRun?: boolean;
|
||||
onlyBranch?: string; // manual override for squash-merge case
|
||||
}): Promise<AdoptionResult>;
|
||||
```
|
||||
|
||||
### Implementation outline
|
||||
|
||||
Mirror `runOneTimeCwdRemap` in `ProcessManager.ts:680–830` for DB lifecycle (open, transaction, finally-close). Add Chroma sync step after SQL commit.
|
||||
|
||||
1. **Resolve main repo path**
|
||||
- `const mainRepo = execSync('git rev-parse --git-common-dir', { cwd: opts.repoPath ?? process.cwd() })` — strip `/.git` suffix to get the working tree root.
|
||||
- This pattern is used in `scripts/cwd-remap.ts:48–51`. Copy that handling verbatim.
|
||||
|
||||
2. **Resolve parent project name**
|
||||
- `const parentProject = getProjectContext(mainRepo).primary` — imported from `src/utils/project-name.ts`.
|
||||
|
||||
3. **Enumerate worktrees**
|
||||
- `git -C <mainRepo> worktree list --porcelain` → parse `worktree <path>`, `branch refs/heads/<name>` lines.
|
||||
- Filter out the main worktree entry (its path equals `mainRepo`).
|
||||
|
||||
4. **Classify as merged**
|
||||
- If `opts.onlyBranch` provided: include only that branch (squash-merge escape hatch).
|
||||
- Else: `git -C <mainRepo> branch --merged HEAD --format='%(refname:short)'` → intersect with worktree branch list.
|
||||
|
||||
5. **Resolve worktree project names**
|
||||
- For each merged worktree path, `const worktreeProject = getProjectContext(worktreePath).primary` → yields the composite `parent/worktree` name.
|
||||
|
||||
6. **SQL transaction** (model on `ProcessManager.ts:745–760, 808`)
|
||||
- Open DB via `new Database(dbPath)` (manage own handle — must close before `dbManager.initialize()` runs).
|
||||
- For each merged worktree:
|
||||
- `SELECT id FROM observations WHERE project = ? AND merged_into_project IS NULL` → collect sqlite IDs to later push to Chroma.
|
||||
- `UPDATE observations SET merged_into_project = ? WHERE project = ? AND merged_into_project IS NULL`.
|
||||
- Same for `session_summaries`.
|
||||
- Commit transaction.
|
||||
- If `dryRun`, roll back instead.
|
||||
|
||||
7. **Chroma metadata sync** (full consistent — NOT lazy)
|
||||
- For the set of sqlite IDs just stamped, call `ChromaSync.updateMergedIntoProject(sqliteIds, parentProject)`.
|
||||
- `ChromaSync.updateMergedIntoProject` implementation:
|
||||
```typescript
|
||||
async updateMergedIntoProject(sqliteIds: number[], mergedIntoProject: string): Promise<void> {
|
||||
if (sqliteIds.length === 0) return;
|
||||
// Batch: look up Chroma doc IDs via metadata filter on sqlite_id, then patch.
|
||||
const where = { sqlite_id: { $in: sqliteIds } };
|
||||
const existing = await chromaMcp.callTool('chroma_get_documents', {
|
||||
collection_name: this.collectionName,
|
||||
where,
|
||||
include: ['metadatas']
|
||||
});
|
||||
const docIds: string[] = existing.ids ?? [];
|
||||
const metadatas: Record<string, unknown>[] = (existing.metadatas ?? []).map(m => ({
|
||||
...m,
|
||||
merged_into_project: mergedIntoProject
|
||||
}));
|
||||
if (docIds.length === 0) return;
|
||||
await chromaMcp.callTool('chroma_update_documents', {
|
||||
collection_name: this.collectionName,
|
||||
ids: docIds,
|
||||
metadatas
|
||||
});
|
||||
}
|
||||
```
|
||||
- On Chroma error: log via `logger.error('CHROMA_SYNC', ...)`, increment `chromaFailed`, but do NOT roll back SQL. SQL is source of truth; a subsequent run will retry the Chroma patch (idempotent — metadata set to same value is a no-op).
|
||||
|
||||
8. **Logging**
|
||||
- `logger.info('SYSTEM', 'Worktree adoption applied', { parentProject, adoptedObservations, adoptedSummaries, chromaUpdates, chromaFailed, mergedBranches })`.
|
||||
- On per-worktree error: `logger.warn('SYSTEM', 'Worktree adoption skipped branch', { worktree, error })` — collect in `errors[]`, continue.
|
||||
|
||||
9. **Re-adoption safety net**
|
||||
- Because Chroma updates can fail independently, add a secondary SQL-side reconciliation: on each adoption run, also find `observations WHERE merged_into_project IS NOT NULL` whose Chroma metadata lacks the field. Run the same `updateMergedIntoProject` on that delta.
|
||||
- Keep this bounded: only reconcile rows adopted in the last N days (e.g. 30) to avoid full-table scans.
|
||||
|
||||
### Verification
|
||||
|
||||
- Dry-run against a repo with one known-merged worktree: result shows correct `adoptedObservations`, DB unchanged, no Chroma writes.
|
||||
- Real run: `SELECT COUNT(*) FROM observations WHERE merged_into_project IS NOT NULL` matches `adoptedObservations`.
|
||||
- Chroma: `chroma_get_documents` with `where: { merged_into_project: 'claude-mem' }` returns the same row count.
|
||||
- Re-run: `adoptedObservations = 0`, `chromaUpdates = 0` (both idempotent).
|
||||
- Simulate Chroma outage (stop chroma): adoption logs `CHROMA_SYNC` error, `chromaFailed > 0`, SQL still stamps. Next run with Chroma back up reconciles the delta.
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- Do NOT rollback SQL on Chroma failure. SQL is authoritative; Chroma is a derived index.
|
||||
- Do NOT call Chroma per-row. Batch by sqlite_id set to minimize round-trips.
|
||||
- Do NOT adopt branches not in `git branch --merged HEAD` unless `onlyBranch` override is explicit.
|
||||
- Do NOT touch observations whose `project` is not a composite worktree name. The worktree-name match is the safety gate.
|
||||
- Do NOT skip the `merged_into_project IS NULL` clause on UPDATE — this is what makes the run idempotent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Query plumbing (SQLite + Chroma $or)
|
||||
|
||||
**What to implement**: Extend the two multi-project read queries in `ObservationCompiler.ts` and the Chroma filter in `SearchManager.ts` to treat `merged_into_project` as a second match axis. Direct Chroma `$or` filter — no SQL-side expansion dance.
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/services/context/ObservationCompiler.ts`
|
||||
- `src/services/sync/SearchManager.ts`
|
||||
|
||||
### 3a. SQLite WHERE-clause extension
|
||||
|
||||
`src/services/context/ObservationCompiler.ts:111–160` (`queryObservationsMulti`): change
|
||||
|
||||
```sql
|
||||
WHERE o.project IN (${projectPlaceholders})
|
||||
```
|
||||
|
||||
to
|
||||
|
||||
```sql
|
||||
WHERE (o.project IN (${projectPlaceholders})
|
||||
OR o.merged_into_project IN (${projectPlaceholders}))
|
||||
```
|
||||
|
||||
Double-bind the `projects` array:
|
||||
|
||||
```typescript
|
||||
.all(
|
||||
...projects, // for o.project IN (...)
|
||||
...projects, // for o.merged_into_project IN (...)
|
||||
...typeArray,
|
||||
...conceptArray,
|
||||
...(platformSource ? [platformSource] : []),
|
||||
config.totalObservationCount
|
||||
)
|
||||
```
|
||||
|
||||
`src/services/context/ObservationCompiler.ts:168–196` (summary variant): apply the same extension, using `ss.merged_into_project`.
|
||||
|
||||
### 3b. Chroma filter extension
|
||||
|
||||
`src/services/sync/SearchManager.ts:174–177`:
|
||||
|
||||
```typescript
|
||||
if (options.project) {
|
||||
const projectFilter = {
|
||||
$or: [
|
||||
{ project: options.project },
|
||||
{ merged_into_project: options.project }
|
||||
]
|
||||
};
|
||||
whereFilter = whereFilter
|
||||
? { $and: [whereFilter, projectFilter] }
|
||||
: projectFilter;
|
||||
}
|
||||
```
|
||||
|
||||
When `options.project` is an array (if that path exists — grep first), build a flat `$or` over both fields × all requested projects.
|
||||
|
||||
### 3c. New-observation Chroma metadata
|
||||
|
||||
`src/services/sync/ChromaSync.ts:132–140` — extend `baseMetadata`:
|
||||
|
||||
```typescript
|
||||
const baseMetadata: Record<string, string | number | null> = {
|
||||
sqlite_id: obs.id,
|
||||
doc_type: 'observation',
|
||||
memory_session_id: obs.memory_session_id,
|
||||
project: obs.project,
|
||||
merged_into_project: obs.merged_into_project ?? null, // NEW
|
||||
created_at_epoch: obs.created_at_epoch,
|
||||
type: obs.type || 'discovery',
|
||||
title: obs.title || 'Untitled'
|
||||
};
|
||||
```
|
||||
|
||||
This makes every new observation Chroma-compatible with the Phase 3b filter from the first sync. For existing rows, Phase 2's adoption engine patches metadata retroactively.
|
||||
|
||||
**Check Chroma metadata type constraints**: Chroma rejects `null` in metadata — confirm via a quick test. If `null` is rejected, OMIT the field when unset (use `if (obs.merged_into_project) baseMetadata.merged_into_project = obs.merged_into_project;`).
|
||||
|
||||
### 3d. ContextBuilder compatibility check
|
||||
|
||||
`src/services/context/ContextBuilder.ts:126–183` — no change needed. `projects = input?.projects ?? context.allProjects` stays as-is; the extended WHERE clause in Phase 3a does all the work.
|
||||
|
||||
### Verification
|
||||
|
||||
- Before adoption: context-inject API for `claude-mem` returns N observations.
|
||||
- After adoption of `claude-mem/dar-es-salaam`: API returns N + M (M = count of dar-es-salaam's own observations).
|
||||
- Semantic search via Chroma (`/search` endpoint or MCP) with `project=claude-mem` returns dar-es-salaam-origin rows too.
|
||||
- Worktree-local queries (`projects=[claude-mem, claude-mem/dar-es-salaam]`) still return `[parent + own]` unchanged.
|
||||
- SQL EXPLAIN on the extended WHERE shows it uses `idx_observations_project` OR `idx_observations_merged_into` (both indices hit).
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- Do NOT lose the `o.project` filter — it's still required (merged-row predicate is additive, not a replacement).
|
||||
- Do NOT forget to double-bind `projects` in the prepared statement — placeholder count must match argument count.
|
||||
- Do NOT add a subquery or JOIN for merged discovery. A flat `OR` + index is faster.
|
||||
- Do NOT write `null` into Chroma metadata if Chroma rejects it. Use the "omit if unset" pattern.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Automatic trigger on worker startup
|
||||
|
||||
**What to implement**: Call `adoptMergedWorktrees()` during worker startup, immediately after `runOneTimeCwdRemap()`. **Not** marker-gated — it runs every worker startup because git state evolves and the engine is idempotent.
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/services/worker-service.ts`
|
||||
|
||||
### Implementation
|
||||
|
||||
Import alongside existing `ProcessManager` imports at lines 41–53:
|
||||
|
||||
```typescript
|
||||
import { adoptMergedWorktrees } from './infrastructure/WorktreeAdoption.js';
|
||||
```
|
||||
|
||||
Insert immediately after the existing `runOneTimeCwdRemap()` call at lines 363–365:
|
||||
|
||||
```typescript
|
||||
runOneTimeCwdRemap();
|
||||
|
||||
try {
|
||||
const result = await adoptMergedWorktrees({});
|
||||
if (result.adoptedObservations > 0 || result.chromaUpdates > 0) {
|
||||
logger.info('SYSTEM', 'Merged worktrees adopted on startup', result);
|
||||
}
|
||||
if (result.errors.length > 0) {
|
||||
logger.warn('SYSTEM', 'Worktree adoption had per-branch errors', { errors: result.errors });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('SYSTEM', 'Worktree adoption failed (non-fatal)', {}, err as Error);
|
||||
}
|
||||
```
|
||||
|
||||
**DB lifecycle note**: `adoptMergedWorktrees` must manage its own DB handle (open + close) before `dbManager.initialize()` runs at line 380. Mirror `runOneTimeCwdRemap`'s finally-block pattern.
|
||||
|
||||
### Verification
|
||||
|
||||
- Restart worker. Log shows "Merged worktrees adopted on startup" only on first run after a new merge lands.
|
||||
- Subsequent restarts log nothing (idempotent).
|
||||
- Simulate adoption exception (e.g., rename git temporarily): log shows error, worker startup continues successfully.
|
||||
- Build-and-sync restart picks up new merges without manual intervention.
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- Do NOT block worker startup on adoption failure. Wrap in try/catch; swallow + log.
|
||||
- Do NOT run adoption after `dbManager.initialize()`. The engine manages its own DB handle; two handles at once risk lock contention.
|
||||
- Do NOT await Chroma sync before returning SQL success. Internally, yes; but don't make worker startup hang on Chroma I/O — cap with a reasonable timeout inside the engine.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — CLI escape hatch
|
||||
|
||||
**What to implement**: `claude-mem adopt [--branch <name>] [--dry-run]` — covers squash-merge where `git branch --merged` returns nothing, and provides a manual override for any adoption run.
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/npx-cli/commands/adopt.ts` (new)
|
||||
- `src/npx-cli/index.ts` (add `case 'adopt'`)
|
||||
- `scripts/adopt-worktrees.ts` (new, optional — admin script for bulk ops)
|
||||
|
||||
### 5a. Command module
|
||||
|
||||
`src/npx-cli/commands/adopt.ts` — follow shape of sibling commands (dynamic-imported by the switch):
|
||||
|
||||
```typescript
|
||||
import pc from 'picocolors';
|
||||
import { adoptMergedWorktrees } from '../../services/infrastructure/WorktreeAdoption.js';
|
||||
|
||||
export interface AdoptCommandOptions {
|
||||
dryRun?: boolean;
|
||||
onlyBranch?: string;
|
||||
}
|
||||
|
||||
export async function runAdoptCommand(opts: AdoptCommandOptions): Promise<void> {
|
||||
const result = await adoptMergedWorktrees({
|
||||
dryRun: opts.dryRun,
|
||||
onlyBranch: opts.onlyBranch
|
||||
});
|
||||
|
||||
console.log(pc.bold(`\nWorktree adoption ${result.dryRun ? pc.yellow('(dry-run)') : pc.green('(applied)')}`));
|
||||
console.log(` Parent project: ${result.parentProject}`);
|
||||
console.log(` Worktrees scanned: ${result.scannedWorktrees}`);
|
||||
console.log(` Merged branches: ${result.mergedBranches.join(', ') || '(none)'}`);
|
||||
console.log(` Observations adopted: ${result.adoptedObservations}`);
|
||||
console.log(` Summaries adopted: ${result.adoptedSummaries}`);
|
||||
console.log(` Chroma docs updated: ${result.chromaUpdates}`);
|
||||
if (result.chromaFailed > 0) {
|
||||
console.log(pc.yellow(` Chroma sync failures: ${result.chromaFailed} (will retry on next run)`));
|
||||
}
|
||||
for (const err of result.errors) {
|
||||
console.log(pc.red(` ! ${err.worktree}: ${err.error}`));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5b. CLI switch
|
||||
|
||||
`src/npx-cli/index.ts` — add between existing cases, following the pattern at lines 28–169:
|
||||
|
||||
```typescript
|
||||
case 'adopt': {
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const branchIndex = args.indexOf('--branch');
|
||||
const onlyBranch = branchIndex !== -1 ? args[branchIndex + 1] : undefined;
|
||||
const { runAdoptCommand } = await import('./commands/adopt.js');
|
||||
await runAdoptCommand({ dryRun, onlyBranch });
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 5c. Admin script (optional)
|
||||
|
||||
`scripts/adopt-worktrees.ts` — Bun shebang script for users without the plugin installed. Model on `scripts/cwd-remap.ts:1–186`. Default: dry-run. Pass `--apply` to commit.
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx claude-mem adopt --dry-run` in a repo with merged worktrees prints what WOULD be adopted without writing.
|
||||
- `npx claude-mem adopt` writes + prints counts.
|
||||
- `npx claude-mem adopt --branch feature/foo` forces adoption of that branch even if `git branch --merged` doesn't include it (squash case).
|
||||
- `bun scripts/adopt-worktrees.ts --apply` equivalent to the CLI.
|
||||
- Help text / unknown command still reports the existing error (CLI pattern preserved).
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- Do NOT require running from the worktree. Detection always resolves up to the common-dir, regardless of cwd.
|
||||
- Do NOT default to `--apply`. Dry-run first matches `scripts/cwd-remap.ts` ergonomics.
|
||||
- Do NOT introduce `commander`, `yargs`, `cac`. Stay with the existing hand-rolled parser.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — UI surfacing
|
||||
|
||||
**What to implement**: When the viewer shows an observation in a parent-project context that originated in a merged worktree, display a "merged from <worktree>" badge so provenance is visible. Keep the original `project` field rendered too.
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/ui/viewer/components/ObservationCard.tsx`
|
||||
- Type definition for `Observation` — wherever `.project` is declared, add `merged_into_project?: string | null`.
|
||||
- Observation serializer on the worker → UI path (grep for `doc_type: 'observation'` or `serializeObservation` to find it).
|
||||
- CSS file for ObservationCard styles.
|
||||
|
||||
### Implementation
|
||||
|
||||
Locate the current label render at `src/ui/viewer/components/ObservationCard.tsx:58`:
|
||||
|
||||
```tsx
|
||||
<span className="card-project">{observation.project}</span>
|
||||
```
|
||||
|
||||
Extend to:
|
||||
|
||||
```tsx
|
||||
<span className="card-project">{observation.project}</span>
|
||||
{observation.merged_into_project && (
|
||||
<span className="card-merged-badge" title={`Merged into ${observation.merged_into_project}`}>
|
||||
merged → {observation.merged_into_project}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
Add CSS for `.card-merged-badge` — subtle secondary chip style (muted color, smaller font). Match existing `.card-source` / `.card-project` aesthetics.
|
||||
|
||||
### Verification
|
||||
|
||||
- After adoption, open viewer at `http://localhost:37777`, select the parent project. Merged observations show both their origin worktree name AND the "merged →" badge.
|
||||
- Worktree view (if still addressable) shows no badge (badge only renders when `merged_into_project` is set; a worktree viewing its own observations would not see it, since in that view `merged_into_project` is the PARENT name, not the current project).
|
||||
- Hover tooltip shows full target project name.
|
||||
|
||||
### Anti-pattern guards
|
||||
|
||||
- Do NOT hide merged observations in the parent view. The goal is visibility.
|
||||
- Do NOT replace `project` display with `merged_into_project`. Both are meaningful: `project` = origin, `merged_into_project` = current home.
|
||||
- Do NOT require a UI setting toggle to show the badge. Default on.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Verification pass
|
||||
|
||||
### Unit tests
|
||||
|
||||
- `adoptMergedWorktrees({ dryRun: true })` against a fixture repo with `[merged, unmerged, squash-merged]` worktrees → classification matches expectation.
|
||||
- `ChromaSync.updateMergedIntoProject` on an empty `sqliteIds` array → no-op, no Chroma call.
|
||||
- Extended `queryObservationsMulti` with a mixed set of `project` and `merged_into_project` matches → returns union, sorted by `created_at_epoch DESC`.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- Start worker → create synthetic observations under `claude-mem/test-wt` → simulate branch merge (`git merge`) → restart worker → context-inject API for `claude-mem` returns test-wt observations.
|
||||
- Same flow with a squash-merge → auto-adoption misses → run `claude-mem adopt --branch test-wt` → API now returns them.
|
||||
- Re-run `claude-mem adopt` twice: second run reports `adoptedObservations: 0, chromaUpdates: 0`.
|
||||
|
||||
### Anti-pattern grep checks
|
||||
|
||||
Run before landing:
|
||||
|
||||
```bash
|
||||
# No one renamed the project field
|
||||
rg "UPDATE observations SET project" src/
|
||||
# (Expected: zero hits other than the existing CWD remap)
|
||||
|
||||
# Adoption only touches via IS NULL guard
|
||||
rg "merged_into_project" src/ -C2
|
||||
# (Expected: all UPDATE sites include "IS NULL" predicate)
|
||||
|
||||
# CLI registered
|
||||
rg "case 'adopt'" src/npx-cli/index.ts
|
||||
# (Expected: one hit)
|
||||
|
||||
# Chroma metadata extension present
|
||||
rg "merged_into_project" src/services/sync/ChromaSync.ts
|
||||
# (Expected: hits in baseMetadata and updateMergedIntoProject)
|
||||
|
||||
# No gh CLI introduced
|
||||
rg "\\bgh\\s+(pr|issue|api)" src/ scripts/
|
||||
# (Expected: zero hits outside .github/workflows/)
|
||||
```
|
||||
|
||||
### Documentation cross-check
|
||||
|
||||
- ObservationCompiler WHERE clause matches the shape used by the shipped worktree-reads-parent feature — both clauses symmetric, visible in a single read of the file.
|
||||
- Chroma metadata field name `merged_into_project` matches SQLite column name exactly (no `mergedIntoProject`, `merged_project`, etc.).
|
||||
- CLI `--branch` flag accepts the same format as worktree composite names.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Files touched | New LOC (approx.) |
|
||||
|---|---|---|
|
||||
| 1. Schema | `src/services/sqlite/migrations/runner.ts` | ~25 |
|
||||
| 2. Adoption engine | `src/services/infrastructure/WorktreeAdoption.ts` (new), `src/services/sync/ChromaSync.ts` (new method) | ~200 |
|
||||
| 3. Query plumbing | `src/services/context/ObservationCompiler.ts`, `src/services/sync/SearchManager.ts`, `src/services/sync/ChromaSync.ts` | ~40 |
|
||||
| 4. Auto-trigger | `src/services/worker-service.ts` | ~15 |
|
||||
| 5. CLI | `src/npx-cli/commands/adopt.ts` (new), `src/npx-cli/index.ts`, `scripts/adopt-worktrees.ts` (new) | ~100 |
|
||||
| 6. UI | `src/ui/viewer/components/ObservationCard.tsx`, Observation type, serializer, CSS | ~20 |
|
||||
| 7. Tests + verification | scattered | — |
|
||||
| **Total** | | **~400 LOC** |
|
||||
|
||||
**Reversibility**: `UPDATE observations SET merged_into_project = NULL` + a Chroma `update_documents` call with the field omitted restores pre-adoption state completely. Nothing is destroyed.
|
||||
|
||||
**Architecture fit**: Mirrors the just-shipped CWD remap migration (`runOneTimeCwdRemap`) for structure, lifecycle, and logging conventions. Chroma metadata sync matches the existing per-observation attach pattern.
|
||||
|
||||
**Blast radius**: Zero risk to existing data (no writes to `project` field). Chroma additions are metadata-only (embeddings untouched). Query extensions are additive OR clauses — existing queries still return what they did.
|
||||
@@ -1,68 +1,5 @@
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
*No context yet. Complete your first session and context will appear here.*
|
||||
|
||||
# $CMEM claude-mem 2026-04-03 6:48pm PDT
|
||||
|
||||
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision
|
||||
Format: ID TIME TYPE TITLE
|
||||
Fetch details: get_observations([IDs]) | Search: mem-search skill
|
||||
|
||||
Stats: 50 obs (18,868t read) | 401,168t work | 95% savings
|
||||
|
||||
### Apr 3, 2026
|
||||
62994 1:47p 🔴 Merge Commit Finalized on thedotmack/npx-gemini-cli Branch
|
||||
62995 1:48p 🔵 Worker Running but Health Endpoint Doesn't Accept POST
|
||||
62996 " 🔵 Worker Health Endpoint Returns Detailed Status via GET
|
||||
62997 1:49p 🔵 Worker Service Timeout and Shutdown Behavior in worker-service.ts
|
||||
62998 " 🔵 claude-mem Hook Architecture Defined in plugin/hooks/hooks.json
|
||||
62999 " 🔵 Session Idle Timeout Architecture: Two-Tier System in claude-mem
|
||||
63000 " 🔵 Orphan Reaper Runs Every 30 Seconds; Sessions Orphaned After 6 Hours
|
||||
63001 1:51p 🔵 POST /api/sessions/complete Removes Sessions from Active Map to Unblock Orphan Reaper
|
||||
63002 1:52p 🔵 Stop Hook Summarize Flow: Extracts Last Assistant Message from Transcript
|
||||
63004 " 🔵 POST /api/sessions/summarize: Privacy Check Before Queuing SDK Agent
|
||||
63005 " 🔵 SessionManager.deleteSession Verifies Subprocess Exit to Prevent Zombies
|
||||
63007 " 🔵 deleteSession: 4-Step Teardown with Generator and Subprocess Timeouts
|
||||
63008 1:53p 🔵 Queue Depth Always Read from Database; Generator Restarts Capped at 3
|
||||
63009 " 🔴 Fixed Lost Summaries: session-complete Now Waits for Pending Work Before Deleting Session
|
||||
63010 1:54p 🔴 SessionEnd Hook Timeout Increased to 180s
|
||||
63014 2:00p 🔵 claude-mem Hook Architecture and Exit Code System
|
||||
63015 2:01p 🔵 SessionEnd Hook Has a 1.5s Default Timeout Controlled by Environment Variable
|
||||
63016 2:02p 🔴 Stop Hook Now Owns Full Session Lifecycle: Summarize → Poll → Complete
|
||||
63017 " 🔵 Missing /api/sessions/status Route — Only DB-ID Variant Exists
|
||||
63018 2:03p 🔴 Added /api/sessions/status Route Registration to SessionRoutes
|
||||
63020 " 🟣 Added handleStatusByClaudeId Handler for GET /api/sessions/status
|
||||
63022 " 🔄 Removed Pending-Work Polling from /api/sessions/complete — Moved to Stop Hook
|
||||
63024 " 🔄 SessionEnd Hook Reverted to Fast Fire-and-Forget (2s Timeout)
|
||||
63026 2:04p 🔵 claude-mem hooks.json Full Hook Lifecycle Configuration
|
||||
63027 2:05p ✅ Push to Pull Request
|
||||
63028 " 🔵 Pre-Push State: claude-mem Repository Changes
|
||||
63029 " 🔴 Fix Lost Summaries: Move Summary Wait into Stop Hook
|
||||
63035 2:11p ✅ Testing Plan Created for tmux-cli npx Installation Flows
|
||||
63036 2:12p 🔵 claude-mem Supports 13 npx Installation Flows Across IDE Integrations
|
||||
63037 " 🔵 Detailed Integration Strategies for All 13 claude-mem npx Installation Flows
|
||||
63038 2:13p ✅ NPX Install Flow Test Plan Document Created
|
||||
63039 " ✅ 12 TODO Tasks Created for npx Install Flow Testing
|
||||
63040 2:19p 🟣 Comprehensive Test Suite Requested for Claude-Mem CLI
|
||||
63041 2:20p 🔵 NPX Install Flow Test Plan Exists for 12 IDE Integrations
|
||||
63042 " 🟣 Phase 2 E2E Runtime Testing Added to NPX Install Test Plan
|
||||
63043 " ✅ Test Tasks Updated with Phase 2 E2E Runtime Steps for 5 IDE Flows
|
||||
63044 " ✅ All Remaining Test Tasks (6–12) Updated with Phase 2 E2E Runtime Steps
|
||||
63079 6:31p ⚖️ Test Execution via Subagents Using /do Command
|
||||
63080 6:32p 🔵 IDE Auto-Detection Module in claude-mem
|
||||
63081 " 🔵 Install Command Architecture with Multi-IDE Dispatch
|
||||
63082 " 🔵 MCP Integrations Module for 6 IDEs
|
||||
63083 " 🔵 Cursor, Windsurf, and Gemini CLI Hook-Based Integrations
|
||||
63084 " 🔵 OpenCode, OpenClaw, and Codex CLI Installers
|
||||
63085 6:33p 🔵 tmux-cli Available for Automated Testing
|
||||
63086 " 🔵 NPX Install Flow Test Plan — 12 IDE Flows
|
||||
63087 6:34p 🟣 Detailed Test Execution Plan Created for NPX Install Flows
|
||||
63103 6:47p 🔵 NPX Install Fails for Windsurf IDE with Missing rxjs Dependency
|
||||
63104 " 🔵 Windsurf Install Failure Was a Dependency Ordering Race
|
||||
63105 " 🟣 claude-mem Gemini CLI Integration: 8 Hooks Registered
|
||||
63106 " 🟣 claude-mem OpenCode Integration: Plugin File + AGENTS.md Context
|
||||
|
||||
Access 401k tokens of past work via get_observations([IDs]) or mem-search skill.
|
||||
|
||||
---
|
||||
*Auto-updated by claude-mem after each session. Use MCP search tools for detailed queries.*
|
||||
Use claude-mem's MCP search tools for manual memory queries.
|
||||
|
||||
+4704
-64
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,11 @@ Or install for Gemini CLI (auto-detects `~/.gemini`):
|
||||
```bash
|
||||
npx claude-mem install --ide gemini-cli
|
||||
```
|
||||
Or install for OpenCode:
|
||||
|
||||
```bash
|
||||
npx claude-mem install --ide opencode
|
||||
```
|
||||
|
||||
Or install from the plugin marketplace inside Claude Code:
|
||||
|
||||
@@ -300,6 +305,45 @@ Settings are managed in `~/.claude-mem/settings.json` (auto-created with default
|
||||
|
||||
See the **[Configuration Guide](https://docs.claude-mem.ai/configuration)** for all available settings and examples.
|
||||
|
||||
### Mode & Language Configuration
|
||||
|
||||
Claude-Mem supports multiple workflow modes and languages via the `CLAUDE_MEM_MODE` setting.
|
||||
|
||||
This option controls both:
|
||||
- The workflow behavior (e.g. code, chill, investigation)
|
||||
- The language used in generated observations
|
||||
|
||||
#### How to Configure
|
||||
|
||||
Edit your settings file at `~/.claude-mem/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_MODE": "code--zh"
|
||||
}
|
||||
```
|
||||
|
||||
Modes are defined in `plugin/modes/`. To see all available modes locally:
|
||||
|
||||
```bash
|
||||
ls ~/.claude/plugins/marketplaces/thedotmack/plugin/modes/
|
||||
```
|
||||
|
||||
#### Available Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------------|-------------------------|
|
||||
| `code` | Default English mode |
|
||||
| `code--zh` | Simplified Chinese mode |
|
||||
| `code--ja` | Japanese mode |
|
||||
|
||||
Language-specific modes follow the pattern `code--[lang]` where `[lang]` is the ISO 639-1 language code (e.g., `zh` for Chinese, `ja` for Japanese, `es` for Spanish).
|
||||
|
||||
> Note: `code--zh` (Simplified Chinese) is already built-in — no additional installation or plugin update is required.
|
||||
|
||||
#### After Changing Mode
|
||||
|
||||
Restart Claude Code to apply the new mode configuration.
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[test]
|
||||
# Force each test file into its own worker process.
|
||||
# Prevents mock.module() calls (which are permanent within a worker)
|
||||
# from leaking across test files in parallel runs.
|
||||
# Note: smol=true increases test startup time by spawning one Bun process per file.
|
||||
# See: https://github.com/thedotmack/claude-mem/issues/1299
|
||||
smol = true
|
||||
@@ -1,83 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 6, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4241 | 11:19 PM | 🟣 | Object-Oriented Architecture Design Document Created | ~662 |
|
||||
| #4240 | 11:11 PM | 🟣 | Worker Service Rewrite Blueprint Created | ~541 |
|
||||
| #4239 | 11:07 PM | 🟣 | Comprehensive Worker Service Performance Analysis Document Created | ~541 |
|
||||
| #4238 | 10:59 PM | 🔵 | Overhead Analysis Document Checked | ~203 |
|
||||
|
||||
### Nov 7, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4609 | 6:33 PM | ✅ | PR #69 Successfully Merged to Main Branch | ~516 |
|
||||
| #4600 | 6:31 PM | 🟣 | Added Worker Service Documentation Suite | ~441 |
|
||||
| #4597 | " | 🔄 | Worker Service Refactored to Object-Oriented Architecture | ~473 |
|
||||
|
||||
### Nov 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5539 | 10:20 PM | 🔵 | Harsh critical audit of context-hook reveals systematic anti-patterns | ~3154 |
|
||||
| #5497 | 9:29 PM | 🔵 | Harsh critical audit of context-hook reveals systematic anti-patterns | ~2815 |
|
||||
| #5495 | 9:28 PM | 🔵 | Context Hook Audit Reveals Project Anti-Patterns | ~660 |
|
||||
| #5476 | 9:17 PM | 🔵 | Critical Code Audit Identified 14 Anti-Patterns in Context Hook | ~887 |
|
||||
| #5391 | 8:45 PM | 🔵 | Critical Code Quality Audit of Context Hook Implementation | ~720 |
|
||||
| #5150 | 7:37 PM | 🟣 | Troubleshooting Skill Added to Claude-Mem Plugin | ~427 |
|
||||
|
||||
### Nov 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6161 | 11:55 PM | 🔵 | YC W26 Application Research and Preparation Completed for Claude-Mem | ~1628 |
|
||||
| #6155 | 11:47 PM | ✅ | Comprehensive Y Combinator Winter 2026 Application Notes Created | ~1045 |
|
||||
| #5979 | 7:58 PM | 🔵 | Smart Contextualization Feature Architecture | ~560 |
|
||||
| #5971 | 7:49 PM | 🔵 | Hooks Reference Documentation Structure | ~448 |
|
||||
| #5929 | 7:08 PM | ✅ | Documentation Updates for v5.4.0 Skill-Based Search Migration | ~604 |
|
||||
| #5927 | " | ✅ | Updated Configuration Documentation for Skill-Based Search | ~497 |
|
||||
| #5920 | 7:05 PM | ✅ | Renamed Architecture Documentation File Reference | ~271 |
|
||||
|
||||
### Nov 18, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #11515 | 8:22 PM | 🔵 | Smart Contextualization Architecture Retrieved with Command Hook Pattern Details | ~502 |
|
||||
|
||||
### Dec 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22294 | 9:43 PM | 🔵 | Documentation Site Structure Located | ~359 |
|
||||
|
||||
### Dec 12, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #24430 | 8:27 PM | ✅ | Removed Final Platform Check Reference from Linux Section | ~320 |
|
||||
| #24429 | " | ✅ | Final Platform Check Reference Removal from Linux Section | ~274 |
|
||||
| #24428 | " | ✅ | Corrected Second Line Number Reference for Migration Marker Logic | ~267 |
|
||||
| #24427 | 8:26 PM | ✅ | Updated Line Number Reference for PM2 Cleanup Implementation | ~260 |
|
||||
| #24426 | " | ✅ | Removed Platform Check from Manual Marker Deletion Scenario | ~338 |
|
||||
| #24425 | " | ✅ | Removed Platform Check from Fresh Install Scenario Flow | ~314 |
|
||||
| #24424 | 8:25 PM | ✅ | Renumbered Manual Marker Deletion Scenario | ~285 |
|
||||
| #24423 | " | ✅ | Renumbered Fresh Install Scenario | ~243 |
|
||||
| #24422 | " | ✅ | Removed Obsolete Windows Platform Detection Scenario | ~311 |
|
||||
| #24421 | " | ✅ | Removed Platform Check from macOS Migration Documentation | ~294 |
|
||||
| #24420 | 8:24 PM | ✅ | Platform Check Removed from Migration Documentation | ~288 |
|
||||
| #24417 | 8:16 PM | ✅ | Code Reference Example Updated to Reflect Actual Cross-Platform Implementation | ~366 |
|
||||
| #24416 | " | ✅ | Architecture Decision Documentation Updated to Reflect Cross-Platform PM2 Cleanup Rationale | ~442 |
|
||||
| #24415 | 8:15 PM | ✅ | Migration Marker Lifecycle Documentation Updated for Unified Cross-Platform Behavior | ~463 |
|
||||
| #24414 | " | ✅ | Platform Comparison Table Updated to Reflect Unified Cross-Platform Migration | ~351 |
|
||||
| #24413 | " | ✅ | Windows Platform-Specific Documentation Completely Rewritten for Unified Migration | ~428 |
|
||||
| #24412 | " | ✅ | User Experience Timeline Updated for Cross-Platform PM2 Cleanup | ~291 |
|
||||
| #24411 | 8:14 PM | ✅ | Migration Marker Lifecycle Documentation Updated for All Platforms | ~277 |
|
||||
| #24410 | " | ✅ | Marker File Platform Behavior Documentation Updated for Unified Migration | ~282 |
|
||||
| #24409 | " | ✅ | Migration Steps Documentation Updated for Cross-Platform PM2 Cleanup | ~278 |
|
||||
| #24408 | 8:13 PM | ✅ | PM2 Migration Documentation Updated to Remove Windows Platform Check | ~280 |
|
||||
</claude-mem-context>
|
||||
@@ -23,14 +23,14 @@ Claude-mem uses **two distinct session IDs** to track conversations and memory:
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. SDKAgent starts, checks hasRealMemorySessionId │
|
||||
│ const hasReal = memorySessionId !== null │
|
||||
│ const hasReal = !!memorySessionId │
|
||||
│ → FALSE (it's NULL) │
|
||||
│ → Resume NOT used (fresh SDK session) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. First SDK message arrives with session_id │
|
||||
│ updateMemorySessionId(sessionDbId, "sdk-gen-abc123") │
|
||||
│ ensureMemorySessionIdRegistered(sessionDbId, "sdk-gen-abc123") │
|
||||
│ │
|
||||
│ Database state: │
|
||||
│ ├─ content_session_id: "user-session-123" │
|
||||
@@ -38,45 +38,43 @@ Claude-mem uses **two distinct session IDs** to track conversations and memory:
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 4. Subsequent prompts use resume │
|
||||
│ const hasReal = memorySessionId !== null │
|
||||
│ → TRUE (it's not NULL) │
|
||||
│ 4. Subsequent prompts may use resume │
|
||||
│ const shouldResume = │
|
||||
│ !!memorySessionId && lastPromptNumber > 1 && !forceInit│
|
||||
│ → TRUE only for continuation prompts in the same runtime │
|
||||
│ → Resume parameter: { resume: "sdk-gen-abc123" } │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Observation Storage
|
||||
|
||||
**CRITICAL**: Observations are stored with `contentSessionId`, NOT the captured SDK `memorySessionId`.
|
||||
**CRITICAL**: Observations are stored with the real `memorySessionId`, NOT `contentSessionId`.
|
||||
|
||||
```typescript
|
||||
// SDKAgent.ts line 332-333
|
||||
this.dbManager.getSessionStore().storeObservation(
|
||||
session.contentSessionId, // ← contentSessionId, not memorySessionId!
|
||||
session.project,
|
||||
obs,
|
||||
// ...
|
||||
);
|
||||
// SessionStore.ts
|
||||
storeObservation(memorySessionId, project, observation, ...);
|
||||
```
|
||||
|
||||
Even though the parameter is named `memorySessionId`, it receives `contentSessionId`. This means:
|
||||
This means:
|
||||
|
||||
- Database column: `observations.memory_session_id`
|
||||
- Stored value: `contentSessionId` (the user's session ID)
|
||||
- Stored value: the captured or synthesized `memorySessionId`
|
||||
- Foreign key: References `sdk_sessions.memory_session_id`
|
||||
|
||||
The observations are linked to the session via `contentSessionId`, which remains constant throughout the session lifecycle.
|
||||
Observation storage is blocked until a real `memorySessionId` is registered in `sdk_sessions`.
|
||||
This is why `SDKAgent` persists the SDK-returned `session_id` immediately through
|
||||
`ensureMemorySessionIdRegistered(...)` before any observation insert can succeed.
|
||||
|
||||
## Key Invariants
|
||||
|
||||
### 1. NULL-Based Detection
|
||||
|
||||
```typescript
|
||||
const hasRealMemorySessionId = session.memorySessionId !== null;
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
```
|
||||
|
||||
- When `memorySessionId === null` → Not yet captured
|
||||
- When `memorySessionId !== null` → Real SDK session captured
|
||||
- When `memorySessionId` is falsy → Not yet captured
|
||||
- When `memorySessionId` is truthy → Real SDK session captured
|
||||
|
||||
### 2. Resume Safety
|
||||
|
||||
@@ -86,12 +84,20 @@ const hasRealMemorySessionId = session.memorySessionId !== null;
|
||||
// ❌ FORBIDDEN - Would resume user's session instead of memory session!
|
||||
query({ resume: contentSessionId })
|
||||
|
||||
// ✅ CORRECT - Only resume when we have real memory session ID
|
||||
// ✅ CORRECT - Only resume for a continuation prompt in a valid runtime
|
||||
query({
|
||||
...(hasRealMemorySessionId && { resume: memorySessionId })
|
||||
...(
|
||||
!!memorySessionId &&
|
||||
lastPromptNumber > 1 &&
|
||||
!forceInit &&
|
||||
{ resume: memorySessionId }
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
`memorySessionId` is necessary but not sufficient.
|
||||
Worker restart and crash-recovery paths may still carry a persisted ID while forcing a fresh INIT run.
|
||||
|
||||
### 3. Session Isolation
|
||||
|
||||
- Each `contentSessionId` maps to exactly one database session
|
||||
@@ -103,7 +109,8 @@ query({
|
||||
- Observations reference `sdk_sessions.memory_session_id`
|
||||
- Initially, `sdk_sessions.memory_session_id` is NULL (no observations can be stored yet)
|
||||
- When SDK session ID is captured, `sdk_sessions.memory_session_id` is set to the real value
|
||||
- Observations are stored using `contentSessionId` and remain retrievable via `contentSessionId`
|
||||
- Observations are stored using that real `memory_session_id`
|
||||
- Queries can still find the session from `content_session_id`, but observation rows themselves stay keyed by `memory_session_id`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
@@ -116,8 +123,8 @@ The test suite validates all critical invariants:
|
||||
### Test Categories
|
||||
|
||||
1. **NULL-Based Detection** - Validates `hasRealMemorySessionId` logic
|
||||
2. **Observation Storage** - Confirms observations use `contentSessionId`
|
||||
3. **Resume Safety** - Prevents `contentSessionId` from being used for resume
|
||||
2. **Observation Storage** - Confirms observations use real `memorySessionId` values after registration
|
||||
3. **Resume Safety** - Prevents `contentSessionId` and stale INIT sessions from being used for resume
|
||||
4. **Cross-Contamination Prevention** - Ensures session isolation
|
||||
5. **Foreign Key Integrity** - Validates cascade behavior
|
||||
6. **Session Lifecycle** - Tests create → capture → resume flow
|
||||
@@ -141,14 +148,14 @@ bun test --verbose
|
||||
### ❌ Using memorySessionId for observations
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't use the captured SDK session ID
|
||||
storeObservation(session.memorySessionId, ...)
|
||||
// WRONG - Don't store observations before memorySessionId is available
|
||||
storeObservation(session.contentSessionId, ...)
|
||||
```
|
||||
|
||||
### ❌ Resuming without checking for NULL
|
||||
|
||||
```typescript
|
||||
// WRONG - memorySessionId could be NULL!
|
||||
// WRONG - memorySessionId alone is not enough
|
||||
if (session.memorySessionId) {
|
||||
query({ resume: session.memorySessionId })
|
||||
}
|
||||
@@ -166,14 +173,14 @@ const resumeId = session.memorySessionId
|
||||
### ✅ Storing observations
|
||||
|
||||
```typescript
|
||||
// Always use contentSessionId
|
||||
storeObservation(session.contentSessionId, project, obs, ...)
|
||||
// Only store after a real memorySessionId has been captured or synthesized
|
||||
storeObservation(session.memorySessionId, project, obs, ...)
|
||||
```
|
||||
|
||||
### ✅ Checking for real memory session ID
|
||||
|
||||
```typescript
|
||||
const hasRealMemorySessionId = session.memorySessionId !== null;
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
```
|
||||
|
||||
### ✅ Using resume parameter
|
||||
@@ -182,7 +189,12 @@ const hasRealMemorySessionId = session.memorySessionId !== null;
|
||||
query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
...(hasRealMemorySessionId && { resume: session.memorySessionId }),
|
||||
...(
|
||||
hasRealMemorySessionId &&
|
||||
session.lastPromptNumber > 1 &&
|
||||
!session.forceInit &&
|
||||
{ resume: session.memorySessionId }
|
||||
),
|
||||
// ... other options
|
||||
}
|
||||
})
|
||||
@@ -234,6 +246,6 @@ WHERE s.content_session_id = 'your-session-id';
|
||||
## References
|
||||
|
||||
- **Implementation**: `src/services/worker/SDKAgent.ts` (lines 72-94)
|
||||
- **Database Schema**: `src/services/sqlite/SessionStore.ts` (line 95-104)
|
||||
- **Session Store**: `src/services/sqlite/SessionStore.ts`
|
||||
- **Tests**: `tests/session_id_usage_validation.test.ts`
|
||||
- **Related Tests**: `tests/session_id_refactor.test.ts`
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# claude-mem Architecture Overview
|
||||
|
||||
## System Layers
|
||||
|
||||
```text
|
||||
+-----------------------------------------------------------+
|
||||
| Claude Code (host) |
|
||||
| +-- Hook System (5 events) |
|
||||
| +-- MCP Client (search tools) |
|
||||
+-----------------------------------------------------------+
|
||||
| CLI Layer (Bun) |
|
||||
| +-- bun-runner.js (Node->Bun bridge) |
|
||||
| +-- hook-command.ts (orchestrator) |
|
||||
| +-- handlers/ (context, session-init, observation, |
|
||||
| summarize, session-complete) |
|
||||
+-----------------------------------------------------------+
|
||||
| Worker Daemon (Express, port 37777) |
|
||||
| +-- SessionManager (session lifecycle) |
|
||||
| +-- SDKAgent (Claude Agent SDK) |
|
||||
| +-- SearchManager (search orchestration) |
|
||||
| +-- ProcessRegistry (subprocess management) |
|
||||
| +-- ChromaSync (embedding synchronization) |
|
||||
+-----------------------------------------------------------+
|
||||
| Storage Layer |
|
||||
| +-- SQLite (claude-mem.db) -- structured data |
|
||||
| +-- ChromaDB (chroma.sqlite3) -- vector embeddings |
|
||||
| +-- MCP Server (interface for Claude Code) |
|
||||
+-----------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Hook Lifecycle
|
||||
|
||||
| Event | Handler | What it does | Timeout |
|
||||
|-------|---------|-------------|---------|
|
||||
| Setup | setup.sh | Install system dependencies | 300s |
|
||||
| SessionStart | smart-install.js + context | Install deps + start worker + inject context | 60s |
|
||||
| UserPromptSubmit | session-init | Register session + start SDK agent + semantic injection | 60s |
|
||||
| PostToolUse | observation | Capture tool usage -> enqueue in worker | 120s |
|
||||
| Summary | summarize | Request session summary from SDK agent | 120s |
|
||||
| SessionEnd | session-complete | End session + drain pending messages | 30s |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
User prompt -> session-init -> /api/sessions/init + /api/context/semantic
|
||||
|
|
||||
Tool use -> observation -> /api/sessions/observations
|
||||
| |
|
||||
| PendingMessageStore.enqueue()
|
||||
| |
|
||||
| SDKAgent.startSession()
|
||||
| |
|
||||
| Claude Agent SDK -> ResponseProcessor
|
||||
| |
|
||||
| +-- storeObservations() -> SQLite
|
||||
| +-- chromaSync.sync() -> ChromaDB
|
||||
| +-- broadcastObservation() -> SSE/UI
|
||||
|
|
||||
Stop -> summarize -> /api/sessions/summarize
|
||||
-> session-complete -> /api/sessions/complete + drain
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### CLAIM-CONFIRM (PendingMessageStore)
|
||||
|
||||
```text
|
||||
enqueue() -> INSERT status='pending'
|
||||
claimNextMessage() -> UPDATE status='processing' (atomic)
|
||||
confirmProcessed() -> DELETE (success)
|
||||
markFailed() -> UPDATE status='failed' (retry < 3)
|
||||
|
||||
Self-healing: messages in 'processing' for >60s reset to 'pending'
|
||||
```
|
||||
|
||||
### Circuit-Breaker (SessionRoutes)
|
||||
|
||||
```text
|
||||
Generator crash -> retry 1 (1s) -> retry 2 (2s) -> retry 3 (4s)
|
||||
-> consecutiveRestarts > 3 -> CIRCUIT-BREAKER
|
||||
-> markAllSessionMessagesAbandoned(sessionDbId)
|
||||
-> Stop. No infinite loop.
|
||||
```
|
||||
|
||||
Counter resets to 0 when generator completes work naturally.
|
||||
|
||||
### Graceful Degradation (hook-command.ts)
|
||||
|
||||
```text
|
||||
Transport errors (ECONNREFUSED, timeout, 5xx) -> exit 0 (never block Claude Code)
|
||||
Client bugs (4xx, TypeError, ReferenceError) -> exit 2 (blocking, needs fix)
|
||||
```
|
||||
|
||||
The worker being unavailable NEVER blocks the user's Claude Code session.
|
||||
|
||||
### Deduplication (observations)
|
||||
|
||||
```text
|
||||
SHA256(memory_session_id + title + narrative)[:16] -> content_hash (16 hex chars)
|
||||
If hash exists within 30s window -> return existing ID (no insert)
|
||||
```
|
||||
|
||||
### Two Types of Session ID
|
||||
|
||||
- `contentSessionId` — from Claude Code, invariant during the session
|
||||
- `memorySessionId` — from SDK Agent, changes on each worker restart
|
||||
|
||||
The conversion between them is handled by SessionStore and is critical for FK constraints.
|
||||
|
||||
## Storage
|
||||
|
||||
### SQLite (claude-mem.db)
|
||||
|
||||
| Table | Key fields | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| sdk_sessions | content_session_id, memory_session_id, status | Session lifecycle |
|
||||
| observations | memory_session_id, type, title, narrative, content_hash | Tool usage observations |
|
||||
| session_summaries | memory_session_id, request, learned, completed | Session summaries |
|
||||
| user_prompts | content_session_id, prompt_text | User prompt history |
|
||||
| pending_messages | session_db_id, status, message_type | CLAIM-CONFIRM queue |
|
||||
| observation_feedback | observation_id, signal_type | Usage tracking |
|
||||
|
||||
### ChromaDB (chroma.sqlite3)
|
||||
|
||||
Vector embeddings for semantic search. Each observation generates multiple documents:
|
||||
|
||||
```text
|
||||
obs_{id}_narrative -> main text
|
||||
obs_{id}_fact_0 -> first fact
|
||||
obs_{id}_fact_1 -> second fact
|
||||
...
|
||||
```
|
||||
|
||||
Accessed via chroma-mcp (MCP process), communication over stdio.
|
||||
|
||||
## Process Management
|
||||
|
||||
- **ProcessRegistry:** Tracks all Claude SDK subprocesses, manages PID lifecycle
|
||||
- **Orphan Reaper (5min):** Kills processes with no active session
|
||||
- **GracefulShutdown:** 7-step shutdown (PID file, children, HTTP server, sessions, MCP, DB, force-kill)
|
||||
@@ -32,7 +32,7 @@ For simple single-turn queries where you don't need to maintain a session, use `
|
||||
import { unstable_v2_prompt } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
const result = await unstable_v2_prompt('What is 2 + 2?', {
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
model: 'claude-sonnet-4-6-20250929'
|
||||
})
|
||||
console.log(result.result)
|
||||
```
|
||||
@@ -45,7 +45,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: { model: 'claude-sonnet-4-5-20250929' }
|
||||
options: { model: 'claude-sonnet-4-6-20250929' }
|
||||
})
|
||||
|
||||
for await (const msg of q) {
|
||||
@@ -71,7 +71,7 @@ The example below creates a session, sends "Hello!" to Claude, and prints the te
|
||||
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
await using session = unstable_v2_createSession({
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
model: 'claude-sonnet-4-6-20250929'
|
||||
})
|
||||
|
||||
await session.send('Hello!')
|
||||
@@ -97,7 +97,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello!',
|
||||
options: { model: 'claude-sonnet-4-5-20250929' }
|
||||
options: { model: 'claude-sonnet-4-6-20250929' }
|
||||
})
|
||||
|
||||
for await (const msg of q) {
|
||||
@@ -123,7 +123,7 @@ This example asks a math question, then asks a follow-up that references the pre
|
||||
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
await using session = unstable_v2_createSession({
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
model: 'claude-sonnet-4-6-20250929'
|
||||
})
|
||||
|
||||
// Turn 1
|
||||
@@ -177,7 +177,7 @@ async function* createInputStream() {
|
||||
|
||||
const q = query({
|
||||
prompt: createInputStream(),
|
||||
options: { model: 'claude-sonnet-4-5-20250929' }
|
||||
options: { model: 'claude-sonnet-4-6-20250929' }
|
||||
})
|
||||
|
||||
for await (const msg of q) {
|
||||
@@ -217,7 +217,7 @@ function getAssistantText(msg: SDKMessage): string | null {
|
||||
|
||||
// Create initial session and have a conversation
|
||||
const session = unstable_v2_createSession({
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
model: 'claude-sonnet-4-6-20250929'
|
||||
})
|
||||
|
||||
await session.send('Remember this number: 42')
|
||||
@@ -235,7 +235,7 @@ session.close()
|
||||
|
||||
// Later: resume the session using the stored ID
|
||||
await using resumedSession = unstable_v2_resumeSession(sessionId!, {
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
model: 'claude-sonnet-4-6-20250929'
|
||||
})
|
||||
|
||||
await resumedSession.send('What number did I ask you to remember?')
|
||||
@@ -254,7 +254,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
// Create initial session
|
||||
const initialQuery = query({
|
||||
prompt: 'Remember this number: 42',
|
||||
options: { model: 'claude-sonnet-4-5-20250929' }
|
||||
options: { model: 'claude-sonnet-4-6-20250929' }
|
||||
})
|
||||
|
||||
// Get session ID from any message
|
||||
@@ -276,7 +276,7 @@ console.log('Session ID:', sessionId)
|
||||
const resumedQuery = query({
|
||||
prompt: 'What number did I ask you to remember?',
|
||||
options: {
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
model: 'claude-sonnet-4-6-20250929',
|
||||
resume: sessionId
|
||||
}
|
||||
})
|
||||
@@ -304,7 +304,7 @@ Sessions can be closed manually or automatically using [`await using`](https://w
|
||||
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
await using session = unstable_v2_createSession({
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
model: 'claude-sonnet-4-6-20250929'
|
||||
})
|
||||
// Session closes automatically when the block exits
|
||||
```
|
||||
@@ -315,7 +315,7 @@ await using session = unstable_v2_createSession({
|
||||
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
const session = unstable_v2_createSession({
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
model: 'claude-sonnet-4-6-20250929'
|
||||
})
|
||||
// ... use the session ...
|
||||
session.close()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# claude-mem Production Guide
|
||||
|
||||
Practical guide based on 23 days of production usage with 3,400+ observations across two physical servers and 8 projects.
|
||||
|
||||
## Recommended Settings
|
||||
|
||||
| Setting | Default | Recommended | Why |
|
||||
|---------|---------|-------------|-----|
|
||||
| CLAUDE_MEM_MAX_CONCURRENT_AGENTS | 2 | 3 | Better throughput without overload |
|
||||
| CLAUDE_MEM_SEMANTIC_INJECT | true | true | Relevant context >> recent context |
|
||||
| CLAUDE_MEM_SEMANTIC_INJECT_LIMIT | 5 | 5 | Sweet spot for token cost vs coverage |
|
||||
| CLAUDE_MEM_TIER_ROUTING_ENABLED | true | true | ~52% cost savings, no quality loss |
|
||||
|
||||
## Health Monitoring
|
||||
|
||||
### Key metrics to watch
|
||||
|
||||
| Metric | Healthy | Warning | Action |
|
||||
|--------|---------|---------|--------|
|
||||
| pending_messages (pending) | 0-5 | >10 | Check worker logs, may need restart |
|
||||
| pending_messages (failed) | 0 | >0 growing | Circuit-breaker may be tripping |
|
||||
| sdk_sessions (active) | 0-3 | >5 stuck | Orphan sessions, worker restart |
|
||||
| WAL size | <10 MB | >20 MB | Run `PRAGMA wal_checkpoint(TRUNCATE)` |
|
||||
| Chroma size | Growing slowly | Sudden jump | Check for sync loops |
|
||||
| Errors/day in logs | 0-2 | >10 | Investigate log patterns |
|
||||
|
||||
### Quick health check
|
||||
|
||||
```bash
|
||||
# Check worker status
|
||||
curl -s http://127.0.0.1:37777/api/health | python3 -m json.tool
|
||||
|
||||
# Check database stats
|
||||
sqlite3 ~/.claude-mem/claude-mem.db "
|
||||
SELECT 'observations' as metric, COUNT(*) as value FROM observations
|
||||
UNION ALL SELECT 'summaries', COUNT(*) FROM session_summaries
|
||||
UNION ALL SELECT 'pending', COUNT(*) FROM pending_messages WHERE status='pending'
|
||||
UNION ALL SELECT 'active_sessions', COUNT(*) FROM sdk_sessions WHERE status='active';
|
||||
"
|
||||
```
|
||||
|
||||
## Multi-Machine Setup
|
||||
|
||||
If running claude-mem on multiple machines, use `claude-mem-sync` to keep observations in sync:
|
||||
|
||||
```bash
|
||||
claude-mem-sync push <remote-host> # local -> remote
|
||||
claude-mem-sync pull <remote-host> # remote -> local
|
||||
claude-mem-sync sync <remote-host> # bidirectional
|
||||
claude-mem-sync status <remote-host> # compare counts
|
||||
```
|
||||
|
||||
Deduplication is by `(created_at, title)` — safe to run repeatedly.
|
||||
|
||||
## Growth Expectations
|
||||
|
||||
Based on active daily development usage:
|
||||
|
||||
| Metric | Per day | Per month | Notes |
|
||||
|--------|---------|-----------|-------|
|
||||
| Observations | ~120 | ~3,600 | Varies with coding activity |
|
||||
| Summaries | ~40 | ~1,200 | One per session |
|
||||
| SQLite | ~0.8 MB | ~24 MB | ~5 KB per observation |
|
||||
| Chroma | ~4 MB | ~120 MB | ~50 KB per observation (embeddings) |
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Summarize error loop
|
||||
|
||||
**Symptom:** Repeated `[ERROR] Missing last_assistant_message` in logs.
|
||||
**Cause:** Transcript with no assistant messages triggers summary attempt that fails repeatedly.
|
||||
**Fix:** PR #1566 — skip summary when transcript is empty.
|
||||
|
||||
### Chroma sync failures
|
||||
|
||||
**Symptom:** `[ERROR] Batch add failed... IDs already exist`
|
||||
**Cause:** MCP timeout during add leaves partial writes; retry fails on existing IDs.
|
||||
**Fix:** PR #1566 — fallback to delete+add reconciliation.
|
||||
|
||||
### Port conflict on startup
|
||||
|
||||
**Symptom:** `Worker failed to start... Is port 37777 in use?`
|
||||
**Cause:** Two sessions starting simultaneously — HTTP check is non-atomic (TOCTOU race).
|
||||
**Fix:** PR #1566 — atomic socket bind on Unix.
|
||||
|
||||
### Orphaned pending messages
|
||||
|
||||
**Symptom:** `pending_messages` table growing with old entries for completed sessions.
|
||||
**Cause:** SIGTERM kills generator before queue is drained.
|
||||
**Fix:** PR #1567 — drain after deleteSession().
|
||||
|
||||
### Context not relevant to current topic
|
||||
|
||||
**Symptom:** Claude receives observations about CSS when you're asking about authentication.
|
||||
**Cause:** Default recency-based injection selects most recent, not most relevant.
|
||||
**Fix:** PR #1568 — semantic injection via Chroma on every prompt.
|
||||
|
||||
## Log Analysis Tips
|
||||
|
||||
```bash
|
||||
# Count errors by day
|
||||
grep '\[ERROR\]' ~/.claude-mem/logs/claude-mem-*.log | \
|
||||
sed 's/\[20[0-9][0-9]-[0-9][0-9]-/\n&/g' | \
|
||||
grep -oP '^\[20\d{2}-\d{2}-\d{2}' | sort | uniq -c
|
||||
|
||||
# Find circuit-breaker trips
|
||||
grep 'circuit\|Circuit\|ABANDONED\|abandoned' ~/.claude-mem/logs/claude-mem-*.log
|
||||
|
||||
# Check pending message health
|
||||
grep 'CLAIMED\|CONFIRMED\|FAILED\|ABANDONED' ~/.claude-mem/logs/claude-mem-$(date +%Y-%m-%d).log | tail -20
|
||||
```
|
||||
@@ -1,88 +0,0 @@
|
||||
# Claude-Mem Public Documentation
|
||||
|
||||
## What This Folder Is
|
||||
|
||||
This `docs/public/` folder contains the **Mintlify documentation site** - the official user-facing documentation for claude-mem. It's a structured documentation platform with a specific file format and organization.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── public/ ← You are here (Mintlify MDX files)
|
||||
│ ├── *.mdx - User-facing documentation pages
|
||||
│ ├── docs.json - Mintlify configuration and navigation
|
||||
│ ├── architecture/ - Technical architecture docs
|
||||
│ ├── usage/ - User guides and workflows
|
||||
│ └── *.webp, *.gif - Assets (logos, screenshots)
|
||||
└── context/ ← Internal documentation (DO NOT put here)
|
||||
└── *.md - Planning docs, audits, references
|
||||
```
|
||||
|
||||
## File Requirements
|
||||
|
||||
### Mintlify Documentation Files (.mdx)
|
||||
All official documentation files must be:
|
||||
- Written in `.mdx` format (Markdown with JSX support)
|
||||
- Listed in `docs.json` navigation structure
|
||||
- Follow Mintlify's schema and conventions
|
||||
|
||||
The documentation is organized into these sections:
|
||||
- **Get Started**: Introduction, installation, usage guides
|
||||
- **Best Practices**: Context engineering, progressive disclosure
|
||||
- **Configuration & Development**: Settings, dev workflow, troubleshooting
|
||||
- **Architecture**: System design, components, technical details
|
||||
|
||||
### Configuration File
|
||||
`docs.json` defines:
|
||||
- Site metadata (name, description, theme)
|
||||
- Navigation structure
|
||||
- Branding (logos, colors)
|
||||
- Footer links and social media
|
||||
|
||||
## What Does NOT Belong Here
|
||||
|
||||
**Planning documents, design docs, and reference materials go in `/docs/context/` instead:**
|
||||
|
||||
Files that belong in `/docs/context/` (NOT here):
|
||||
- Planning documents (`*-plan.md`, `*-outline.md`)
|
||||
- Implementation analysis (`*-audit.md`, `*-code-reference.md`)
|
||||
- Error tracking (`typescript-errors.md`)
|
||||
- Internal design documents
|
||||
- PR review responses
|
||||
- Reference materials (like `agent-sdk-ref.md`)
|
||||
- Work-in-progress documentation
|
||||
|
||||
## How to Add Official Documentation
|
||||
|
||||
1. Create a new `.mdx` file in the appropriate subdirectory
|
||||
2. Add the file path to `docs.json` navigation
|
||||
3. Use Mintlify's frontmatter and components
|
||||
4. Follow the existing documentation style
|
||||
5. Test locally: `npx mintlify dev`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
**For contributors working on claude-mem:**
|
||||
- Read `/CLAUDE.md` in the project root for development instructions
|
||||
- Place planning/design docs in `/docs/context/`
|
||||
- Only add user-facing documentation to `/docs/public/`
|
||||
- Test documentation locally with Mintlify CLI before committing
|
||||
|
||||
## Testing Documentation
|
||||
|
||||
```bash
|
||||
# Validate docs structure
|
||||
npx mintlify validate
|
||||
|
||||
# Check for broken links
|
||||
npx mintlify broken-links
|
||||
|
||||
# Run local dev server
|
||||
npx mintlify dev
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
**Simple Rule**:
|
||||
- `/docs/public/` = Official user documentation (Mintlify .mdx files) ← YOU ARE HERE
|
||||
- `/docs/context/` = Internal docs, plans, references, audits
|
||||
@@ -860,7 +860,7 @@ async startSession(session: ActiveSession, worker?: any) {
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
model: 'claude-sonnet-4-6',
|
||||
disallowedTools: ['Bash', 'Read', 'Write', ...], // Observer-only
|
||||
abortController: session.abortController
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"usage/openrouter-provider",
|
||||
"usage/gemini-provider",
|
||||
"usage/search-tools",
|
||||
"usage/knowledge-agents",
|
||||
"usage/claude-desktop",
|
||||
"usage/private-tags",
|
||||
"usage/export-import",
|
||||
@@ -70,6 +71,7 @@
|
||||
"pages": [
|
||||
"context-engineering",
|
||||
"progressive-disclosure",
|
||||
"file-read-gate",
|
||||
"smart-explore-benchmark"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: "File Read Gate"
|
||||
description: "How claude-mem intercepts file reads to save tokens using observation history"
|
||||
---
|
||||
|
||||
# File Read Gate
|
||||
|
||||
## What It Is
|
||||
|
||||
The File Read Gate is a **PreToolUse hook** that intercepts Claude's `Read` tool calls. When Claude tries to read a file that has prior observations in the database, the gate blocks the read and instead shows a compact timeline of past work on that file. Claude then decides the cheapest path to get the context it needs.
|
||||
|
||||
This is a concrete implementation of [progressive disclosure](/progressive-disclosure) -- show what exists first, let the agent decide what to fetch.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Claude calls Read("src/services/worker-service.ts")
|
||||
↓
|
||||
PreToolUse hook fires
|
||||
↓
|
||||
File size < 1,500 bytes? ──→ Allow read (timeline costs more than file)
|
||||
↓ No
|
||||
Project excluded? ──→ Allow read
|
||||
↓ No
|
||||
Query worker: GET /api/observations/by-file
|
||||
↓
|
||||
No observations found? ──→ Allow read
|
||||
↓ Has observations
|
||||
Deduplicate (1 per session)
|
||||
Rank by specificity
|
||||
Limit to 15
|
||||
↓
|
||||
DENY read with timeline
|
||||
```
|
||||
|
||||
When the gate fires, Claude sees a message like this:
|
||||
|
||||
```
|
||||
Current: 2026-04-07 3:25pm PDT
|
||||
Read blocked: This file has prior observations. Choose the cheapest path:
|
||||
- Already know enough? The timeline below may be all you need (semantic priming).
|
||||
- Need details? get_observations([IDs]) -- ~300 tokens each.
|
||||
- Need current code? smart_outline("path") for structure (~1-2k tokens),
|
||||
smart_unfold("path", "<symbol>") for a specific function (~400-2k tokens).
|
||||
- Need to edit? Use smart tools for line numbers, then sed via Bash.
|
||||
|
||||
### Apr 5, 2026
|
||||
42301 2:15pm Fixed database connection pooling
|
||||
42298 1:50pm Refactored worker startup sequence
|
||||
|
||||
### Mar 28, 2026
|
||||
41890 4:30pm Added health check endpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Decision Tree
|
||||
|
||||
Claude has four options after seeing the timeline, ordered from cheapest to most expensive:
|
||||
|
||||
| Option | Token Cost | When to Use |
|
||||
|--------|-----------|-------------|
|
||||
| **Semantic priming** | 0 extra | Timeline titles tell Claude enough to proceed |
|
||||
| **get_observations([IDs])** | ~300 each | Need specific details from past work |
|
||||
| **smart_outline / smart_unfold** | ~1-2k | Need current code structure or a specific function |
|
||||
| **Full file read** | 5k-50k | File has changed significantly since observations |
|
||||
|
||||
In practice, most file reads resolve at the semantic priming or get_observations level, saving thousands of tokens per interaction.
|
||||
|
||||
---
|
||||
|
||||
## Current Date/Time for Temporal Reasoning
|
||||
|
||||
The timeline includes the current date and time as its first line:
|
||||
|
||||
```
|
||||
Current: 2026-04-07 3:25pm PDT
|
||||
```
|
||||
|
||||
This lets Claude reason about how recent the observations are relative to now. For example:
|
||||
|
||||
- **Observations from today** -- likely still accurate, semantic priming is safe
|
||||
- **Observations from last week** -- probably accurate, get_observations for details
|
||||
- **Observations from months ago** -- file may have changed, consider smart_outline or full read
|
||||
|
||||
The timestamp format matches the session start context header (`YYYY-MM-DD time timezone`), so Claude sees consistent temporal markers throughout its session.
|
||||
|
||||
---
|
||||
|
||||
## Token Economics
|
||||
|
||||
A typical source file costs **5,000-50,000 tokens** to read in full. The File Read Gate replaces that with:
|
||||
|
||||
| Component | Tokens |
|
||||
|-----------|--------|
|
||||
| Timeline header + instructions | ~120 |
|
||||
| 15 observation entries | ~250 |
|
||||
| **Total timeline** | **~370** |
|
||||
|
||||
If Claude needs more detail, it fetches individual observations at ~300 tokens each. Even fetching 3 observations totals ~1,270 tokens -- still a **75-97% savings** over reading the full file.
|
||||
|
||||
### Real-World Example
|
||||
|
||||
Without the gate (reading `worker-service.ts`):
|
||||
```
|
||||
Read: 18,000 tokens
|
||||
```
|
||||
|
||||
With the gate:
|
||||
```
|
||||
Timeline: 370 tokens
|
||||
+ 2 observations: 600 tokens
|
||||
Total: 970 tokens (95% savings)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Specificity Ranking
|
||||
|
||||
Not all observations about a file are equally relevant. The gate scores each observation by how specifically it relates to the target file:
|
||||
|
||||
| Signal | Score Bonus |
|
||||
|--------|------------|
|
||||
| File was **modified** (not just read) | +2 |
|
||||
| Observation covers **3 or fewer** total files | +2 |
|
||||
| Observation covers **4-8** total files | +1 |
|
||||
| Observation covers **9+** files (survey-like) | +0 |
|
||||
|
||||
Higher-scoring observations appear first in the timeline. An observation where the file was the primary modification target ranks above one where the file was incidentally read alongside 20 others.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Small File Bypass
|
||||
|
||||
Files smaller than **1,500 bytes** always pass through the gate without interception. At that size, the timeline (~370 tokens) would cost more than reading the file directly. This threshold is hardcoded in `src/cli/handlers/file-context.ts`.
|
||||
|
||||
### Project Exclusions
|
||||
|
||||
Projects matching patterns in `CLAUDE_MEM_EXCLUDED_PROJECTS` skip the gate entirely. Configure this in `~/.claude-mem/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_EXCLUDED_PROJECTS": "/tmp/*,/scratch/*"
|
||||
}
|
||||
```
|
||||
|
||||
### How to Disable the Gate
|
||||
|
||||
The File Read Gate is implemented as a PreToolUse hook on the `Read` tool matcher. To disable it, remove the `Read` matcher entry from the hooks configuration:
|
||||
|
||||
1. Open your Claude Code settings:
|
||||
```
|
||||
~/.claude/settings.json
|
||||
```
|
||||
|
||||
2. Find the claude-mem hooks section under `hooks.PreToolUse` and remove the entry with the `Read` matcher.
|
||||
|
||||
Alternatively, if you want to keep the gate installed but bypass it for a specific read, Claude can ask you to allow the read -- the gate's deny decision is presented to the user, who can override it.
|
||||
|
||||
<Note>
|
||||
Disabling the gate means Claude will read full files every time, which increases token usage but ensures it always sees the latest code. This is a reasonable choice for small projects or when observations are sparse.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## How It Fits Together
|
||||
|
||||
The File Read Gate is one piece of claude-mem's layered context strategy:
|
||||
|
||||
1. **Session Start**: Inject timeline of recent observations (layer 1 -- metadata)
|
||||
2. **File Read Gate**: Intercept reads with observation history (layer 1 -- metadata)
|
||||
3. **get_observations**: Fetch specific observation details on demand (layer 2 -- details)
|
||||
4. **smart_outline / smart_unfold**: Read current code structure efficiently (layer 3 -- source)
|
||||
5. **Full file read**: Last resort when everything else is insufficient
|
||||
|
||||
Each layer is progressively more expensive. The gate ensures Claude starts at the cheapest layer and escalates only when needed.
|
||||
@@ -33,6 +33,7 @@ Restart Claude Code. Context from previous sessions will automatically appear in
|
||||
- 🌐 **Multilingual Modes** - Supports 28 languages (Spanish, Chinese, French, Japanese, etc.)
|
||||
- 🎭 **Mode System** - Switch between workflows (Code, Email Investigation, Chill)
|
||||
- 🔍 **MCP Search Tools** - Query your project history with natural language
|
||||
- 🧠 **Knowledge Agents** - Build queryable "brains" from your observation history
|
||||
- 🌐 **Web Viewer UI** - Real-time memory stream visualization at http://localhost:37777
|
||||
- 🔒 **Privacy Control** - Use `<private>` tags to exclude sensitive content from storage
|
||||
- ⚙️ **Context Configuration** - Fine-grained control over what context gets injected
|
||||
@@ -115,4 +116,7 @@ See [Architecture Overview](architecture/overview) for details.
|
||||
<Card title="Search Tools" icon="magnifying-glass" href="/usage/search-tools">
|
||||
Query your project history
|
||||
</Card>
|
||||
<Card title="Knowledge Agents" icon="brain" href="/usage/knowledge-agents">
|
||||
Build queryable corpora from your history
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -46,7 +46,7 @@ GET /api/context/recent?project=my-project&limit=3
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
CLAUDE_MEM_MODEL=claude-sonnet-4-5 # Model for observations/summaries
|
||||
CLAUDE_MEM_MODEL=claude-sonnet-4-6 # Model for observations/summaries
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS=50 # Observations injected at SessionStart
|
||||
CLAUDE_MEM_WORKER_PORT=37777 # Worker service port
|
||||
CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
title: "Knowledge Agents"
|
||||
description: "Build queryable AI brains from your observation history"
|
||||
---
|
||||
|
||||
# Knowledge Agents
|
||||
|
||||
Knowledge agents let you compile a slice of your claude-mem observation history into a **queryable "brain"** that answers questions conversationally. Instead of getting raw search results back, you get synthesized, grounded answers drawn from your actual project history -- decisions, discoveries, bugfixes, and features.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Three ways to use knowledge agents, from simplest to most powerful.
|
||||
|
||||
### 1. Create a Knowledge Agent
|
||||
|
||||
Use the `/knowledge-agent` skill or the MCP tools directly:
|
||||
|
||||
```
|
||||
build_corpus name="hooks-expertise" query="hooks architecture" project="claude-mem" limit=200
|
||||
```
|
||||
|
||||
This searches your observation history, collects matching records, and saves them as a corpus file. Then prime it — this loads the corpus into a Claude session's context window:
|
||||
|
||||
```
|
||||
prime_corpus name="hooks-expertise"
|
||||
```
|
||||
|
||||
Your knowledge agent is ready. The returned `session_id` **is** the agent — a Claude session with your history baked in.
|
||||
|
||||
### 2. Ask a Single Question
|
||||
|
||||
Once primed, ask any question and get a grounded answer:
|
||||
|
||||
```
|
||||
query_corpus name="hooks-expertise" question="What are the 5 lifecycle hooks and when does each fire?"
|
||||
```
|
||||
|
||||
The agent answers grounded in its corpus — responses are drawn from your actual project history, reducing hallucination and guessing. Each follow-up question builds on the prior conversation:
|
||||
|
||||
```
|
||||
query_corpus name="hooks-expertise" question="Which hook handles context injection?"
|
||||
```
|
||||
|
||||
### 3. Start a Fresh Conversation
|
||||
|
||||
If the conversation drifts, or you want to ask an unrelated question against the same corpus, reprime to start clean:
|
||||
|
||||
```
|
||||
reprime_corpus name="hooks-expertise"
|
||||
```
|
||||
|
||||
This creates a **new session** with the full corpus reloaded — like opening a fresh chat with the same "brain." All prior Q&A context is cleared, but the corpus knowledge remains. Use this when:
|
||||
|
||||
- The conversation went off-track and you want a clean slate
|
||||
- You're switching topics within the same corpus
|
||||
- You want to ask a question without prior answers biasing the response
|
||||
|
||||
### Keeping It Current
|
||||
|
||||
When new observations are added to your project, rebuild the corpus to pull in the latest, then reprime:
|
||||
|
||||
```
|
||||
rebuild_corpus name="hooks-expertise"
|
||||
reprime_corpus name="hooks-expertise"
|
||||
```
|
||||
|
||||
Rebuild re-runs the original search filters. Reprime loads the refreshed data into a new session.
|
||||
|
||||
---
|
||||
|
||||
## The Workflow: Build, Prime, Query
|
||||
|
||||
```
|
||||
BUILD ──> PRIME ──> QUERY
|
||||
```
|
||||
|
||||
### 1. Build a Corpus
|
||||
|
||||
A corpus is a filtered collection of observations saved as a JSON file. Use search filters to select exactly the slice of history you want.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:37777/api/corpus \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "hooks-expertise",
|
||||
"query": "hooks architecture",
|
||||
"project": "claude-mem",
|
||||
"types": ["decision", "discovery"],
|
||||
"limit": 200
|
||||
}'
|
||||
```
|
||||
|
||||
Under the hood, `CorpusBuilder` searches your observations, hydrates full records, parses structured fields (facts, concepts, files), calculates stats, and writes everything to `~/.claude-mem/corpora/hooks-expertise.corpus.json`.
|
||||
|
||||
### 2. Prime the Knowledge Agent
|
||||
|
||||
Priming loads the entire corpus into a Claude session's context window.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:37777/api/corpus/hooks-expertise/prime
|
||||
```
|
||||
|
||||
The agent renders all observations into full-detail text and feeds them to the Claude Agent SDK. Claude reads the corpus and acknowledges the themes. The returned `session_id` **is** the knowledge agent -- a Claude session with your history baked in.
|
||||
|
||||
### 3. Query
|
||||
|
||||
Resume the primed session and ask questions.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:37777/api/corpus/hooks-expertise/query \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "question": "What are the 5 lifecycle hooks?" }'
|
||||
```
|
||||
|
||||
Each follow-up question adds to the conversation naturally. If the session expires, the agent auto-reprimes from the corpus file and retries.
|
||||
|
||||
---
|
||||
|
||||
## Filter Options
|
||||
|
||||
Use these parameters when building a corpus to control which observations are included:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | string | Name for the corpus (used in all subsequent API calls) |
|
||||
| `project` | string | Filter by project name |
|
||||
| `types` | string[] | Filter by observation type (bugfix, feature, decision, discovery, refactor, change) |
|
||||
| `concepts` | string[] | Filter by tagged concepts |
|
||||
| `files` | string[] | Filter by files read or modified |
|
||||
| `query` | string | Full-text search query |
|
||||
| `dateStart` | string | Start date filter (YYYY-MM-DD) |
|
||||
| `dateEnd` | string | End date filter (YYYY-MM-DD) |
|
||||
| `limit` | number | Maximum observations to include |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
MCP Tools HTTP API
|
||||
(mcp-server.ts) (worker on :37777)
|
||||
| |
|
||||
build_corpus ──┤ |
|
||||
list_corpora ──┤ |
|
||||
prime_corpus ──┤── callWorkerAPIPost() ──>|
|
||||
query_corpus ──┤ |
|
||||
rebuild_corpus ──┤ |
|
||||
reprime_corpus ──┘ |
|
||||
v
|
||||
CorpusRoutes
|
||||
(8 endpoints)
|
||||
/ | \
|
||||
CorpusBuilder | KnowledgeAgent
|
||||
| | |
|
||||
SearchOrchestrator | Agent SDK V1
|
||||
SessionStore | query() + resume
|
||||
|
|
||||
CorpusStore
|
||||
(~/.claude-mem/corpora/)
|
||||
```
|
||||
|
||||
**Key insight:** The Agent SDK's `resume` option lets you prime a session once (upload the corpus), save the `session_id`, and resume it for every future question. The corpus stays in context permanently -- no re-uploading, no prompt caching tricks. The 1M token context window makes this viable: 2,000 observations at ~300 tokens each fits comfortably.
|
||||
|
||||
---
|
||||
|
||||
## When to Use `/knowledge-agent` vs `/mem-search`
|
||||
|
||||
| | `/mem-search` | `/knowledge-agent` |
|
||||
|---|---|---|
|
||||
| **Returns** | Raw observation records | Synthesized conversational answers |
|
||||
| **Best for** | Finding specific observations, IDs, timelines | Asking questions about patterns, decisions, architecture |
|
||||
| **Token model** | Pay-per-query (3-layer progressive disclosure) | Pay-once at prime time, then cheap follow-ups |
|
||||
| **Interaction** | Search, filter, fetch | Ask questions in natural language |
|
||||
| **Data freshness** | Always current (queries database live) | Snapshot at build time (rebuild to refresh) |
|
||||
| **Setup** | None -- works immediately | Build + prime required before first query |
|
||||
|
||||
**Rule of thumb:** Use `/mem-search` when you need to find something specific. Use `/knowledge-agent` when you want to understand something broadly.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/corpus` | Build a new corpus from filters |
|
||||
| GET | `/api/corpus` | List all corpora with stats |
|
||||
| GET | `/api/corpus/:name` | Get corpus metadata |
|
||||
| DELETE | `/api/corpus/:name` | Delete a corpus |
|
||||
| POST | `/api/corpus/:name/rebuild` | Rebuild from stored filters |
|
||||
| POST | `/api/corpus/:name/prime` | Create AI session with corpus loaded |
|
||||
| POST | `/api/corpus/:name/query` | Ask the knowledge agent a question |
|
||||
| POST | `/api/corpus/:name/reprime` | Fresh session (wipe prior Q&A) |
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Session expiry**: If `resume` fails, the agent auto-reprimes from the corpus file and retries
|
||||
- **SDK process exit**: If the Claude process exits after yielding all messages, the agent treats it as success when the session_id or answer was already captured
|
||||
- **Empty corpus**: A corpus with 0 observations is valid (just empty)
|
||||
- **Model from settings**: Reads `CLAUDE_MEM_MODEL` from user settings -- no hardcoded model IDs
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Memory Search](/usage/search-tools) - The 3-layer search workflow for finding specific observations
|
||||
- [Progressive Disclosure](/progressive-disclosure) - Philosophy behind token-efficient retrieval
|
||||
- [Architecture Overview](/architecture/overview) - System components
|
||||
+58
-9
@@ -80,17 +80,18 @@ setup_tty() {
|
||||
if [[ -t 0 ]]; then
|
||||
# stdin IS a terminal — use it directly
|
||||
TTY_FD=0
|
||||
elif [[ -e /dev/tty ]]; then
|
||||
# stdin is piped (curl | bash) but /dev/tty is available
|
||||
elif [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||
# In non-interactive mode, do not require /dev/tty
|
||||
TTY_FD=0
|
||||
elif [[ -r /dev/tty ]]; then
|
||||
# stdin is piped (curl | bash) but /dev/tty is available and readable
|
||||
exec 3</dev/tty
|
||||
TTY_FD=3
|
||||
else
|
||||
# No terminal available at all
|
||||
if [[ "$NON_INTERACTIVE" != "true" ]]; then
|
||||
echo "Error: No terminal available for interactive prompts." >&2
|
||||
echo "Use --non-interactive or run directly: bash install.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Error: No terminal available for interactive prompts." >&2
|
||||
echo "Use --non-interactive or run directly: bash install.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -787,11 +788,16 @@ install_plugin() {
|
||||
const configPath = process.env.INSTALLER_CONFIG_FILE;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const entry = config?.plugins?.entries?.['claude-mem'];
|
||||
if (entry || config?.plugins?.slots?.memory === 'claude-mem') {
|
||||
const allowHasClaudeMem = Array.isArray(config?.plugins?.allow) && config.plugins.allow.includes('claude-mem');
|
||||
if (entry || config?.plugins?.slots?.memory === 'claude-mem' || allowHasClaudeMem) {
|
||||
// Save the config block so we can restore it after install
|
||||
process.stdout.write(JSON.stringify(entry?.config || {}));
|
||||
// Remove the stale entry so OpenClaw CLI can run
|
||||
if (entry) delete config.plugins.entries['claude-mem'];
|
||||
// Also remove stale allowlist reference — this alone can block ALL CLI commands
|
||||
if (Array.isArray(config?.plugins?.allow)) {
|
||||
config.plugins.allow = config.plugins.allow.filter((x) => x !== 'claude-mem');
|
||||
}
|
||||
// Also remove the slot reference — if the slot points to a plugin
|
||||
// that isn't in entries, OpenClaw's config validator rejects ALL commands
|
||||
if (config?.plugins?.slots?.memory === 'claude-mem') {
|
||||
@@ -818,6 +824,49 @@ install_plugin() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure claude-mem is present in plugins.allow after successful install+enable.
|
||||
# Some OpenClaw environments require explicit allowlisting for local plugins.
|
||||
# This write is guaranteed: if config doesn't exist, configure_memory_slot() will create it.
|
||||
if [[ -f "$oc_config" ]]; then
|
||||
if ! INSTALLER_CONFIG_FILE="$oc_config" node -e "
|
||||
const fs = require('fs');
|
||||
const configPath = process.env.INSTALLER_CONFIG_FILE;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (!config.plugins) config.plugins = {};
|
||||
if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
|
||||
if (!config.plugins.allow.includes('claude-mem')) {
|
||||
config.plugins.allow.push('claude-mem');
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
console.log('Added claude-mem to plugins.allow');
|
||||
} else {
|
||||
console.log('claude-mem already in plugins.allow');
|
||||
}
|
||||
" 2>&1; then
|
||||
warn "Failed to write plugins.allow — claude-mem may need manual allowlisting"
|
||||
fi
|
||||
else
|
||||
# Config doesn't exist yet; configure_memory_slot() will create it with plugins.allow
|
||||
# We'll add claude-mem to the allowlist in a follow-up step after config is materialized
|
||||
info "OpenClaw config not yet materialized; will ensure allowlist in post-install"
|
||||
# Force config materialization by running a harmless OpenClaw command
|
||||
if run_openclaw status --json >/dev/null 2>&1 && [[ -f "$oc_config" ]]; then
|
||||
if ! INSTALLER_CONFIG_FILE="$oc_config" node -e "
|
||||
const fs = require('fs');
|
||||
const configPath = process.env.INSTALLER_CONFIG_FILE;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (!config.plugins) config.plugins = {};
|
||||
if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
|
||||
if (!config.plugins.allow.includes('claude-mem')) {
|
||||
config.plugins.allow.push('claude-mem');
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
console.log('Added claude-mem to plugins.allow (post-materialization)');
|
||||
}
|
||||
" 2>&1; then
|
||||
warn "Failed to write plugins.allow after materialization — configure manually"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restore saved plugin config (workerPort, syncMemoryFile, observationFeed, etc.)
|
||||
# from any pre-existing installation that was temporarily removed above.
|
||||
if [[ -n "$saved_plugin_config" && "$saved_plugin_config" != "{}" ]]; then
|
||||
@@ -1101,7 +1150,7 @@ write_settings() {
|
||||
|
||||
// All defaults from SettingsDefaultsManager.ts
|
||||
const defaults = {
|
||||
CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
|
||||
CLAUDE_MEM_MODEL: 'claude-sonnet-4-6',
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
|
||||
CLAUDE_MEM_WORKER_PORT: '37777',
|
||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"default": 37777,
|
||||
"description": "Port for Claude-Mem worker service"
|
||||
},
|
||||
"workerHost": {
|
||||
"type": "string",
|
||||
"default": "127.0.0.1",
|
||||
"description": "Hostname for Claude-Mem worker service. Set to host.docker.internal when the gateway runs in Docker and the worker runs on the host."
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"default": "openclaw",
|
||||
|
||||
@@ -979,3 +979,207 @@ describe("SSE stream integration", () => {
|
||||
await getService().stop({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("circuit breaker", () => {
|
||||
// Reset circuit breaker state before each test by firing gateway_start.
|
||||
// The circuit is module-level state, so tests would otherwise bleed into each other.
|
||||
beforeEach(async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort: 59999 });
|
||||
claudeMemPlugin(api);
|
||||
await fireEvent("gateway_start", {}, {});
|
||||
});
|
||||
|
||||
it("opens after threshold failures and stops further requests", async () => {
|
||||
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
|
||||
claudeMemPlugin(api);
|
||||
// Reset circuit inside the test body to guard against timers from preceding
|
||||
// tests (e.g. completionDelayMs timers) that may fire between beforeEach and here.
|
||||
await fireEvent("gateway_start", {}, {});
|
||||
|
||||
// Fire threshold+1 calls so the circuit is open by the end of the loop
|
||||
// regardless of whether a concurrent timer fires at the exact boundary.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-open-${i}` });
|
||||
}
|
||||
|
||||
// Circuit is now OPEN. Subsequent calls must be silently dropped.
|
||||
const logCountBeforeDrop = logs.length;
|
||||
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-drop" });
|
||||
const noisyDropLogs = logs.slice(logCountBeforeDrop).filter(
|
||||
(l) => l.includes("failed") || l.includes("disabling")
|
||||
);
|
||||
assert.equal(noisyDropLogs.length, 0, "calls when circuit is open should be silently dropped");
|
||||
});
|
||||
|
||||
it("logs individual failures while circuit is closed, then disabling when it opens", async () => {
|
||||
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
|
||||
claudeMemPlugin(api);
|
||||
await fireEvent("gateway_start", {}, {});
|
||||
const logsAfterReset = logs.length;
|
||||
|
||||
// Fire exactly threshold (3) calls
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-log-${i}` });
|
||||
}
|
||||
|
||||
const newLogs = logs.slice(logsAfterReset);
|
||||
// At least some failures should have been logged (circuit was active)
|
||||
assert.ok(newLogs.length > 0, "threshold calls should produce log output");
|
||||
// Exactly one disabling warning should appear
|
||||
const disablingLogs = newLogs.filter((l) => l.includes("disabling requests"));
|
||||
assert.equal(disablingLogs.length, 1, "should emit exactly one disabling warning when circuit opens");
|
||||
// The last call (the threshold-crossing one) should NOT log an individual failure
|
||||
const failureLogs = newLogs.filter((l) => l.includes("failed:"));
|
||||
assert.ok(failureLogs.length < 3, "threshold-crossing call should not log an individual failure");
|
||||
});
|
||||
|
||||
it("resets on gateway_start, allowing connections again", async () => {
|
||||
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
|
||||
claudeMemPlugin(api);
|
||||
await fireEvent("gateway_start", {}, {});
|
||||
|
||||
// Open the circuit by firing threshold+1 calls
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-reset-${i}` });
|
||||
}
|
||||
|
||||
// Confirm circuit is open (call is silently dropped)
|
||||
const logCountWhileOpen = logs.length;
|
||||
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-while-open" });
|
||||
assert.equal(
|
||||
logs.slice(logCountWhileOpen).filter((l) => l.includes("failed") || l.includes("disabling")).length,
|
||||
0,
|
||||
"call while circuit is open should be silently dropped"
|
||||
);
|
||||
|
||||
// gateway_start resets the circuit
|
||||
await fireEvent("gateway_start", {}, {});
|
||||
|
||||
// Next call should attempt to connect again (not silently drop)
|
||||
const logCountAfterReset = logs.length;
|
||||
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-after-reset" });
|
||||
const newLogs = logs.slice(logCountAfterReset);
|
||||
assert.ok(
|
||||
newLogs.some((l) => l.includes("failed:") || l.includes("disabling")),
|
||||
"should attempt worker connection after gateway_start reset"
|
||||
);
|
||||
});
|
||||
|
||||
it("HALF_OPEN allows only a single probe — non-2xx keeps circuit open, 2xx closes it", async () => {
|
||||
// ---- Phase 1: open the circuit via network failures (unreachable port) ----
|
||||
// Reset circuit state first
|
||||
const resetMock = createMockApi({ workerPort: 59999 });
|
||||
claudeMemPlugin(resetMock.api);
|
||||
await resetMock.fireEvent("gateway_start", {}, {});
|
||||
|
||||
// Drive 4 failures to ensure circuit is OPEN
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await resetMock.fireEvent("before_agent_start", { prompt: "probe-test" }, { sessionKey: `probe-phase1-${i}` });
|
||||
}
|
||||
|
||||
// ---- Phase 2: advance clock so cooldown has elapsed ----
|
||||
// _circuitOpenedAt was set during Phase 1 using the real Date.now().
|
||||
// Advancing Date.now by 31s means the next circuitAllow call sees the cooldown elapsed.
|
||||
const realDateNow = Date.now.bind(Date);
|
||||
Date.now = () => realDateNow() + 31_000;
|
||||
|
||||
try {
|
||||
// ---- Phase 3: non-2xx probe — circuit should stay OPEN ----
|
||||
// Start a server that returns 500 for all requests
|
||||
let serverA: Server | null = null;
|
||||
const portA: number = await new Promise((resolve) => {
|
||||
serverA = createServer((_req: IncomingMessage, res: ServerResponse) => {
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
serverA!.listen(0, () => {
|
||||
const addr = serverA!.address();
|
||||
resolve((addr as any).port);
|
||||
});
|
||||
});
|
||||
|
||||
// Reuse the same module-level circuit state — just change the worker port.
|
||||
// Create a new mock api instance pointed at server A (500 responder).
|
||||
const mockA = createMockApi({ workerPort: portA });
|
||||
claudeMemPlugin(mockA.api);
|
||||
// Do NOT fire gateway_start here — we want the OPEN circuit state from Phase 1.
|
||||
|
||||
// The circuit is OPEN but the mocked clock says cooldown elapsed.
|
||||
// The next call should: transition to HALF_OPEN, set _halfOpenProbeInFlight=true,
|
||||
// send the probe to server A (which returns 500), then call circuitOnFailure
|
||||
// and re-open the circuit.
|
||||
const logCountAtProbe = mockA.logs.length;
|
||||
await mockA.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-call-non2xx" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const probeALogs = mockA.logs.slice(logCountAtProbe);
|
||||
// After a 500 response, circuitOnFailure is called which logs "disabling requests"
|
||||
// (because state was HALF_OPEN) and logger.warn logs the 500 status.
|
||||
assert.ok(
|
||||
probeALogs.some((l) => l.includes("disabling") || l.includes("returned 500") || l.includes("Worker POST")),
|
||||
"non-2xx probe should keep circuit open (expected disabling or 500 status log)"
|
||||
);
|
||||
|
||||
// Verify probe flag resets: a second call with cooldown elapsed should be allowed as a new probe
|
||||
// (i.e., _halfOpenProbeInFlight was cleared by circuitOnFailure).
|
||||
// But without advancing time further the circuit is OPEN again — so calls are dropped.
|
||||
const logCountAfterFailedProbe = mockA.logs.length;
|
||||
await mockA.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-concurrent" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const droppedLogs = mockA.logs.slice(logCountAfterFailedProbe).filter(
|
||||
(l) => l.includes("failed") || l.includes("disabling")
|
||||
);
|
||||
assert.equal(droppedLogs.length, 0, "call should be silently dropped while circuit is OPEN again after failed probe");
|
||||
|
||||
serverA!.close();
|
||||
|
||||
// ---- Phase 4: 2xx probe — circuit should close ----
|
||||
// Re-open the circuit with fresh failures, then probe with a 200-returning server.
|
||||
// Reset circuit state first.
|
||||
const resetMock2 = createMockApi({ workerPort: 59999 });
|
||||
claudeMemPlugin(resetMock2.api);
|
||||
await resetMock2.fireEvent("gateway_start", {}, {});
|
||||
|
||||
// Drive failures (still using mocked Date.now, but _circuitOpenedAt will be set to
|
||||
// the mocked time, so cooldown is NOT elapsed yet from the mocked perspective).
|
||||
// We need to temporarily restore real Date.now while opening the circuit, then
|
||||
// re-mock it for the probe.
|
||||
Date.now = realDateNow;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await resetMock2.fireEvent("before_agent_start", { prompt: "probe-test" }, { sessionKey: `probe-phase4-${i}` });
|
||||
}
|
||||
// Re-advance the clock past cooldown
|
||||
Date.now = () => realDateNow() + 31_000;
|
||||
|
||||
let serverB: Server | null = null;
|
||||
const portB: number = await new Promise((resolve) => {
|
||||
serverB = createServer((_req: IncomingMessage, res: ServerResponse) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));
|
||||
});
|
||||
serverB!.listen(0, () => {
|
||||
const addr = serverB!.address();
|
||||
resolve((addr as any).port);
|
||||
});
|
||||
});
|
||||
|
||||
const mockB = createMockApi({ workerPort: portB });
|
||||
claudeMemPlugin(mockB.api);
|
||||
// Do NOT fire gateway_start — reuse OPEN circuit state from resetMock2.
|
||||
|
||||
const logCountBeforeSuccessProbe = mockB.logs.length;
|
||||
await mockB.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-call-2xx" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const successProbeLogs = mockB.logs.slice(logCountBeforeSuccessProbe);
|
||||
assert.ok(
|
||||
successProbeLogs.some((l) => l.includes("restored") || l.includes("circuit closed")),
|
||||
"2xx probe should close the circuit — expected 'restored' or 'circuit closed' log"
|
||||
);
|
||||
|
||||
serverB!.close();
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+236
-46
@@ -183,6 +183,7 @@ interface ClaudeMemPluginConfig {
|
||||
syncMemoryFileExclude?: string[];
|
||||
project?: string;
|
||||
workerPort?: number;
|
||||
workerHost?: string;
|
||||
observationFeed?: {
|
||||
enabled?: boolean;
|
||||
channel?: string;
|
||||
@@ -198,6 +199,7 @@ interface ClaudeMemPluginConfig {
|
||||
|
||||
const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
|
||||
const DEFAULT_WORKER_PORT = 37777;
|
||||
const DEFAULT_WORKER_HOST = "127.0.0.1";
|
||||
|
||||
// Emoji pool for deterministic auto-assignment to unknown agents.
|
||||
// Uses a hash of the agentId to pick a consistent emoji — no persistent state needed.
|
||||
@@ -256,8 +258,77 @@ function buildGetSourceLabel(
|
||||
// Worker HTTP Client
|
||||
// ============================================================================
|
||||
|
||||
let _workerHost = DEFAULT_WORKER_HOST;
|
||||
|
||||
function workerBaseUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
return `http://${_workerHost}:${port}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worker Circuit Breaker
|
||||
// ============================================================================
|
||||
// Prevents CPU-spinning retry loops when the worker is unreachable.
|
||||
// After CIRCUIT_BREAKER_THRESHOLD consecutive network errors, the circuit
|
||||
// opens and all worker calls are silently dropped for CIRCUIT_BREAKER_COOLDOWN_MS.
|
||||
// After the cooldown, one probe attempt is allowed to check if the worker recovered.
|
||||
|
||||
const CIRCUIT_BREAKER_THRESHOLD = 3;
|
||||
const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
|
||||
|
||||
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
||||
|
||||
let _circuitState: CircuitState = "CLOSED";
|
||||
let _circuitFailures = 0;
|
||||
let _circuitOpenedAt = 0;
|
||||
let _halfOpenProbeInFlight = false;
|
||||
|
||||
function circuitAllow(logger: PluginLogger): boolean {
|
||||
if (_circuitState === "CLOSED") return true;
|
||||
if (_circuitState === "OPEN") {
|
||||
if (Date.now() - _circuitOpenedAt >= CIRCUIT_BREAKER_COOLDOWN_MS) {
|
||||
_circuitState = "HALF_OPEN";
|
||||
logger.info("[claude-mem] Circuit breaker: probing worker connection");
|
||||
if (_halfOpenProbeInFlight) return false;
|
||||
_halfOpenProbeInFlight = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// HALF_OPEN: allow one probe through
|
||||
if (_halfOpenProbeInFlight) return false;
|
||||
_halfOpenProbeInFlight = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function circuitOnSuccess(logger: PluginLogger): void {
|
||||
if (_circuitState !== "CLOSED") {
|
||||
logger.info("[claude-mem] Worker connection restored — circuit closed");
|
||||
}
|
||||
_circuitState = "CLOSED";
|
||||
_circuitFailures = 0;
|
||||
_halfOpenProbeInFlight = false;
|
||||
}
|
||||
|
||||
function circuitOnFailure(logger: PluginLogger): void {
|
||||
_halfOpenProbeInFlight = false;
|
||||
_circuitFailures++;
|
||||
if (
|
||||
_circuitState === "HALF_OPEN" ||
|
||||
(_circuitState === "CLOSED" && _circuitFailures >= CIRCUIT_BREAKER_THRESHOLD)
|
||||
) {
|
||||
_circuitState = "OPEN";
|
||||
_circuitOpenedAt = Date.now();
|
||||
logger.warn(
|
||||
`[claude-mem] Worker unreachable — disabling requests for ${CIRCUIT_BREAKER_COOLDOWN_MS / 1000}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function circuitReset(): void {
|
||||
_circuitState = "CLOSED";
|
||||
_circuitFailures = 0;
|
||||
_circuitOpenedAt = 0;
|
||||
_halfOpenProbeInFlight = false;
|
||||
}
|
||||
|
||||
async function workerPost(
|
||||
@@ -266,6 +337,7 @@ async function workerPost(
|
||||
body: Record<string, unknown>,
|
||||
logger: PluginLogger
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (!circuitAllow(logger)) return null;
|
||||
try {
|
||||
const response = await fetch(`${workerBaseUrl(port)}${path}`, {
|
||||
method: "POST",
|
||||
@@ -273,13 +345,18 @@ async function workerPost(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
circuitOnFailure(logger);
|
||||
logger.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
circuitOnSuccess(logger);
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||
circuitOnFailure(logger);
|
||||
if (_circuitState !== "OPEN") {
|
||||
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -290,13 +367,24 @@ function workerPostFireAndForget(
|
||||
body: Record<string, unknown>,
|
||||
logger: PluginLogger
|
||||
): void {
|
||||
if (!circuitAllow(logger)) return;
|
||||
fetch(`${workerBaseUrl(port)}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
circuitOnFailure(logger);
|
||||
logger.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
|
||||
return;
|
||||
}
|
||||
circuitOnSuccess(logger);
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||
circuitOnFailure(logger);
|
||||
if (_circuitState !== "OPEN") {
|
||||
logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,16 +393,22 @@ async function workerGetText(
|
||||
path: string,
|
||||
logger: PluginLogger
|
||||
): Promise<string | null> {
|
||||
if (!circuitAllow(logger)) return null;
|
||||
try {
|
||||
const response = await fetch(`${workerBaseUrl(port)}${path}`);
|
||||
if (!response.ok) {
|
||||
circuitOnFailure(logger);
|
||||
logger.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
circuitOnSuccess(logger);
|
||||
return await response.text();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
|
||||
circuitOnFailure(logger);
|
||||
if (_circuitState !== "OPEN") {
|
||||
logger.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -533,6 +627,7 @@ async function connectToSSEStream(
|
||||
export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig;
|
||||
const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT;
|
||||
_workerHost = userConfig.workerHost || DEFAULT_WORKER_HOST;
|
||||
const baseProjectName = userConfig.project || "openclaw";
|
||||
const getSourceLabel = buildGetSourceLabel(userConfig.observationFeed?.emojis);
|
||||
|
||||
@@ -547,6 +642,14 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
// Session tracking for observation I/O
|
||||
// ------------------------------------------------------------------
|
||||
const sessionIds = new Map<string, string>();
|
||||
const canonicalSessionKeys = new Map<string, string>();
|
||||
const sessionAliasesByCanonicalKey = new Map<string, Set<string>>();
|
||||
const pendingCompletionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const recentPromptInits = new Map<string, number>();
|
||||
const completionDelayMs = (() => {
|
||||
const val = Number((userConfig as Record<string, unknown>).completionDelayMs);
|
||||
return Number.isFinite(val) ? Math.max(0, val) : 5000;
|
||||
})();
|
||||
const syncMemoryFile = userConfig.syncMemoryFile !== false; // default true
|
||||
const syncMemoryFileExclude = new Set(userConfig.syncMemoryFileExclude || []);
|
||||
|
||||
@@ -565,6 +668,83 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
return true;
|
||||
}
|
||||
|
||||
type SessionTrackingContext = {
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
channelId?: string;
|
||||
conversationId?: string;
|
||||
};
|
||||
|
||||
function getSessionAliases(ctx: SessionTrackingContext): string[] {
|
||||
const aliases = new Set<string>();
|
||||
for (const rawKey of [ctx.sessionKey, ctx.conversationId, ctx.channelId]) {
|
||||
const key = typeof rawKey === "string" ? rawKey.trim() : "";
|
||||
if (key) aliases.add(key);
|
||||
}
|
||||
if (aliases.size === 0) aliases.add("default");
|
||||
return Array.from(aliases);
|
||||
}
|
||||
|
||||
function rememberSessionContext(ctx: SessionTrackingContext): { canonicalKey: string; contentSessionId: string } {
|
||||
const aliases = getSessionAliases(ctx);
|
||||
let canonicalKey = aliases.find((alias) => canonicalSessionKeys.has(alias));
|
||||
canonicalKey = canonicalKey ? canonicalSessionKeys.get(canonicalKey)! : aliases[0];
|
||||
let aliasSet = sessionAliasesByCanonicalKey.get(canonicalKey);
|
||||
if (!aliasSet) {
|
||||
aliasSet = new Set([canonicalKey]);
|
||||
sessionAliasesByCanonicalKey.set(canonicalKey, aliasSet);
|
||||
}
|
||||
for (const alias of aliases) {
|
||||
aliasSet.add(alias);
|
||||
canonicalSessionKeys.set(alias, canonicalKey);
|
||||
}
|
||||
const contentSessionId = getContentSessionId(canonicalKey);
|
||||
for (const alias of aliasSet) {
|
||||
sessionIds.set(alias, contentSessionId);
|
||||
}
|
||||
return { canonicalKey, contentSessionId };
|
||||
}
|
||||
|
||||
function shouldSkipDuplicatePromptInit(contentSessionId: string, project: string, prompt: string): boolean {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of recentPromptInits) {
|
||||
if (now - timestamp > 2000) recentPromptInits.delete(key);
|
||||
}
|
||||
const cacheKey = `${contentSessionId}::${project}::${prompt}`;
|
||||
const lastSeenAt = recentPromptInits.get(cacheKey);
|
||||
// Note: cache is set unconditionally before return. If workerPost fails
|
||||
// after this check, a retry within 2s would be incorrectly skipped.
|
||||
// Acceptable because before_agent_start is not retried by the runtime.
|
||||
recentPromptInits.set(cacheKey, now);
|
||||
return typeof lastSeenAt === "number" && now - lastSeenAt <= 2000;
|
||||
}
|
||||
|
||||
function clearSessionContext(ctx: SessionTrackingContext): void {
|
||||
const aliases = getSessionAliases(ctx);
|
||||
const canonicalKey = aliases
|
||||
.map((alias) => canonicalSessionKeys.get(alias))
|
||||
.find(Boolean) || aliases[0];
|
||||
const knownAliases = sessionAliasesByCanonicalKey.get(canonicalKey) || new Set([canonicalKey, ...aliases]);
|
||||
for (const alias of knownAliases) {
|
||||
canonicalSessionKeys.delete(alias);
|
||||
sessionIds.delete(alias);
|
||||
}
|
||||
sessionAliasesByCanonicalKey.delete(canonicalKey);
|
||||
sessionIds.delete(canonicalKey);
|
||||
}
|
||||
|
||||
function scheduleSessionComplete(contentSessionId: string): void {
|
||||
const existingTimer = pendingCompletionTimers.get(contentSessionId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
const timer = setTimeout(() => {
|
||||
pendingCompletionTimers.delete(contentSessionId);
|
||||
workerPostFireAndForget(workerPort, "/api/sessions/complete", {
|
||||
contentSessionId,
|
||||
}, api.logger);
|
||||
}, completionDelayMs);
|
||||
pendingCompletionTimers.set(contentSessionId, timer);
|
||||
}
|
||||
|
||||
// TTL cache for context injection to avoid re-fetching on every LLM turn.
|
||||
// before_prompt_build fires on every turn; caching for 60s keeps the worker
|
||||
// load manageable while still picking up new observations reasonably quickly.
|
||||
@@ -600,61 +780,54 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: session_start — init claude-mem session (fires on /new, /reset)
|
||||
// Event: session_start — track session (fires on /new, /reset)
|
||||
// Init is deferred to before_agent_start to avoid duplicate prompt records.
|
||||
// ------------------------------------------------------------------
|
||||
api.on("session_start", async (_event, ctx) => {
|
||||
const contentSessionId = getContentSessionId(ctx.sessionKey);
|
||||
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: getProjectName(ctx),
|
||||
prompt: "",
|
||||
}, api.logger);
|
||||
|
||||
api.logger.info(`[claude-mem] Session initialized: ${contentSessionId}`);
|
||||
const { contentSessionId } = rememberSessionContext(ctx);
|
||||
api.logger.info(`[claude-mem] Session tracking initialized: ${contentSessionId}`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: message_received — capture inbound user prompts from channels
|
||||
// Event: message_received — alias tracking only; init deferred to before_agent_start
|
||||
// ------------------------------------------------------------------
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
const sessionKey = ctx.conversationId || ctx.channelId || "default";
|
||||
const contentSessionId = getContentSessionId(sessionKey);
|
||||
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: baseProjectName,
|
||||
prompt: event.content || "[media prompt]",
|
||||
}, api.logger);
|
||||
const { canonicalKey, contentSessionId } = rememberSessionContext(ctx);
|
||||
api.logger.info(`[claude-mem] Message received — prompt capture deferred to before_agent_start: session=${canonicalKey} contentSessionId=${contentSessionId} hasContent=${Boolean(event.content)}`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: after_compaction — re-init session after context compaction
|
||||
// Event: after_compaction — preserve session tracking after context compaction.
|
||||
// Re-init is intentionally NOT called here; the worker retains session state
|
||||
// independently and re-initializing would create duplicate prompt records.
|
||||
// ------------------------------------------------------------------
|
||||
api.on("after_compaction", async (_event, ctx) => {
|
||||
const contentSessionId = getContentSessionId(ctx.sessionKey);
|
||||
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: getProjectName(ctx),
|
||||
prompt: "",
|
||||
}, api.logger);
|
||||
|
||||
api.logger.info(`[claude-mem] Session re-initialized after compaction: ${contentSessionId}`);
|
||||
const { contentSessionId } = rememberSessionContext(ctx);
|
||||
api.logger.info(`[claude-mem] Session preserved after compaction: ${contentSessionId}`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: before_agent_start — init session
|
||||
// Event: before_agent_start — single init point with dedup guard
|
||||
// ------------------------------------------------------------------
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
const { contentSessionId } = rememberSessionContext(ctx);
|
||||
const projectName = getProjectName(ctx);
|
||||
const promptText = event.prompt || "agent run";
|
||||
|
||||
if (shouldSkipDuplicatePromptInit(contentSessionId, projectName, promptText)) {
|
||||
api.logger.info(`[claude-mem] Skipping duplicate prompt init: contentSessionId=${contentSessionId} project=${projectName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize session in the worker so observations are not skipped
|
||||
// (the privacy check requires a stored user prompt to exist)
|
||||
const contentSessionId = getContentSessionId(ctx.sessionKey);
|
||||
await workerPost(workerPort, "/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: getProjectName(ctx),
|
||||
prompt: event.prompt || "agent run",
|
||||
project: projectName,
|
||||
prompt: promptText,
|
||||
}, api.logger);
|
||||
|
||||
api.logger.info(`[claude-mem] Session initialized via before_agent_start: contentSessionId=${contentSessionId} project=${projectName}`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -686,7 +859,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
// Skip memory_ tools to prevent recursive observation loops
|
||||
if (toolName.startsWith("memory_")) return;
|
||||
|
||||
const contentSessionId = getContentSessionId(ctx.sessionKey);
|
||||
const { canonicalKey, contentSessionId } = rememberSessionContext(ctx);
|
||||
|
||||
// Extract result text from all content blocks
|
||||
let toolResponseText = "";
|
||||
@@ -704,13 +877,23 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||
}
|
||||
|
||||
// Resolve workspaceDir with fallback chain.
|
||||
// Empty cwd causes worker-side observation queueing failures,
|
||||
// so we drop the observation rather than sending cwd: "".
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
api.logger.warn(`[claude-mem] Skipping observation persist because workspaceDir is unavailable: session=${canonicalKey} tool=${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget: send observation to worker
|
||||
workerPostFireAndForget(workerPort, "/api/sessions/observations", {
|
||||
contentSessionId,
|
||||
tool_name: toolName,
|
||||
tool_input: event.params || {},
|
||||
tool_response: toolResponseText,
|
||||
cwd: "",
|
||||
cwd: workspaceDir,
|
||||
}, api.logger);
|
||||
});
|
||||
|
||||
@@ -718,7 +901,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
// Event: agent_end — summarize and complete session
|
||||
// ------------------------------------------------------------------
|
||||
api.on("agent_end", async (event, ctx) => {
|
||||
const contentSessionId = getContentSessionId(ctx.sessionKey);
|
||||
const { contentSessionId } = rememberSessionContext(ctx);
|
||||
|
||||
// Extract last assistant message for summarization
|
||||
let lastAssistantMessage = "";
|
||||
@@ -747,25 +930,32 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
last_assistant_message: lastAssistantMessage,
|
||||
}, api.logger);
|
||||
|
||||
workerPostFireAndForget(workerPort, "/api/sessions/complete", {
|
||||
contentSessionId,
|
||||
}, api.logger);
|
||||
api.logger.info(`[claude-mem] Scheduling session complete in ${completionDelayMs}ms: ${contentSessionId}`);
|
||||
scheduleSessionComplete(contentSessionId);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: session_end — clean up session tracking to prevent unbounded growth
|
||||
// ------------------------------------------------------------------
|
||||
api.on("session_end", async (_event, ctx) => {
|
||||
const key = ctx.sessionKey || "default";
|
||||
sessionIds.delete(key);
|
||||
clearSessionContext(ctx);
|
||||
api.logger.info(`[claude-mem] Session tracking cleaned up`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: gateway_start — clear session tracking for fresh start
|
||||
// ------------------------------------------------------------------
|
||||
api.on("gateway_start", async () => {
|
||||
circuitReset();
|
||||
sessionIds.clear();
|
||||
contextCache.clear();
|
||||
recentPromptInits.clear();
|
||||
canonicalSessionKeys.clear();
|
||||
sessionAliasesByCanonicalKey.clear();
|
||||
for (const timer of pendingCompletionTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
pendingCompletionTimers.clear();
|
||||
api.logger.info("[claude-mem] Gateway started — session tracking reset");
|
||||
});
|
||||
|
||||
@@ -1047,5 +1237,5 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
},
|
||||
});
|
||||
|
||||
api.logger.info(`[claude-mem] OpenClaw plugin loaded — v1.0.0 (worker: 127.0.0.1:${workerPort})`);
|
||||
api.logger.info(`[claude-mem] OpenClaw plugin loaded — v1.0.0 (worker: ${_workerHost}:${workerPort})`);
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ test_write_settings_new_file() {
|
||||
|
||||
local model
|
||||
model="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_MODEL);")"
|
||||
assert_eq "claude-sonnet-4-5" "$model" "CLAUDE_MEM_MODEL defaults to claude-sonnet-4-5"
|
||||
assert_eq "claude-sonnet-4-6" "$model" "CLAUDE_MEM_MODEL defaults to claude-sonnet-4-6"
|
||||
|
||||
HOME="$ORIGINAL_HOME"
|
||||
rm -rf "$fake_home"
|
||||
|
||||
+32
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.7.0",
|
||||
"version": "12.2.0",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run build-and-sync",
|
||||
"build": "node scripts/build-hooks.js",
|
||||
"build": "node scripts/sync-plugin-manifests.js && node scripts/build-hooks.js",
|
||||
"build-and-sync": "npm run build && npm run sync-marketplace && sleep 1 && cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:restart",
|
||||
"sync-marketplace": "node scripts/sync-marketplace.cjs",
|
||||
"sync-marketplace:force": "node scripts/sync-marketplace.cjs --force",
|
||||
@@ -115,7 +115,7 @@
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"dompurify": "^3.3.1",
|
||||
"express": "^4.18.2",
|
||||
"glob": "^11.0.3",
|
||||
"glob": "^13.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"picocolors": "^1.1.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -124,6 +124,12 @@
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@derekstride/tree-sitter-sql": "^0.3.11",
|
||||
"@tree-sitter-grammars/tree-sitter-lua": "^0.4.1",
|
||||
"@tree-sitter-grammars/tree-sitter-markdown": "^0.3.2",
|
||||
"@tree-sitter-grammars/tree-sitter-toml": "^0.7.0",
|
||||
"@tree-sitter-grammars/tree-sitter-yaml": "^0.7.1",
|
||||
"@tree-sitter-grammars/tree-sitter-zig": "^1.1.2",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/express": "^4.17.21",
|
||||
@@ -132,20 +138,42 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"esbuild": "^0.27.2",
|
||||
"np": "^11.0.2",
|
||||
"tree-sitter-bash": "^0.25.1",
|
||||
"tree-sitter-c": "^0.24.1",
|
||||
"tree-sitter-cli": "^0.26.5",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-css": "^0.25.0",
|
||||
"tree-sitter-elixir": "^0.3.5",
|
||||
"tree-sitter-go": "^0.25.0",
|
||||
"tree-sitter-haskell": "^0.23.1",
|
||||
"tree-sitter-java": "^0.23.5",
|
||||
"tree-sitter-javascript": "^0.25.0",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-php": "^0.24.2",
|
||||
"tree-sitter-python": "^0.25.0",
|
||||
"tree-sitter-ruby": "^0.23.1",
|
||||
"tree-sitter-rust": "^0.24.0",
|
||||
"tree-sitter-scala": "^0.24.0",
|
||||
"tree-sitter-scss": "^1.0.0",
|
||||
"tree-sitter-swift": "^0.7.1",
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"tree-kill": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-c",
|
||||
"tree-sitter-cli",
|
||||
"tree-sitter-cpp",
|
||||
"tree-sitter-go",
|
||||
"tree-sitter-java",
|
||||
"tree-sitter-javascript",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-ruby",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Nov 6, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4091 | 1:12 PM | 🔵 | Claude Plugin Configuration Structure | ~170 |
|
||||
|
||||
### Nov 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5739 | 4:43 PM | 🔵 | Plugin Metadata Configuration | ~199 |
|
||||
|
||||
### Dec 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22284 | 9:41 PM | 🔵 | Claude Plugin Metadata Configuration | ~183 |
|
||||
</claude-mem-context>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.7.0",
|
||||
"version": "12.2.0",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
"mcpServers": {
|
||||
"mcp-search": {
|
||||
"type": "stdio",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs"
|
||||
"command": "bun",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Jan 10, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #39050 | 3:44 PM | 🔵 | Plugin commands directory is empty | ~255 |
|
||||
</claude-mem-context>
|
||||
@@ -1,35 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Oct 25, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #2518 | 11:47 PM | 🔴 | Removed Invalid 'matcher' Field from SessionStart Hook | ~228 |
|
||||
| #2517 | " | 🔵 | Project hooks.json Template Also Empty | ~222 |
|
||||
| #2501 | 11:11 PM | 🔵 | Context Hook Fails Due to Missing @anthropic-ai/sdk Dependency | ~245 |
|
||||
|
||||
### Oct 27, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #2718 | 12:00 AM | 🔴 | Removed incorrect failOnError configuration from hook | ~165 |
|
||||
|
||||
### Nov 18, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #11518 | 8:22 PM | 🔵 | Smart Contextualization Switched from Skill to HTTP API | ~498 |
|
||||
|
||||
### Dec 24, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32309 | 3:09 PM | 🔵 | Claude-mem hooks system configuration structure | ~435 |
|
||||
|
||||
### Jan 9, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38802 | 5:11 PM | 🔵 | Claude-Mem Hook Configuration Architecture | ~450 |
|
||||
</claude-mem-context>
|
||||
+21
-9
@@ -7,7 +7,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
|
||||
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
@@ -19,17 +19,17 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start",
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; curl -sf http://localhost:37777/health >/dev/null 2>&1 || true; echo '{\"continue\":true,\"suppressOutput\":true}'",
|
||||
"timeout": 60
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; if curl -sf http://localhost:37777/health >/dev/null 2>&1; then node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context || true; fi",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
@@ -40,7 +40,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -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
|
||||
}
|
||||
]
|
||||
@@ -52,18 +52,30 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code file-context",
|
||||
"timeout": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -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
|
||||
}
|
||||
]
|
||||
@@ -74,8 +86,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}});r.on('error',()=>{});r.end(JSON.stringify({contentSessionId:s}));process.exit(0)}catch{process.exit(0)}})\"",
|
||||
"timeout": 2
|
||||
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
"system_identity": "You are a Claude-Mem, a specialized observer tool for creating searchable memory FOR FUTURE SESSIONS.\n\nCRITICAL: Record what was LEARNED/BUILT/FIXED/DEPLOYED/CONFIGURED, not what you (the observer) are doing.\n\nYou do not have access to tools. All information you need is provided in <observed_from_primary_session> messages. Create observations from what you observe - no investigation needed.",
|
||||
"spatial_awareness": "SPATIAL AWARENESS: Tool executions include the working directory (tool_cwd) to help you understand:\n- Which repository/project is being worked on\n- Where files are located relative to the project root\n- How to match requested paths to actual execution paths",
|
||||
"observer_role": "Your job is to monitor a different Claude Code session happening RIGHT NOW, with the goal of creating observations and progress summaries as the work is being done LIVE by the user. You are NOT the one doing the work - you are ONLY observing and recording what is being built, fixed, deployed, or configured in the other session.",
|
||||
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on deliverables and capabilities:\n- What the system NOW DOES differently (new capabilities)\n- What shipped to users/production (features, fixes, configs, docs)\n- Changes in technical domains (auth, data, UI, infra, DevOps, docs)\n\nUse verbs like: implemented, fixed, deployed, configured, migrated, optimized, added, refactored\n\n✅ GOOD EXAMPLES (describes what was built):\n- \"Authentication now supports OAuth2 with PKCE flow\"\n- \"Deployment pipeline runs canary releases with auto-rollback\"\n- \"Database indexes optimized for common query patterns\"\n\n❌ BAD EXAMPLES (describes observation process - DO NOT DO THIS):\n- \"Analyzed authentication implementation and stored findings\"\n- \"Tracked deployment steps and logged outcomes\"\n- \"Monitored database performance and recorded metrics\"",
|
||||
"skip_guidance": "WHEN TO SKIP\n------------\nSkip routine operations:\n- Empty status checks\n- Package installations with no errors\n- Simple file listings\n- Repetitive operations you've already documented\n- If file related research comes back as empty or not found\n- **No output necessary if skipping.**",
|
||||
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on durable technical signal:\n- What the system NOW DOES differently (new capabilities)\n- What shipped to users/production (features, fixes, configs, docs)\n- Changes in technical domains (auth, data, UI, infra, DevOps, docs)\n- Concrete debugging or investigative findings from logs, traces, queue state, database rows, and code-path inspection\n\nUse verbs like: implemented, fixed, deployed, configured, migrated, optimized, added, refactored, discovered, confirmed, traced\n\n✅ GOOD EXAMPLES (describes what was built or learned):\n- \"Authentication now supports OAuth2 with PKCE flow\"\n- \"Deployment pipeline runs canary releases with auto-rollback\"\n- \"Database indexes optimized for common query patterns\"\n- \"Observation queue for claude-mem session timed out waiting for an agent pool slot\"\n- \"Fallback processing abandoned pending messages after Gemini and OpenRouter returned 404\"\n\n❌ BAD EXAMPLES (describes observation process - DO NOT DO THIS):\n- \"Analyzed authentication implementation and stored findings\"\n- \"Tracked deployment steps and logged outcomes\"\n- \"Monitored database performance and recorded metrics\"",
|
||||
"skip_guidance": "WHEN TO SKIP\n------------\nSkip routine operations:\n- Empty status checks\n- Package installations with no errors\n- Simple file listings with no follow-on finding\n- Repetitive operations you've already documented\n- File related research that comes back empty or not found\n\nIf skipping, return an empty response only. Do not explain the skip in prose.",
|
||||
"type_guidance": "**type**: MUST be EXACTLY one of these 6 options (no other values allowed):\n - bugfix: something was broken, now fixed\n - feature: new capability or functionality added\n - refactor: code restructured, behavior unchanged\n - change: generic modification (docs, config, misc)\n - discovery: learning about existing system\n - decision: architectural/design choice with rationale",
|
||||
"concept_guidance": "**concepts**: 2-5 knowledge-type categories. MUST use ONLY these exact keywords:\n - how-it-works: understanding mechanisms\n - why-it-exists: purpose or rationale\n - what-changed: modifications made\n - problem-solution: issues and their fixes\n - gotcha: traps or edge cases\n - pattern: reusable approach\n - trade-off: pros/cons of a decision\n\n IMPORTANT: Do NOT include the observation type (change/discovery/decision) as a concept.\n Types and concepts are separate dimensions.",
|
||||
"field_guidance": "**facts**: Concise, self-contained statements\nEach fact is ONE piece of information\n No pronouns - each fact must stand alone\n Include specific details: filenames, functions, values\n\n**files**: All files touched (full paths from project root)",
|
||||
@@ -122,4 +122,4 @@
|
||||
"summary_format_instruction": "Respond in this XML format:",
|
||||
"summary_footer": "IMPORTANT! DO NOT do any work right now other than generating this next PROGRESS SUMMARY - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the summary content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful summary content.\n\nThank you, this summary will be very useful for keeping track of our progress!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"name": "Meme Token Trading",
|
||||
"description": "Solana memecoin activity monitoring, pump detection, and trading signal analysis",
|
||||
"version": "1.0.0",
|
||||
"observation_types": [
|
||||
{
|
||||
"id": "pump-detected",
|
||||
"label": "Pump Detected",
|
||||
"description": "Token showing rapid price increase with high trading activity (U/m surge, multi-timeframe gains)",
|
||||
"emoji": "🚀",
|
||||
"work_emoji": "📈"
|
||||
},
|
||||
{
|
||||
"id": "dump-detected",
|
||||
"label": "Dump Detected",
|
||||
"description": "Token showing rapid price decline, sell pressure, or activity collapse after a pump",
|
||||
"emoji": "💀",
|
||||
"work_emoji": "📉"
|
||||
},
|
||||
{
|
||||
"id": "signal-change",
|
||||
"label": "Signal Change",
|
||||
"description": "Token transitioning between signal tiers (FLAT/WATCH/RISING/STRONG) indicating momentum shift",
|
||||
"emoji": "🔄",
|
||||
"work_emoji": "📊"
|
||||
},
|
||||
{
|
||||
"id": "token-profile",
|
||||
"label": "Token Profile",
|
||||
"description": "Notable token characteristics: pool size, age, buy pressure pattern, liquidity ratio, repeat behavior",
|
||||
"emoji": "🪙",
|
||||
"work_emoji": "🔍"
|
||||
},
|
||||
{
|
||||
"id": "market-condition",
|
||||
"label": "Market Condition",
|
||||
"description": "Broad market state observation: lull, heating up, multiple pumps, activity distribution across tokens",
|
||||
"emoji": "🌡️",
|
||||
"work_emoji": "📊"
|
||||
},
|
||||
{
|
||||
"id": "algorithm-insight",
|
||||
"label": "Algorithm Insight",
|
||||
"description": "Observation about sorting behavior, signal accuracy, false positives, filter gaps, or ranking quality",
|
||||
"emoji": "⚙️",
|
||||
"work_emoji": "🔧"
|
||||
}
|
||||
],
|
||||
"observation_concepts": [
|
||||
{
|
||||
"id": "early-detection",
|
||||
"label": "Early Detection",
|
||||
"description": "Token caught before or during the initial pump phase"
|
||||
},
|
||||
{
|
||||
"id": "lifecycle",
|
||||
"label": "Lifecycle",
|
||||
"description": "Full pump-hold-dump cycle or multi-wave pattern observed"
|
||||
},
|
||||
{
|
||||
"id": "false-signal",
|
||||
"label": "False Signal",
|
||||
"description": "Token ranked high but not actually pumping, or filter/ranking issue"
|
||||
},
|
||||
{
|
||||
"id": "whale-activity",
|
||||
"label": "Whale Activity",
|
||||
"description": "Large buy pressure relative to pool size suggesting whale involvement"
|
||||
},
|
||||
{
|
||||
"id": "repeat-pumper",
|
||||
"label": "Repeat Pumper",
|
||||
"description": "Token that cycles through multiple pump-dump waves"
|
||||
},
|
||||
{
|
||||
"id": "dead-cat-bounce",
|
||||
"label": "Dead Cat Bounce",
|
||||
"description": "Brief recovery in a dumping token that tricks the ranking into surfacing it"
|
||||
},
|
||||
{
|
||||
"id": "sustained-momentum",
|
||||
"label": "Sustained Momentum",
|
||||
"description": "Token maintaining high activity and gains over extended period (5+ minutes)"
|
||||
}
|
||||
],
|
||||
"prompts": {
|
||||
"system_identity": "You are Claude-Mem, a specialized observer for Solana memecoin trading activity.\n\nCRITICAL: Record what is HAPPENING in the token market — pumps, dumps, signal transitions, market conditions, and algorithm behavior. Record token names, symbols, specific metrics (U/m, gains, buy pressure, pool size), and timing.\n\nYou do not have access to tools. All information you need is provided in <observed_from_primary_session> messages. Create observations from what you observe.",
|
||||
"spatial_awareness": "SPATIAL AWARENESS: You are observing a live token activity monitor connected to Jupiter DEX on Solana.\n- Tokens are ranked by updatesPerMinute (U/m) as the primary metric\n- Signal tiers: STRONG (45+ U/m), RISING (30+), WATCH (15+), FLAT (<15)\n- Key metrics: U/m, 1-5 minute price gains, buyPressure5m, liquidity pool size, token age\n- The sorting algorithm prioritizes activity (U/m) over price gains\n- Staleness decay: tokens with no updates for 5+ seconds get linearly decayed to 0 U/m over 10 seconds",
|
||||
"observer_role": "Your job is to monitor meme token trading activity happening RIGHT NOW, creating observations about pumps, dumps, market conditions, and algorithm behavior. You are tracking the HOT POTATO GAME — which tokens have the most trading activity and whether that activity leads to real price movement.",
|
||||
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on trading signals and market behavior:\n- Pump detection: token symbol, U/m, signal tier, price gains across timeframes, buy pressure, pool size\n- Dump detection: activity collapse, negative gains, sell pressure\n- Signal transitions: FLAT→WATCH→RISING→STRONG or reverse\n- Multi-wave pumps: tokens that pump, die, then pump again\n- Market conditions: how many STRONG/RISING tokens, overall activity level\n- Algorithm quality: false positives, tokens that shouldn't be ranked high, filter gaps\n- Buy pressure ratios: buyPressure5m relative to pool liquidity (high ratio = potential whale)\n\nALWAYS INCLUDE SPECIFIC NUMBERS:\n- U/m value and signal tier\n- Price gains (1m%, 2m%, 3m%, 4m%, 5m%)\n- Buy pressure dollar amount\n- Pool liquidity\n- Token age and discovery time\n\n✅ GOOD EXAMPLES:\n- \"MEMEMAN hit 58 U/m STRONG with +82.3% 3m gain, $2.5K buy pressure on $7K pool, discovered 5 minutes ago\"\n- \"Market in deep lull: no STRONG/RISING tokens, all FLAT at 1-9 U/m, only noise-level shuffling\"\n- \"思念熊 appeared for 8th time — repeat pumper cycling FLAT→WATCH→RISING then collapsing within 3 checks\"\n\n❌ BAD EXAMPLES:\n- \"Observed token activity and recorded findings\"\n- \"Monitored market conditions and logged results\"",
|
||||
"skip_guidance": "WHEN TO SKIP\n------------\nSkip these:\n- Routine checks with no notable changes from previous observation\n- Tokens at 1-2 U/m with 0% gains (background noise)\n- Repeat observations of the same token at the same signal tier with no meaningful metric change\n- Code file reads or edits (these are algorithm changes, not token observations)\n- **No output necessary if skipping.**",
|
||||
"type_guidance": "**type**: MUST be EXACTLY one of these 6 options (no other values allowed):\n - pump-detected: rapid price increase with high trading activity\n - dump-detected: rapid price decline, sell pressure, or activity collapse\n - signal-change: token transitioning between signal tiers (FLAT/WATCH/RISING/STRONG)\n - token-profile: notable token characteristics, patterns, or repeat behavior\n - market-condition: broad market state (lull, heating up, multiple pumps)\n - algorithm-insight: observation about sorting behavior, ranking quality, or filter gaps",
|
||||
"concept_guidance": "**concepts**: 2-5 knowledge-type categories. MUST use ONLY these exact keywords:\n - early-detection: token caught before or during initial pump\n - lifecycle: full pump-hold-dump cycle or multi-wave pattern\n - false-signal: token ranked high but not actually pumping\n - whale-activity: large buy pressure relative to pool size\n - repeat-pumper: token cycling through multiple pump-dump waves\n - dead-cat-bounce: brief recovery tricking the ranking\n - sustained-momentum: high activity and gains over 5+ minutes\n\n IMPORTANT: Do NOT include the observation type as a concept.\n Types and concepts are separate dimensions.",
|
||||
"field_guidance": "**facts**: Concise, self-contained statements about token activity\nEach fact is ONE piece of information\n No pronouns - each fact must stand alone\n ALWAYS include: token symbol, U/m, signal tier, specific gain percentages, buy pressure, pool size\n Include timing: when discovered, how long at current tier, which check number\n\n**files**: Leave empty for token observations (no files involved)",
|
||||
"output_format_header": "OUTPUT FORMAT\n-------------\nOutput observations using this XML structure:",
|
||||
"format_examples": "**Token Observation Examples:**\n\n<observation>\n <type>pump-detected</type>\n <title>SIMULAT Reaches RISING at 36 U/m With +45.5% 3m Gain</title>\n <subtitle>6-day-old token building sustained momentum over 5 consecutive checks since discovery at 6 U/m</subtitle>\n <facts>\n <fact>SIMULAT reached 36 U/m RISING signal tier at 10:33 PM</fact>\n <fact>SIMULAT price gains: +15.3% 1m, +33.9% 2m, +45.5% 3m</fact>\n <fact>SIMULAT buy pressure $4.8K on $4K pool (1.2:1 pressure-to-pool ratio)</fact>\n <fact>SIMULAT first detected at 6 U/m FLAT, promoted through WATCH to RISING over 4 minutes</fact>\n </facts>\n <narrative>SIMULAT demonstrated the ideal early-detection pattern for the activity-first algorithm. First appearing at 6 U/m with +15% 1m gain, it steadily built activity through WATCH to RISING over 4 minutes. The 1.2:1 buy-pressure-to-pool ratio suggests concentrated buying interest. This token was surfaced 4 minutes before its biggest price move.</narrative>\n <concepts><concept>early-detection</concept><concept>sustained-momentum</concept></concepts>\n <files></files>\n</observation>",
|
||||
"footer": "IMPORTANT! DO NOT do any work right now other than generating OBSERVATIONS from the token monitoring data.\n\nNever reference yourself or your own actions. Focus on what is happening in the market. Include specific numbers — U/m, gains, buy pressure, pool size — in every observation. Token observations without specific metrics are useless.\n\nThese observations help us understand which tokens pump, how the algorithm detects them, and what patterns emerge over time. Thank you!",
|
||||
|
||||
"xml_title_placeholder": "[Token Symbol + Key Metric Change, e.g. 'MEMEMAN Hits 58 U/m STRONG With +82% 3m Gain']",
|
||||
"xml_subtitle_placeholder": "[One sentence with timing and context (max 24 words)]",
|
||||
"xml_fact_placeholder": "[Token symbol + specific metric: U/m value, signal tier, gain %, buy pressure $, pool size $]",
|
||||
"xml_narrative_placeholder": "[**narrative**: What happened, how fast, what the metrics say about the move, and what it means for the algorithm's detection quality]",
|
||||
"xml_concept_placeholder": "[early-detection | lifecycle | false-signal | whale-activity | repeat-pumper | dead-cat-bounce | sustained-momentum]",
|
||||
"xml_file_placeholder": "",
|
||||
|
||||
"xml_summary_request_placeholder": "[Short title: time range + key market events, e.g. '10:18-10:48 PM — MEMEMAN triple pump, SIMULAT +85% slow build']",
|
||||
"xml_summary_investigated_placeholder": "[What tokens were tracked? How many checks performed? Total updates processed?]",
|
||||
"xml_summary_learned_placeholder": "[What patterns emerged? Which token archetypes appeared? How did the algorithm perform?]",
|
||||
"xml_summary_completed_placeholder": "[How long monitored? Key pumps detected? Algorithm changes deployed?]",
|
||||
"xml_summary_next_steps_placeholder": "[What to watch for next? Any algorithm improvements identified?]",
|
||||
"xml_summary_notes_placeholder": "[Market conditions, unusual patterns, algorithm edge cases observed]",
|
||||
|
||||
"header_memory_start": "TOKEN MONITORING START\n=======================",
|
||||
"header_memory_continued": "TOKEN MONITORING CONTINUED\n===========================",
|
||||
"header_summary_checkpoint": "MARKET SUMMARY CHECKPOINT\n===========================",
|
||||
|
||||
"continuation_greeting": "Hello memory agent, you are continuing to observe live meme token trading activity.",
|
||||
"continuation_instruction": "IMPORTANT: Continue generating observations from token monitoring data using the XML structure below. Focus on NEW pumps, dumps, signal changes, and market shifts since your last observation.",
|
||||
|
||||
"summary_instruction": "Write a market summary covering: tokens that pumped, tokens that dumped, market conditions (hot vs lull periods), algorithm performance, and any patterns observed. Include specific metrics for the most notable tokens. This is a checkpoint — the monitoring session is ongoing.",
|
||||
"summary_context_label": "Token Monitoring Data:",
|
||||
"summary_format_instruction": "Respond in this XML format:",
|
||||
"summary_footer": "IMPORTANT! DO NOT do any work right now other than generating this MARKET SUMMARY.\n\nNever reference yourself or your own actions. Focus on what happened in the token market. Include specific numbers. Thank you!"
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "10.7.0",
|
||||
"version": "12.2.0",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
@@ -14,7 +14,22 @@
|
||||
"tree-sitter-python": "^0.25.0",
|
||||
"tree-sitter-ruby": "^0.23.1",
|
||||
"tree-sitter-rust": "^0.24.0",
|
||||
"tree-sitter-typescript": "^0.23.2"
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-swift": "^0.7.1",
|
||||
"tree-sitter-php": "^0.24.2",
|
||||
"tree-sitter-elixir": "^0.3.5",
|
||||
"@tree-sitter-grammars/tree-sitter-lua": "^0.4.1",
|
||||
"tree-sitter-scala": "^0.24.0",
|
||||
"tree-sitter-bash": "^0.25.1",
|
||||
"tree-sitter-haskell": "^0.23.1",
|
||||
"@tree-sitter-grammars/tree-sitter-zig": "^1.1.2",
|
||||
"tree-sitter-css": "^0.25.0",
|
||||
"tree-sitter-scss": "^1.0.0",
|
||||
"@tree-sitter-grammars/tree-sitter-toml": "^0.7.0",
|
||||
"@tree-sitter-grammars/tree-sitter-yaml": "^0.7.1",
|
||||
"@derekstride/tree-sitter-sql": "^0.3.11",
|
||||
"@tree-sitter-grammars/tree-sitter-markdown": "^0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
Never read built source files in this directory. These are compiled outputs — read the source files in `src/` instead.
|
||||
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Dec 4, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #20052 | 3:23 PM | ✅ | Built and deployed version 6.5.2 to marketplace | ~321 |
|
||||
|
||||
### Dec 7, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21251 | 6:06 PM | 🔵 | Context Hook Plugin Architecture and Worker Communication | ~405 |
|
||||
|
||||
### Dec 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22092 | 6:40 PM | 🔵 | Queue Depth Check Not Found in Minified Code | ~217 |
|
||||
| #22091 | " | 🔵 | Save Hook Script Structure Revealed | ~472 |
|
||||
| #22085 | 6:34 PM | 🔵 | Examined pre-tool-use-hook.js implementation showing timing-only logic | ~330 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22557 | 1:08 AM | ✅ | Build completed for version 7.0.3 | ~342 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23444 | 2:25 PM | 🟣 | Build Pipeline Execution Successful | ~293 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #24057 | 2:56 PM | ✅ | Hook Scripts Shebang Verification | ~294 |
|
||||
| #24056 | 2:55 PM | ✅ | Worker CLI Shebang Verification | ~258 |
|
||||
| #24055 | " | ✅ | Build Successful with Bun Runtime Shebangs | ~355 |
|
||||
|
||||
### Dec 12, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #24636 | 10:46 PM | 🔵 | Duplicate Smart Install Scripts in Project Structure | ~288 |
|
||||
| #24635 | " | 🔵 | Claude-Mem Smart Install Script Architecture | ~371 |
|
||||
| #24359 | 7:00 PM | 🟣 | Phase 1 Critical Code Fixes Completed via Agent Task | ~441 |
|
||||
| #24358 | 6:59 PM | ✅ | Completed Phase 1 Code Fixes for better-sqlite3 Migration | ~385 |
|
||||
| #24357 | " | ✅ | Removed createRequire Import from smart-install.js | ~284 |
|
||||
| #24356 | " | ✅ | Removed Native Module Verification from main() Function | ~384 |
|
||||
| #24355 | " | ✅ | Removed better-sqlite3 Error Detection from runNpmInstall() | ~324 |
|
||||
| #24354 | 6:58 PM | ✅ | Removed getWindowsErrorHelp() Function from smart-install.js | ~356 |
|
||||
| #24353 | " | ✅ | Removed verifyNativeModules() Function from smart-install.js | ~340 |
|
||||
| #24352 | " | ✅ | Removed better-sqlite3 Existence Check from needsInstall() | ~266 |
|
||||
| #24351 | " | ✅ | Removed BETTER_SQLITE3_PATH Constant from smart-install.js | ~226 |
|
||||
| #24344 | 6:56 PM | 🔵 | smart-install.js Contains Obsolete better-sqlite3 Dependencies | ~380 |
|
||||
|
||||
### Dec 13, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #25286 | 8:41 PM | 🔵 | New Hook Fails with Node.js Path Error | ~298 |
|
||||
| #25285 | " | 🔵 | Context Hook Runs Successfully with Node.js | ~306 |
|
||||
| #25283 | " | 🔵 | Bun Wrapper Analysis: Fallback Detection System | ~416 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26800 | 11:39 PM | ✅ | Version 7.2.3 Build Complete With Worker Restart Fix | ~394 |
|
||||
| #26791 | 11:38 PM | ✅ | Phase 3 Complete: Project Built Successfully With Worker Restart Fix | ~446 |
|
||||
| #26720 | 11:23 PM | 🔵 | Smart Install Handles Dependencies But No Worker Coordination | ~468 |
|
||||
| #26719 | " | 🔵 | Worker CLI Provides Start/Stop/Restart Commands With Health Check Validation | ~490 |
|
||||
| #26718 | " | 🔵 | Worker CLI Restart Implementation Details | ~452 |
|
||||
| #26717 | 11:22 PM | 🔵 | Context Hook Worker Startup Logic Handles Initial Start But Not Post-Update Restart | ~485 |
|
||||
| #26716 | " | 🔵 | Context Hook Worker Startup Logic Revealed | ~538 |
|
||||
| #26715 | " | 🔵 | Smart Install Script Handles Dependency Installation Without Worker Restart | ~430 |
|
||||
| #26052 | 7:13 PM | 🔵 | Examined Minified Context Hook Source Code | ~285 |
|
||||
| #25686 | 4:22 PM | 🔵 | SessionRoutes tracks missing last_user_message errors at two different locations | ~456 |
|
||||
| #25685 | " | 🔵 | Progress summary generation system uses Claude to create XML-formatted session checkpoints | ~461 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27554 | 4:48 PM | ✅ | Project built successfully with version 7.3.1 | ~306 |
|
||||
|
||||
### Dec 17, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28924 | 7:29 PM | 🔵 | Plugin MCP Server Uses Bun Runtime | ~283 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32983 | 11:04 PM | 🟣 | Complete build and deployment pipeline executed | ~260 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36873 | 1:55 AM | 🔵 | Smart-Install Script Analyzed for Homebrew Path Implementation | ~466 |
|
||||
|
||||
### Jan 7, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38169 | 7:21 PM | 🔵 | SessionStart Hook Output Pattern Investigation Complete | ~464 |
|
||||
| #38168 | " | 🔵 | Smart-Install Script Outputs All Status Messages to stderr via console.error | ~438 |
|
||||
| #38167 | 7:20 PM | 🔵 | Context-Hook Uses stdin Event Handlers for Non-TTY JSON Output Mode | ~396 |
|
||||
| #38166 | " | 🔵 | User-Message-Hook Executes at Top Level with Await and Exit Code 1 | ~423 |
|
||||
| #38165 | " | 🔵 | Context-Hook Has Minimal Console Output in Compiled Code | ~333 |
|
||||
| #38164 | " | 🔵 | Worker-Service Script is Large 1575-Line Multi-Purpose Service Manager | ~352 |
|
||||
| #38163 | 7:19 PM | 🔵 | Worker-Service Script Uses console.log and console.error for Output | ~385 |
|
||||
| #38162 | " | 🔵 | Smart-Install Script Auto-Installs Bun and UV Dependencies | ~495 |
|
||||
| #38161 | " | 🔵 | User-Message-Hook Outputs to stderr and Exits with Code 1 | ~211 |
|
||||
| #38160 | 7:18 PM | 🔵 | Context-Hook Returns JSON with hookSpecificOutput Structure | ~470 |
|
||||
</claude-mem-context>
|
||||
@@ -47,14 +47,29 @@ function fixBrokenScriptPath(argPath) {
|
||||
* Find Bun executable - checks PATH first, then common install locations
|
||||
*/
|
||||
function findBun() {
|
||||
// Try PATH first
|
||||
const pathCheck = spawnSync(IS_WINDOWS ? 'where' : 'which', ['bun'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: IS_WINDOWS
|
||||
});
|
||||
// Try PATH first.
|
||||
// On Windows, pass a single command string to avoid Node 22+ DEP0190 deprecation warning
|
||||
// (triggered when an args array is combined with shell:true, as the args are only
|
||||
// concatenated, not escaped). Fixes #1503.
|
||||
const pathCheck = IS_WINDOWS
|
||||
? spawnSync('where bun', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: true
|
||||
})
|
||||
: spawnSync('which', ['bun'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
if (pathCheck.status === 0 && pathCheck.stdout.trim()) {
|
||||
// On Windows, prefer bun.cmd over bun (bun is a shell script, bun.cmd is the Windows batch file)
|
||||
if (IS_WINDOWS) {
|
||||
const bunCmdPath = pathCheck.stdout.split('\n').find(line => line.trim().endsWith('bun.cmd'));
|
||||
if (bunCmdPath) {
|
||||
return bunCmdPath.trim();
|
||||
}
|
||||
}
|
||||
return 'bun'; // Found in PATH
|
||||
}
|
||||
|
||||
@@ -152,17 +167,31 @@ const stdinData = await collectStdin();
|
||||
|
||||
// Spawn Bun with the provided script and args
|
||||
// Use spawn (not spawnSync) to properly handle stdio
|
||||
// Note: Don't use shell mode on Windows - it breaks paths with spaces in usernames
|
||||
// On Windows, use cmd.exe to execute bun.cmd since npm-installed bun is a batch file
|
||||
// Use windowsHide to prevent a visible console window from spawning on Windows
|
||||
const child = spawn(bunPath, args, {
|
||||
stdio: [stdinData ? 'pipe' : 'ignore', 'inherit', 'inherit'],
|
||||
const spawnOptions = {
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
windowsHide: true,
|
||||
env: process.env
|
||||
});
|
||||
};
|
||||
|
||||
// Write buffered stdin to child's pipe, then close it so the child sees EOF
|
||||
if (stdinData && child.stdin) {
|
||||
child.stdin.write(stdinData);
|
||||
let spawnCmd = bunPath;
|
||||
let spawnArgs = args;
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
// On Windows, bun.cmd must be executed via cmd /c
|
||||
spawnCmd = 'cmd';
|
||||
spawnArgs = ['/c', bunPath, ...args];
|
||||
}
|
||||
|
||||
const child = spawn(spawnCmd, spawnArgs, spawnOptions);
|
||||
|
||||
// Write buffered stdin to child's pipe, then close it so the child sees EOF.
|
||||
// Fall back to '{}' when no stdin data is available so worker-service.cjs
|
||||
// always receives valid JSON input even when Claude Code doesn't pipe stdin
|
||||
// (e.g. during SessionStart on some platforms). Fixes #1560.
|
||||
if (child.stdin) {
|
||||
child.stdin.write(stdinData || '{}');
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
@@ -171,6 +200,12 @@ child.on('error', (err) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
child.on('close', (code, signal) => {
|
||||
// Fix #1505: When the "start" subcommand forks a daemon, the parent bun
|
||||
// process may be killed by signal (e.g. SIGKILL, exit code 137). The daemon
|
||||
// is running fine — treat signal-based exits for "start" as success.
|
||||
if ((signal || code > 128) && args.includes('start')) {
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
+128
-48
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@
|
||||
* for both cache and marketplace installs), falling back to script location
|
||||
* and legacy paths.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, openSync, readSync, closeSync } from 'fs';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
@@ -449,7 +449,7 @@ function installDeps() {
|
||||
console.error('⚠️ Bun install failed, falling back to npm...');
|
||||
console.error(' (This can happen with npm alias packages like *-cjs)');
|
||||
try {
|
||||
execSync('npm install', { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
execSync('npm install --legacy-peer-deps', { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
} catch (npmError) {
|
||||
throw new Error('Both bun and npm install failed: ' + npmError.message);
|
||||
}
|
||||
@@ -490,6 +490,56 @@ function verifyCriticalModules() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mach-O 64-bit magic values as seen when reading the first 4 file bytes with readUInt32LE.
|
||||
// Native arm64/x86_64 Mach-O files start with bytes [CF FA ED FE]; readUInt32LE gives 0xFEEDFACF.
|
||||
// Byte-swapped (big-endian) Mach-O files start with bytes [FE ED FA CF]; readUInt32LE gives 0xCFFAEDFE.
|
||||
const MACHO_MAGIC_NATIVE = 0xFEEDFACF; // native 64-bit (arm64/x86_64) — file bytes CF FA ED FE
|
||||
const MACHO_MAGIC_SWAPPED = 0xCFFAEDFE; // byte-swapped 64-bit — file bytes FE ED FA CF
|
||||
|
||||
/**
|
||||
* Warn when the bundled claude-mem binary cannot run on the current platform.
|
||||
*
|
||||
* The committed binary (plugin/scripts/claude-mem) is compiled for macOS arm64.
|
||||
* On Linux or Windows it produces "Exec format error" and silently fails.
|
||||
* This check surfaces the incompatibility at install time so users know why
|
||||
* the binary path doesn't work, and confirms the JS fallback (bun-runner.js →
|
||||
* worker-service.cjs) is active and covers all functionality.
|
||||
*
|
||||
* Fixes #1547 — Plugin silently fails on Linux ARM64.
|
||||
*/
|
||||
export function checkBinaryPlatformCompatibility(binaryPath = join(ROOT, 'scripts', 'claude-mem')) {
|
||||
|
||||
if (!existsSync(binaryPath)) {
|
||||
return; // Binary absent — nothing to check (e.g. after npm install which excludes it)
|
||||
}
|
||||
|
||||
// The binary only matters on non-macOS platforms; on macOS it works correctly.
|
||||
if (process.platform === 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the first 4 bytes to identify the binary format.
|
||||
let fd;
|
||||
try {
|
||||
const buf = Buffer.alloc(4);
|
||||
fd = openSync(binaryPath, 'r');
|
||||
readSync(fd, buf, 0, 4, 0);
|
||||
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic === MACHO_MAGIC_NATIVE || magic === MACHO_MAGIC_SWAPPED) {
|
||||
console.error('⚠️ Platform notice: The bundled claude-mem binary is macOS-only.');
|
||||
console.error(` Current platform: ${process.platform} ${process.arch}`);
|
||||
console.error(' The binary will not execute on this platform.');
|
||||
console.error(' Plugin functionality is provided by the JS fallback');
|
||||
console.error(' (bun-runner.js → worker-service.cjs) which works on all platforms.');
|
||||
}
|
||||
} catch {
|
||||
// Unreadable binary — not critical, skip silently
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
try {
|
||||
// Step 1: Ensure Bun is installed and meets minimum version (REQUIRED)
|
||||
@@ -546,7 +596,7 @@ try {
|
||||
if (!verifyCriticalModules()) {
|
||||
console.error('⚠️ Retrying install with npm...');
|
||||
try {
|
||||
execSync('npm install --production', { cwd: ROOT, stdio: ['pipe', 'pipe', 'inherit'], shell: IS_WINDOWS });
|
||||
execSync('npm install --production --legacy-peer-deps', { cwd: ROOT, stdio: ['pipe', 'pipe', 'inherit'], shell: IS_WINDOWS });
|
||||
} catch {
|
||||
// npm also failed
|
||||
}
|
||||
@@ -582,6 +632,9 @@ try {
|
||||
// Step 4: Install CLI to PATH
|
||||
installCLI();
|
||||
|
||||
// Step 5: Warn if the bundled native binary is incompatible with this platform
|
||||
checkBinaryPlatformCompatibility();
|
||||
|
||||
// Output valid JSON for Claude Code hook contract
|
||||
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||
} catch (e) {
|
||||
|
||||
+488
-301
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: knowledge-agent
|
||||
description: Build and query AI-powered knowledge bases from claude-mem observations. Use when users want to create focused "brains" from their observation history, ask questions about past work patterns, or compile expertise on specific topics.
|
||||
---
|
||||
|
||||
# Knowledge Agent
|
||||
|
||||
Build and query AI-powered knowledge bases from claude-mem observations.
|
||||
|
||||
## What Are Knowledge Agents?
|
||||
|
||||
Knowledge agents are filtered corpora of observations compiled into a conversational AI session. Build a corpus from your observation history, prime it (loads the knowledge into an AI session), then ask it questions conversationally.
|
||||
|
||||
Think of them as custom "brains": "everything about hooks", "all decisions from the last month", "all bugfixes for the worker service".
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Build a corpus
|
||||
|
||||
```text
|
||||
build_corpus name="hooks-expertise" description="Everything about the hooks lifecycle" project="claude-mem" concepts="hooks" limit=500
|
||||
```
|
||||
|
||||
Filter options:
|
||||
- `project` — filter by project name
|
||||
- `types` — comma-separated: decision, bugfix, feature, refactor, discovery, change
|
||||
- `concepts` — comma-separated concept tags
|
||||
- `files` — comma-separated file paths (prefix match)
|
||||
- `query` — semantic search query
|
||||
- `dateStart` / `dateEnd` — ISO date range
|
||||
- `limit` — max observations (default 500)
|
||||
|
||||
### Step 2: Prime the corpus
|
||||
|
||||
```text
|
||||
prime_corpus name="hooks-expertise"
|
||||
```
|
||||
|
||||
This creates an AI session loaded with all the corpus knowledge. Takes a moment for large corpora.
|
||||
|
||||
### Step 3: Query
|
||||
|
||||
```text
|
||||
query_corpus name="hooks-expertise" question="What are the 5 lifecycle hooks and when does each fire?"
|
||||
```
|
||||
|
||||
The knowledge agent answers from its corpus. Follow-up questions maintain context.
|
||||
|
||||
### Step 4: List corpora
|
||||
|
||||
```text
|
||||
list_corpora
|
||||
```
|
||||
|
||||
Shows all corpora with stats and priming status.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Focused corpora work best** — "hooks architecture" beats "everything ever"
|
||||
- **Prime once, query many times** — the session persists across queries
|
||||
- **Reprime for fresh context** — if the conversation drifts, reprime to reset
|
||||
- **Rebuild to update** — when new observations are added, rebuild then reprime
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Rebuild a corpus (refresh with new observations)
|
||||
|
||||
```text
|
||||
rebuild_corpus name="hooks-expertise"
|
||||
```
|
||||
|
||||
After rebuilding, reprime to load the updated knowledge:
|
||||
|
||||
### Reprime (fresh session)
|
||||
|
||||
```text
|
||||
reprime_corpus name="hooks-expertise"
|
||||
```
|
||||
|
||||
Clears prior Q&A context and reloads the corpus into a new session.
|
||||
@@ -125,3 +125,7 @@ get_observations(ids=[11131, 10942, 10855], orderBy="date_desc")
|
||||
- **Full observation:** ~500-1000 tokens each
|
||||
- **Batch fetch:** 1 HTTP request vs N individual requests
|
||||
- **10x token savings** by filtering before fetching
|
||||
|
||||
## Knowledge Agents
|
||||
|
||||
Want synthesized answers instead of raw records? Use `/knowledge-agent` to build a queryable corpus from your observation history. The knowledge agent reads all matching observations and answers questions conversationally.
|
||||
|
||||
@@ -143,3 +143,48 @@ Use smart_* tools for code exploration, Read for non-code files. Mix freely.
|
||||
| Explore agent | ~39,000-59,000 | Cross-file synthesis with narrative |
|
||||
|
||||
**4-8x savings** on file understanding (outline + unfold vs Read). **11-18x savings** on codebase exploration vs Explore agent. The narrower the query, the wider the gap — a 27-line function costs 55x less to read via unfold than via an Explore agent, because the agent still reads the entire file.
|
||||
|
||||
## Language Support
|
||||
|
||||
Smart-explore uses **tree-sitter AST parsing** for structural analysis. Unsupported file types fall back to text-based search.
|
||||
|
||||
### Bundled Languages
|
||||
|
||||
| Language | Extensions |
|
||||
|----------|-----------|
|
||||
| JavaScript | `.js`, `.mjs`, `.cjs` |
|
||||
| TypeScript | `.ts` |
|
||||
| TSX / JSX | `.tsx`, `.jsx` |
|
||||
| Python | `.py`, `.pyw` |
|
||||
| Go | `.go` |
|
||||
| Rust | `.rs` |
|
||||
| Ruby | `.rb` |
|
||||
| Java | `.java` |
|
||||
| C | `.c`, `.h` |
|
||||
| C++ | `.cpp`, `.cc`, `.cxx`, `.hpp`, `.hh` |
|
||||
|
||||
Files with unrecognized extensions are parsed as plain text — `smart_search` still works (grep-style), but `smart_outline` and `smart_unfold` will not extract structured symbols.
|
||||
|
||||
### Custom Grammars (`.claude-mem.json`)
|
||||
|
||||
You can register additional tree-sitter grammars for file types not in the bundled list. Create or update `.claude-mem.json` in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"grammars": {
|
||||
".sol": "tree-sitter-solidity",
|
||||
".graphql": "tree-sitter-graphql"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each key is a file extension; each value is the npm package name of the tree-sitter grammar. The grammar must be installed locally (`npm install tree-sitter-solidity`). Once registered, `smart_outline` and `smart_unfold` will parse those extensions structurally instead of falling back to plain text.
|
||||
|
||||
### Markdown Special Support
|
||||
|
||||
Markdown files (`.md`, `.mdx`) receive special handling beyond the generic plain-text fallback:
|
||||
|
||||
- **`smart_outline`** — extracts headings (`#`, `##`, `###`) as the symbol tree. Use it to navigate long documents without reading the full file.
|
||||
- **`smart_search`** — searches within code fences as well as prose, so queries for function names inside ` ```ts ``` ` blocks work as expected.
|
||||
- **`smart_unfold`** — expands heading sections rather than function bodies; each section up to the next same-level heading is returned as a chunk.
|
||||
- **Frontmatter** — YAML frontmatter (lines between leading `---` delimiters) is included in `smart_outline` output under a synthetic `frontmatter` symbol so metadata like `title:` and `description:` is visible without reading the whole file.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Nov 5, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #3910 | 8:28 PM | ✅ | Refined stats counter visual design | ~343 |
|
||||
| #3909 | " | 🟣 | Added clarifying descriptions to settings UI | ~335 |
|
||||
| #3812 | 6:08 PM | 🟣 | Enhanced card typography and centered content layout | ~358 |
|
||||
|
||||
### Nov 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5133 | 7:29 PM | ✅ | Version 5.2.3 Released with Build Process | ~487 |
|
||||
| #4916 | 1:49 PM | ⚖️ | Claude Mem Pro Premium Offering Implementation Plan Finalized | ~946 |
|
||||
| #4902 | 1:35 PM | 🟣 | Claude Mem Pro Premium Project Initialization | ~679 |
|
||||
| #4901 | 1:31 PM | ⚖️ | Premium claude-mem Project Architecture and Planning | ~797 |
|
||||
|
||||
### Dec 1, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #18480 | 3:39 PM | ✅ | Successfully Rebuilt Plugin After Merge Conflict Resolution | ~294 |
|
||||
|
||||
### Dec 4, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #20052 | 3:23 PM | ✅ | Built and deployed version 6.5.2 to marketplace | ~321 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22557 | 1:08 AM | ✅ | Build completed for version 7.0.3 | ~342 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23444 | 2:25 PM | 🟣 | Build Pipeline Execution Successful | ~293 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27554 | 4:48 PM | ✅ | Project built successfully with version 7.3.1 | ~306 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32983 | 11:04 PM | 🟣 | Complete build and deployment pipeline executed | ~260 |
|
||||
| #32965 | 10:53 PM | 🔵 | Found plugin/ui/viewer.html - potential styling source | ~201 |
|
||||
| #32966 | " | 🔵 | viewer.html contains modal CSS including modal-header and modal-body | ~218 |
|
||||
| #32967 | " | 🔵 | ContextSettingsModal.tsx uses CSS classes defined in viewer.html | ~218 |
|
||||
| #32968 | " | 🔵 | Need to add CSS for footer to viewer.html | ~223 |
|
||||
</claude-mem-context>
|
||||
File diff suppressed because one or more lines are too long
+137
-1
@@ -355,6 +355,14 @@
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
@@ -549,6 +557,42 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tab:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
border-color: var(--color-border-focus);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.source-tab.active {
|
||||
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
|
||||
border-color: var(--color-bg-button);
|
||||
color: var(--color-text-button);
|
||||
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.18);
|
||||
}
|
||||
|
||||
.settings-btn,
|
||||
.theme-toggle-btn {
|
||||
background: var(--color-bg-card);
|
||||
@@ -887,6 +931,49 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-source {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.source-claude {
|
||||
background: rgba(255, 138, 61, 0.12);
|
||||
color: #c25a00;
|
||||
border-color: rgba(255, 138, 61, 0.22);
|
||||
}
|
||||
|
||||
.source-codex {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
color: #0f5ba7;
|
||||
border-color: rgba(33, 150, 243, 0.24);
|
||||
}
|
||||
|
||||
.source-cursor {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
color: #6d28d9;
|
||||
border-color: rgba(124, 58, 237, 0.24);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-claude {
|
||||
color: #ffb067;
|
||||
border-color: rgba(255, 176, 103, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-codex {
|
||||
color: #8fc7ff;
|
||||
border-color: rgba(143, 199, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-cursor {
|
||||
color: #c4b5fd;
|
||||
border-color: rgba(196, 181, 253, 0.2);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
margin-bottom: 14px;
|
||||
@@ -1043,6 +1130,19 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Merged-into-parent provenance badge */
|
||||
.card-merged-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-type-badge-bg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border-color: var(--color-border-summary);
|
||||
background: var(--color-bg-summary);
|
||||
@@ -1483,6 +1583,10 @@
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1491,6 +1595,11 @@
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Hide icon links (docs, github, twitter) on tablet */
|
||||
.icon-link {
|
||||
display: none;
|
||||
@@ -1544,6 +1653,28 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.source-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logomark {
|
||||
height: 28px;
|
||||
}
|
||||
@@ -1732,6 +1863,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-selector select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-selector select {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
@@ -2873,4 +3009,4 @@
|
||||
<script src="viewer-bundle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Dec 19, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30153 | 8:24 PM | 🔵 | Context Builder Creates Formatted Email Investigation Context | ~384 |
|
||||
| #30152 | " | 🔵 | Ragtime Current Implementation: Manual Context Injection Via buildContextForEmail | ~357 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30437 | 4:23 PM | 🔵 | Ragtime processes emails through Claude Agent SDK with claude-mem plugin | ~397 |
|
||||
| #30436 | 4:22 PM | 🔵 | Ragtime displays worker URL on localhost:37777 | ~219 |
|
||||
| #30340 | 3:42 PM | 🔄 | Relocated simple ragtime.ts to ragtime folder | ~219 |
|
||||
| #30339 | 3:41 PM | ✅ | Deleted overengineered ragtime.ts script | ~201 |
|
||||
| #30336 | 3:40 PM | 🔵 | Ragtime Email Corpus Processor Architecture | ~495 |
|
||||
| #30335 | " | 🔵 | Ragtime Uses Separate Noncommercial License | ~259 |
|
||||
| #30252 | 3:17 PM | 🟣 | Multi-Format Email Corpus Loader | ~436 |
|
||||
</claude-mem-context>
|
||||
@@ -1 +0,0 @@
|
||||
Never read built source files in this directory. These are compiled outputs — read the source files in `src/` instead.
|
||||
@@ -1,137 +0,0 @@
|
||||
# Error Handling Anti-Pattern Rules
|
||||
|
||||
This folder contains `detect-error-handling-antipatterns.ts` - run it before committing any error handling changes.
|
||||
|
||||
## The Try-Catch Problem That Cost 10 Hours
|
||||
|
||||
A single overly-broad try-catch block wasted 10 hours of debugging time by silently swallowing errors.
|
||||
**This pattern is BANNED.**
|
||||
|
||||
## BEFORE You Write Any Try-Catch
|
||||
|
||||
**RUN THIS TEST FIRST:**
|
||||
```bash
|
||||
bun run scripts/anti-pattern-test/detect-error-handling-antipatterns.ts
|
||||
```
|
||||
|
||||
**You MUST answer these 5 questions to the user BEFORE writing try-catch:**
|
||||
|
||||
1. **What SPECIFIC error am I catching?** (Name the error type: `FileNotFoundError`, `NetworkTimeout`, `ValidationError`)
|
||||
2. **Show documentation proving this error can occur** (Link to docs or show me the source code)
|
||||
3. **Why can't this error be prevented?** (If it can be prevented, prevent it instead)
|
||||
4. **What will the catch block DO?** (Must include logging + either rethrow OR explicit fallback)
|
||||
5. **Why shouldn't this error propagate?** (Justify swallowing it rather than letting caller handle)
|
||||
|
||||
**If you cannot answer ALL 5 questions with specifics, DO NOT write the try-catch.**
|
||||
|
||||
## FORBIDDEN PATTERNS (Zero Tolerance)
|
||||
|
||||
### CRITICAL - Never Allowed
|
||||
|
||||
```typescript
|
||||
// FORBIDDEN: Empty catch
|
||||
try {
|
||||
doSomething();
|
||||
} catch {}
|
||||
|
||||
// FORBIDDEN: Catch without logging
|
||||
try {
|
||||
doSomething();
|
||||
} catch (error) {
|
||||
return null; // Silent failure!
|
||||
}
|
||||
|
||||
// FORBIDDEN: Large try blocks (>10 lines)
|
||||
try {
|
||||
// 50 lines of code
|
||||
// Multiple operations
|
||||
// Different failure modes
|
||||
} catch (error) {
|
||||
logger.error('Something failed'); // Which thing?!
|
||||
}
|
||||
|
||||
// FORBIDDEN: Promise empty catch
|
||||
promise.catch(() => {}); // Error disappears into void
|
||||
|
||||
// FORBIDDEN: Try-catch to fix TypeScript errors
|
||||
try {
|
||||
// @ts-ignore
|
||||
const value = response.propertyThatDoesntExist;
|
||||
} catch {}
|
||||
```
|
||||
|
||||
### ALLOWED Patterns
|
||||
|
||||
```typescript
|
||||
// GOOD: Specific, logged, explicit handling
|
||||
try {
|
||||
await fetch(url);
|
||||
} catch (error) {
|
||||
if (error instanceof NetworkError) {
|
||||
logger.warn('SYNC', 'Network request failed, will retry', { url }, error);
|
||||
return null; // Explicit: null means "fetch failed"
|
||||
}
|
||||
throw error; // Unexpected errors propagate
|
||||
}
|
||||
|
||||
// GOOD: Minimal scope, clear recovery
|
||||
try {
|
||||
JSON.parse(data);
|
||||
} catch (error) {
|
||||
logger.error('CONFIG', 'Corrupt settings file, using defaults', {}, error);
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
// GOOD: Fire-and-forget with logging
|
||||
backgroundTask()
|
||||
.catch(error => logger.warn('BACKGROUND', 'Task failed', {}, error));
|
||||
|
||||
// GOOD: Ignored anti-pattern for genuine hot paths only
|
||||
try {
|
||||
checkIfProcessAlive(pid);
|
||||
} catch (error) {
|
||||
// [ANTI-PATTERN IGNORED]: Tight loop checking 100s of PIDs during cleanup
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## Ignoring Anti-Patterns (Rare)
|
||||
|
||||
**Only for genuine hot paths** where logging would cause performance problems:
|
||||
|
||||
```typescript
|
||||
// [ANTI-PATTERN IGNORED]: Reason why logging is impossible
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- **Hot paths only** - code in tight loops called 1000s of times
|
||||
- If you can add logging, ADD LOGGING - don't ignore
|
||||
- Valid examples:
|
||||
- "Tight loop checking process exit status during cleanup"
|
||||
- "Health check polling every 100ms"
|
||||
- Invalid examples:
|
||||
- "Expected JSON parse failures" - Just add logger.debug
|
||||
- "Common fallback path" - Just add logger.debug
|
||||
|
||||
## The Meta-Rule
|
||||
|
||||
**UNCERTAINTY TRIGGERS RESEARCH, NOT TRY-CATCH**
|
||||
|
||||
When you're unsure if a property exists or a method signature is correct:
|
||||
1. **READ** the source code or documentation
|
||||
2. **VERIFY** with the Read tool
|
||||
3. **USE** TypeScript types to catch errors at compile time
|
||||
4. **WRITE** code you KNOW is correct
|
||||
|
||||
Never use try-catch to paper over uncertainty. That wastes hours of debugging time later.
|
||||
|
||||
## Critical Path Protection
|
||||
|
||||
These files are **NEVER** allowed to have catch-and-continue:
|
||||
- `SDKAgent.ts` - Errors must propagate, not hide
|
||||
- `GeminiAgent.ts` - Must fail loud, not silent
|
||||
- `OpenRouterAgent.ts` - Must fail loud, not silent
|
||||
- `SessionStore.ts` - Database errors must propagate
|
||||
- `worker-service.ts` - Core service errors must be visible
|
||||
|
||||
On critical paths, prefer **NO TRY-CATCH** and let errors propagate naturally.
|
||||
@@ -27,6 +27,48 @@ const CONTEXT_GENERATOR = {
|
||||
source: 'src/services/context-generator.ts'
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip hardcoded __dirname/__filename from bundled CJS output.
|
||||
*
|
||||
* When esbuild converts ESM TypeScript source to CJS format, it inlines
|
||||
* __dirname and __filename as static strings based on the SOURCE file paths
|
||||
* at build time. These `var __dirname = "/build/machine/path/..."` declarations
|
||||
* shadow the runtime's native __dirname (provided by Bun/Node's CJS module
|
||||
* wrapper), causing path resolution to fail on end-user machines.
|
||||
*
|
||||
* This post-build step removes those hardcoded assignments so the runtime
|
||||
* globals are used instead.
|
||||
*
|
||||
* See: https://github.com/thedotmack/claude-mem/issues/1410
|
||||
*/
|
||||
function stripHardcodedDirname(filePath) {
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
const before = content.length;
|
||||
|
||||
// Match both double-quoted and single-quoted string literals.
|
||||
// esbuild currently emits double quotes, but single quotes are handled
|
||||
// defensively in case future versions change quoting style.
|
||||
const str = `(?:"[^"]*"|'[^']*')`;
|
||||
|
||||
for (const id of ['__dirname', '__filename']) {
|
||||
// Remove `var <id> = "...", rest` → `var rest`
|
||||
content = content.replace(new RegExp(`\\bvar ${id}\\s*=\\s*${str},\\s*`, 'g'), 'var ');
|
||||
// Remove standalone `var <id> = "...";`
|
||||
content = content.replace(new RegExp(`\\bvar ${id}\\s*=\\s*${str};\\s*`, 'g'), '');
|
||||
// Remove `, <id> = "..."` from mid/end of var declarations
|
||||
content = content.replace(new RegExp(`,\\s*${id}\\s*=\\s*${str}`, 'g'), '');
|
||||
}
|
||||
|
||||
// Clean up dangling `var ;` left when __dirname was the sole declarator
|
||||
content = content.replace(/\bvar\s*;/g, '');
|
||||
|
||||
const removed = before - content.length;
|
||||
if (removed > 0) {
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(` ✓ Stripped hardcoded __dirname/__filename paths (${removed} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHooks() {
|
||||
console.log('🔨 Building claude-mem hooks and worker service...\n');
|
||||
|
||||
@@ -69,6 +111,21 @@ async function buildHooks() {
|
||||
'tree-sitter-ruby': '^0.23.1',
|
||||
'tree-sitter-rust': '^0.24.0',
|
||||
'tree-sitter-typescript': '^0.23.2',
|
||||
'tree-sitter-kotlin': '^0.3.8',
|
||||
'tree-sitter-swift': '^0.7.1',
|
||||
'tree-sitter-php': '^0.24.2',
|
||||
'tree-sitter-elixir': '^0.3.5',
|
||||
'@tree-sitter-grammars/tree-sitter-lua': '^0.4.1',
|
||||
'tree-sitter-scala': '^0.24.0',
|
||||
'tree-sitter-bash': '^0.25.1',
|
||||
'tree-sitter-haskell': '^0.23.1',
|
||||
'@tree-sitter-grammars/tree-sitter-zig': '^1.1.2',
|
||||
'tree-sitter-css': '^0.25.0',
|
||||
'tree-sitter-scss': '^1.0.0',
|
||||
'@tree-sitter-grammars/tree-sitter-toml': '^0.7.0',
|
||||
'@tree-sitter-grammars/tree-sitter-yaml': '^0.7.1',
|
||||
'@derekstride/tree-sitter-sql': '^0.3.11',
|
||||
'@tree-sitter-grammars/tree-sitter-markdown': '^0.3.2',
|
||||
},
|
||||
engines: {
|
||||
node: '>=18.0.0',
|
||||
@@ -124,6 +181,9 @@ async function buildHooks() {
|
||||
}
|
||||
});
|
||||
|
||||
// Fix hardcoded __dirname/__filename in bundled output (#1410)
|
||||
stripHardcodedDirname(`${hooksDir}/${WORKER_SERVICE.name}.cjs`);
|
||||
|
||||
// Make worker service executable
|
||||
fs.chmodSync(`${hooksDir}/${WORKER_SERVICE.name}.cjs`, 0o755);
|
||||
const workerStats = fs.statSync(`${hooksDir}/${WORKER_SERVICE.name}.cjs`);
|
||||
@@ -152,6 +212,21 @@ async function buildHooks() {
|
||||
'tree-sitter-java',
|
||||
'tree-sitter-c',
|
||||
'tree-sitter-cpp',
|
||||
'tree-sitter-kotlin',
|
||||
'tree-sitter-swift',
|
||||
'tree-sitter-php',
|
||||
'tree-sitter-elixir',
|
||||
'@tree-sitter-grammars/tree-sitter-lua',
|
||||
'tree-sitter-scala',
|
||||
'tree-sitter-bash',
|
||||
'tree-sitter-haskell',
|
||||
'@tree-sitter-grammars/tree-sitter-zig',
|
||||
'tree-sitter-css',
|
||||
'tree-sitter-scss',
|
||||
'@tree-sitter-grammars/tree-sitter-toml',
|
||||
'@tree-sitter-grammars/tree-sitter-yaml',
|
||||
'@derekstride/tree-sitter-sql',
|
||||
'@tree-sitter-grammars/tree-sitter-markdown',
|
||||
],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
@@ -161,11 +236,50 @@ async function buildHooks() {
|
||||
}
|
||||
});
|
||||
|
||||
// Fix hardcoded __dirname/__filename in bundled output (#1410)
|
||||
stripHardcodedDirname(`${hooksDir}/${MCP_SERVER.name}.cjs`);
|
||||
|
||||
// Make MCP server executable
|
||||
fs.chmodSync(`${hooksDir}/${MCP_SERVER.name}.cjs`, 0o755);
|
||||
const mcpServerStats = fs.statSync(`${hooksDir}/${MCP_SERVER.name}.cjs`);
|
||||
console.log(`✓ mcp-server built (${(mcpServerStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// GUARDRAIL (#1645): The MCP server runs under Node, but the entire `bun:`
|
||||
// module namespace (bun:sqlite, bun:ffi, bun:test, etc.) is Bun-only. If
|
||||
// any transitive import in mcp-server.ts ever pulls one in, the bundle
|
||||
// will crash on first require under Node — which is exactly the regression
|
||||
// PR #1645 fixed for `bun:sqlite`. Fail the build instead of shipping a
|
||||
// broken bundle so future contributors get an immediate signal.
|
||||
//
|
||||
// Only flag actual `require("bun:...")` / `require('bun:...')` calls, not
|
||||
// the bare string — error messages and inline comments may legitimately
|
||||
// mention `bun:sqlite` by name without re-introducing the import.
|
||||
const mcpBundleContent = fs.readFileSync(`${hooksDir}/${MCP_SERVER.name}.cjs`, 'utf-8');
|
||||
const bunRequireRegex = /require\(\s*["']bun:[a-z][a-z0-9_-]*["']\s*\)/;
|
||||
const bunRequireMatch = mcpBundleContent.match(bunRequireRegex);
|
||||
if (bunRequireMatch) {
|
||||
throw new Error(
|
||||
`mcp-server.cjs contains a Bun-only ${bunRequireMatch[0]} call. This means a transitive import in src/servers/mcp-server.ts pulled in code from worker-service.ts (or another module that touches DatabaseManager/ChromaSync). The MCP server runs under Node and cannot load bun:* modules. Audit recent imports in src/servers/mcp-server.ts and src/services/worker-spawner.ts — the spawner module is intentionally lightweight and MUST NOT import anything that touches SQLite or other Bun-only modules. See PR #1645 for context.`
|
||||
);
|
||||
}
|
||||
|
||||
// SECONDARY GUARDRAIL (#1645 round 11): bundle size budget. The bun:sqlite
|
||||
// regex above catches the specific regression class we already know about,
|
||||
// but esbuild could in theory change how it emits external module specifiers
|
||||
// and silently slip past the regex. A bundle-size budget catches the
|
||||
// structural symptom (worker-service.ts dragged into the bundle blew the
|
||||
// size from ~358KB to ~1.96MB) regardless of how the imports look.
|
||||
//
|
||||
// 600KB is a generous ceiling — current size is ~384KB, the broken v12.0.0
|
||||
// bundle was ~1920KB, and there's plenty of headroom for legitimate growth
|
||||
// before we'd want to revisit this number.
|
||||
const MCP_SERVER_MAX_BYTES = 600 * 1024;
|
||||
if (mcpServerStats.size > MCP_SERVER_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`mcp-server.cjs is ${(mcpServerStats.size / 1024).toFixed(2)} KB, exceeding the ${(MCP_SERVER_MAX_BYTES / 1024).toFixed(0)} KB budget. This usually means a transitive import pulled worker-service.ts (or another heavy module) into the MCP bundle. The MCP server is supposed to be a thin HTTP wrapper — audit recent imports in src/servers/mcp-server.ts and src/services/worker-spawner.ts. See PR #1645 for context on why this guardrail exists.`
|
||||
);
|
||||
}
|
||||
|
||||
// Build context generator
|
||||
console.log(`\n🔧 Building context generator...`);
|
||||
await build({
|
||||
@@ -184,6 +298,9 @@ async function buildHooks() {
|
||||
// No banner needed: CJS files under Node.js have __dirname/__filename natively
|
||||
});
|
||||
|
||||
// Fix hardcoded __dirname/__filename in bundled output (#1410)
|
||||
stripHardcodedDirname(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
||||
|
||||
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
||||
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/bin/bash
|
||||
# claude-mem-sync — Synchronize claude-mem observations between machines
|
||||
#
|
||||
# Usage:
|
||||
# claude-mem-sync push <remote-host> # local → remote
|
||||
# claude-mem-sync pull <remote-host> # remote → local
|
||||
# claude-mem-sync sync <remote-host> # bidirectional (push + pull)
|
||||
# claude-mem-sync status <remote-host> # compare counts
|
||||
#
|
||||
# Prerequisites:
|
||||
# - SSH access to remote host (key-based auth recommended)
|
||||
# - Python 3 on both machines
|
||||
# - claude-mem installed on both machines (~/.claude-mem/claude-mem.db)
|
||||
#
|
||||
# Environment variables:
|
||||
# CLAUDE_MEM_DB Local database path (default: ~/.claude-mem/claude-mem.db)
|
||||
# CLAUDE_MEM_REMOTE_DB Remote database path (default: ~/.claude-mem/claude-mem.db)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOCAL_DB="${CLAUDE_MEM_DB:-$HOME/.claude-mem/claude-mem.db}"
|
||||
COMMAND="${1:?Usage: claude-mem-sync <push|pull|sync|status> <remote-host>}"
|
||||
REMOTE_HOST="${2:?Missing remote host. Usage: claude-mem-sync $COMMAND <remote-host>}"
|
||||
REMOTE_DB="${CLAUDE_MEM_REMOTE_DB:-\$HOME/.claude-mem/claude-mem.db}"
|
||||
TMPDIR="/tmp/claude-mem-sync-$$"
|
||||
|
||||
mkdir -p "$TMPDIR"
|
||||
trap "rm -rf $TMPDIR" EXIT
|
||||
|
||||
# Column lists for observations and session_summaries
|
||||
OBS_COLS="memory_session_id,project,text,type,title,subtitle,facts,narrative,concepts,files_read,files_modified,prompt_number,discovery_tokens,created_at,created_at_epoch"
|
||||
SUM_COLS="memory_session_id,project,request,investigated,learned,completed,next_steps,files_read,files_edited,notes,prompt_number,discovery_tokens,created_at,created_at_epoch"
|
||||
|
||||
export_obs() {
|
||||
local db="$1" output="$2"
|
||||
python3 -c "
|
||||
import sqlite3, json, sys
|
||||
conn = sqlite3.connect('$db')
|
||||
cur = conn.cursor()
|
||||
cur.execute('''SELECT $OBS_COLS FROM observations ORDER BY created_at''')
|
||||
cols = '$OBS_COLS'.split(',')
|
||||
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
cur.execute('''SELECT $SUM_COLS FROM session_summaries ORDER BY created_at''')
|
||||
cols2 = '$SUM_COLS'.split(',')
|
||||
sums = [dict(zip(cols2, r)) for r in cur.fetchall()]
|
||||
json.dump({'observations': rows, 'summaries': sums}, open('$output', 'w'))
|
||||
print(f'{len(rows)} obs, {len(sums)} sums exported', file=sys.stderr)
|
||||
conn.close()
|
||||
"
|
||||
}
|
||||
|
||||
import_obs() {
|
||||
local db="$1" input="$2"
|
||||
python3 -c "
|
||||
import sqlite3, json, sys
|
||||
conn = sqlite3.connect('$db')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT created_at, title FROM observations')
|
||||
existing = set((r[0],r[1]) for r in cur.fetchall())
|
||||
cur.execute('SELECT created_at, request FROM session_summaries')
|
||||
existing_s = set((r[0],r[1]) for r in cur.fetchall())
|
||||
data = json.load(open('$input'))
|
||||
oi, si = 0, 0
|
||||
obs_cols = '$OBS_COLS'.split(',')
|
||||
sum_cols = '$SUM_COLS'.split(',')
|
||||
obs_placeholders = ','.join(['?'] * len(obs_cols))
|
||||
sum_placeholders = ','.join(['?'] * len(sum_cols))
|
||||
for o in data['observations']:
|
||||
if (o['created_at'], o['title']) not in existing:
|
||||
cur.execute(f'INSERT INTO observations ($OBS_COLS) VALUES ({obs_placeholders})',
|
||||
tuple(o[k] for k in obs_cols))
|
||||
oi += 1
|
||||
for s in data['summaries']:
|
||||
if (s['created_at'], s['request']) not in existing_s:
|
||||
cur.execute(f'INSERT INTO session_summaries ($SUM_COLS) VALUES ({sum_placeholders})',
|
||||
tuple(s[k] for k in sum_cols))
|
||||
si += 1
|
||||
conn.commit()
|
||||
print(f'{oi} new obs, {si} new sums imported', file=sys.stderr)
|
||||
conn.close()
|
||||
"
|
||||
}
|
||||
|
||||
count_db() {
|
||||
local db="$1"
|
||||
python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('$db')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT COUNT(*) FROM observations')
|
||||
obs = cur.fetchone()[0]
|
||||
cur.execute('SELECT COUNT(*) FROM session_summaries')
|
||||
sums = cur.fetchone()[0]
|
||||
cur.execute('SELECT MAX(created_at) FROM observations')
|
||||
last = cur.fetchone()[0] or 'empty'
|
||||
print(f'{obs} obs, {sums} sums (last: {last[:19]})')
|
||||
conn.close()
|
||||
"
|
||||
}
|
||||
|
||||
case "$COMMAND" in
|
||||
push)
|
||||
echo "=== Push: local → $REMOTE_HOST ==="
|
||||
export_obs "$LOCAL_DB" "$TMPDIR/export.json"
|
||||
scp -q "$TMPDIR/export.json" "$REMOTE_HOST:/tmp/mem-import.json"
|
||||
# Run import on remote
|
||||
ssh "$REMOTE_HOST" "python3 -c \"
|
||||
import sqlite3, json, sys
|
||||
conn = sqlite3.connect('$REMOTE_DB')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT created_at, title FROM observations')
|
||||
existing = set((r[0],r[1]) for r in cur.fetchall())
|
||||
cur.execute('SELECT created_at, request FROM session_summaries')
|
||||
existing_s = set((r[0],r[1]) for r in cur.fetchall())
|
||||
data = json.load(open('/tmp/mem-import.json'))
|
||||
obs_cols = '$OBS_COLS'.split(',')
|
||||
sum_cols = '$SUM_COLS'.split(',')
|
||||
obs_ph = ','.join(['?'] * len(obs_cols))
|
||||
sum_ph = ','.join(['?'] * len(sum_cols))
|
||||
oi, si = 0, 0
|
||||
for o in data['observations']:
|
||||
if (o['created_at'], o['title']) not in existing:
|
||||
cur.execute(f'INSERT INTO observations ($OBS_COLS) VALUES ({obs_ph})', tuple(o[k] for k in obs_cols))
|
||||
oi += 1
|
||||
for s in data['summaries']:
|
||||
if (s['created_at'], s['request']) not in existing_s:
|
||||
cur.execute(f'INSERT INTO session_summaries ($SUM_COLS) VALUES ({sum_ph})', tuple(s[k] for k in sum_cols))
|
||||
si += 1
|
||||
conn.commit()
|
||||
print(f'Remote: {oi} new obs, {si} new sums imported', file=sys.stderr)
|
||||
conn.close()
|
||||
\""
|
||||
;;
|
||||
pull)
|
||||
echo "=== Pull: $REMOTE_HOST → local ==="
|
||||
ssh "$REMOTE_HOST" "python3 -c \"
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('$REMOTE_DB')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT $OBS_COLS FROM observations ORDER BY created_at')
|
||||
cols = '$OBS_COLS'.split(',')
|
||||
obs = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
cur.execute('SELECT $SUM_COLS FROM session_summaries ORDER BY created_at')
|
||||
cols2 = '$SUM_COLS'.split(',')
|
||||
sums = [dict(zip(cols2, r)) for r in cur.fetchall()]
|
||||
json.dump({'observations': obs, 'summaries': sums}, open('/tmp/mem-export.json', 'w'))
|
||||
print(f'{len(obs)} obs, {len(sums)} sums exported')
|
||||
conn.close()
|
||||
\""
|
||||
scp -q "$REMOTE_HOST:/tmp/mem-export.json" "$TMPDIR/import.json"
|
||||
import_obs "$LOCAL_DB" "$TMPDIR/import.json"
|
||||
;;
|
||||
sync)
|
||||
echo "=== Bidirectional sync with $REMOTE_HOST ==="
|
||||
"$0" push "$REMOTE_HOST"
|
||||
"$0" pull "$REMOTE_HOST"
|
||||
"$0" status "$REMOTE_HOST"
|
||||
;;
|
||||
status)
|
||||
echo "=== Local ==="
|
||||
count_db "$LOCAL_DB"
|
||||
echo "=== Remote ($REMOTE_HOST) ==="
|
||||
ssh "$REMOTE_HOST" "python3 -c \"
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('$REMOTE_DB')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT COUNT(*) FROM observations')
|
||||
obs = cur.fetchone()[0]
|
||||
cur.execute('SELECT COUNT(*) FROM session_summaries')
|
||||
sums = cur.fetchone()[0]
|
||||
cur.execute('SELECT MAX(created_at) FROM observations')
|
||||
last = cur.fetchone()[0] or 'empty'
|
||||
print(f'{obs} obs, {sums} sums (last: {last[:19]})')
|
||||
conn.close()
|
||||
\""
|
||||
;;
|
||||
*)
|
||||
echo "Usage: claude-mem-sync <push|pull|sync|status> <remote-host>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* cwd-remap — Rewrite sdk_sessions.project (+ observations.project,
|
||||
* session_summaries.project) using the cwd captured per-message in
|
||||
* pending_messages.cwd as the single source of truth.
|
||||
*
|
||||
* For each distinct cwd:
|
||||
* - git -C <cwd> rev-parse --git-dir AND --git-common-dir
|
||||
* If they differ → worktree. parent = basename(dirname(common-dir)),
|
||||
* project = parent/<basename(cwd)>.
|
||||
* Else → project = basename(cwd).
|
||||
* - If the directory doesn't exist, or git errors, skip that cwd.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/cwd-remap.ts # dry-run (default)
|
||||
* bun scripts/cwd-remap.ts --apply # write updates in a single transaction
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { homedir } from 'os';
|
||||
import { join, basename, dirname } from 'path';
|
||||
import { existsSync, copyFileSync } from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const DB_PATH = join(homedir(), '.claude-mem', 'claude-mem.db');
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
type Classification =
|
||||
| { kind: 'main'; project: string }
|
||||
| { kind: 'worktree'; project: string; parent: string }
|
||||
| { kind: 'skip'; reason: string };
|
||||
|
||||
function git(cwd: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
|
||||
if (r.status !== 0) {
|
||||
const stderr = (r.stderr ?? '').trim();
|
||||
if (stderr && !/not a git repository/i.test(stderr)) {
|
||||
console.error(`git ${args.join(' ')} failed in ${cwd}: ${stderr}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function classify(cwd: string): Classification {
|
||||
if (!existsSync(cwd)) return { kind: 'skip', reason: 'cwd-missing' };
|
||||
|
||||
const gitDir = git(cwd, ['rev-parse', '--absolute-git-dir']);
|
||||
if (!gitDir) return { kind: 'skip', reason: 'not-a-git-repo' };
|
||||
|
||||
const commonDir = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
||||
if (!commonDir) return { kind: 'skip', reason: 'no-common-dir' };
|
||||
|
||||
// Use the worktree root, not the cwd — a session may be in a subdir.
|
||||
const toplevel = git(cwd, ['rev-parse', '--show-toplevel']);
|
||||
if (!toplevel) return { kind: 'skip', reason: 'no-toplevel' };
|
||||
const leaf = basename(toplevel);
|
||||
|
||||
if (gitDir === commonDir) {
|
||||
return { kind: 'main', project: leaf };
|
||||
}
|
||||
|
||||
// worktree: common-dir = <parent-repo>/.git (normal) or <parent>.git (bare).
|
||||
// Normal: dirname strips the trailing /.git. Bare: strip the .git suffix.
|
||||
const parentRepoDir = commonDir.endsWith('/.git')
|
||||
? dirname(commonDir)
|
||||
: commonDir.replace(/\.git$/, '');
|
||||
const parent = basename(parentRepoDir);
|
||||
return { kind: 'worktree', project: `${parent}/${leaf}`, parent };
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(DB_PATH)) {
|
||||
console.error(`DB not found at ${DB_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (APPLY) {
|
||||
const backup = `${DB_PATH}.bak-cwd-remap-${Date.now()}`;
|
||||
copyFileSync(DB_PATH, backup);
|
||||
console.log(`Backup created: ${backup}`);
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
const cwdRows = db.prepare(`
|
||||
SELECT cwd, COUNT(*) AS messages
|
||||
FROM pending_messages
|
||||
WHERE cwd IS NOT NULL AND cwd != ''
|
||||
GROUP BY cwd
|
||||
`).all() as Array<{ cwd: string; messages: number }>;
|
||||
|
||||
console.log(`Classifying ${cwdRows.length} distinct cwds via git...`);
|
||||
|
||||
const byCwd = new Map<string, Classification>();
|
||||
const counts = { main: 0, worktree: 0, skip: 0 };
|
||||
for (const { cwd } of cwdRows) {
|
||||
const c = classify(cwd);
|
||||
byCwd.set(cwd, c);
|
||||
counts[c.kind]++;
|
||||
}
|
||||
console.log(` main=${counts.main} worktree=${counts.worktree} skip=${counts.skip}`);
|
||||
|
||||
// Skipped cwds (so user sees what's missing)
|
||||
const skipped = [...byCwd.entries()].filter(([, c]) => c.kind === 'skip') as Array<[string, Extract<Classification, { kind: 'skip' }>]>;
|
||||
if (skipped.length) {
|
||||
console.log('\nSkipped cwds:');
|
||||
for (const [cwd, c] of skipped) console.log(` [${c.reason}] ${cwd}`);
|
||||
}
|
||||
|
||||
// Per-session target: use the EARLIEST pending_messages.cwd for each session.
|
||||
// (Dominant-cwd is wrong: claude-mem's own hooks run from nested dirs like
|
||||
// `.context/claude-mem/` and dominate the count, misattributing the session.)
|
||||
const sessionRows = db.prepare(`
|
||||
SELECT s.id AS session_id, s.memory_session_id, s.content_session_id, s.project AS old_project, p.cwd
|
||||
FROM sdk_sessions s
|
||||
JOIN pending_messages p ON p.content_session_id = s.content_session_id
|
||||
WHERE p.cwd IS NOT NULL AND p.cwd != ''
|
||||
AND p.id = (
|
||||
SELECT MIN(p2.id) FROM pending_messages p2
|
||||
WHERE p2.content_session_id = s.content_session_id
|
||||
AND p2.cwd IS NOT NULL AND p2.cwd != ''
|
||||
)
|
||||
`).all() as Array<{ session_id: number; memory_session_id: string | null; content_session_id: string; old_project: string; cwd: string }>;
|
||||
|
||||
type Target = { sessionId: number; memorySessionId: string | null; contentSessionId: string; oldProject: string; newProject: string; cwd: string };
|
||||
const perSession = new Map<number, Target>();
|
||||
|
||||
for (const r of sessionRows) {
|
||||
const c = byCwd.get(r.cwd);
|
||||
if (!c || c.kind === 'skip') continue;
|
||||
perSession.set(r.session_id, {
|
||||
sessionId: r.session_id,
|
||||
memorySessionId: r.memory_session_id,
|
||||
contentSessionId: r.content_session_id,
|
||||
oldProject: r.old_project,
|
||||
newProject: c.project,
|
||||
cwd: r.cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const targets = [...perSession.values()].filter(t => t.oldProject !== t.newProject);
|
||||
|
||||
console.log(`\nSessions linked to a classified cwd: ${perSession.size}`);
|
||||
console.log(`Sessions whose project would change: ${targets.length}`);
|
||||
|
||||
const summary = new Map<string, number>();
|
||||
for (const t of targets) {
|
||||
const key = `${t.oldProject} → ${t.newProject}`;
|
||||
summary.set(key, (summary.get(key) ?? 0) + 1);
|
||||
}
|
||||
const rows = [...summary.entries()]
|
||||
.map(([mapping, n]) => ({ mapping, sessions: n }))
|
||||
.sort((a, b) => b.sessions - a.sessions);
|
||||
console.log('\nTop mappings:');
|
||||
console.table(rows.slice(0, 30));
|
||||
if (rows.length > 30) console.log(` …and ${rows.length - 30} more mappings`);
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('\nDry-run only. Re-run with --apply to perform UPDATEs.');
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const updSession = db.prepare('UPDATE sdk_sessions SET project = ? WHERE id = ?');
|
||||
const updObs = db.prepare('UPDATE observations SET project = ? WHERE memory_session_id = ?');
|
||||
const updSum = db.prepare('UPDATE session_summaries SET project = ? WHERE memory_session_id = ?');
|
||||
|
||||
let sessionN = 0, obsN = 0, sumN = 0;
|
||||
const tx = db.transaction(() => {
|
||||
for (const t of targets) {
|
||||
sessionN += updSession.run(t.newProject, t.sessionId).changes;
|
||||
if (t.memorySessionId) {
|
||||
obsN += updObs.run(t.newProject, t.memorySessionId).changes;
|
||||
sumN += updSum.run(t.newProject, t.memorySessionId).changes;
|
||||
}
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
console.log(`\nApplied. sessions=${sessionN} observations=${obsN} session_summaries=${sumN}`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# E2E Test: Knowledge Agents
|
||||
# Fully hands-off test of the complete knowledge agent lifecycle.
|
||||
# Designed to be orchestrated via tmux-cli from Claude Code.
|
||||
#
|
||||
# Flow: health check → build corpus → list → get → prime → query → reprime → query → rebuild → delete → verify
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
WORKER_URL="http://localhost:37777"
|
||||
CORPUS_NAME="e2e-test-knowledge-agent"
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
LOG_FILE="${HOME}/.claude-mem/logs/e2e-knowledge-agents-$(date +%Y%m%d-%H%M%S).log"
|
||||
|
||||
# -- Helpers ------------------------------------------------------------------
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
||||
pass() { PASS_COUNT=$((PASS_COUNT + 1)); log "PASS: $1"; }
|
||||
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); log "FAIL: $1 — $2"; }
|
||||
|
||||
assert_http_status() {
|
||||
local description="$1" expected_status="$2" actual_status="$3"
|
||||
if [[ "$actual_status" == "$expected_status" ]]; then
|
||||
pass "$description (HTTP $actual_status)"
|
||||
else
|
||||
fail "$description" "expected HTTP $expected_status, got $actual_status"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_field() {
|
||||
local description="$1" json="$2" field="$3" expected="$4"
|
||||
local actual
|
||||
actual=$(echo "$json" | jq -r "$field" 2>/dev/null || echo "PARSE_ERROR")
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
pass "$description ($field=$actual)"
|
||||
else
|
||||
fail "$description" "expected $field=$expected, got $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_field_not_empty() {
|
||||
local description="$1" json="$2" field="$3"
|
||||
local actual
|
||||
actual=$(echo "$json" | jq -r "$field" 2>/dev/null || echo "")
|
||||
if [[ -n "$actual" && "$actual" != "null" && "$actual" != "" ]]; then
|
||||
pass "$description ($field is present)"
|
||||
else
|
||||
fail "$description" "$field is empty or null"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_field_numeric_gt() {
|
||||
local description="$1" json="$2" field="$3" min_value="$4"
|
||||
local actual
|
||||
actual=$(echo "$json" | jq -r "$field" 2>/dev/null || echo "0")
|
||||
if [[ "$actual" -gt "$min_value" ]] 2>/dev/null; then
|
||||
pass "$description ($field=$actual > $min_value)"
|
||||
else
|
||||
fail "$description" "expected $field > $min_value, got $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
curl_get() {
|
||||
curl -sS --connect-timeout 5 --max-time 30 -w '\n%{http_code}' "$WORKER_URL$1" 2>/dev/null || printf '\n000'
|
||||
}
|
||||
|
||||
curl_post() {
|
||||
local path="$1" body="$2" max_time="${3:-30}"
|
||||
curl -sS --connect-timeout 5 --max-time "$max_time" -w '\n%{http_code}' -X POST "$WORKER_URL$path" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$body" 2>/dev/null || printf '\n000'
|
||||
}
|
||||
|
||||
curl_delete() {
|
||||
curl -sS --connect-timeout 5 --max-time 30 -w '\n%{http_code}' -X DELETE "$WORKER_URL$1" 2>/dev/null || printf '\n000'
|
||||
}
|
||||
|
||||
extract_body_and_status() {
|
||||
local response="$1"
|
||||
RESPONSE_BODY=$(echo "$response" | sed '$d')
|
||||
RESPONSE_STATUS=$(echo "$response" | tail -1)
|
||||
}
|
||||
|
||||
# -- Cleanup ------------------------------------------------------------------
|
||||
|
||||
cleanup_test_corpus() {
|
||||
log "Cleaning up test corpus '$CORPUS_NAME'..."
|
||||
curl -s -X DELETE "$WORKER_URL/api/corpus/$CORPUS_NAME" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# -- Tests --------------------------------------------------------------------
|
||||
|
||||
test_worker_health() {
|
||||
log "=== Test: Worker Health ==="
|
||||
local response
|
||||
response=$(curl_get "/api/health")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Worker health check" "200" "$RESPONSE_STATUS"
|
||||
}
|
||||
|
||||
test_worker_readiness() {
|
||||
log "=== Test: Worker Readiness ==="
|
||||
local response
|
||||
response=$(curl_get "/api/readiness")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Worker readiness check" "200" "$RESPONSE_STATUS"
|
||||
}
|
||||
|
||||
test_build_corpus() {
|
||||
log "=== Test: Build Corpus ==="
|
||||
local response
|
||||
response=$(curl_post "/api/corpus" "{
|
||||
\"name\": \"$CORPUS_NAME\",
|
||||
\"description\": \"E2E test corpus for knowledge agents\",
|
||||
\"query\": \"architecture\",
|
||||
\"limit\": 20
|
||||
}")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Build corpus" "200" "$RESPONSE_STATUS"
|
||||
assert_json_field "Build corpus name" "$RESPONSE_BODY" ".name" "$CORPUS_NAME"
|
||||
assert_json_field_not_empty "Build corpus description" "$RESPONSE_BODY" ".description"
|
||||
assert_json_field_not_empty "Build corpus stats" "$RESPONSE_BODY" ".stats.observation_count"
|
||||
log "Build response: $(echo "$RESPONSE_BODY" | jq -c '{name, stats: .stats}' 2>/dev/null)"
|
||||
}
|
||||
|
||||
test_list_corpora() {
|
||||
log "=== Test: List Corpora ==="
|
||||
local response
|
||||
response=$(curl_get "/api/corpus")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "List corpora" "200" "$RESPONSE_STATUS"
|
||||
|
||||
# Verify our test corpus is in the list
|
||||
local found
|
||||
found=$(echo "$RESPONSE_BODY" | jq -r ".[] | select(.name == \"$CORPUS_NAME\") | .name" 2>/dev/null)
|
||||
if [[ "$found" == "$CORPUS_NAME" ]]; then
|
||||
pass "Test corpus found in list"
|
||||
else
|
||||
fail "Test corpus in list" "corpus '$CORPUS_NAME' not found"
|
||||
fi
|
||||
}
|
||||
|
||||
test_get_corpus() {
|
||||
log "=== Test: Get Corpus ==="
|
||||
local response
|
||||
response=$(curl_get "/api/corpus/$CORPUS_NAME")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Get corpus" "200" "$RESPONSE_STATUS"
|
||||
assert_json_field "Get corpus name" "$RESPONSE_BODY" ".name" "$CORPUS_NAME"
|
||||
assert_json_field "Get corpus session_id (pre-prime)" "$RESPONSE_BODY" ".session_id" "null"
|
||||
}
|
||||
|
||||
test_get_corpus_404() {
|
||||
log "=== Test: Get Nonexistent Corpus ==="
|
||||
local response
|
||||
response=$(curl_get "/api/corpus/nonexistent-corpus-that-does-not-exist")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Get nonexistent corpus returns 404" "404" "$RESPONSE_STATUS"
|
||||
}
|
||||
|
||||
test_prime_corpus() {
|
||||
log "=== Test: Prime Corpus ==="
|
||||
log " (This may take 30-120 seconds — Agent SDK session is being created...)"
|
||||
local response
|
||||
response=$(curl_post "/api/corpus/$CORPUS_NAME/prime" '{}' 300)
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Prime corpus" "200" "$RESPONSE_STATUS"
|
||||
assert_json_field_not_empty "Prime returns session_id" "$RESPONSE_BODY" ".session_id"
|
||||
assert_json_field "Prime returns corpus name" "$RESPONSE_BODY" ".name" "$CORPUS_NAME"
|
||||
log "Prime response: $(echo "$RESPONSE_BODY" | jq -c '{name, session_id: (.session_id | .[0:20] + "...")}' 2>/dev/null)"
|
||||
}
|
||||
|
||||
test_query_corpus() {
|
||||
log "=== Test: Query Corpus ==="
|
||||
local response
|
||||
response=$(curl_post "/api/corpus/$CORPUS_NAME/query" '{"question": "What are the main topics and themes in this knowledge base? Give a brief summary."}' 300)
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Query corpus" "200" "$RESPONSE_STATUS"
|
||||
assert_json_field_not_empty "Query returns answer" "$RESPONSE_BODY" ".answer"
|
||||
assert_json_field_not_empty "Query returns session_id" "$RESPONSE_BODY" ".session_id"
|
||||
|
||||
local answer_length
|
||||
answer_length=$(echo "$RESPONSE_BODY" | jq -r '.answer | length' 2>/dev/null || echo "0")
|
||||
if [[ "$answer_length" -gt 50 ]]; then
|
||||
pass "Query answer is substantive (${answer_length} chars)"
|
||||
else
|
||||
fail "Query answer length" "expected > 50 chars, got $answer_length"
|
||||
fi
|
||||
log "Query answer preview: $(echo "$RESPONSE_BODY" | jq -r '.answer' 2>/dev/null | head -3)"
|
||||
}
|
||||
|
||||
test_query_without_prime() {
|
||||
log "=== Test: Query Unprimed Corpus ==="
|
||||
# Build a second corpus but don't prime it
|
||||
curl_post "/api/corpus" "{\"name\": \"e2e-unprimed-test\", \"limit\": 5}" > /dev/null 2>&1
|
||||
local response
|
||||
response=$(curl_post "/api/corpus/e2e-unprimed-test/query" '{"question": "test"}' 30)
|
||||
extract_body_and_status "$response"
|
||||
# Should fail because corpus isn't primed
|
||||
if [[ "$RESPONSE_STATUS" != "200" ]] || echo "$RESPONSE_BODY" | jq -r '.error' 2>/dev/null | grep -qi "prime\|session"; then
|
||||
pass "Query unprimed corpus correctly rejected"
|
||||
else
|
||||
fail "Query unprimed corpus" "expected error about priming, got HTTP $RESPONSE_STATUS"
|
||||
fi
|
||||
# Cleanup
|
||||
curl -s -X DELETE "$WORKER_URL/api/corpus/e2e-unprimed-test" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
test_reprime_corpus() {
|
||||
log "=== Test: Reprime Corpus ==="
|
||||
log " (Creating fresh session...)"
|
||||
|
||||
# Capture old session_id
|
||||
local old_response old_session_id
|
||||
old_response=$(curl_get "/api/corpus/$CORPUS_NAME")
|
||||
extract_body_and_status "$old_response"
|
||||
old_session_id=$(echo "$RESPONSE_BODY" | jq -r '.session_id' 2>/dev/null)
|
||||
|
||||
local response
|
||||
response=$(curl_post "/api/corpus/$CORPUS_NAME/reprime" '{}' 300)
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Reprime corpus" "200" "$RESPONSE_STATUS"
|
||||
assert_json_field_not_empty "Reprime returns session_id" "$RESPONSE_BODY" ".session_id"
|
||||
|
||||
local new_session_id
|
||||
new_session_id=$(echo "$RESPONSE_BODY" | jq -r '.session_id' 2>/dev/null)
|
||||
if [[ "$new_session_id" != "$old_session_id" ]]; then
|
||||
pass "Reprime created new session (different session_id)"
|
||||
else
|
||||
fail "Reprime session_id" "expected new session_id, got same as before"
|
||||
fi
|
||||
}
|
||||
|
||||
test_query_after_reprime() {
|
||||
log "=== Test: Query After Reprime ==="
|
||||
local response
|
||||
response=$(curl_post "/api/corpus/$CORPUS_NAME/query" '{"question": "List the types of observations in this knowledge base."}' 300)
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Query after reprime" "200" "$RESPONSE_STATUS"
|
||||
assert_json_field_not_empty "Answer after reprime" "$RESPONSE_BODY" ".answer"
|
||||
log "Post-reprime answer preview: $(echo "$RESPONSE_BODY" | jq -r '.answer' 2>/dev/null | head -3)"
|
||||
}
|
||||
|
||||
test_rebuild_corpus() {
|
||||
log "=== Test: Rebuild Corpus ==="
|
||||
local response
|
||||
response=$(curl_post "/api/corpus/$CORPUS_NAME/rebuild" '{}' 60)
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Rebuild corpus" "200" "$RESPONSE_STATUS"
|
||||
assert_json_field "Rebuild returns name" "$RESPONSE_BODY" ".name" "$CORPUS_NAME"
|
||||
assert_json_field_not_empty "Rebuild returns stats" "$RESPONSE_BODY" ".stats.observation_count"
|
||||
}
|
||||
|
||||
test_delete_corpus() {
|
||||
log "=== Test: Delete Corpus ==="
|
||||
local response
|
||||
response=$(curl_delete "/api/corpus/$CORPUS_NAME")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Delete corpus" "200" "$RESPONSE_STATUS"
|
||||
|
||||
# Verify it's gone
|
||||
local verify_response
|
||||
verify_response=$(curl_get "/api/corpus/$CORPUS_NAME")
|
||||
extract_body_and_status "$verify_response"
|
||||
assert_http_status "Deleted corpus returns 404" "404" "$RESPONSE_STATUS"
|
||||
}
|
||||
|
||||
test_delete_nonexistent() {
|
||||
log "=== Test: Delete Nonexistent Corpus ==="
|
||||
local response
|
||||
response=$(curl_delete "/api/corpus/nonexistent-corpus-that-does-not-exist")
|
||||
extract_body_and_status "$response"
|
||||
assert_http_status "Delete nonexistent returns 404" "404" "$RESPONSE_STATUS"
|
||||
}
|
||||
|
||||
# -- Main ---------------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
log "======================================================"
|
||||
log " Knowledge Agents E2E Test"
|
||||
log " $(date)"
|
||||
log "======================================================"
|
||||
log ""
|
||||
|
||||
# Cleanup any leftover test data
|
||||
cleanup_test_corpus
|
||||
|
||||
# Phase 1: Health checks
|
||||
test_worker_health
|
||||
test_worker_readiness
|
||||
log ""
|
||||
|
||||
# Phase 2: CRUD operations
|
||||
test_build_corpus
|
||||
test_list_corpora
|
||||
test_get_corpus
|
||||
test_get_corpus_404
|
||||
log ""
|
||||
|
||||
# Phase 3: Agent SDK operations (prime + query)
|
||||
test_prime_corpus
|
||||
test_query_corpus
|
||||
test_query_without_prime
|
||||
log ""
|
||||
|
||||
# Phase 4: Reprime + query again
|
||||
test_reprime_corpus
|
||||
test_query_after_reprime
|
||||
log ""
|
||||
|
||||
# Phase 5: Rebuild + cleanup
|
||||
test_rebuild_corpus
|
||||
test_delete_corpus
|
||||
test_delete_nonexistent
|
||||
log ""
|
||||
|
||||
# Summary
|
||||
local total=$((PASS_COUNT + FAIL_COUNT))
|
||||
log "======================================================"
|
||||
log " RESULTS: $PASS_COUNT/$total passed, $FAIL_COUNT failed"
|
||||
log "======================================================"
|
||||
|
||||
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
||||
log " STATUS: FAILED"
|
||||
log " Log: $LOG_FILE"
|
||||
exit 1
|
||||
else
|
||||
log " STATUS: ALL PASSED"
|
||||
log " Log: $LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -94,9 +94,12 @@ function getTrackedFolders(workingDir: string): Set<string> {
|
||||
const absPath = path.join(workingDir, file);
|
||||
let dir = path.dirname(absPath);
|
||||
|
||||
// Add all parent directories up to (but not including) the working dir
|
||||
while (dir.length > workingDir.length && dir.startsWith(workingDir)) {
|
||||
// Add all parent directories up to and including the working dir itself.
|
||||
// The working dir is included so that root-level files (stored in the DB
|
||||
// as bare filenames with no directory component) can be matched. Fixes #1514.
|
||||
while (dir.length >= workingDir.length && dir.startsWith(workingDir)) {
|
||||
folders.add(dir);
|
||||
if (dir === workingDir) break;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
}
|
||||
@@ -164,19 +167,37 @@ function findObservationsByFolder(db: Database, relativeFolderPath: string, proj
|
||||
// Query more results than needed since we'll filter some out
|
||||
const queryLimit = limit * 3;
|
||||
|
||||
const sql = `
|
||||
SELECT o.*, o.discovery_tokens
|
||||
FROM observations o
|
||||
WHERE o.project = ?
|
||||
AND (o.files_modified LIKE ? OR o.files_read LIKE ?)
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
// For the root folder (empty relativeFolderPath), observations may have bare
|
||||
// filenames stored without any directory component (e.g. ["dashboard.html"]).
|
||||
// In that case the LIKE pattern below would never match, so we fetch all
|
||||
// observations for the project and let isDirectChild filter to root-level files.
|
||||
// Fixes #1514.
|
||||
let allMatches: ObservationRow[];
|
||||
|
||||
// Files in DB are stored as relative paths like "src/services/foo.ts"
|
||||
// Match any file that starts with this folder path (we'll filter to direct children below)
|
||||
const likePattern = `%"${relativeFolderPath}/%`;
|
||||
const allMatches = db.prepare(sql).all(project, likePattern, likePattern, queryLimit) as ObservationRow[];
|
||||
if (relativeFolderPath === '' || relativeFolderPath === '.') {
|
||||
const sql = `
|
||||
SELECT o.*, o.discovery_tokens
|
||||
FROM observations o
|
||||
WHERE o.project = ?
|
||||
AND (o.files_modified IS NOT NULL OR o.files_read IS NOT NULL)
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
allMatches = db.prepare(sql).all(project, queryLimit) as ObservationRow[];
|
||||
} else {
|
||||
const sql = `
|
||||
SELECT o.*, o.discovery_tokens
|
||||
FROM observations o
|
||||
WHERE o.project = ?
|
||||
AND (o.files_modified LIKE ? OR o.files_read LIKE ?)
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
// Files in DB are stored as relative paths like "src/services/foo.ts"
|
||||
// Match any file that starts with this folder path (we'll filter to direct children below)
|
||||
const likePattern = `%"${relativeFolderPath}/%`;
|
||||
allMatches = db.prepare(sql).all(project, likePattern, likePattern, queryLimit) as ObservationRow[];
|
||||
}
|
||||
|
||||
// Filter to only observations with direct child files (not in subfolders)
|
||||
return allMatches.filter(obs => hasDirectChildFile(obs, relativeFolderPath)).slice(0, limit);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
const packageJsonPath = path.join(rootDir, 'package.json');
|
||||
const codexPluginPath = path.join(rootDir, '.codex-plugin', 'plugin.json');
|
||||
const claudePluginPath = path.join(rootDir, '.claude-plugin', 'plugin.json');
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function syncCodexPlugin(plugin, pkg) {
|
||||
const author =
|
||||
typeof plugin.author === 'object' && plugin.author ? plugin.author : {};
|
||||
|
||||
return {
|
||||
...plugin,
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
homepage: pkg.homepage,
|
||||
repository: normalizeRepositoryUrl(pkg.repository),
|
||||
license: pkg.license,
|
||||
keywords: pkg.keywords,
|
||||
author: {
|
||||
...author,
|
||||
name: normalizeAuthorName(pkg.author),
|
||||
},
|
||||
interface: {
|
||||
...plugin.interface,
|
||||
developerName: normalizeAuthorName(pkg.author),
|
||||
websiteURL: normalizeRepositoryUrl(pkg.repository),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function syncClaudePlugin(plugin, pkg) {
|
||||
return {
|
||||
...plugin,
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
homepage: pkg.homepage,
|
||||
repository: normalizeRepositoryUrl(pkg.repository),
|
||||
license: pkg.license,
|
||||
keywords: pkg.keywords,
|
||||
author: {
|
||||
...(typeof plugin.author === 'object' && plugin.author ? plugin.author : {}),
|
||||
name: normalizeAuthorName(pkg.author),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuthorName(author) {
|
||||
if (typeof author === 'string') return author;
|
||||
if (author && typeof author === 'object' && typeof author.name === 'string') return author.name;
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeRepositoryUrl(repository) {
|
||||
if (typeof repository === 'string') return repository.replace(/\.git$/, '');
|
||||
if (repository && typeof repository === 'object' && typeof repository.url === 'string')
|
||||
return repository.url.replace(/\.git$/, '');
|
||||
return '';
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const filePath of [packageJsonPath, codexPluginPath, claudePluginPath]) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Missing required file: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const pkg = readJson(packageJsonPath);
|
||||
const codexPlugin = readJson(codexPluginPath);
|
||||
const claudePlugin = readJson(claudePluginPath);
|
||||
|
||||
writeJson(codexPluginPath, syncCodexPlugin(codexPlugin, pkg));
|
||||
writeJson(claudePluginPath, syncClaudePlugin(claudePlugin, pkg));
|
||||
|
||||
console.log('✓ Synced plugin manifests from package.json');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,3 +0,0 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
@@ -1,34 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23825 | 11:12 PM | ✅ | Worker Port Set to 38888 for Migration Phase | ~283 |
|
||||
| #23824 | " | 🔵 | Worker Port Sourced from getWorkerPort() Utility | ~247 |
|
||||
| #23816 | 10:52 PM | 🟣 | Worker CLI Command Interface Created | ~325 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #24060 | 2:58 PM | 🔴 | Worker CLI Start Command Exit Behavior Fixed | ~232 |
|
||||
|
||||
### Dec 12, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #24359 | 7:00 PM | 🟣 | Phase 1 Critical Code Fixes Completed via Agent Task | ~441 |
|
||||
| #24358 | 6:59 PM | ✅ | Completed Phase 1 Code Fixes for better-sqlite3 Migration | ~385 |
|
||||
| #24348 | 6:57 PM | 🔴 | Added Defensive Break Statement to worker-cli.ts Restart Case | ~269 |
|
||||
| #24345 | " | 🔵 | worker-cli.ts Missing Break Statement in Switch Case | ~318 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26766 | 11:30 PM | ⚖️ | Root Cause Identified: Missing Post-Install Worker Restart Trigger in Plugin Update Flow | ~604 |
|
||||
| #26722 | 11:23 PM | 🔵 | Worker CLI TypeScript Source Shows Simple ProcessManager Delegation | ~394 |
|
||||
| #26721 | " | 🔵 | Worker CLI Source Code Shows Simple Restart Logic Without Delays | ~425 |
|
||||
</claude-mem-context>
|
||||
@@ -1,3 +0,0 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
@@ -13,7 +13,7 @@ import type { PlatformAdapter } from '../types.js';
|
||||
* Notification → observation (system events like ToolPermission)
|
||||
*
|
||||
* Agent:
|
||||
* BeforeAgent → user-message (captures user prompt)
|
||||
* BeforeAgent → session-init (initializes session, captures user prompt)
|
||||
* AfterAgent → observation (full agent response)
|
||||
*
|
||||
* Tool:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const contextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -31,6 +32,7 @@ export const contextHandler: EventHandler = {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
const context = getProjectContext(cwd);
|
||||
const port = getWorkerPort();
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
// Check if terminal output should be shown (load settings early)
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
@@ -38,7 +40,7 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
// Pass all projects (parent + worktree if applicable) for unified timeline
|
||||
const projectsParam = context.allProjects.join(',');
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(platformSource)}`;
|
||||
const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath;
|
||||
|
||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* File Context Handler - PreToolUse
|
||||
*
|
||||
* Injects relevant observation history when Claude reads/edits a file,
|
||||
* so it can avoid duplicating past work.
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { parseJsonArray } from '../../shared/timeline-formatting.js';
|
||||
import { statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
|
||||
/** Skip the gate for files smaller than this — timeline overhead exceeds file read cost. */
|
||||
const FILE_READ_GATE_MIN_BYTES = 1_500;
|
||||
|
||||
/** Fetch more candidates than the display limit so dedup still fills 15 slots. */
|
||||
const FETCH_LOOKAHEAD_LIMIT = 40;
|
||||
|
||||
/** Maximum observations to show in the timeline. */
|
||||
const DISPLAY_LIMIT = 15;
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
decision: '\u2696\uFE0F',
|
||||
bugfix: '\uD83D\uDD34',
|
||||
feature: '\uD83D\uDFE3',
|
||||
refactor: '\uD83D\uDD04',
|
||||
discovery: '\uD83D\uDD35',
|
||||
change: '\u2705',
|
||||
};
|
||||
|
||||
function compactTime(timeStr: string): string {
|
||||
return timeStr.toLowerCase().replace(' am', 'a').replace(' pm', 'p');
|
||||
}
|
||||
|
||||
function formatTime(epoch: number): string {
|
||||
const date = new Date(epoch);
|
||||
return date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
function formatDate(epoch: number): string {
|
||||
const date = new Date(epoch);
|
||||
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
interface ObservationRow {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
title: string | null;
|
||||
type: string;
|
||||
created_at_epoch: number;
|
||||
files_read: string | null;
|
||||
files_modified: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate and rank observations for the timeline display.
|
||||
*
|
||||
* 1. Same-session dedup: keep only the most recent observation per session
|
||||
* (input is already sorted newest-first by SQL).
|
||||
* 2. Specificity scoring: rank by how specifically the observation is about
|
||||
* the target file (modified > read-only, fewer total files > many).
|
||||
* 3. Truncate to displayLimit.
|
||||
*/
|
||||
function deduplicateObservations(
|
||||
observations: ObservationRow[],
|
||||
targetPath: string,
|
||||
displayLimit: number
|
||||
): ObservationRow[] {
|
||||
// Phase 1: Keep only the most recent observation per session
|
||||
const seenSessions = new Set<string>();
|
||||
const dedupedBySession: ObservationRow[] = [];
|
||||
for (const obs of observations) {
|
||||
const sessionKey = obs.memory_session_id ?? `no-session-${obs.id}`;
|
||||
if (!seenSessions.has(sessionKey)) {
|
||||
seenSessions.add(sessionKey);
|
||||
dedupedBySession.push(obs);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Score by specificity to the target file
|
||||
const scored = dedupedBySession.map(obs => {
|
||||
const filesRead = parseJsonArray(obs.files_read);
|
||||
const filesModified = parseJsonArray(obs.files_modified);
|
||||
const totalFiles = filesRead.length + filesModified.length;
|
||||
const normalizedTarget = targetPath.replace(/\\/g, '/');
|
||||
const inModified = filesModified.some(f => f.replace(/\\/g, '/') === normalizedTarget);
|
||||
|
||||
let specificityScore = 0;
|
||||
if (inModified) specificityScore += 2;
|
||||
if (totalFiles <= 3) specificityScore += 2;
|
||||
else if (totalFiles <= 8) specificityScore += 1;
|
||||
// totalFiles > 8: no bonus (survey-like observation)
|
||||
|
||||
return { obs, specificityScore };
|
||||
});
|
||||
|
||||
// Stable sort: higher specificity first, preserve chronological order within same score
|
||||
scored.sort((a, b) => b.specificityScore - a.specificityScore);
|
||||
|
||||
return scored.slice(0, displayLimit).map(s => s.obs);
|
||||
}
|
||||
|
||||
function formatFileTimeline(
|
||||
observations: ObservationRow[],
|
||||
filePath: string,
|
||||
truncated: boolean
|
||||
): string {
|
||||
// Escape filePath for safe interpolation into recovery hints (quotes, backslashes, newlines)
|
||||
const safePath = filePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
// Group observations by day
|
||||
const byDay = new Map<string, ObservationRow[]>();
|
||||
for (const obs of observations) {
|
||||
const day = formatDate(obs.created_at_epoch);
|
||||
if (!byDay.has(day)) {
|
||||
byDay.set(day, []);
|
||||
}
|
||||
byDay.get(day)!.push(obs);
|
||||
}
|
||||
|
||||
// Sort days chronologically (use earliest observation in each group, not first — which is specificity-sorted)
|
||||
const sortedDays = Array.from(byDay.entries()).sort((a, b) => {
|
||||
const aEpoch = Math.min(...a[1].map(o => o.created_at_epoch));
|
||||
const bEpoch = Math.min(...b[1].map(o => o.created_at_epoch));
|
||||
return aEpoch - bEpoch;
|
||||
});
|
||||
|
||||
// Include current date/time so the model can judge recency of observations
|
||||
const now = new Date();
|
||||
const currentDate = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
const currentTime = now.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).toLowerCase().replace(' ', '');
|
||||
const currentTimezone = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
|
||||
|
||||
const headerLine = truncated
|
||||
? `This file has prior observations. Only line 1 was read to save tokens.`
|
||||
: `This file has prior observations. The requested section was read normally.`;
|
||||
|
||||
const lines: string[] = [
|
||||
`Current: ${currentDate} ${currentTime} ${currentTimezone}`,
|
||||
headerLine,
|
||||
`- **Already know enough?** The timeline below may be all you need (semantic priming).`,
|
||||
`- **Need details?** get_observations([IDs]) — ~300 tokens each.`,
|
||||
`- **Need full file?** Read again with offset/limit for the section you need.`,
|
||||
`- **Need to edit?** Edit works — the file is registered as read. Use smart_outline("${safePath}") for line numbers.`,
|
||||
];
|
||||
|
||||
for (const [day, dayObservations] of sortedDays) {
|
||||
// Sort within each day chronologically (deduplicateObservations reorders by specificity)
|
||||
const chronological = [...dayObservations].sort((a, b) => a.created_at_epoch - b.created_at_epoch);
|
||||
lines.push(`### ${day}`);
|
||||
for (const obs of chronological) {
|
||||
const title = (obs.title || 'Untitled').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||
const icon = TYPE_ICONS[obs.type] || '\u2753';
|
||||
const time = compactTime(formatTime(obs.created_at_epoch));
|
||||
lines.push(`${obs.id} ${time} ${icon} ${title}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const fileContextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Extract file_path from toolInput
|
||||
const toolInput = input.toolInput as Record<string, unknown> | undefined;
|
||||
const filePath = toolInput?.file_path as string | undefined;
|
||||
|
||||
if (!filePath) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// Preserve user-supplied offset/limit to avoid read-dedup collisions (fixes #1719)
|
||||
const userOffset = typeof toolInput?.offset === 'number' && Number.isFinite(toolInput.offset) && toolInput.offset >= 0
|
||||
? Math.floor(toolInput.offset) : undefined;
|
||||
const userLimit = typeof toolInput?.limit === 'number' && Number.isFinite(toolInput.limit) && toolInput.limit > 0
|
||||
? Math.floor(toolInput.limit) : undefined;
|
||||
const isTargetedRead = userOffset !== undefined || userLimit !== undefined;
|
||||
|
||||
// Stat the file once: size (gate) + mtime (cache invalidation).
|
||||
// 0 = stat failed non-fatally (e.g. EPERM) — skip mtime check, fall through to truncation.
|
||||
let fileMtimeMs = 0;
|
||||
try {
|
||||
const statPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(input.cwd || process.cwd(), filePath);
|
||||
const stat = statSync(statPath);
|
||||
// Skip gate for files below the token-economics threshold — timeline (~370 tokens)
|
||||
// costs more than reading small files directly.
|
||||
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
fileMtimeMs = stat.mtimeMs;
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') return { continue: true, suppressOutput: true };
|
||||
// Other errors (symlink, permission denied) — fall through and let gate proceed
|
||||
}
|
||||
|
||||
// Check if project is excluded from tracking
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
if (input.cwd && isProjectExcluded(input.cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
|
||||
logger.debug('HOOK', 'Project excluded from tracking, skipping file context', { cwd: input.cwd });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// Ensure worker is running
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// Query worker for observations related to this file
|
||||
try {
|
||||
const context = getProjectContext(input.cwd);
|
||||
// Observations store relative paths — convert absolute to relative using cwd
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
||||
const queryParams = new URLSearchParams({ path: relativePath });
|
||||
// Pass all project names (parent + worktree) for unified lookup
|
||||
if (context.allProjects.length > 0) {
|
||||
queryParams.set('projects', context.allProjects.join(','));
|
||||
}
|
||||
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
|
||||
|
||||
const response = await workerHttpRequest(`/api/observations/by-file?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('HOOK', 'File context query failed, skipping', { status: response.status, filePath });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
const data = await response.json() as { observations: ObservationRow[]; count: number };
|
||||
|
||||
if (!data.observations || data.observations.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// mtime invalidation: bypass truncation when the file is newer than the latest observation.
|
||||
// Uses >= to handle same-millisecond edits (cost: one extra full read vs risk of stuck truncation).
|
||||
if (fileMtimeMs > 0) {
|
||||
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
|
||||
if (fileMtimeMs >= newestObservationMs) {
|
||||
logger.debug('HOOK', 'File modified since last observation, skipping truncation', {
|
||||
filePath: relativePath,
|
||||
fileMtimeMs,
|
||||
newestObservationMs,
|
||||
});
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: one per session, ranked by specificity to this file
|
||||
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
|
||||
if (dedupedObservations.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// Unconstrained → truncate to 1 line; targeted → preserve offset/limit.
|
||||
const truncated = !isTargetedRead;
|
||||
const timeline = formatFileTimeline(dedupedObservations, filePath, truncated);
|
||||
const updatedInput: Record<string, unknown> = { file_path: filePath };
|
||||
if (isTargetedRead) {
|
||||
if (userOffset !== undefined) updatedInput.offset = userOffset;
|
||||
if (userLimit !== undefined) updatedInput.limit = userLimit;
|
||||
} else {
|
||||
updatedInput.limit = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: timeline,
|
||||
permissionDecision: 'allow',
|
||||
updatedInput,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('HOOK', 'File context fetch error, skipping', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const fileEditHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -20,6 +21,7 @@ export const fileEditHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId, cwd, filePath, edits } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('fileEditHandler requires filePath');
|
||||
@@ -42,6 +44,7 @@ export const fileEditHandler: EventHandler = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
|
||||
@@ -13,6 +13,7 @@ import { observationHandler } from './observation.js';
|
||||
import { summarizeHandler } from './summarize.js';
|
||||
import { userMessageHandler } from './user-message.js';
|
||||
import { fileEditHandler } from './file-edit.js';
|
||||
import { fileContextHandler } from './file-context.js';
|
||||
import { sessionCompleteHandler } from './session-complete.js';
|
||||
|
||||
export type EventType =
|
||||
@@ -22,7 +23,8 @@ export type EventType =
|
||||
| 'summarize' // Stop - generate summary (phase 1)
|
||||
| 'session-complete' // Stop - complete session (phase 2) - fixes #842
|
||||
| 'user-message' // SessionStart (parallel) - display to user
|
||||
| 'file-edit'; // Cursor afterFileEdit
|
||||
| 'file-edit' // Cursor afterFileEdit
|
||||
| 'file-context'; // PreToolUse - inject file observation history
|
||||
|
||||
const handlers: Record<EventType, EventHandler> = {
|
||||
'context': contextHandler,
|
||||
@@ -31,7 +33,8 @@ const handlers: Record<EventType, EventHandler> = {
|
||||
'summarize': summarizeHandler,
|
||||
'session-complete': sessionCompleteHandler,
|
||||
'user-message': userMessageHandler,
|
||||
'file-edit': fileEditHandler
|
||||
'file-edit': fileEditHandler,
|
||||
'file-context': fileContextHandler
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -64,4 +67,5 @@ export { observationHandler } from './observation.js';
|
||||
export { summarizeHandler } from './summarize.js';
|
||||
export { userMessageHandler } from './user-message.js';
|
||||
export { fileEditHandler } from './file-edit.js';
|
||||
export { fileContextHandler } from './file-context.js';
|
||||
export { sessionCompleteHandler } from './session-complete.js';
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const observationHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -22,6 +23,7 @@ export const observationHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId, cwd, toolName, toolInput, toolResponse } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!toolName) {
|
||||
// No tool name provided - skip observation gracefully
|
||||
@@ -51,6 +53,7 @@ export const observationHandler: EventHandler = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -23,6 +24,7 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||
@@ -39,7 +41,8 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId
|
||||
contentSessionId: sessionId,
|
||||
platformSource
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { getProjectName } from '../../utils/project-name.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const sessionInitHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -41,7 +42,8 @@ export const sessionInitHandler: EventHandler = {
|
||||
// Use placeholder so sessions still get created and tracked for memory
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
|
||||
const project = getProjectName(cwd);
|
||||
const project = getProjectContext(cwd).primary;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
|
||||
|
||||
@@ -52,7 +54,8 @@ export const sessionInitHandler: EventHandler = {
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
project,
|
||||
prompt
|
||||
prompt,
|
||||
platformSource
|
||||
})
|
||||
});
|
||||
|
||||
@@ -87,17 +90,18 @@ export const sessionInitHandler: EventHandler = {
|
||||
|
||||
// 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) {
|
||||
// no need to re-start the SDK agent on every turn.
|
||||
// Note: we do NOT return here — semantic injection below must run on every prompt.
|
||||
const skipAgentInit = Boolean(initResult.contextInjected);
|
||||
if (skipAgentInit) {
|
||||
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) {
|
||||
if (!skipAgentInit && input.platform !== 'cursor' && sessionDbId) {
|
||||
// Strip leading slash from commands for memory agent
|
||||
// /review 101 -> review 101 (more semantic for observations)
|
||||
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
|
||||
@@ -115,14 +119,58 @@ export const sessionInitHandler: EventHandler = {
|
||||
// Log but don't throw - SDK agent failure should not block the user's prompt
|
||||
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
|
||||
}
|
||||
} else if (input.platform === 'cursor') {
|
||||
} else if (!skipAgentInit && input.platform === 'cursor') {
|
||||
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
|
||||
}
|
||||
|
||||
// Semantic context injection: query Chroma for relevant past observations
|
||||
// and inject as additionalContext so Claude receives relevant memory each prompt.
|
||||
// Controlled by CLAUDE_MEM_SEMANTIC_INJECT setting (default: true).
|
||||
const semanticInject =
|
||||
String(settings.CLAUDE_MEM_SEMANTIC_INJECT).toLowerCase() === 'true';
|
||||
let additionalContext = '';
|
||||
|
||||
if (semanticInject && prompt && prompt.length >= 20 && prompt !== '[media prompt]') {
|
||||
try {
|
||||
const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5';
|
||||
const semanticRes = await workerHttpRequest('/api/context/semantic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ q: prompt, project, limit })
|
||||
});
|
||||
if (semanticRes.ok) {
|
||||
const data = await semanticRes.json() as { context: string; count: number };
|
||||
if (data.context) {
|
||||
additionalContext = data.context;
|
||||
logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, {
|
||||
sessionId: sessionDbId, count: data.count
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Graceful degradation — semantic injection is optional
|
||||
logger.debug('HOOK', 'Semantic injection unavailable', {
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | project=${project}`, {
|
||||
sessionId: sessionDbId
|
||||
});
|
||||
|
||||
// Return with semantic context if available
|
||||
if (additionalContext) {
|
||||
return {
|
||||
continue: true,
|
||||
suppressOutput: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-util
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { extractLastMessage } from '../../shared/transcript-parser.js';
|
||||
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
@@ -52,17 +53,30 @@ export const summarizeHandler: EventHandler = {
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
// Skip summary if transcript has no assistant message (prevents repeated
|
||||
// empty summarize requests that pollute logs — upstream bug)
|
||||
if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
|
||||
logger.debug('HOOK', 'No assistant message in transcript - skipping summary', {
|
||||
sessionId,
|
||||
transcriptPath
|
||||
});
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
||||
hasLastAssistantMessage: !!lastAssistantMessage
|
||||
});
|
||||
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
|
||||
const response = await workerHttpRequest('/api/sessions/summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
last_assistant_message: lastAssistantMessage,
|
||||
platformSource
|
||||
}),
|
||||
timeoutMs: SUMMARIZE_TIMEOUT_MS
|
||||
});
|
||||
@@ -77,20 +91,32 @@ export const summarizeHandler: EventHandler = {
|
||||
// This keeps the Stop hook alive (120s timeout) so the SDK agent
|
||||
// can finish processing the summary before SessionEnd kills the session.
|
||||
const waitStart = Date.now();
|
||||
let summaryStored: boolean | null = null;
|
||||
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
try {
|
||||
const statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, {
|
||||
timeoutMs: 5000
|
||||
});
|
||||
if (statusResponse.ok) {
|
||||
const status = await statusResponse.json() as { queueLength?: number };
|
||||
if ((status.queueLength ?? 0) === 0) {
|
||||
logger.info('HOOK', 'Summary processing complete', {
|
||||
const status = await statusResponse.json() as { queueLength?: number; summaryStored?: boolean | null };
|
||||
const queueLength = status.queueLength ?? 0;
|
||||
// Only treat an empty queue as completion when the session exists (non-404).
|
||||
// A 404 means the session was not found — not that processing finished.
|
||||
if (queueLength === 0 && statusResponse.status !== 404) {
|
||||
summaryStored = status.summaryStored ?? null;
|
||||
logger.info('HOOK', 'Summary processing complete', {
|
||||
waitedMs: Date.now() - waitStart,
|
||||
summaryStored
|
||||
});
|
||||
// Warn when the agent processed a summarize request but produced no storable summary.
|
||||
// This is the silent-failure path described in #1633: queue empties but no summary record exists.
|
||||
if (summaryStored === false) {
|
||||
logger.warn('HOOK', 'Summary was not stored: LLM response likely lacked valid <summary> tags (#1633)', {
|
||||
sessionId,
|
||||
waitedMs: Date.now() - waitStart
|
||||
});
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Worker may be busy — keep polling
|
||||
|
||||
+7
-1
@@ -17,7 +17,13 @@ export interface NormalizedHookInput {
|
||||
export interface HookResult {
|
||||
continue?: boolean;
|
||||
suppressOutput?: boolean;
|
||||
hookSpecificOutput?: { hookEventName: string; additionalContext: string };
|
||||
hookSpecificOutput?: {
|
||||
hookEventName: string;
|
||||
additionalContext: string;
|
||||
permissionDecision?: 'allow' | 'deny';
|
||||
permissionDecisionReason?: string;
|
||||
updatedInput?: Record<string, unknown>;
|
||||
};
|
||||
systemMessage?: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,8 @@ export function detectInstalledIDEs(): IDEInfo[] {
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
detected: existsSync(join(home, '.cursor')),
|
||||
supported: false,
|
||||
supported: true,
|
||||
hint: 'hooks + MCP integration',
|
||||
},
|
||||
{
|
||||
id: 'copilot-cli',
|
||||
|
||||
+548
-21
@@ -1,37 +1,564 @@
|
||||
/**
|
||||
* Install command for `npx claude-mem install`.
|
||||
*
|
||||
* Delegates to Claude Code's native plugin system — two commands handle
|
||||
* marketplace registration, plugin installation, dependency setup, and
|
||||
* settings enablement.
|
||||
* Replaces the git-clone + build workflow. The npm package already ships
|
||||
* a pre-built `plugin/` directory; this command copies it into the right
|
||||
* locations and registers it with Claude Code.
|
||||
*
|
||||
* Pure Node.js — no Bun APIs used.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { execSync } from 'child_process';
|
||||
import { cpSync, existsSync, readFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// Non-TTY detection: @clack/prompts crashes with ENOENT in non-TTY environments
|
||||
const isInteractive = process.stdin.isTTY === true;
|
||||
|
||||
/** Run a list of tasks, falling back to plain console.log when non-TTY */
|
||||
interface TaskDescriptor {
|
||||
title: string;
|
||||
task: (message: (msg: string) => void) => Promise<string>;
|
||||
}
|
||||
|
||||
async function runTasks(tasks: TaskDescriptor[]): Promise<void> {
|
||||
if (isInteractive) {
|
||||
await p.tasks(tasks);
|
||||
} else {
|
||||
for (const t of tasks) {
|
||||
const result = await t.task((msg: string) => console.log(` ${msg}`));
|
||||
console.log(` ${result}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Log helpers that fall back to console.log in non-TTY */
|
||||
const log = {
|
||||
info: (msg: string) => isInteractive ? p.log.info(msg) : console.log(` ${msg}`),
|
||||
success: (msg: string) => isInteractive ? p.log.success(msg) : console.log(` ${msg}`),
|
||||
warn: (msg: string) => isInteractive ? p.log.warn(msg) : console.warn(` ${msg}`),
|
||||
error: (msg: string) => isInteractive ? p.log.error(msg) : console.error(` ${msg}`),
|
||||
};
|
||||
import {
|
||||
claudeSettingsPath,
|
||||
ensureDirectoryExists,
|
||||
installedPluginsPath,
|
||||
IS_WINDOWS,
|
||||
knownMarketplacesPath,
|
||||
marketplaceDirectory,
|
||||
npmPackagePluginDirectory,
|
||||
npmPackageRootDirectory,
|
||||
pluginCacheDirectory,
|
||||
pluginsDirectory,
|
||||
readPluginVersion,
|
||||
writeJsonFileAtomic,
|
||||
} from '../utils/paths.js';
|
||||
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||
import { detectInstalledIDEs } from './ide-detection.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function registerMarketplace(): void {
|
||||
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||
|
||||
knownMarketplaces['thedotmack'] = {
|
||||
source: {
|
||||
source: 'github',
|
||||
repo: 'thedotmack/claude-mem',
|
||||
},
|
||||
installLocation: marketplaceDirectory(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
autoUpdate: true,
|
||||
};
|
||||
|
||||
ensureDirectoryExists(pluginsDirectory());
|
||||
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
|
||||
}
|
||||
|
||||
function registerPlugin(version: string): void {
|
||||
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
|
||||
|
||||
if (!installedPlugins.version) installedPlugins.version = 2;
|
||||
if (!installedPlugins.plugins) installedPlugins.plugins = {};
|
||||
|
||||
const cachePath = pluginCacheDirectory(version);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
installedPlugins.plugins['claude-mem@thedotmack'] = [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: cachePath,
|
||||
version,
|
||||
installedAt: now,
|
||||
lastUpdated: now,
|
||||
},
|
||||
];
|
||||
|
||||
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
|
||||
}
|
||||
|
||||
function enablePluginInClaudeSettings(): void {
|
||||
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
|
||||
|
||||
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
||||
settings.enabledPlugins['claude-mem@thedotmack'] = true;
|
||||
|
||||
writeJsonFileAtomic(claudeSettingsPath(), settings);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IDE setup dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns a list of IDE IDs that failed setup. */
|
||||
async function setupIDEs(selectedIDEs: string[]): Promise<string[]> {
|
||||
const failedIDEs: string[] = [];
|
||||
|
||||
for (const ideId of selectedIDEs) {
|
||||
switch (ideId) {
|
||||
case 'claude-code': {
|
||||
// Claude Code uses its native plugin CLI — two commands handle
|
||||
// marketplace registration, plugin installation, and enablement.
|
||||
try {
|
||||
execSync(
|
||||
'claude plugin marketplace add thedotmack/claude-mem && claude plugin install claude-mem',
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
log.success('Claude Code: plugin installed via CLI.');
|
||||
} catch {
|
||||
log.error('Claude Code: plugin install failed. Is `claude` CLI on your PATH?');
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cursor': {
|
||||
const { installCursorHooks, configureCursorMcp } = await import('../../services/integrations/CursorHooksInstaller.js');
|
||||
const cursorResult = await installCursorHooks('user');
|
||||
if (cursorResult === 0) {
|
||||
const mcpResult = configureCursorMcp('user');
|
||||
if (mcpResult === 0) {
|
||||
log.success('Cursor: hooks + MCP installed.');
|
||||
} else {
|
||||
log.success('Cursor: hooks installed (MCP setup failed — run `npx claude-mem cursor mcp` to retry).');
|
||||
}
|
||||
} else {
|
||||
log.error('Cursor: hook installation failed.');
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gemini-cli': {
|
||||
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
|
||||
const geminiResult = await installGeminiCliHooks();
|
||||
if (geminiResult === 0) {
|
||||
log.success('Gemini CLI: hooks installed.');
|
||||
} else {
|
||||
log.error('Gemini CLI: hook installation failed.');
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'opencode': {
|
||||
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
|
||||
const openCodeResult = await installOpenCodeIntegration();
|
||||
if (openCodeResult === 0) {
|
||||
log.success('OpenCode: plugin installed.');
|
||||
} else {
|
||||
log.error('OpenCode: plugin installation failed.');
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'windsurf': {
|
||||
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
|
||||
const windsurfResult = await installWindsurfHooks();
|
||||
if (windsurfResult === 0) {
|
||||
log.success('Windsurf: hooks installed.');
|
||||
} else {
|
||||
log.error('Windsurf: hook installation failed.');
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'openclaw': {
|
||||
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
|
||||
const openClawResult = await installOpenClawIntegration();
|
||||
if (openClawResult === 0) {
|
||||
log.success('OpenClaw: plugin installed.');
|
||||
} else {
|
||||
log.error('OpenClaw: plugin installation failed.');
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex-cli': {
|
||||
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
||||
const codexResult = await installCodexCli();
|
||||
if (codexResult === 0) {
|
||||
log.success('Codex CLI: transcript watching configured.');
|
||||
} else {
|
||||
log.error('Codex CLI: integration setup failed.');
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'copilot-cli':
|
||||
case 'antigravity':
|
||||
case 'goose':
|
||||
case 'crush':
|
||||
case 'roo-code':
|
||||
case 'warp': {
|
||||
const { MCP_IDE_INSTALLERS } = await import('../../services/integrations/McpIntegrations.js');
|
||||
const mcpInstaller = MCP_IDE_INSTALLERS[ideId];
|
||||
if (mcpInstaller) {
|
||||
const mcpResult = await mcpInstaller();
|
||||
const allIDEs = detectInstalledIDEs();
|
||||
const ideInfo = allIDEs.find((i) => i.id === ideId);
|
||||
const ideLabel = ideInfo?.label ?? ideId;
|
||||
if (mcpResult === 0) {
|
||||
log.success(`${ideLabel}: MCP integration installed.`);
|
||||
} else {
|
||||
log.error(`${ideLabel}: MCP integration failed.`);
|
||||
failedIDEs.push(ideId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const allIDEs = detectInstalledIDEs();
|
||||
const ide = allIDEs.find((i) => i.id === ideId);
|
||||
if (ide && !ide.supported) {
|
||||
log.warn(`Support for ${ide.label} coming soon.`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failedIDEs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive IDE selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function promptForIDESelection(): Promise<string[]> {
|
||||
const detectedIDEs = detectInstalledIDEs();
|
||||
const detected = detectedIDEs.filter((ide) => ide.detected);
|
||||
|
||||
if (detected.length === 0) {
|
||||
log.warn('No supported IDEs detected. Installing for Claude Code by default.');
|
||||
return ['claude-code'];
|
||||
}
|
||||
|
||||
const options = detected.map((ide) => ({
|
||||
value: ide.id,
|
||||
label: ide.label,
|
||||
hint: ide.supported ? ide.hint : 'coming soon',
|
||||
}));
|
||||
|
||||
const result = await p.multiselect({
|
||||
message: 'Which IDEs do you use?',
|
||||
options,
|
||||
initialValues: detected
|
||||
.filter((ide) => ide.supported)
|
||||
.map((ide) => ide.id),
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(result)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return result as string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core copy logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function copyPluginToMarketplace(): void {
|
||||
const marketplaceDir = marketplaceDirectory();
|
||||
const packageRoot = npmPackageRootDirectory();
|
||||
|
||||
ensureDirectoryExists(marketplaceDir);
|
||||
|
||||
// Only copy directories/files that are actually needed at runtime.
|
||||
// The npm package ships plugin/, package.json, node_modules/, openclaw/, dist/.
|
||||
// When running from a dev checkout, the root contains many extra dirs
|
||||
// (.claude, .agents, src, docs, etc.) that must NOT be copied.
|
||||
const allowedTopLevelEntries = [
|
||||
'plugin',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'node_modules',
|
||||
'openclaw',
|
||||
'dist',
|
||||
'LICENSE',
|
||||
'README.md',
|
||||
'CHANGELOG.md',
|
||||
];
|
||||
|
||||
for (const entry of allowedTopLevelEntries) {
|
||||
const sourcePath = join(packageRoot, entry);
|
||||
const destPath = join(marketplaceDir, entry);
|
||||
if (!existsSync(sourcePath)) continue;
|
||||
|
||||
// Clean replace: remove stale files from previous installs before copying
|
||||
if (existsSync(destPath)) {
|
||||
rmSync(destPath, { recursive: true, force: true });
|
||||
}
|
||||
cpSync(sourcePath, destPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function copyPluginToCache(version: string): void {
|
||||
const sourcePluginDirectory = npmPackagePluginDirectory();
|
||||
const cachePath = pluginCacheDirectory(version);
|
||||
|
||||
// Clean replace: remove stale cache before copying
|
||||
rmSync(cachePath, { recursive: true, force: true });
|
||||
ensureDirectoryExists(cachePath);
|
||||
cpSync(sourcePluginDirectory, cachePath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// npm install in marketplace dir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runNpmInstallInMarketplace(): void {
|
||||
const marketplaceDir = marketplaceDirectory();
|
||||
const packageJsonPath = join(marketplaceDir, 'package.json');
|
||||
|
||||
if (!existsSync(packageJsonPath)) return;
|
||||
|
||||
execSync('npm install --production', {
|
||||
cwd: marketplaceDir,
|
||||
stdio: 'pipe',
|
||||
...(IS_WINDOWS ? { shell: true as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger smart-install for Bun / uv
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runSmartInstall(): boolean {
|
||||
const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js');
|
||||
|
||||
if (!existsSync(smartInstallPath)) {
|
||||
log.warn('smart-install.js not found — skipping Bun/uv auto-install.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`node "${smartInstallPath}"`, {
|
||||
stdio: 'inherit',
|
||||
...(IS_WINDOWS ? { shell: true as const } : {}),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstallOptions {
|
||||
/** Unused — kept for CLI compat. IDE integrations are separate. */
|
||||
/** When provided, skip the interactive IDE multi-select and use this IDE. */
|
||||
ide?: string;
|
||||
}
|
||||
|
||||
export async function runInstallCommand(_options: InstallOptions = {}): Promise<void> {
|
||||
console.log(pc.bold('claude-mem install'));
|
||||
console.log();
|
||||
export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
|
||||
const version = readPluginVersion();
|
||||
|
||||
try {
|
||||
execSync(
|
||||
'claude plugin marketplace add thedotmack/claude-mem && claude plugin install claude-mem',
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(pc.red('Installation failed.'));
|
||||
console.error('Make sure Claude Code CLI is installed and on your PATH.');
|
||||
process.exit(1);
|
||||
if (isInteractive) {
|
||||
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
|
||||
} else {
|
||||
console.log('claude-mem install');
|
||||
}
|
||||
log.info(`Version: ${pc.cyan(version)}`);
|
||||
log.info(`Platform: ${process.platform} (${process.arch})`);
|
||||
|
||||
// Check for existing installation
|
||||
const marketplaceDir = marketplaceDirectory();
|
||||
const alreadyInstalled = existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'));
|
||||
|
||||
if (alreadyInstalled) {
|
||||
// Read existing version
|
||||
try {
|
||||
const existingPluginJson = JSON.parse(
|
||||
readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'),
|
||||
);
|
||||
log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`);
|
||||
} catch {
|
||||
log.warn('Existing installation detected.');
|
||||
}
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
const shouldContinue = await p.confirm({
|
||||
message: 'Overwrite existing installation?',
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(pc.green('claude-mem installed successfully!'));
|
||||
console.log();
|
||||
console.log('Open Claude Code and start a conversation — memory is automatic.');
|
||||
// IDE selection
|
||||
let selectedIDEs: string[];
|
||||
if (options.ide) {
|
||||
selectedIDEs = [options.ide];
|
||||
const allIDEs = detectInstalledIDEs();
|
||||
const match = allIDEs.find((i) => i.id === options.ide);
|
||||
if (match && !match.supported) {
|
||||
log.error(`Support for ${match.label} coming soon.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!match) {
|
||||
log.error(`Unknown IDE: ${options.ide}`);
|
||||
log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (process.stdin.isTTY) {
|
||||
selectedIDEs = await promptForIDESelection();
|
||||
} else {
|
||||
// Non-interactive: default to claude-code
|
||||
selectedIDEs = ['claude-code'];
|
||||
}
|
||||
|
||||
// Non-Claude-Code IDEs need the manual file copy / registration flow.
|
||||
// Claude Code handles its own installation via `claude plugin install`.
|
||||
const needsManualInstall = selectedIDEs.some((id) => id !== 'claude-code');
|
||||
|
||||
if (needsManualInstall) {
|
||||
await runTasks([
|
||||
{
|
||||
title: 'Copying plugin files',
|
||||
task: async (message) => {
|
||||
message('Copying to marketplace directory...');
|
||||
copyPluginToMarketplace();
|
||||
return `Plugin files copied ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Caching plugin version',
|
||||
task: async (message) => {
|
||||
message(`Caching v${version}...`);
|
||||
copyPluginToCache(version);
|
||||
return `Plugin cached (v${version}) ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Registering marketplace',
|
||||
task: async () => {
|
||||
registerMarketplace();
|
||||
return `Marketplace registered ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Registering plugin',
|
||||
task: async () => {
|
||||
registerPlugin(version);
|
||||
return `Plugin registered ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Enabling plugin in Claude settings',
|
||||
task: async () => {
|
||||
enablePluginInClaudeSettings();
|
||||
return `Plugin enabled ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Installing dependencies',
|
||||
task: async (message) => {
|
||||
message('Running npm install...');
|
||||
try {
|
||||
runNpmInstallInMarketplace();
|
||||
return `Dependencies installed ${pc.green('OK')}`;
|
||||
} catch {
|
||||
return `Dependencies may need manual install ${pc.yellow('!')}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Setting up Bun and uv',
|
||||
task: async (message) => {
|
||||
message('Running smart-install...');
|
||||
return runSmartInstall()
|
||||
? `Runtime dependencies ready ${pc.green('OK')}`
|
||||
: `Runtime setup may need attention ${pc.yellow('!')}`;
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// IDE-specific setup
|
||||
const failedIDEs = await setupIDEs(selectedIDEs);
|
||||
|
||||
// Summary
|
||||
const installStatus = failedIDEs.length > 0 ? 'Installation Partial' : 'Installation Complete';
|
||||
const summaryLines = [
|
||||
`Version: ${pc.cyan(version)}`,
|
||||
`Plugin dir: ${pc.cyan(marketplaceDir)}`,
|
||||
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`,
|
||||
];
|
||||
if (failedIDEs.length > 0) {
|
||||
summaryLines.push(`Failed: ${pc.red(failedIDEs.join(', '))}`);
|
||||
}
|
||||
|
||||
if (isInteractive) {
|
||||
p.note(summaryLines.join('\n'), installStatus);
|
||||
} else {
|
||||
console.log(`\n ${installStatus}`);
|
||||
summaryLines.forEach(l => console.log(` ${l}`));
|
||||
}
|
||||
|
||||
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||
const nextSteps = [
|
||||
'Open Claude Code and start a conversation -- memory is automatic!',
|
||||
`View your memories: ${pc.underline(`http://localhost:${workerPort}`)}`,
|
||||
`Search past work: use ${pc.bold('/mem-search')} in Claude Code`,
|
||||
`Start worker: ${pc.bold('npx claude-mem start')}`,
|
||||
];
|
||||
|
||||
if (isInteractive) {
|
||||
p.note(nextSteps.join('\n'), 'Next Steps');
|
||||
if (failedIDEs.length > 0) {
|
||||
p.outro(pc.yellow('claude-mem installed with some IDE setup failures.'));
|
||||
} else {
|
||||
p.outro(pc.green('claude-mem installed successfully!'));
|
||||
}
|
||||
} else {
|
||||
console.log('\n Next Steps');
|
||||
nextSteps.forEach(l => console.log(` ${l}`));
|
||||
if (failedIDEs.length > 0) {
|
||||
console.log('\nclaude-mem installed with some IDE setup failures.');
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('\nclaude-mem installed successfully!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,45 @@ export function runStatusCommand(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the worker API at `GET /api/search?q=<query>`.
|
||||
* Stamp merged-worktree provenance on observations/summaries and keep Chroma
|
||||
* metadata in lockstep. Delegates to the worker-service.cjs `adopt` subcommand
|
||||
* so adoption runs in Bun (needed for bun:sqlite) while preserving the user's
|
||||
* working directory — that's what the engine uses to locate the parent repo.
|
||||
*/
|
||||
export function runAdoptCommand(extraArgs: string[] = []): void {
|
||||
ensureInstalledOrExit();
|
||||
const bunPath = resolveBunOrExit();
|
||||
const workerScript = workerServiceScriptPath();
|
||||
|
||||
if (!existsSync(workerScript)) {
|
||||
console.error(pc.red(`Worker script not found at: ${workerScript}`));
|
||||
console.error('The installation may be corrupted. Try: npx claude-mem install');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pass user's cwd explicitly via --cwd because we override cwd on spawn to
|
||||
// marketplaceDirectory() (required for the worker's own file resolution).
|
||||
const userCwd = process.cwd();
|
||||
const args = [workerScript, 'adopt', '--cwd', userCwd, ...extraArgs];
|
||||
|
||||
const child = spawn(bunPath, args, {
|
||||
stdio: 'inherit',
|
||||
cwd: marketplaceDirectory(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(pc.red(`Failed to start Bun: ${error.message}`));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('close', (exitCode) => {
|
||||
process.exit(exitCode ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the worker API at `GET /api/search?query=<query>`.
|
||||
*/
|
||||
export async function runSearchCommand(queryParts: string[]): Promise<void> {
|
||||
ensureInstalledOrExit();
|
||||
@@ -114,7 +152,7 @@ export async function runSearchCommand(queryParts: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?q=${encodeURIComponent(query)}`;
|
||||
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?query=${encodeURIComponent(query)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(searchUrl);
|
||||
|
||||
@@ -52,6 +52,7 @@ ${pc.bold('Runtime Commands')} (requires Bun, delegates to installed plugin):
|
||||
${pc.cyan('npx claude-mem restart')} Restart worker service
|
||||
${pc.cyan('npx claude-mem status')} Show worker status
|
||||
${pc.cyan('npx claude-mem search <query>')} Search observations
|
||||
${pc.cyan('npx claude-mem adopt [--dry-run] [--branch <name>]')} Stamp merged worktrees into parent project
|
||||
${pc.cyan('npx claude-mem transcript watch')} Start transcript watcher
|
||||
|
||||
${pc.bold('IDE Identifiers')}:
|
||||
@@ -145,6 +146,13 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Adopt merged worktrees -------------------------------------------
|
||||
case 'adopt': {
|
||||
const { runAdoptCommand } = await import('./commands/runtime.js');
|
||||
runAdoptCommand(args.slice(1));
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Transcript --------------------------------------------------------
|
||||
case 'transcript': {
|
||||
const subCommand = args[1]?.toLowerCase();
|
||||
|
||||
+26
-5
@@ -50,9 +50,8 @@ export function parseObservations(text: string, correlationId?: string): ParsedO
|
||||
const files_read = extractArrayElements(obsContent, 'files_read', 'file');
|
||||
const files_modified = extractArrayElements(obsContent, 'files_modified', 'file');
|
||||
|
||||
// NOTE FROM THEDOTMACK: ALWAYS save observations - never skip. 10/24/2025
|
||||
// All fields except type are nullable in schema
|
||||
// If type is missing or invalid, use first type from mode as fallback
|
||||
// All fields except type are nullable in schema.
|
||||
// If type is missing or invalid, use first type from mode as fallback.
|
||||
|
||||
// Determine final type using active mode's valid types
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
@@ -75,7 +74,7 @@ export function parseObservations(text: string, correlationId?: string): ParsedO
|
||||
const cleanedConcepts = concepts.filter(c => c !== finalType);
|
||||
|
||||
if (cleanedConcepts.length !== concepts.length) {
|
||||
logger.error('PARSER', 'Removed observation type from concepts array', {
|
||||
logger.debug('PARSER', 'Removed observation type from concepts array', {
|
||||
correlationId,
|
||||
type: finalType,
|
||||
originalConcepts: concepts,
|
||||
@@ -83,6 +82,19 @@ export function parseObservations(text: string, correlationId?: string): ParsedO
|
||||
});
|
||||
}
|
||||
|
||||
// Skip ghost observations — records where every content field is null/empty.
|
||||
// These accumulate when the LLM emits a bare <observation/> (or one with only <type>)
|
||||
// due to context overflow. They carry no information and pollute the context window.
|
||||
// (subtitle and file lists are intentionally excluded from this guard: an observation
|
||||
// with only a subtitle is still too thin to be useful on its own.)
|
||||
if (!title && !narrative && facts.length === 0 && cleanedConcepts.length === 0) {
|
||||
logger.warn('PARSER', 'Skipping empty observation (all content fields null)', {
|
||||
correlationId,
|
||||
type: finalType
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
observations.push({
|
||||
type: finalType,
|
||||
title,
|
||||
@@ -138,7 +150,7 @@ export function parseSummary(text: string, sessionId?: number): ParsedSummary |
|
||||
const next_steps = extractField(summaryContent, 'next_steps');
|
||||
const notes = extractField(summaryContent, 'notes'); // Optional
|
||||
|
||||
// NOTE FROM THEDOTMACK: 100% of the time we must SAVE the summary, even if fields are missing. 10/24/2025
|
||||
// NOTE FROM THEDOTMACK: 100% of the time we must SAVE the summary, even if fields are missing. 10/24/2025
|
||||
// NEVER DO THIS NONSENSE AGAIN.
|
||||
|
||||
// Validate required fields are present (notes is optional)
|
||||
@@ -154,6 +166,15 @@ export function parseSummary(text: string, sessionId?: number): ParsedSummary |
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// Guard: if NO sub-tags matched at all, this is a false positive —
|
||||
// <summary> accidentally appeared inside an <observation> response with no structured content.
|
||||
// This is NOT the same as missing some fields (which we intentionally allow above).
|
||||
// Fix for #1360.
|
||||
if (!request && !investigated && !learned && !completed && !next_steps) {
|
||||
logger.warn('PARSER', 'Summary match has no sub-tags — skipping false positive', { sessionId });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
investigated,
|
||||
|
||||
+6
-2
@@ -116,7 +116,11 @@ export function buildObservationPrompt(obs: Observation): string {
|
||||
<occurred_at>${new Date(obs.created_at_epoch).toISOString()}</occurred_at>${obs.cwd ? `\n <working_directory>${obs.cwd}</working_directory>` : ''}
|
||||
<parameters>${JSON.stringify(toolInput, null, 2)}</parameters>
|
||||
<outcome>${JSON.stringify(toolOutput, null, 2)}</outcome>
|
||||
</observed_from_primary_session>`;
|
||||
</observed_from_primary_session>
|
||||
|
||||
Return either one or more <observation>...</observation> blocks, or an empty response if this tool use should be skipped.
|
||||
Concrete debugging findings from logs, queue state, database rows, session routing, or code-path inspection count as durable discoveries and should be recorded.
|
||||
Never reply with prose such as "Skipping", "No substantive tool executions", or any explanation outside XML. Non-XML text is discarded.`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,4 +239,4 @@ ${mode.prompts.format_examples}
|
||||
${mode.prompts.footer}
|
||||
|
||||
${mode.prompts.header_memory_continued}`;
|
||||
}
|
||||
}
|
||||
|
||||
+254
-8
@@ -16,7 +16,6 @@ import { logger } from '../utils/logger.js';
|
||||
// CRITICAL: Redirect console to stderr BEFORE other imports
|
||||
// MCP uses stdio transport where stdout is reserved for JSON-RPC protocol messages.
|
||||
// Any logs to stdout break the protocol (Claude Desktop parses "[2025..." as JSON array).
|
||||
const _originalLog = console['log'];
|
||||
console['log'] = (...args: any[]) => {
|
||||
logger.error('CONSOLE', 'Intercepted console output (MCP protocol protection)', undefined, { args });
|
||||
};
|
||||
@@ -27,11 +26,70 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { workerHttpRequest } from '../shared/worker-utils.js';
|
||||
import { getWorkerPort, workerHttpRequest } from '../shared/worker-utils.js';
|
||||
import { ensureWorkerStarted } from '../services/worker-spawner.js';
|
||||
import { searchCodebase, formatSearchResults } from '../services/smart-file-read/search.js';
|
||||
import { parseFile, formatFoldedView, unfoldSymbol } from '../services/smart-file-read/parser.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Resolve the path to worker-service.cjs, which lives alongside mcp-server.cjs
|
||||
// in the plugin's scripts directory. We need an explicit path because the MCP
|
||||
// server runs under Node while the worker must run under Bun, so we can't rely
|
||||
// on `__filename` pointing to a self-spawnable script.
|
||||
//
|
||||
// In the deployed CJS bundle, `__dirname` is always defined — the import.meta
|
||||
// fallback only exists to keep the source future-proof against an eventual
|
||||
// ESM port. Both fallback branches should be functionally unreachable today.
|
||||
let mcpServerDirResolutionFailed = false;
|
||||
const mcpServerDir = (() => {
|
||||
if (typeof __dirname !== 'undefined') return __dirname;
|
||||
try {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
// Last-ditch fallback: cwd is almost certainly wrong, but throwing here
|
||||
// would crash the MCP server before it can serve a single request. Mark
|
||||
// the failure so the existence check below can produce a single, loud,
|
||||
// root-cause-attributing log line instead of a confusing "missing worker
|
||||
// bundle" warning that hides the dirname resolution failure.
|
||||
mcpServerDirResolutionFailed = true;
|
||||
return process.cwd();
|
||||
}
|
||||
})();
|
||||
const WORKER_SCRIPT_PATH = resolve(mcpServerDir, 'worker-service.cjs');
|
||||
|
||||
/**
|
||||
* Surface a clear, actionable error if the worker bundle isn't where we
|
||||
* expect. Without this check, a missing or partial install only fails later
|
||||
* inside spawnDaemon as a generic "failed to spawn" message.
|
||||
*
|
||||
* If dirname resolution itself failed (extremely unlikely in CJS), attribute
|
||||
* the missing-bundle warning to the root cause so the user doesn't waste time
|
||||
* looking for an install bug that doesn't exist.
|
||||
*
|
||||
* Called lazily from `ensureWorkerConnection` (not at module load) so that
|
||||
* tests or tools that import this module without booting the MCP server
|
||||
* don't see noisy ERROR-level log lines for a worker they never intended
|
||||
* to start. The check is cheap and idempotent, so calling it on every
|
||||
* auto-start attempt is fine.
|
||||
*/
|
||||
function errorIfWorkerScriptMissing(): void {
|
||||
// Only log here when the dirname resolution itself failed — that's the
|
||||
// mcp-server-specific root cause attribution that the spawner cannot
|
||||
// provide. The plain "missing bundle" case is already covered by the
|
||||
// existsSync guard inside ensureWorkerStarted, and logging from both
|
||||
// sites would produce a confusing double-log on the same code path.
|
||||
if (!mcpServerDirResolutionFailed) return;
|
||||
if (existsSync(WORKER_SCRIPT_PATH)) return;
|
||||
|
||||
logger.error(
|
||||
'SYSTEM',
|
||||
'mcp-server: dirname resolution failed (both __dirname and import.meta.url are unavailable). Fell back to process.cwd() and the resolved WORKER_SCRIPT_PATH does not exist. This is the actual problem — the worker bundle is fine, but mcp-server cannot locate it. Worker auto-start will fail until the dirname-resolution path is fixed.',
|
||||
{ workerScriptPath: WORKER_SCRIPT_PATH, mcpServerDir }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map tool names to Worker HTTP endpoints
|
||||
@@ -144,6 +202,44 @@ async function verifyWorkerConnection(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Worker is available for Codex and other MCP-only clients.
|
||||
* Claude hooks already start the worker; this path makes Codex turnkey.
|
||||
*/
|
||||
async function ensureWorkerConnection(): Promise<boolean> {
|
||||
if (await verifyWorkerConnection()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn('SYSTEM', 'Worker not available, attempting auto-start for MCP client');
|
||||
|
||||
// Validate the worker bundle path lazily here (rather than at module load)
|
||||
// so that tests/tools that import this module without booting the MCP
|
||||
// server don't see noisy ERROR-level log lines for a worker they never
|
||||
// intended to start.
|
||||
errorIfWorkerScriptMissing();
|
||||
|
||||
try {
|
||||
const port = getWorkerPort();
|
||||
const started = await ensureWorkerStarted(port, WORKER_SCRIPT_PATH);
|
||||
if (!started) {
|
||||
logger.error(
|
||||
'SYSTEM',
|
||||
'Worker auto-start returned false — MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running. Check earlier log lines for the specific failure reason (Bun not found, missing worker bundle, port conflict, etc.).'
|
||||
);
|
||||
}
|
||||
return started;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'SYSTEM',
|
||||
'Worker auto-start threw — MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running.',
|
||||
undefined,
|
||||
error as Error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definitions with HTTP-based handlers
|
||||
* Minimal descriptions - use help() tool with operation parameter for detailed docs
|
||||
@@ -188,7 +284,17 @@ NEVER fetch full details without filtering first. 10x token savings.`,
|
||||
description: 'Step 1: Search memory. Returns index with IDs. Params: query, limit, project, type, obs_type, dateStart, dateEnd, offset, orderBy',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
limit: { type: 'number', description: 'Max results (default 20)' },
|
||||
project: { type: 'string', description: 'Filter by project name' },
|
||||
type: { type: 'string', description: 'Filter by observation type' },
|
||||
obs_type: { type: 'string', description: 'Filter by obs_type field' },
|
||||
dateStart: { type: 'string', description: 'Start date filter (ISO)' },
|
||||
dateEnd: { type: 'string', description: 'End date filter (ISO)' },
|
||||
offset: { type: 'number', description: 'Pagination offset' },
|
||||
orderBy: { type: 'string', description: 'Sort order: date_desc or date_asc' }
|
||||
},
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
@@ -201,7 +307,13 @@ NEVER fetch full details without filtering first. 10x token savings.`,
|
||||
description: 'Step 2: Get context around results. Params: anchor (observation ID) OR query (finds anchor automatically), depth_before, depth_after, project',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
properties: {
|
||||
anchor: { type: 'number', description: 'Observation ID to center the timeline around' },
|
||||
query: { type: 'string', description: 'Query to find anchor automatically' },
|
||||
depth_before: { type: 'number', description: 'Items before anchor (default 3)' },
|
||||
depth_after: { type: 'number', description: 'Items after anchor (default 3)' },
|
||||
project: { type: 'string', description: 'Filter by project name' }
|
||||
},
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
@@ -339,6 +451,111 @@ NEVER fetch full details without filtering first. 10x token savings.`,
|
||||
}]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'build_corpus',
|
||||
description: 'Build a knowledge corpus from filtered observations. Creates a queryable knowledge agent. Params: name (required), description, project, types (comma-separated), concepts (comma-separated), files (comma-separated), query, dateStart, dateEnd, limit',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Corpus name (used as filename)' },
|
||||
description: { type: 'string', description: 'What this corpus is about' },
|
||||
project: { type: 'string', description: 'Filter by project' },
|
||||
types: { type: 'string', description: 'Comma-separated observation types: decision,bugfix,feature,refactor,discovery,change' },
|
||||
concepts: { type: 'string', description: 'Comma-separated concepts to filter by' },
|
||||
files: { type: 'string', description: 'Comma-separated file paths to filter by' },
|
||||
query: { type: 'string', description: 'Semantic search query' },
|
||||
dateStart: { type: 'string', description: 'Start date (ISO format)' },
|
||||
dateEnd: { type: 'string', description: 'End date (ISO format)' },
|
||||
limit: { type: 'number', description: 'Maximum observations (default 500)' }
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIPost('/api/corpus', args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_corpora',
|
||||
description: 'List all knowledge corpora with their stats and priming status',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPI('/api/corpus', args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'prime_corpus',
|
||||
description: 'Prime a knowledge corpus — creates an AI session loaded with the corpus knowledge. Must be called before query_corpus.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name of the corpus to prime' }
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const { name, ...rest } = args;
|
||||
if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name');
|
||||
return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/prime`, rest);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'query_corpus',
|
||||
description: 'Ask a question to a primed knowledge corpus. The corpus must be primed first with prime_corpus.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name of the corpus to query' },
|
||||
question: { type: 'string', description: 'The question to ask' }
|
||||
},
|
||||
required: ['name', 'question'],
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const { name, ...rest } = args;
|
||||
if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name');
|
||||
return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/query`, rest);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'rebuild_corpus',
|
||||
description: 'Rebuild a knowledge corpus from its stored filter — re-runs the search to refresh with new observations. Does not re-prime the session.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name of the corpus to rebuild' }
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const { name, ...rest } = args;
|
||||
if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name');
|
||||
return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/rebuild`, rest);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'reprime_corpus',
|
||||
description: 'Create a fresh knowledge agent session for a corpus, clearing prior Q&A context. Use when conversation has drifted or after rebuilding.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name of the corpus to reprime' }
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: true
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const { name, ...rest } = args;
|
||||
if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name');
|
||||
return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/reprime`, rest);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -392,6 +609,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
// Prevents orphaned MCP server processes when Claude Code exits unexpectedly
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let isCleaningUp = false;
|
||||
|
||||
function handleStdioClosed() {
|
||||
cleanup('stdio-closed');
|
||||
}
|
||||
|
||||
function handleStdioError(error: Error) {
|
||||
logger.warn('SYSTEM', 'MCP stdio stream errored, shutting down', {
|
||||
message: error.message
|
||||
});
|
||||
cleanup('stdio-error');
|
||||
}
|
||||
|
||||
function attachStdioLifecycle() {
|
||||
process.stdin.on('end', handleStdioClosed);
|
||||
process.stdin.on('close', handleStdioClosed);
|
||||
process.stdin.on('error', handleStdioError);
|
||||
}
|
||||
|
||||
function detachStdioLifecycle() {
|
||||
process.stdin.off('end', handleStdioClosed);
|
||||
process.stdin.off('close', handleStdioClosed);
|
||||
process.stdin.off('error', handleStdioError);
|
||||
}
|
||||
|
||||
function startParentHeartbeat() {
|
||||
// ppid-based orphan detection only works on Unix
|
||||
@@ -414,9 +655,13 @@ function startParentHeartbeat() {
|
||||
|
||||
// Cleanup function — synchronous to ensure consistent behavior whether called
|
||||
// from signal handlers, heartbeat interval, or awaited in async context
|
||||
function cleanup() {
|
||||
function cleanup(reason: string = 'shutdown') {
|
||||
if (isCleaningUp) return;
|
||||
isCleaningUp = true;
|
||||
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
logger.info('SYSTEM', 'MCP server shutting down');
|
||||
detachStdioLifecycle();
|
||||
logger.info('SYSTEM', 'MCP server shutting down', { reason });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -428,6 +673,7 @@ process.on('SIGINT', cleanup);
|
||||
async function main() {
|
||||
// Start the MCP server
|
||||
const transport = new StdioServerTransport();
|
||||
attachStdioLifecycle();
|
||||
await server.connect(transport);
|
||||
logger.info('SYSTEM', 'Claude-mem search server started');
|
||||
|
||||
@@ -436,7 +682,7 @@ async function main() {
|
||||
|
||||
// Check Worker availability in background
|
||||
setTimeout(async () => {
|
||||
const workerAvailable = await verifyWorkerConnection();
|
||||
const workerAvailable = await ensureWorkerConnection();
|
||||
if (!workerAvailable) {
|
||||
logger.error('SYSTEM', 'Worker not available', undefined, {});
|
||||
logger.error('SYSTEM', 'Tools will fail until Worker is started');
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23832 | 11:15 PM | 🔵 | Current worker-service.ts Lacks Admin Endpoints | ~393 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26740 | 11:26 PM | 🔵 | Worker Service Refactored to Orchestrator with Background Initialization | ~421 |
|
||||
| #26739 | 11:25 PM | 🔵 | Worker Service Architecture Uses Domain Services and Background Initialization | ~438 |
|
||||
| #26255 | 8:31 PM | 🔵 | Context Generator Timeline Rendering Logic Details File Grouping Implementation | ~397 |
|
||||
| #26251 | 8:30 PM | 🔵 | Worker Service Orchestrates Domain Services and Route Handlers | ~292 |
|
||||
| #26246 | 8:29 PM | 🔵 | Context Generator Implements Rich Date-Grouped Timeline Format | ~468 |
|
||||
|
||||
### Dec 17, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28548 | 4:49 PM | 🔵 | Worker service cleanup method uses Unix-specific process management | ~323 |
|
||||
| #28446 | 4:23 PM | 🔵 | Worker Service Refactored to Orchestrator Pattern | ~529 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29340 | 3:11 PM | ✅ | Constructor Initialization Comment Updated | ~267 |
|
||||
| #29339 | " | ✅ | Class Member Comment Updated in WorkerService | ~267 |
|
||||
| #29338 | " | ✅ | Service Import Comment Updated | ~222 |
|
||||
| #29337 | 3:10 PM | ✅ | Terminology Update in Worker Service Documentation | ~268 |
|
||||
| #29239 | 12:11 AM | 🔵 | Worker Service Refactored as Domain-Driven Orchestrator | ~477 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30808 | 6:05 PM | 🔴 | Fixed worker readiness check to fail on initialization errors | ~315 |
|
||||
| #30800 | 6:03 PM | 🔵 | Dual Error Logging in Background Initialization | ~367 |
|
||||
| #30799 | " | 🔵 | Background Initialization Invocation Pattern | ~365 |
|
||||
| #30797 | " | 🔵 | Background Initialization Sequence and Error Handler Confirmed | ~450 |
|
||||
| #30795 | 6:02 PM | 🔵 | Readiness Endpoint Returns 503 During Initialization | ~397 |
|
||||
| #30793 | " | 🔵 | Dual Initialization State Tracking Pattern | ~388 |
|
||||
| #30791 | " | 🔵 | Worker Service Constructor Defers SearchRoutes Initialization | ~387 |
|
||||
| #30790 | " | 🔵 | Initialization Promise Resolver Pattern Located | ~321 |
|
||||
| #30788 | " | 🔵 | Worker Service Initialization Resolves Promise Despite Errors | ~388 |
|
||||
|
||||
### Jan 1, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35654 | 11:29 PM | ✅ | Added APPROVED OVERRIDE annotation for instruction loading HTTP route error handler | ~339 |
|
||||
| #35651 | 11:28 PM | ✅ | Added APPROVED OVERRIDE annotation for shutdown error handler with process.exit | ~354 |
|
||||
| #35649 | " | ✅ | Added APPROVED OVERRIDE annotation for readiness check retry loop error handling | ~374 |
|
||||
| #35647 | " | ✅ | Added APPROVED OVERRIDE annotation for port availability probe error handling | ~327 |
|
||||
| #35646 | " | ✅ | Added APPROVED OVERRIDE annotation for Cursor context file update error handling | ~342 |
|
||||
| #35643 | 11:27 PM | ✅ | Added APPROVED OVERRIDE annotation for PID file cleanup error handling | ~320 |
|
||||
</claude-mem-context>
|
||||
@@ -10,7 +10,7 @@ import { homedir } from 'os';
|
||||
import { unlinkSync } from 'fs';
|
||||
import { SessionStore } from '../sqlite/SessionStore.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getProjectName } from '../../utils/project-name.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
|
||||
import type { ContextInput, ContextConfig, Observation, SessionSummary } from './types.js';
|
||||
import { loadContextConfig } from './ContextConfigLoader.js';
|
||||
@@ -29,8 +29,8 @@ import { renderHeader } from './sections/HeaderRenderer.js';
|
||||
import { renderTimeline } from './sections/TimelineRenderer.js';
|
||||
import { shouldShowSummary, renderSummaryFields } from './sections/SummaryRenderer.js';
|
||||
import { renderPreviouslySection, renderFooter } from './sections/FooterRenderer.js';
|
||||
import { renderMarkdownEmptyState } from './formatters/MarkdownFormatter.js';
|
||||
import { renderColorEmptyState } from './formatters/ColorFormatter.js';
|
||||
import { renderAgentEmptyState } from './formatters/AgentFormatter.js';
|
||||
import { renderHumanEmptyState } from './formatters/HumanFormatter.js';
|
||||
|
||||
// Version marker path for native module error handling
|
||||
const VERSION_MARKER_PATH = path.join(
|
||||
@@ -66,8 +66,8 @@ function initializeDatabase(): SessionStore | null {
|
||||
/**
|
||||
* Render empty state when no data exists
|
||||
*/
|
||||
function renderEmptyState(project: string, useColors: boolean): string {
|
||||
return useColors ? renderColorEmptyState(project) : renderMarkdownEmptyState(project);
|
||||
function renderEmptyState(project: string, forHuman: boolean): string {
|
||||
return forHuman ? renderHumanEmptyState(project) : renderAgentEmptyState(project);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +80,7 @@ function buildContextOutput(
|
||||
config: ContextConfig,
|
||||
cwd: string,
|
||||
sessionId: string | undefined,
|
||||
useColors: boolean
|
||||
forHuman: boolean
|
||||
): string {
|
||||
const output: string[] = [];
|
||||
|
||||
@@ -88,7 +88,7 @@ function buildContextOutput(
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
// Render header section
|
||||
output.push(...renderHeader(project, economics, config, useColors));
|
||||
output.push(...renderHeader(project, economics, config, forHuman));
|
||||
|
||||
// Prepare timeline data
|
||||
const displaySummaries = summaries.slice(0, config.sessionCount);
|
||||
@@ -97,22 +97,22 @@ function buildContextOutput(
|
||||
const fullObservationIds = getFullObservationIds(observations, config.fullObservationCount);
|
||||
|
||||
// Render timeline
|
||||
output.push(...renderTimeline(timeline, fullObservationIds, config, cwd, useColors));
|
||||
output.push(...renderTimeline(timeline, fullObservationIds, config, cwd, forHuman));
|
||||
|
||||
// Render most recent summary if applicable
|
||||
const mostRecentSummary = summaries[0];
|
||||
const mostRecentObservation = observations[0];
|
||||
|
||||
if (shouldShowSummary(config, mostRecentSummary, mostRecentObservation)) {
|
||||
output.push(...renderSummaryFields(mostRecentSummary, useColors));
|
||||
output.push(...renderSummaryFields(mostRecentSummary, forHuman));
|
||||
}
|
||||
|
||||
// Render previously section (prior assistant message)
|
||||
const priorMessages = getPriorSessionMessages(observations, config, sessionId, cwd);
|
||||
output.push(...renderPreviouslySection(priorMessages, useColors));
|
||||
output.push(...renderPreviouslySection(priorMessages, forHuman));
|
||||
|
||||
// Render footer
|
||||
output.push(...renderFooter(economics, config, useColors));
|
||||
output.push(...renderFooter(economics, config, forHuman));
|
||||
|
||||
return output.join('\n').trimEnd();
|
||||
}
|
||||
@@ -125,14 +125,19 @@ function buildContextOutput(
|
||||
*/
|
||||
export async function generateContext(
|
||||
input?: ContextInput,
|
||||
useColors: boolean = false
|
||||
forHuman: boolean = false
|
||||
): Promise<string> {
|
||||
const config = loadContextConfig();
|
||||
const cwd = input?.cwd ?? process.cwd();
|
||||
const project = getProjectName(cwd);
|
||||
const context = getProjectContext(cwd);
|
||||
const platformSource = input?.platform_source;
|
||||
|
||||
// Use provided projects array (for worktree support) or fall back to single project
|
||||
const projects = input?.projects || [project];
|
||||
// Single source of truth: explicit projects override cwd-derived context.
|
||||
// `project` (used for header + single-project query) is always the last entry
|
||||
// of `projects` so the empty-state header and the query target stay in sync
|
||||
// when a caller passes `projects` without a matching cwd (e.g. worker route).
|
||||
const projects = input?.projects?.length ? input.projects : context.allProjects;
|
||||
const project = projects[projects.length - 1] ?? context.primary;
|
||||
|
||||
// Full mode: fetch all observations but keep normal rendering (level 1 summaries)
|
||||
if (input?.full) {
|
||||
@@ -149,15 +154,15 @@ export async function generateContext(
|
||||
try {
|
||||
// Query data for all projects (supports worktree: parent + worktree combined)
|
||||
const observations = projects.length > 1
|
||||
? queryObservationsMulti(db, projects, config)
|
||||
: queryObservations(db, project, config);
|
||||
? queryObservationsMulti(db, projects, config, platformSource)
|
||||
: queryObservations(db, project, config, platformSource);
|
||||
const summaries = projects.length > 1
|
||||
? querySummariesMulti(db, projects, config)
|
||||
: querySummaries(db, project, config);
|
||||
? querySummariesMulti(db, projects, config, platformSource)
|
||||
: querySummaries(db, project, config, platformSource);
|
||||
|
||||
// Handle empty state
|
||||
if (observations.length === 0 && summaries.length === 0) {
|
||||
return renderEmptyState(project, useColors);
|
||||
return renderEmptyState(project, forHuman);
|
||||
}
|
||||
|
||||
// Build and return context
|
||||
@@ -168,7 +173,7 @@ export async function generateContext(
|
||||
config,
|
||||
cwd,
|
||||
input?.session_id,
|
||||
useColors
|
||||
forHuman
|
||||
);
|
||||
|
||||
return output;
|
||||
|
||||
@@ -8,6 +8,7 @@ import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { SessionStore } from '../sqlite/SessionStore.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { SYSTEM_REMINDER_REGEX } from '../../utils/tag-stripping.js';
|
||||
import { CLAUDE_CONFIG_DIR } from '../../shared/paths.js';
|
||||
import type {
|
||||
ContextConfig,
|
||||
@@ -25,7 +26,8 @@ import { SUMMARY_LOOKAHEAD } from './types.js';
|
||||
export function queryObservations(
|
||||
db: SessionStore,
|
||||
project: string,
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): Observation[] {
|
||||
const typeArray = Array.from(config.observationTypes);
|
||||
const typePlaceholders = typeArray.map(() => '?').join(',');
|
||||
@@ -34,19 +36,39 @@ export function queryObservations(
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT
|
||||
id, memory_session_id, type, title, subtitle, narrative,
|
||||
facts, concepts, files_read, files_modified, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE project = ?
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.discovery_tokens,
|
||||
o.created_at,
|
||||
o.created_at_epoch
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
WHERE (o.project = ? OR o.merged_into_project = ?)
|
||||
AND type IN (${typePlaceholders})
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(concepts)
|
||||
SELECT 1 FROM json_each(o.concepts)
|
||||
WHERE value IN (${conceptPlaceholders})
|
||||
)
|
||||
ORDER BY created_at_epoch DESC
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(project, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
|
||||
`).all(
|
||||
project,
|
||||
project,
|
||||
...typeArray,
|
||||
...conceptArray,
|
||||
...(platformSource ? [platformSource] : []),
|
||||
config.totalObservationCount
|
||||
) as Observation[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,15 +77,30 @@ export function queryObservations(
|
||||
export function querySummaries(
|
||||
db: SessionStore,
|
||||
project: string,
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): SessionSummary[] {
|
||||
return db.db.prepare(`
|
||||
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
WHERE (ss.project = ? OR ss.merged_into_project = ?)
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(project, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
`).all(
|
||||
...[project, project, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD]
|
||||
) as SessionSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +112,8 @@ export function querySummaries(
|
||||
export function queryObservationsMulti(
|
||||
db: SessionStore,
|
||||
projects: string[],
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): Observation[] {
|
||||
const typeArray = Array.from(config.observationTypes);
|
||||
const typePlaceholders = typeArray.map(() => '?').join(',');
|
||||
@@ -87,19 +125,41 @@ export function queryObservationsMulti(
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT
|
||||
id, memory_session_id, type, title, subtitle, narrative,
|
||||
facts, concepts, files_read, files_modified, discovery_tokens,
|
||||
created_at, created_at_epoch, project
|
||||
FROM observations
|
||||
WHERE project IN (${projectPlaceholders})
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.discovery_tokens,
|
||||
o.created_at,
|
||||
o.created_at_epoch,
|
||||
o.project
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
WHERE (o.project IN (${projectPlaceholders})
|
||||
OR o.merged_into_project IN (${projectPlaceholders}))
|
||||
AND type IN (${typePlaceholders})
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(concepts)
|
||||
SELECT 1 FROM json_each(o.concepts)
|
||||
WHERE value IN (${conceptPlaceholders})
|
||||
)
|
||||
ORDER BY created_at_epoch DESC
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(...projects, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
|
||||
`).all(
|
||||
...projects,
|
||||
...projects,
|
||||
...typeArray,
|
||||
...conceptArray,
|
||||
...(platformSource ? [platformSource] : []),
|
||||
config.totalObservationCount
|
||||
) as Observation[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,18 +171,33 @@ export function queryObservationsMulti(
|
||||
export function querySummariesMulti(
|
||||
db: SessionStore,
|
||||
projects: string[],
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): SessionSummary[] {
|
||||
// Build IN clause for projects
|
||||
const projectPlaceholders = projects.map(() => '?').join(',');
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch, project
|
||||
FROM session_summaries
|
||||
WHERE project IN (${projectPlaceholders})
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch,
|
||||
ss.project
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
WHERE (ss.project IN (${projectPlaceholders})
|
||||
OR ss.merged_into_project IN (${projectPlaceholders}))
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(...projects, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
`).all(...projects, ...projects, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,7 +239,7 @@ export function extractPriorMessages(transcriptPath: string): PriorMessages {
|
||||
text += block.text;
|
||||
}
|
||||
}
|
||||
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
||||
text = text.replace(SYSTEM_REMINDER_REGEX, '').trim();
|
||||
if (text) {
|
||||
lastAssistantMessage = text;
|
||||
break;
|
||||
|
||||
+31
-31
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* MarkdownFormatter - Formats context output as compact markdown for LLM injection
|
||||
* AgentFormatter - Formats context output as compact markdown for LLM injection
|
||||
*
|
||||
* Optimized for token efficiency: flat lines instead of tables, no repeated headers.
|
||||
* The colored terminal formatter (ColorFormatter.ts) handles human-readable display separately.
|
||||
* The human-readable terminal formatter (HumanFormatter.ts) handles human-readable display separately.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -31,19 +31,19 @@ function formatHeaderDateTime(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown header
|
||||
* Render agent header
|
||||
*/
|
||||
export function renderMarkdownHeader(project: string): string[] {
|
||||
export function renderAgentHeader(project: string): string[] {
|
||||
return [
|
||||
`# $CMEM ${project} ${formatHeaderDateTime()}`,
|
||||
`# [${project}] recent context, ${formatHeaderDateTime()}`,
|
||||
''
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown legend
|
||||
* Render agent legend
|
||||
*/
|
||||
export function renderMarkdownLegend(): string[] {
|
||||
export function renderAgentLegend(): string[] {
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
const typeLegendItems = mode.observation_types.map(t => `${t.emoji}${t.id}`).join(' ');
|
||||
|
||||
@@ -56,23 +56,23 @@ export function renderMarkdownLegend(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown column key - no longer needed in compact format
|
||||
* Render agent column key - no longer needed in compact format
|
||||
*/
|
||||
export function renderMarkdownColumnKey(): string[] {
|
||||
export function renderAgentColumnKey(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown context index instructions - folded into legend
|
||||
* Render agent context index instructions - folded into legend
|
||||
*/
|
||||
export function renderMarkdownContextIndex(): string[] {
|
||||
export function renderAgentContextIndex(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown context economics
|
||||
* Render agent context economics
|
||||
*/
|
||||
export function renderMarkdownContextEconomics(
|
||||
export function renderAgentContextEconomics(
|
||||
economics: TokenEconomics,
|
||||
config: ContextConfig
|
||||
): string[] {
|
||||
@@ -98,18 +98,18 @@ export function renderMarkdownContextEconomics(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown day header
|
||||
* Render agent day header
|
||||
*/
|
||||
export function renderMarkdownDayHeader(day: string): string[] {
|
||||
export function renderAgentDayHeader(day: string): string[] {
|
||||
return [
|
||||
`### ${day}`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown file header - no longer renders table headers in compact format
|
||||
* Render agent file header - no longer renders table headers in compact format
|
||||
*/
|
||||
export function renderMarkdownFileHeader(_file: string): string[] {
|
||||
export function renderAgentFileHeader(_file: string): string[] {
|
||||
// File grouping eliminated in compact format - file context is in observation titles
|
||||
return [];
|
||||
}
|
||||
@@ -124,7 +124,7 @@ function compactTime(time: string): string {
|
||||
/**
|
||||
* Render compact flat line for observation (replaces table row)
|
||||
*/
|
||||
export function renderMarkdownTableRow(
|
||||
export function renderAgentTableRow(
|
||||
obs: Observation,
|
||||
timeDisplay: string,
|
||||
_config: ContextConfig
|
||||
@@ -137,9 +137,9 @@ export function renderMarkdownTableRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown full observation
|
||||
* Render agent full observation
|
||||
*/
|
||||
export function renderMarkdownFullObservation(
|
||||
export function renderAgentFullObservation(
|
||||
obs: Observation,
|
||||
timeDisplay: string,
|
||||
detailField: string | null,
|
||||
@@ -172,9 +172,9 @@ export function renderMarkdownFullObservation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown summary item in timeline
|
||||
* Render agent summary item in timeline
|
||||
*/
|
||||
export function renderMarkdownSummaryItem(
|
||||
export function renderAgentSummaryItem(
|
||||
summary: { id: number; request: string | null },
|
||||
formattedTime: string
|
||||
): string[] {
|
||||
@@ -184,17 +184,17 @@ export function renderMarkdownSummaryItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown summary field
|
||||
* Render agent summary field
|
||||
*/
|
||||
export function renderMarkdownSummaryField(label: string, value: string | null): string[] {
|
||||
export function renderAgentSummaryField(label: string, value: string | null): string[] {
|
||||
if (!value) return [];
|
||||
return [`**${label}**: ${value}`, ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown previously section
|
||||
* Render agent previously section
|
||||
*/
|
||||
export function renderMarkdownPreviouslySection(priorMessages: PriorMessages): string[] {
|
||||
export function renderAgentPreviouslySection(priorMessages: PriorMessages): string[] {
|
||||
if (!priorMessages.assistantMessage) return [];
|
||||
|
||||
return [
|
||||
@@ -209,9 +209,9 @@ export function renderMarkdownPreviouslySection(priorMessages: PriorMessages): s
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown footer
|
||||
* Render agent footer
|
||||
*/
|
||||
export function renderMarkdownFooter(totalDiscoveryTokens: number, totalReadTokens: number): string[] {
|
||||
export function renderAgentFooter(totalDiscoveryTokens: number, totalReadTokens: number): string[] {
|
||||
const workTokensK = Math.round(totalDiscoveryTokens / 1000);
|
||||
return [
|
||||
'',
|
||||
@@ -220,8 +220,8 @@ export function renderMarkdownFooter(totalDiscoveryTokens: number, totalReadToke
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown empty state
|
||||
* Render agent empty state
|
||||
*/
|
||||
export function renderMarkdownEmptyState(project: string): string {
|
||||
return `# $CMEM ${project} ${formatHeaderDateTime()}\n\nNo previous sessions found.`;
|
||||
export function renderAgentEmptyState(project: string): string {
|
||||
return `# [${project}] recent context, ${formatHeaderDateTime()}\n\nNo previous sessions found.`;
|
||||
}
|
||||
+29
-29
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* ColorFormatter - Formats context output with ANSI colors for terminal
|
||||
* HumanFormatter - Formats context output with ANSI colors for terminal
|
||||
*
|
||||
* Handles all colored formatting for context injection (terminal display).
|
||||
*/
|
||||
@@ -30,9 +30,9 @@ function formatHeaderDateTime(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored header
|
||||
* Render human-readable header
|
||||
*/
|
||||
export function renderColorHeader(project: string): string[] {
|
||||
export function renderHumanHeader(project: string): string[] {
|
||||
return [
|
||||
'',
|
||||
`${colors.bright}${colors.cyan}[${project}] recent context, ${formatHeaderDateTime()}${colors.reset}`,
|
||||
@@ -42,9 +42,9 @@ export function renderColorHeader(project: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored legend
|
||||
* Render human-readable legend
|
||||
*/
|
||||
export function renderColorLegend(): string[] {
|
||||
export function renderHumanLegend(): string[] {
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
const typeLegendItems = mode.observation_types.map(t => `${t.emoji} ${t.id}`).join(' | ');
|
||||
|
||||
@@ -55,9 +55,9 @@ export function renderColorLegend(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored column key
|
||||
* Render human-readable column key
|
||||
*/
|
||||
export function renderColorColumnKey(): string[] {
|
||||
export function renderHumanColumnKey(): string[] {
|
||||
return [
|
||||
`${colors.bright}Column Key${colors.reset}`,
|
||||
`${colors.dim} Read: Tokens to read this observation (cost to learn it now)${colors.reset}`,
|
||||
@@ -67,9 +67,9 @@ export function renderColorColumnKey(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored context index instructions
|
||||
* Render human-readable context index instructions
|
||||
*/
|
||||
export function renderColorContextIndex(): string[] {
|
||||
export function renderHumanContextIndex(): string[] {
|
||||
return [
|
||||
`${colors.dim}Context Index: This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.${colors.reset}`,
|
||||
'',
|
||||
@@ -82,9 +82,9 @@ export function renderColorContextIndex(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored context economics
|
||||
* Render human-readable context economics
|
||||
*/
|
||||
export function renderColorContextEconomics(
|
||||
export function renderHumanContextEconomics(
|
||||
economics: TokenEconomics,
|
||||
config: ContextConfig
|
||||
): string[] {
|
||||
@@ -111,9 +111,9 @@ export function renderColorContextEconomics(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored day header
|
||||
* Render human-readable day header
|
||||
*/
|
||||
export function renderColorDayHeader(day: string): string[] {
|
||||
export function renderHumanDayHeader(day: string): string[] {
|
||||
return [
|
||||
`${colors.bright}${colors.cyan}${day}${colors.reset}`,
|
||||
''
|
||||
@@ -121,18 +121,18 @@ export function renderColorDayHeader(day: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored file header
|
||||
* Render human-readable file header
|
||||
*/
|
||||
export function renderColorFileHeader(file: string): string[] {
|
||||
export function renderHumanFileHeader(file: string): string[] {
|
||||
return [
|
||||
`${colors.dim}${file}${colors.reset}`
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored table row for observation
|
||||
* Render human-readable table row for observation
|
||||
*/
|
||||
export function renderColorTableRow(
|
||||
export function renderHumanTableRow(
|
||||
obs: Observation,
|
||||
time: string,
|
||||
showTime: boolean,
|
||||
@@ -150,9 +150,9 @@ export function renderColorTableRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored full observation
|
||||
* Render human-readable full observation
|
||||
*/
|
||||
export function renderColorFullObservation(
|
||||
export function renderHumanFullObservation(
|
||||
obs: Observation,
|
||||
time: string,
|
||||
showTime: boolean,
|
||||
@@ -181,9 +181,9 @@ export function renderColorFullObservation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored summary item in timeline
|
||||
* Render human-readable summary item in timeline
|
||||
*/
|
||||
export function renderColorSummaryItem(
|
||||
export function renderHumanSummaryItem(
|
||||
summary: { id: number; request: string | null },
|
||||
formattedTime: string
|
||||
): string[] {
|
||||
@@ -195,17 +195,17 @@ export function renderColorSummaryItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored summary field
|
||||
* Render human-readable summary field
|
||||
*/
|
||||
export function renderColorSummaryField(label: string, value: string | null, color: string): string[] {
|
||||
export function renderHumanSummaryField(label: string, value: string | null, color: string): string[] {
|
||||
if (!value) return [];
|
||||
return [`${color}${label}:${colors.reset} ${value}`, ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored previously section
|
||||
* Render human-readable previously section
|
||||
*/
|
||||
export function renderColorPreviouslySection(priorMessages: PriorMessages): string[] {
|
||||
export function renderHumanPreviouslySection(priorMessages: PriorMessages): string[] {
|
||||
if (!priorMessages.assistantMessage) return [];
|
||||
|
||||
return [
|
||||
@@ -220,9 +220,9 @@ export function renderColorPreviouslySection(priorMessages: PriorMessages): stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored footer
|
||||
* Render human-readable footer
|
||||
*/
|
||||
export function renderColorFooter(totalDiscoveryTokens: number, totalReadTokens: number): string[] {
|
||||
export function renderHumanFooter(totalDiscoveryTokens: number, totalReadTokens: number): string[] {
|
||||
const workTokensK = Math.round(totalDiscoveryTokens / 1000);
|
||||
return [
|
||||
'',
|
||||
@@ -231,8 +231,8 @@ export function renderColorFooter(totalDiscoveryTokens: number, totalReadTokens:
|
||||
}
|
||||
|
||||
/**
|
||||
* Render colored empty state
|
||||
* Render human-readable empty state
|
||||
*/
|
||||
export function renderColorEmptyState(project: string): string {
|
||||
export function renderHumanEmptyState(project: string): string {
|
||||
return `\n${colors.bright}${colors.cyan}[${project}] recent context, ${formatHeaderDateTime()}${colors.reset}\n${colors.gray}${'─'.repeat(60)}${colors.reset}\n\n${colors.dim}No previous sessions found for this project yet.${colors.reset}\n`;
|
||||
}
|
||||
@@ -6,20 +6,20 @@
|
||||
|
||||
import type { ContextConfig, TokenEconomics, PriorMessages } from '../types.js';
|
||||
import { shouldShowContextEconomics } from '../TokenCalculator.js';
|
||||
import * as Markdown from '../formatters/MarkdownFormatter.js';
|
||||
import * as Color from '../formatters/ColorFormatter.js';
|
||||
import * as Agent from '../formatters/AgentFormatter.js';
|
||||
import * as Human from '../formatters/HumanFormatter.js';
|
||||
|
||||
/**
|
||||
* Render the previously section (prior assistant message)
|
||||
*/
|
||||
export function renderPreviouslySection(
|
||||
priorMessages: PriorMessages,
|
||||
useColors: boolean
|
||||
forHuman: boolean
|
||||
): string[] {
|
||||
if (useColors) {
|
||||
return Color.renderColorPreviouslySection(priorMessages);
|
||||
if (forHuman) {
|
||||
return Human.renderHumanPreviouslySection(priorMessages);
|
||||
}
|
||||
return Markdown.renderMarkdownPreviouslySection(priorMessages);
|
||||
return Agent.renderAgentPreviouslySection(priorMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,15 +28,15 @@ export function renderPreviouslySection(
|
||||
export function renderFooter(
|
||||
economics: TokenEconomics,
|
||||
config: ContextConfig,
|
||||
useColors: boolean
|
||||
forHuman: boolean
|
||||
): string[] {
|
||||
// Only show footer if we have savings to display
|
||||
if (!shouldShowContextEconomics(config) || economics.totalDiscoveryTokens <= 0 || economics.savings <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (useColors) {
|
||||
return Color.renderColorFooter(economics.totalDiscoveryTokens, economics.totalReadTokens);
|
||||
if (forHuman) {
|
||||
return Human.renderHumanFooter(economics.totalDiscoveryTokens, economics.totalReadTokens);
|
||||
}
|
||||
return Markdown.renderMarkdownFooter(economics.totalDiscoveryTokens, economics.totalReadTokens);
|
||||
return Agent.renderAgentFooter(economics.totalDiscoveryTokens, economics.totalReadTokens);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import type { ContextConfig, TokenEconomics } from '../types.js';
|
||||
import { shouldShowContextEconomics } from '../TokenCalculator.js';
|
||||
import * as Markdown from '../formatters/MarkdownFormatter.js';
|
||||
import * as Color from '../formatters/ColorFormatter.js';
|
||||
import * as Agent from '../formatters/AgentFormatter.js';
|
||||
import * as Human from '../formatters/HumanFormatter.js';
|
||||
|
||||
/**
|
||||
* Render the complete header section
|
||||
@@ -16,44 +16,44 @@ export function renderHeader(
|
||||
project: string,
|
||||
economics: TokenEconomics,
|
||||
config: ContextConfig,
|
||||
useColors: boolean
|
||||
forHuman: boolean
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
// Main header
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorHeader(project));
|
||||
if (forHuman) {
|
||||
output.push(...Human.renderHumanHeader(project));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownHeader(project));
|
||||
output.push(...Agent.renderAgentHeader(project));
|
||||
}
|
||||
|
||||
// Legend
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorLegend());
|
||||
if (forHuman) {
|
||||
output.push(...Human.renderHumanLegend());
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownLegend());
|
||||
output.push(...Agent.renderAgentLegend());
|
||||
}
|
||||
|
||||
// Column key
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorColumnKey());
|
||||
if (forHuman) {
|
||||
output.push(...Human.renderHumanColumnKey());
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownColumnKey());
|
||||
output.push(...Agent.renderAgentColumnKey());
|
||||
}
|
||||
|
||||
// Context index instructions
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorContextIndex());
|
||||
if (forHuman) {
|
||||
output.push(...Human.renderHumanContextIndex());
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownContextIndex());
|
||||
output.push(...Agent.renderAgentContextIndex());
|
||||
}
|
||||
|
||||
// Context economics
|
||||
if (shouldShowContextEconomics(config)) {
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorContextEconomics(economics, config));
|
||||
if (forHuman) {
|
||||
output.push(...Human.renderHumanContextEconomics(economics, config));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownContextEconomics(economics, config));
|
||||
output.push(...Agent.renderAgentContextEconomics(economics, config));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import type { ContextConfig, Observation, SessionSummary } from '../types.js';
|
||||
import { colors } from '../types.js';
|
||||
import * as Markdown from '../formatters/MarkdownFormatter.js';
|
||||
import * as Color from '../formatters/ColorFormatter.js';
|
||||
import * as Agent from '../formatters/AgentFormatter.js';
|
||||
import * as Human from '../formatters/HumanFormatter.js';
|
||||
|
||||
/**
|
||||
* Check if summary should be displayed
|
||||
@@ -45,20 +45,20 @@ export function shouldShowSummary(
|
||||
*/
|
||||
export function renderSummaryFields(
|
||||
summary: SessionSummary,
|
||||
useColors: boolean
|
||||
forHuman: boolean
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorSummaryField('Investigated', summary.investigated, colors.blue));
|
||||
output.push(...Color.renderColorSummaryField('Learned', summary.learned, colors.yellow));
|
||||
output.push(...Color.renderColorSummaryField('Completed', summary.completed, colors.green));
|
||||
output.push(...Color.renderColorSummaryField('Next Steps', summary.next_steps, colors.magenta));
|
||||
if (forHuman) {
|
||||
output.push(...Human.renderHumanSummaryField('Investigated', summary.investigated, colors.blue));
|
||||
output.push(...Human.renderHumanSummaryField('Learned', summary.learned, colors.yellow));
|
||||
output.push(...Human.renderHumanSummaryField('Completed', summary.completed, colors.green));
|
||||
output.push(...Human.renderHumanSummaryField('Next Steps', summary.next_steps, colors.magenta));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownSummaryField('Investigated', summary.investigated));
|
||||
output.push(...Markdown.renderMarkdownSummaryField('Learned', summary.learned));
|
||||
output.push(...Markdown.renderMarkdownSummaryField('Completed', summary.completed));
|
||||
output.push(...Markdown.renderMarkdownSummaryField('Next Steps', summary.next_steps));
|
||||
output.push(...Agent.renderAgentSummaryField('Investigated', summary.investigated));
|
||||
output.push(...Agent.renderAgentSummaryField('Learned', summary.learned));
|
||||
output.push(...Agent.renderAgentSummaryField('Completed', summary.completed));
|
||||
output.push(...Agent.renderAgentSummaryField('Next Steps', summary.next_steps));
|
||||
}
|
||||
|
||||
return output;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* TimelineRenderer - Renders the chronological timeline of observations and summaries
|
||||
*
|
||||
* Handles day grouping and rendering. In markdown (LLM) mode, uses flat compact lines.
|
||||
* In color (terminal) mode, uses file grouping with visual formatting.
|
||||
* Handles day grouping and rendering. In agent (LLM) mode, uses flat compact lines.
|
||||
* In human (terminal) mode, uses file grouping with visual formatting.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -12,8 +12,8 @@ import type {
|
||||
SummaryTimelineItem,
|
||||
} from '../types.js';
|
||||
import { formatTime, formatDate, formatDateTime, extractFirstFile, parseJsonArray } from '../../../shared/timeline-formatting.js';
|
||||
import * as Markdown from '../formatters/MarkdownFormatter.js';
|
||||
import * as Color from '../formatters/ColorFormatter.js';
|
||||
import * as Agent from '../formatters/AgentFormatter.js';
|
||||
import * as Human from '../formatters/HumanFormatter.js';
|
||||
|
||||
/**
|
||||
* Group timeline items by day
|
||||
@@ -51,9 +51,9 @@ function getDetailField(obs: Observation, config: ContextConfig): string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items (markdown/LLM mode - flat compact lines)
|
||||
* Render a single day's timeline items (agent/LLM mode - flat compact lines)
|
||||
*/
|
||||
function renderDayTimelineMarkdown(
|
||||
function renderDayTimelineAgent(
|
||||
day: string,
|
||||
dayItems: TimelineItem[],
|
||||
fullObservationIds: Set<number>,
|
||||
@@ -61,17 +61,15 @@ function renderDayTimelineMarkdown(
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(...Markdown.renderMarkdownDayHeader(day));
|
||||
output.push(...Agent.renderAgentDayHeader(day));
|
||||
|
||||
let lastTime = '';
|
||||
|
||||
for (const item of dayItems) {
|
||||
if (item.type === 'summary') {
|
||||
lastTime = '';
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
output.push(...Markdown.renderMarkdownSummaryItem(summary, formattedTime));
|
||||
output.push(...Agent.renderAgentSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const time = formatTime(obs.created_at);
|
||||
@@ -83,9 +81,9 @@ function renderDayTimelineMarkdown(
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
output.push(...Markdown.renderMarkdownFullObservation(obs, timeDisplay, detailField, config));
|
||||
output.push(...Agent.renderAgentFullObservation(obs, timeDisplay, detailField, config));
|
||||
} else {
|
||||
output.push(Markdown.renderMarkdownTableRow(obs, timeDisplay, config));
|
||||
output.push(Agent.renderAgentTableRow(obs, timeDisplay, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,9 +92,9 @@ function renderDayTimelineMarkdown(
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items (color/terminal mode - file grouped with tables)
|
||||
* Render a single day's timeline items (human/terminal mode - file grouped with tables)
|
||||
*/
|
||||
function renderDayTimelineColor(
|
||||
function renderDayTimelineHuman(
|
||||
day: string,
|
||||
dayItems: TimelineItem[],
|
||||
fullObservationIds: Set<number>,
|
||||
@@ -105,7 +103,7 @@ function renderDayTimelineColor(
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(...Color.renderColorDayHeader(day));
|
||||
output.push(...Human.renderHumanDayHeader(day));
|
||||
|
||||
let currentFile: string | null = null;
|
||||
let lastTime = '';
|
||||
@@ -117,7 +115,7 @@ function renderDayTimelineColor(
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
output.push(...Color.renderColorSummaryItem(summary, formattedTime));
|
||||
output.push(...Human.renderHumanSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const file = extractFirstFile(obs.files_modified, cwd, obs.files_read);
|
||||
@@ -129,15 +127,15 @@ function renderDayTimelineColor(
|
||||
|
||||
// Check if we need a new file section
|
||||
if (file !== currentFile) {
|
||||
output.push(...Color.renderColorFileHeader(file));
|
||||
output.push(...Human.renderHumanFileHeader(file));
|
||||
currentFile = file;
|
||||
}
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
output.push(...Color.renderColorFullObservation(obs, time, showTime, detailField, config));
|
||||
output.push(...Human.renderHumanFullObservation(obs, time, showTime, detailField, config));
|
||||
} else {
|
||||
output.push(Color.renderColorTableRow(obs, time, showTime, config));
|
||||
output.push(Human.renderHumanTableRow(obs, time, showTime, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,12 +154,12 @@ export function renderDayTimeline(
|
||||
fullObservationIds: Set<number>,
|
||||
config: ContextConfig,
|
||||
cwd: string,
|
||||
useColors: boolean
|
||||
forHuman: boolean
|
||||
): string[] {
|
||||
if (useColors) {
|
||||
return renderDayTimelineColor(day, dayItems, fullObservationIds, config, cwd);
|
||||
if (forHuman) {
|
||||
return renderDayTimelineHuman(day, dayItems, fullObservationIds, config, cwd);
|
||||
}
|
||||
return renderDayTimelineMarkdown(day, dayItems, fullObservationIds, config);
|
||||
return renderDayTimelineAgent(day, dayItems, fullObservationIds, config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,13 +170,13 @@ export function renderTimeline(
|
||||
fullObservationIds: Set<number>,
|
||||
config: ContextConfig,
|
||||
cwd: string,
|
||||
useColors: boolean
|
||||
forHuman: boolean
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
const itemsByDay = groupTimelineByDay(timeline);
|
||||
|
||||
for (const [day, dayItems] of itemsByDay) {
|
||||
output.push(...renderDayTimeline(day, dayItems, fullObservationIds, config, cwd, useColors));
|
||||
output.push(...renderDayTimeline(day, dayItems, fullObservationIds, config, cwd, forHuman));
|
||||
}
|
||||
|
||||
return output;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ContextInput {
|
||||
projects?: string[];
|
||||
/** When true, return ALL observations with no limit */
|
||||
full?: boolean;
|
||||
platform_source?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export interface ContextConfig {
|
||||
export interface Observation {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
platform_source?: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
@@ -70,6 +72,7 @@ export interface Observation {
|
||||
export interface SessionSummary {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
platform_source?: string;
|
||||
request: string | null;
|
||||
investigated: string | null;
|
||||
learned: string | null;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 25, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #41877 | 12:09 PM | ⚖️ | Deploy Existing Consumer Preview Without Creating New Packages | ~361 |
|
||||
| #41873 | 12:03 PM | 🔵 | Claude-mem mode configuration system types documented | ~504 |
|
||||
</claude-mem-context>
|
||||
@@ -1,10 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36864 | 1:52 AM | 🔵 | ProcessManager Module Imports Reviewed | ~245 |
|
||||
| #36860 | 1:50 AM | 🔵 | ProcessManager Source Code Reviewed for WMIC Implementation | ~608 |
|
||||
</claude-mem-context>
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import net from 'net';
|
||||
import { readFileSync } from 'fs';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { MARKETPLACE_ROOT } from '../../shared/paths.js';
|
||||
@@ -35,17 +36,43 @@ async function httpRequestToWorker(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is in use by querying the health endpoint
|
||||
* Check if a port is in use by attempting an atomic socket bind.
|
||||
* More reliable than HTTP health check for daemon spawn guards —
|
||||
* prevents TOCTOU race where two daemons both see "port free" via
|
||||
* HTTP and then both try to listen() (upstream bug workaround).
|
||||
*
|
||||
* Falls back to HTTP health check on Windows where socket bind
|
||||
* behavior differs.
|
||||
*/
|
||||
export async function isPortInUse(port: number): Promise<boolean> {
|
||||
try {
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
// [ANTI-PATTERN IGNORED]: Health check polls every 500ms, logging would flood
|
||||
return false;
|
||||
if (process.platform === 'win32') {
|
||||
// APPROVED OVERRIDE: Windows keeps HTTP health check because socket bind
|
||||
// semantics differ (SO_REUSEADDR defaults, firewall prompts). The TOCTOU
|
||||
// race remains on Windows but is an accepted limitation — the atomic
|
||||
// socket approach would cause false positives or UAC popups.
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unix: atomic socket bind check — no TOCTOU race
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(false));
|
||||
});
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, rmSync, statSync, utimesSync } from 'fs';
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, rmSync, statSync, utimesSync, copyFileSync } from 'fs';
|
||||
import { exec, execSync, spawn, spawnSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_TIMEOUTS } from '../../shared/hook-constants.js';
|
||||
@@ -71,21 +71,62 @@ function lookupBinaryInPath(binaryName: string, platform: NodeJS.Platform): stri
|
||||
}
|
||||
}
|
||||
|
||||
// Memoize the resolved runtime path for the no-options call site (which is
|
||||
// what spawnDaemon uses). Caches successful resolutions so repeated spawn
|
||||
// attempts (crash loops, health thrashing) don't repeatedly hit `statSync`
|
||||
// on the candidate paths.
|
||||
//
|
||||
// IMPORTANT: only success is cached. A `null` result (Bun not found) is
|
||||
// never cached so that a long-running MCP server can recover if the user
|
||||
// installs Bun in another terminal between the first failed lookup and a
|
||||
// subsequent retry. Caching `null` would permanently break the process
|
||||
// until restart. Per PR #1645 round-10 review.
|
||||
//
|
||||
// `undefined` means "not yet resolved"; tests that pass options bypass the
|
||||
// cache entirely.
|
||||
let cachedWorkerRuntimePath: string | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Reset the memoized runtime path. Exported for test isolation only —
|
||||
* production code never needs to call this.
|
||||
*/
|
||||
export function resetWorkerRuntimePathCache(): void {
|
||||
cachedWorkerRuntimePath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime executable for spawning the worker daemon.
|
||||
*
|
||||
* Windows must prefer Bun because worker-service.cjs imports bun:sqlite,
|
||||
* which is unavailable in Node.js.
|
||||
* worker-service.cjs imports `bun:sqlite`, so it MUST run under Bun on every
|
||||
* platform — not just Windows. When the caller is already running under Bun
|
||||
* (e.g. the worker self-spawning from a hook), we reuse process.execPath to
|
||||
* avoid an extra PATH lookup. Otherwise (notably when the MCP server running
|
||||
* under Node spawns the worker for the first time) we locate the Bun binary
|
||||
* via env vars, well-known install locations, and finally the system PATH.
|
||||
*/
|
||||
export function resolveWorkerRuntimePath(options: RuntimeResolverOptions = {}): string | null {
|
||||
// Memoization fast path — only when called with no injected options. Tests
|
||||
// that pass options always run the full resolution (and never populate or
|
||||
// read the cache) to keep the existing test cases deterministic.
|
||||
const isMemoizable = Object.keys(options).length === 0;
|
||||
if (isMemoizable && cachedWorkerRuntimePath !== undefined) {
|
||||
return cachedWorkerRuntimePath;
|
||||
}
|
||||
|
||||
const result = resolveWorkerRuntimePathUncached(options);
|
||||
|
||||
// Only cache successful resolutions. See the comment on
|
||||
// `cachedWorkerRuntimePath` above for the rationale.
|
||||
if (isMemoizable && result !== null) {
|
||||
cachedWorkerRuntimePath = result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveWorkerRuntimePathUncached(options: RuntimeResolverOptions): string | null {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
|
||||
// Non-Windows currently relies on the runtime that launched worker-service.
|
||||
if (platform !== 'win32') {
|
||||
return execPath;
|
||||
}
|
||||
|
||||
// If already running under Bun, reuse it directly.
|
||||
if (isBunExecutablePath(execPath)) {
|
||||
return execPath;
|
||||
@@ -96,15 +137,26 @@ export function resolveWorkerRuntimePath(options: RuntimeResolverOptions = {}):
|
||||
const pathExists = options.pathExists ?? existsSync;
|
||||
const lookupInPath = options.lookupInPath ?? lookupBinaryInPath;
|
||||
|
||||
const candidatePaths = [
|
||||
env.BUN,
|
||||
env.BUN_PATH,
|
||||
path.join(homeDirectory, '.bun', 'bin', 'bun.exe'),
|
||||
path.join(homeDirectory, '.bun', 'bin', 'bun'),
|
||||
env.USERPROFILE ? path.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe') : undefined,
|
||||
env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'bun', 'bun.exe') : undefined,
|
||||
env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'bun', 'bin', 'bun.exe') : undefined,
|
||||
];
|
||||
const candidatePaths: (string | undefined)[] = platform === 'win32'
|
||||
? [
|
||||
env.BUN,
|
||||
env.BUN_PATH,
|
||||
path.join(homeDirectory, '.bun', 'bin', 'bun.exe'),
|
||||
path.join(homeDirectory, '.bun', 'bin', 'bun'),
|
||||
env.USERPROFILE ? path.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe') : undefined,
|
||||
env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'bun', 'bun.exe') : undefined,
|
||||
env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'bun', 'bin', 'bun.exe') : undefined,
|
||||
]
|
||||
: [
|
||||
env.BUN,
|
||||
env.BUN_PATH,
|
||||
path.join(homeDirectory, '.bun', 'bin', 'bun'),
|
||||
'/usr/local/bin/bun',
|
||||
'/opt/homebrew/bin/bun',
|
||||
'/home/linuxbrew/.linuxbrew/bin/bun',
|
||||
'/usr/bin/bun', // Debian/Ubuntu apt install path
|
||||
'/snap/bin/bun', // Ubuntu Snap install path
|
||||
];
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
const normalized = candidate?.trim();
|
||||
@@ -114,7 +166,11 @@ export function resolveWorkerRuntimePath(options: RuntimeResolverOptions = {}):
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Allow command-style values from env (e.g. BUN=bun)
|
||||
// Allow command-style values from env (e.g. BUN=bun). The previous branch
|
||||
// would also match this candidate via isBunExecutablePath('bun') === true,
|
||||
// but pathExists('bun') is false because it's a relative name — so this
|
||||
// branch is what actually fires for the bare-command case. We return the
|
||||
// bare name unchanged so child_process.spawn() resolves it via PATH.
|
||||
if (normalized.toLowerCase() === 'bun') {
|
||||
return normalized;
|
||||
}
|
||||
@@ -453,6 +509,19 @@ export async function aggressiveStartupCleanup(): Promise<void> {
|
||||
const pidsToKill: number[] = [];
|
||||
const allPatterns = [...AGGRESSIVE_CLEANUP_PATTERNS, ...AGE_GATED_CLEANUP_PATTERNS];
|
||||
|
||||
// Protect parent process (the hook that spawned us) from being killed.
|
||||
// Without this, a new daemon kills its own parent hook process (#1426).
|
||||
//
|
||||
// Note: readPidFile() is not used here because start() writes the new PID
|
||||
// before initializeBackground() calls this function, so readPidFile() would
|
||||
// just return process.pid (already protected). If a pre-existing worker needs
|
||||
// protection, ensureWorkerStarted() handles that by returning early when a
|
||||
// healthy worker is detected — we never reach this code in that case.
|
||||
const protectedPids = new Set<number>([currentPid]);
|
||||
if (process.ppid && process.ppid > 0) {
|
||||
protectedPids.add(process.ppid);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isWindows) {
|
||||
// Use WQL -Filter for server-side filtering (no $_ pipeline syntax).
|
||||
@@ -475,7 +544,7 @@ export async function aggressiveStartupCleanup(): Promise<void> {
|
||||
|
||||
for (const proc of processList) {
|
||||
const pid = proc.ProcessId;
|
||||
if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue;
|
||||
if (!Number.isInteger(pid) || pid <= 0 || protectedPids.has(pid)) continue;
|
||||
|
||||
const commandLine = proc.CommandLine || '';
|
||||
const isAggressive = AGGRESSIVE_CLEANUP_PATTERNS.some(p => commandLine.includes(p));
|
||||
@@ -518,7 +587,7 @@ export async function aggressiveStartupCleanup(): Promise<void> {
|
||||
const etime = match[2];
|
||||
const command = match[3];
|
||||
|
||||
if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue;
|
||||
if (!Number.isInteger(pid) || pid <= 0 || protectedPids.has(pid)) continue;
|
||||
|
||||
const isAggressive = AGGRESSIVE_CLEANUP_PATTERNS.some(p => command.includes(p));
|
||||
|
||||
@@ -608,6 +677,161 @@ export function runOneTimeChromaMigration(dataDirectory?: string): void {
|
||||
logger.info('SYSTEM', 'Chroma migration marker written', { markerPath });
|
||||
}
|
||||
|
||||
const CWD_REMAP_MARKER_FILENAME = '.cwd-remap-applied-v1';
|
||||
|
||||
type CwdClassification =
|
||||
| { kind: 'main'; project: string }
|
||||
| { kind: 'worktree'; project: string }
|
||||
| { kind: 'skip' };
|
||||
|
||||
function gitQuery(cwd: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', ['-C', cwd, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000
|
||||
});
|
||||
if (r.status !== 0) return null;
|
||||
return (r.stdout ?? '').trim();
|
||||
}
|
||||
|
||||
function classifyCwdForRemap(cwd: string): CwdClassification {
|
||||
if (!existsSync(cwd)) return { kind: 'skip' };
|
||||
|
||||
const gitDir = gitQuery(cwd, ['rev-parse', '--absolute-git-dir']);
|
||||
if (!gitDir) return { kind: 'skip' };
|
||||
|
||||
const commonDir = gitQuery(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
||||
if (!commonDir) return { kind: 'skip' };
|
||||
|
||||
const toplevel = gitQuery(cwd, ['rev-parse', '--show-toplevel']);
|
||||
if (!toplevel) return { kind: 'skip' };
|
||||
const leaf = path.basename(toplevel);
|
||||
|
||||
if (gitDir === commonDir) {
|
||||
return { kind: 'main', project: leaf };
|
||||
}
|
||||
|
||||
const parentRepoDir = commonDir.endsWith('/.git')
|
||||
? path.dirname(commonDir)
|
||||
: commonDir.replace(/\.git$/, '');
|
||||
const parent = path.basename(parentRepoDir);
|
||||
return { kind: 'worktree', project: `${parent}/${leaf}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time remap of sdk_sessions.project (+ observations.project,
|
||||
* session_summaries.project) using the cwd captured in pending_messages.cwd
|
||||
* as the source of truth. Required because pre-worktree builds stored bare
|
||||
* project names that collide across parent/worktree checkouts.
|
||||
*
|
||||
* Backs up the DB before writes. Idempotent via marker file. Skips silently
|
||||
* if the DB or pending_messages table doesn't exist yet (fresh install).
|
||||
*
|
||||
* @param dataDirectory - Override for DATA_DIR (used in tests)
|
||||
*/
|
||||
export function runOneTimeCwdRemap(dataDirectory?: string): void {
|
||||
const effectiveDataDir = dataDirectory ?? DATA_DIR;
|
||||
const markerPath = path.join(effectiveDataDir, CWD_REMAP_MARKER_FILENAME);
|
||||
const dbPath = path.join(effectiveDataDir, 'claude-mem.db');
|
||||
|
||||
if (existsSync(markerPath)) {
|
||||
logger.debug('SYSTEM', 'cwd-remap marker exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
mkdirSync(effectiveDataDir, { recursive: true });
|
||||
writeFileSync(markerPath, new Date().toISOString());
|
||||
logger.debug('SYSTEM', 'No DB present, cwd-remap marker written without work', { dbPath });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn('SYSTEM', 'Running one-time cwd-based project remap', { dbPath });
|
||||
|
||||
let db: import('bun:sqlite').Database | null = null;
|
||||
try {
|
||||
const { Database } = require('bun:sqlite') as typeof import('bun:sqlite');
|
||||
|
||||
const probe = new Database(dbPath, { readonly: true });
|
||||
const hasPending = probe.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='pending_messages'"
|
||||
).get() as { name: string } | undefined;
|
||||
probe.close();
|
||||
|
||||
if (!hasPending) {
|
||||
mkdirSync(effectiveDataDir, { recursive: true });
|
||||
writeFileSync(markerPath, new Date().toISOString());
|
||||
logger.info('SYSTEM', 'pending_messages table not present, cwd-remap skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
const backup = `${dbPath}.bak-cwd-remap-${Date.now()}`;
|
||||
copyFileSync(dbPath, backup);
|
||||
logger.info('SYSTEM', 'DB backed up before cwd-remap', { backup });
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
const cwdRows = db.prepare(`
|
||||
SELECT cwd FROM pending_messages
|
||||
WHERE cwd IS NOT NULL AND cwd != ''
|
||||
GROUP BY cwd
|
||||
`).all() as Array<{ cwd: string }>;
|
||||
|
||||
const byCwd = new Map<string, CwdClassification>();
|
||||
for (const { cwd } of cwdRows) byCwd.set(cwd, classifyCwdForRemap(cwd));
|
||||
|
||||
const sessionRows = db.prepare(`
|
||||
SELECT s.id AS session_id, s.memory_session_id, s.project AS old_project, p.cwd
|
||||
FROM sdk_sessions s
|
||||
JOIN pending_messages p ON p.content_session_id = s.content_session_id
|
||||
WHERE p.cwd IS NOT NULL AND p.cwd != ''
|
||||
AND p.id = (
|
||||
SELECT MIN(p2.id) FROM pending_messages p2
|
||||
WHERE p2.content_session_id = s.content_session_id
|
||||
AND p2.cwd IS NOT NULL AND p2.cwd != ''
|
||||
)
|
||||
`).all() as Array<{ session_id: number; memory_session_id: string | null; old_project: string; cwd: string }>;
|
||||
|
||||
type Target = { sessionId: number; memorySessionId: string | null; newProject: string };
|
||||
const targets: Target[] = [];
|
||||
for (const r of sessionRows) {
|
||||
const c = byCwd.get(r.cwd);
|
||||
if (!c || c.kind === 'skip') continue;
|
||||
if (r.old_project === c.project) continue;
|
||||
targets.push({ sessionId: r.session_id, memorySessionId: r.memory_session_id, newProject: c.project });
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
logger.info('SYSTEM', 'cwd-remap: no sessions need updating');
|
||||
} else {
|
||||
const updSession = db.prepare('UPDATE sdk_sessions SET project = ? WHERE id = ?');
|
||||
const updObs = db.prepare('UPDATE observations SET project = ? WHERE memory_session_id = ?');
|
||||
const updSum = db.prepare('UPDATE session_summaries SET project = ? WHERE memory_session_id = ?');
|
||||
|
||||
let sessionN = 0, obsN = 0, sumN = 0;
|
||||
const tx = db.transaction(() => {
|
||||
for (const t of targets) {
|
||||
sessionN += updSession.run(t.newProject, t.sessionId).changes;
|
||||
if (t.memorySessionId) {
|
||||
obsN += updObs.run(t.newProject, t.memorySessionId).changes;
|
||||
sumN += updSum.run(t.newProject, t.memorySessionId).changes;
|
||||
}
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
logger.info('SYSTEM', 'cwd-remap applied', { sessions: sessionN, observations: obsN, summaries: sumN, backup });
|
||||
}
|
||||
|
||||
mkdirSync(effectiveDataDir, { recursive: true });
|
||||
writeFileSync(markerPath, new Date().toISOString());
|
||||
logger.info('SYSTEM', 'cwd-remap marker written', { markerPath });
|
||||
} catch (err) {
|
||||
logger.error('SYSTEM', 'cwd-remap failed, marker not written (will retry on next startup)', {}, err as Error);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached daemon process
|
||||
* Returns the child PID or undefined if spawn failed
|
||||
@@ -635,16 +859,24 @@ export function spawnDaemon(
|
||||
...extraEnv
|
||||
});
|
||||
|
||||
// worker-service.cjs imports `bun:sqlite`, so the spawned runtime MUST be
|
||||
// Bun on every platform — never the current process.execPath, which may be
|
||||
// Node when the caller is the MCP server. Resolve once before the OS branch
|
||||
// split so we don't pay for a duplicate PATH lookup if Bun isn't found at a
|
||||
// well-known path. See resolveWorkerRuntimePath() for the candidate list.
|
||||
const runtimePath = resolveWorkerRuntimePath();
|
||||
if (!runtimePath) {
|
||||
logger.error(
|
||||
'SYSTEM',
|
||||
'Bun runtime not found — install from https://bun.sh and ensure it is on PATH or set BUN env var. The worker daemon requires Bun because it uses bun:sqlite.'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
// Use PowerShell Start-Process to spawn a hidden, independent process
|
||||
// Unlike WMIC, PowerShell inherits environment variables from parent
|
||||
// -WindowStyle Hidden prevents console popup
|
||||
const runtimePath = resolveWorkerRuntimePath();
|
||||
|
||||
if (!runtimePath) {
|
||||
logger.error('SYSTEM', 'Failed to locate Bun runtime for Windows worker spawn');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use -EncodedCommand to avoid all shell quoting issues with spaces in paths
|
||||
const psScript = `Start-Process -FilePath '${runtimePath.replace(/'/g, "''")}' -ArgumentList @('${scriptPath.replace(/'/g, "''")}','--daemon') -WindowStyle Hidden`;
|
||||
@@ -656,6 +888,13 @@ export function spawnDaemon(
|
||||
windowsHide: true,
|
||||
env
|
||||
});
|
||||
// Windows success sentinel: PowerShell `Start-Process` does not return
|
||||
// the spawned PID, and we don't want to pay for an extra `Get-Process`
|
||||
// round-trip just to discover it. Return 0 (a conventionally invalid
|
||||
// Unix PID) so callers can distinguish "spawn dispatched" from "spawn
|
||||
// failed". Callers MUST use `pid === undefined` to detect failure —
|
||||
// never falsy checks like `if (!pid)`, which would silently treat
|
||||
// success as failure here.
|
||||
return 0;
|
||||
} catch (error) {
|
||||
// APPROVED OVERRIDE: Windows daemon spawn is best-effort; log and let callers fall back to health checks/retry flow.
|
||||
@@ -668,9 +907,10 @@ export function spawnDaemon(
|
||||
// controlling terminal. This prevents SIGHUP from reaching the daemon
|
||||
// even if the in-process SIGHUP handler somehow fails (belt-and-suspenders).
|
||||
// Fall back to standard detached spawn if setsid is not available.
|
||||
// `runtimePath` was resolved at the top of this function (see comment there).
|
||||
const setsidPath = '/usr/bin/setsid';
|
||||
if (existsSync(setsidPath)) {
|
||||
const child = spawn(setsidPath, [process.execPath, scriptPath, '--daemon'], {
|
||||
const child = spawn(setsidPath, [runtimePath, scriptPath, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env
|
||||
@@ -685,7 +925,7 @@ export function spawnDaemon(
|
||||
}
|
||||
|
||||
// Fallback: standard detached spawn (macOS, systems without setsid)
|
||||
const child = spawn(process.execPath, [scriptPath, '--daemon'], {
|
||||
const child = spawn(runtimePath, [scriptPath, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* WorktreeAdoption - Stamp observations from merged worktrees into their parent project.
|
||||
*
|
||||
* Given a parent repo path, this engine:
|
||||
* 1. Uses git to enumerate worktrees of the parent repo.
|
||||
* 2. Classifies each worktree's branch as "merged" (in `git branch --merged HEAD`)
|
||||
* or manually overridden via `onlyBranch` (for squash-merge detection).
|
||||
* 3. Stamps `merged_into_project` on `observations` and `session_summaries` rows
|
||||
* whose `project` matches the composite `parent/worktree` name.
|
||||
* 4. Propagates the same metadata to Chroma so semantic search includes the
|
||||
* adopted rows under the parent project.
|
||||
*
|
||||
* `project` is never overwritten — it remains immutable provenance. The
|
||||
* `merged_into_project` column is a virtual pointer that query layers OR into
|
||||
* their WHERE predicates.
|
||||
*
|
||||
* DB lifecycle mirrors `runOneTimeCwdRemap` in ProcessManager.ts: we manage our
|
||||
* own Database handle (open -> transaction -> close in finally) so this engine
|
||||
* can be called on worker startup before `dbManager.initialize()` without
|
||||
* contending on the shared handle.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
import { ChromaSync } from '../sync/ChromaSync.js';
|
||||
|
||||
const DEFAULT_DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
|
||||
export interface AdoptionResult {
|
||||
repoPath: string;
|
||||
parentProject: string;
|
||||
scannedWorktrees: number;
|
||||
mergedBranches: string[];
|
||||
adoptedObservations: number;
|
||||
adoptedSummaries: number;
|
||||
chromaUpdates: number;
|
||||
chromaFailed: number;
|
||||
dryRun: boolean;
|
||||
errors: Array<{ worktree: string; error: string }>;
|
||||
}
|
||||
|
||||
interface WorktreeEntry {
|
||||
path: string;
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
const GIT_TIMEOUT_MS = 5000;
|
||||
|
||||
class DryRunRollback extends Error {
|
||||
constructor() {
|
||||
super('dry-run rollback');
|
||||
this.name = 'DryRunRollback';
|
||||
}
|
||||
}
|
||||
|
||||
function gitCapture(cwd: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', ['-C', cwd, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: GIT_TIMEOUT_MS
|
||||
});
|
||||
if (r.status !== 0) return null;
|
||||
return (r.stdout ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main working-tree root for an arbitrary cwd inside a repo or worktree.
|
||||
* Mirrors the handling in `scripts/cwd-remap.ts:48-51`.
|
||||
*/
|
||||
function resolveMainRepoPath(cwd: string): string | null {
|
||||
const commonDir = gitCapture(cwd, [
|
||||
'rev-parse',
|
||||
'--path-format=absolute',
|
||||
'--git-common-dir'
|
||||
]);
|
||||
if (!commonDir) return null;
|
||||
|
||||
// Normal: common-dir is "<repo>/.git". Bare: strip the trailing ".git".
|
||||
const mainRoot = commonDir.endsWith('/.git')
|
||||
? path.dirname(commonDir)
|
||||
: commonDir.replace(/\.git$/, '');
|
||||
return existsSync(mainRoot) ? mainRoot : null;
|
||||
}
|
||||
|
||||
function listWorktrees(mainRepo: string): WorktreeEntry[] {
|
||||
const raw = gitCapture(mainRepo, ['worktree', 'list', '--porcelain']);
|
||||
if (!raw) return [];
|
||||
|
||||
const entries: WorktreeEntry[] = [];
|
||||
let current: Partial<WorktreeEntry> = {};
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (current.path) entries.push({ path: current.path, branch: current.branch ?? null });
|
||||
current = { path: line.slice('worktree '.length).trim(), branch: null };
|
||||
} else if (line.startsWith('branch ')) {
|
||||
// `branch refs/heads/<name>` — strip the ref prefix.
|
||||
const refName = line.slice('branch '.length).trim();
|
||||
current.branch = refName.startsWith('refs/heads/')
|
||||
? refName.slice('refs/heads/'.length)
|
||||
: refName;
|
||||
} else if (line === '' && current.path) {
|
||||
entries.push({ path: current.path, branch: current.branch ?? null });
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
if (current.path) entries.push({ path: current.path, branch: current.branch ?? null });
|
||||
return entries;
|
||||
}
|
||||
|
||||
function listMergedBranches(mainRepo: string): Set<string> {
|
||||
const raw = gitCapture(mainRepo, [
|
||||
'branch',
|
||||
'--merged',
|
||||
'HEAD',
|
||||
'--format=%(refname:short)'
|
||||
]);
|
||||
if (!raw) return new Set();
|
||||
return new Set(
|
||||
raw.split('\n').map(b => b.trim()).filter(b => b.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp `merged_into_project` on observations and session_summaries for every
|
||||
* worktree of `opts.repoPath` whose branch has been merged into the parent's HEAD.
|
||||
*
|
||||
* SQL writes are idempotent: an UPDATE only touches rows where
|
||||
* `merged_into_project IS NULL`. `result.adoptedObservations` / `adoptedSummaries`
|
||||
* reflect the actual SQL changes on each run.
|
||||
*
|
||||
* Chroma patches are self-healing: the Chroma id set is built from ALL
|
||||
* observations whose `project` matches a merged worktree (both unadopted rows
|
||||
* AND rows previously stamped to this parent), and `updateMergedIntoProject`
|
||||
* is idempotent, so a transient Chroma failure on an earlier run is retried
|
||||
* automatically on the next adoption pass. `result.chromaUpdates` therefore
|
||||
* counts the total Chroma writes performed this pass (which may exceed
|
||||
* `adoptedObservations` when retries happen).
|
||||
*/
|
||||
export async function adoptMergedWorktrees(opts: {
|
||||
repoPath?: string;
|
||||
dataDirectory?: string;
|
||||
dryRun?: boolean;
|
||||
onlyBranch?: string;
|
||||
} = {}): Promise<AdoptionResult> {
|
||||
const dataDirectory = opts.dataDirectory ?? DEFAULT_DATA_DIR;
|
||||
const dryRun = opts.dryRun ?? false;
|
||||
const startCwd = opts.repoPath ?? process.cwd();
|
||||
|
||||
const mainRepo = resolveMainRepoPath(startCwd);
|
||||
const parentProject = mainRepo ? getProjectContext(mainRepo).primary : '';
|
||||
|
||||
const result: AdoptionResult = {
|
||||
repoPath: mainRepo ?? startCwd,
|
||||
parentProject,
|
||||
scannedWorktrees: 0,
|
||||
mergedBranches: [],
|
||||
adoptedObservations: 0,
|
||||
adoptedSummaries: 0,
|
||||
chromaUpdates: 0,
|
||||
chromaFailed: 0,
|
||||
dryRun,
|
||||
errors: []
|
||||
};
|
||||
|
||||
if (!mainRepo) {
|
||||
logger.debug('SYSTEM', 'Worktree adoption skipped (not a git repo)', { startCwd });
|
||||
return result;
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDirectory, 'claude-mem.db');
|
||||
if (!existsSync(dbPath)) {
|
||||
logger.debug('SYSTEM', 'Worktree adoption skipped (no DB yet)', { dbPath });
|
||||
return result;
|
||||
}
|
||||
|
||||
const allWorktrees = listWorktrees(mainRepo);
|
||||
const childWorktrees = allWorktrees.filter(w => w.path !== mainRepo);
|
||||
result.scannedWorktrees = childWorktrees.length;
|
||||
|
||||
if (childWorktrees.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let targets: WorktreeEntry[];
|
||||
if (opts.onlyBranch) {
|
||||
targets = childWorktrees.filter(w => w.branch === opts.onlyBranch);
|
||||
} else {
|
||||
const merged = listMergedBranches(mainRepo);
|
||||
targets = childWorktrees.filter(w => w.branch !== null && merged.has(w.branch));
|
||||
}
|
||||
|
||||
result.mergedBranches = targets
|
||||
.map(t => t.branch)
|
||||
.filter((b): b is string => b !== null);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const adoptedSqliteIds: number[] = [];
|
||||
|
||||
let db: import('bun:sqlite').Database | null = null;
|
||||
try {
|
||||
const { Database } = require('bun:sqlite') as typeof import('bun:sqlite');
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Schema guard: adoption may be invoked on worker startup before
|
||||
// DatabaseManager runs migrations. If the `merged_into_project` column
|
||||
// isn't present yet, prepared statements below will fail with
|
||||
// "no such column", silently skipping adoption until the next restart.
|
||||
// Return early so the next boot (post-migration) picks this up.
|
||||
interface ColumnInfo { name: string }
|
||||
const obsColumns = db
|
||||
.prepare('PRAGMA table_info(observations)')
|
||||
.all() as ColumnInfo[];
|
||||
const sumColumns = db
|
||||
.prepare('PRAGMA table_info(session_summaries)')
|
||||
.all() as ColumnInfo[];
|
||||
const obsHasColumn = obsColumns.some(c => c.name === 'merged_into_project');
|
||||
const sumHasColumn = sumColumns.some(c => c.name === 'merged_into_project');
|
||||
if (!obsHasColumn || !sumHasColumn) {
|
||||
logger.debug(
|
||||
'SYSTEM',
|
||||
'Worktree adoption skipped (merged_into_project column missing; will run after migration)',
|
||||
{ obsHasColumn, sumHasColumn }
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Select ALL observations for the worktree project (both unadopted rows
|
||||
// AND rows already stamped to this parent), not just unadopted ones. This
|
||||
// ensures a transient Chroma failure on a prior run gets retried the next
|
||||
// time adoption executes: SQL may already be stamped, but we re-include
|
||||
// those ids in the Chroma patch set (updateMergedIntoProject is idempotent
|
||||
// — it replays the same metadata write).
|
||||
const selectObsForPatch = db.prepare(
|
||||
`SELECT id FROM observations
|
||||
WHERE project = ?
|
||||
AND (merged_into_project IS NULL OR merged_into_project = ?)`
|
||||
);
|
||||
const updateObs = db.prepare(
|
||||
'UPDATE observations SET merged_into_project = ? WHERE project = ? AND merged_into_project IS NULL'
|
||||
);
|
||||
const updateSum = db.prepare(
|
||||
'UPDATE session_summaries SET merged_into_project = ? WHERE project = ? AND merged_into_project IS NULL'
|
||||
);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const wt of targets) {
|
||||
try {
|
||||
const worktreeProject = getProjectContext(wt.path).primary;
|
||||
const rows = selectObsForPatch.all(
|
||||
worktreeProject,
|
||||
parentProject
|
||||
) as Array<{ id: number }>;
|
||||
for (const r of rows) adoptedSqliteIds.push(r.id);
|
||||
|
||||
// updateObs/updateSum only touch WHERE merged_into_project IS NULL,
|
||||
// so .changes reflects only newly-adopted rows (not the re-patched ones).
|
||||
const obsChanges = updateObs.run(parentProject, worktreeProject).changes;
|
||||
const sumChanges = updateSum.run(parentProject, worktreeProject).changes;
|
||||
result.adoptedObservations += obsChanges;
|
||||
result.adoptedSummaries += sumChanges;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn('SYSTEM', 'Worktree adoption skipped branch', {
|
||||
worktree: wt.path,
|
||||
branch: wt.branch,
|
||||
error: message
|
||||
});
|
||||
result.errors.push({ worktree: wt.path, error: message });
|
||||
}
|
||||
}
|
||||
if (dryRun) {
|
||||
// Throw a dedicated error to force rollback. Caught below by instanceof check.
|
||||
throw new DryRunRollback();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
tx();
|
||||
} catch (err) {
|
||||
if (err instanceof DryRunRollback) {
|
||||
// Rolled back as intended for dry-run — counts are still useful.
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
|
||||
if (!dryRun && adoptedSqliteIds.length > 0) {
|
||||
const chromaSync = new ChromaSync('claude-mem');
|
||||
try {
|
||||
await chromaSync.updateMergedIntoProject(adoptedSqliteIds, parentProject);
|
||||
result.chromaUpdates = adoptedSqliteIds.length;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'CHROMA_SYNC',
|
||||
'Worktree adoption Chroma patch failed (SQL already committed)',
|
||||
{ parentProject, sqliteIdCount: adoptedSqliteIds.length },
|
||||
err as Error
|
||||
);
|
||||
result.chromaFailed = adoptedSqliteIds.length;
|
||||
} finally {
|
||||
await chromaSync.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
result.adoptedObservations > 0 ||
|
||||
result.adoptedSummaries > 0 ||
|
||||
result.chromaUpdates > 0 ||
|
||||
result.errors.length > 0
|
||||
) {
|
||||
logger.info('SYSTEM', 'Worktree adoption applied', {
|
||||
parentProject,
|
||||
dryRun,
|
||||
scannedWorktrees: result.scannedWorktrees,
|
||||
mergedBranches: result.mergedBranches,
|
||||
adoptedObservations: result.adoptedObservations,
|
||||
adoptedSummaries: result.adoptedSummaries,
|
||||
chromaUpdates: result.chromaUpdates,
|
||||
chromaFailed: result.chromaFailed,
|
||||
errors: result.errors.length
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run adoption once per distinct parent repo referenced by recorded cwds.
|
||||
*
|
||||
* Worker startup adoption cannot use `process.cwd()` as a seed — the daemon is
|
||||
* spawned with cwd=marketplace-plugin-dir, which isn't a git repo. Instead, we
|
||||
* derive candidate parent repos from `pending_messages.cwd` (the user's actual
|
||||
* working directories), dedupe via `resolveMainRepoPath`, and run adoption
|
||||
* against each. Failures on individual repos are logged but don't short-circuit
|
||||
* the others.
|
||||
*
|
||||
* Safe to call before `dbManager.initialize()`: opens its own short-lived DB
|
||||
* handle (readonly) to enumerate cwds, then delegates to `adoptMergedWorktrees`
|
||||
* which opens its own writable handle.
|
||||
*/
|
||||
export async function adoptMergedWorktreesForAllKnownRepos(opts: {
|
||||
dataDirectory?: string;
|
||||
dryRun?: boolean;
|
||||
} = {}): Promise<AdoptionResult[]> {
|
||||
const dataDirectory = opts.dataDirectory ?? DEFAULT_DATA_DIR;
|
||||
const dbPath = path.join(dataDirectory, 'claude-mem.db');
|
||||
const results: AdoptionResult[] = [];
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
logger.debug('SYSTEM', 'Worktree adoption skipped (no DB yet)', { dbPath });
|
||||
return results;
|
||||
}
|
||||
|
||||
const uniqueParents = new Set<string>();
|
||||
let db: import('bun:sqlite').Database | null = null;
|
||||
try {
|
||||
const { Database } = require('bun:sqlite') as typeof import('bun:sqlite');
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
|
||||
const hasPending = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='pending_messages'"
|
||||
).get() as { name: string } | undefined;
|
||||
if (!hasPending) {
|
||||
logger.debug('SYSTEM', 'Worktree adoption skipped (pending_messages table missing)');
|
||||
return results;
|
||||
}
|
||||
|
||||
const cwdRows = db.prepare(`
|
||||
SELECT cwd FROM pending_messages
|
||||
WHERE cwd IS NOT NULL AND cwd != ''
|
||||
GROUP BY cwd
|
||||
`).all() as Array<{ cwd: string }>;
|
||||
|
||||
for (const { cwd } of cwdRows) {
|
||||
const mainRepo = resolveMainRepoPath(cwd);
|
||||
if (mainRepo) uniqueParents.add(mainRepo);
|
||||
}
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
|
||||
if (uniqueParents.size === 0) {
|
||||
logger.debug('SYSTEM', 'Worktree adoption found no known parent repos');
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const repoPath of uniqueParents) {
|
||||
try {
|
||||
const result = await adoptMergedWorktrees({
|
||||
repoPath,
|
||||
dataDirectory,
|
||||
dryRun: opts.dryRun
|
||||
});
|
||||
results.push(result);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'SYSTEM',
|
||||
'Worktree adoption failed for parent repo (continuing)',
|
||||
{ repoPath, error: err instanceof Error ? err.message : String(err) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* 1. Writes/merges transcript-watch config to ~/.claude-mem/transcript-watch.json
|
||||
* 2. Sets up watch for ~/.codex/sessions/**\/*.jsonl using existing watcher
|
||||
* 3. Injects context via ~/.codex/AGENTS.md (Codex reads this natively)
|
||||
* 3. Injects context via workspace-local AGENTS.md files (Codex reads these natively)
|
||||
*
|
||||
* Anti-patterns:
|
||||
* - Does NOT add notify hooks -- transcript watching is sufficient
|
||||
@@ -67,7 +67,7 @@ function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
|
||||
|
||||
return parsed;
|
||||
} catch (parseError) {
|
||||
logger.error('CODEX', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
|
||||
logger.error('SYSTEM', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
|
||||
|
||||
// Back up corrupt file
|
||||
const backupPath = `${configPath}.backup.${Date.now()}`;
|
||||
@@ -130,42 +130,10 @@ function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inject claude-mem context section into ~/.codex/AGENTS.md.
|
||||
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md and GEMINI.md.
|
||||
* Remove legacy claude-mem context from ~/.codex/AGENTS.md.
|
||||
* Codex now uses workspace-local AGENTS.md files to avoid cross-project bleed.
|
||||
* Preserves any existing user content outside the tags.
|
||||
*/
|
||||
function injectCodexAgentsMdContext(): void {
|
||||
try {
|
||||
mkdirSync(CODEX_DIR, { recursive: true });
|
||||
|
||||
let existingContent = '';
|
||||
if (existsSync(CODEX_AGENTS_MD_PATH)) {
|
||||
existingContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||
}
|
||||
|
||||
// Initial placeholder content -- will be populated after first session
|
||||
const contextContent = [
|
||||
'# Recent Activity',
|
||||
'',
|
||||
'<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->',
|
||||
'',
|
||||
'*No context yet. Complete your first session and context will appear here.*',
|
||||
].join('\n');
|
||||
|
||||
const finalContent = replaceTaggedContent(existingContent, contextContent);
|
||||
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent);
|
||||
console.log(` Injected context placeholder into ${CODEX_AGENTS_MD_PATH}`);
|
||||
} catch (error) {
|
||||
// Non-fatal -- transcript watching still works without context injection
|
||||
logger.warn('CODEX', 'Failed to inject AGENTS.md context', { error: (error as Error).message });
|
||||
console.warn(` Warning: Could not inject context into AGENTS.md: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claude-mem context section from AGENTS.md.
|
||||
* Preserves user content outside the <claude-mem-context> tags.
|
||||
*/
|
||||
function removeCodexAgentsMdContext(): void {
|
||||
try {
|
||||
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
|
||||
@@ -179,7 +147,6 @@ function removeCodexAgentsMdContext(): void {
|
||||
|
||||
if (startIdx === -1 || endIdx === -1) return;
|
||||
|
||||
// Remove the tagged section and any surrounding blank lines
|
||||
const before = content.substring(0, startIdx).replace(/\n+$/, '');
|
||||
const after = content.substring(endIdx + endTag.length).replace(/^\n+/, '');
|
||||
const finalContent = (before + (after ? '\n\n' + after : '')).trim();
|
||||
@@ -187,17 +154,21 @@ function removeCodexAgentsMdContext(): void {
|
||||
if (finalContent) {
|
||||
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent + '\n');
|
||||
} else {
|
||||
// File would be empty -- leave it empty rather than deleting
|
||||
// (user may have other tooling that expects it to exist)
|
||||
writeFileSync(CODEX_AGENTS_MD_PATH, '');
|
||||
}
|
||||
|
||||
console.log(` Removed context section from ${CODEX_AGENTS_MD_PATH}`);
|
||||
console.log(` Removed legacy global context from ${CODEX_AGENTS_MD_PATH}`);
|
||||
} catch (error) {
|
||||
logger.warn('CODEX', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
|
||||
logger.warn('SYSTEM', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Codex now uses workspace-local AGENTS.md via transcript processor fallback.
|
||||
* Preserves user content outside the <claude-mem-context> tags.
|
||||
*/
|
||||
const cleanupLegacyCodexAgentsMdContext = removeCodexAgentsMdContext;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: Install
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,7 +177,7 @@ function removeCodexAgentsMdContext(): void {
|
||||
* Install Codex CLI integration for claude-mem.
|
||||
*
|
||||
* 1. Merges Codex transcript-watch config into ~/.claude-mem/transcript-watch.json
|
||||
* 2. Injects context placeholder into ~/.codex/AGENTS.md
|
||||
* 2. Cleans up any legacy global context block in ~/.codex/AGENTS.md
|
||||
*
|
||||
* @returns 0 on success, 1 on failure
|
||||
*/
|
||||
@@ -222,19 +193,19 @@ export async function installCodexCli(): Promise<number> {
|
||||
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
|
||||
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
|
||||
|
||||
// Step 2: Inject context into AGENTS.md
|
||||
injectCodexAgentsMdContext();
|
||||
// Step 2: Clean up legacy global AGENTS.md context
|
||||
cleanupLegacyCodexAgentsMdContext();
|
||||
|
||||
console.log(`
|
||||
Installation complete!
|
||||
|
||||
Transcript watch config: ${DEFAULT_CONFIG_PATH}
|
||||
Context file: ${CODEX_AGENTS_MD_PATH}
|
||||
Context files: <workspace>/AGENTS.md
|
||||
|
||||
How it works:
|
||||
- claude-mem watches Codex session JSONL files for new activity
|
||||
- No hooks needed -- transcript watching is fully automatic
|
||||
- Context from past sessions is injected via ${CODEX_AGENTS_MD_PATH}
|
||||
- Context from past sessions is injected via AGENTS.md in the active Codex workspace
|
||||
|
||||
Next steps:
|
||||
1. Start claude-mem worker: npx claude-mem start
|
||||
@@ -284,8 +255,8 @@ export function uninstallCodexCli(): number {
|
||||
console.log(' No transcript-watch.json found -- nothing to remove.');
|
||||
}
|
||||
|
||||
// Step 2: Remove context section from AGENTS.md
|
||||
removeCodexAgentsMdContext();
|
||||
// Step 2: Remove legacy global context section from AGENTS.md
|
||||
cleanupLegacyCodexAgentsMdContext();
|
||||
|
||||
console.log('\nUninstallation complete!');
|
||||
console.log('Restart claude-mem worker to apply changes.\n');
|
||||
@@ -340,20 +311,20 @@ export function checkCodexCliStatus(): number {
|
||||
// Check context config
|
||||
if (codexWatch.context) {
|
||||
console.log(` Context mode: ${codexWatch.context.mode}`);
|
||||
console.log(` Context path: ${codexWatch.context.path ?? 'default'}`);
|
||||
console.log(` Context path: ${codexWatch.context.path ?? '<workspace>/AGENTS.md (default)'}`);
|
||||
console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`);
|
||||
}
|
||||
|
||||
// Check AGENTS.md
|
||||
// Check legacy global AGENTS.md usage
|
||||
if (existsSync(CODEX_AGENTS_MD_PATH)) {
|
||||
const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||
if (mdContent.includes('<claude-mem-context>')) {
|
||||
console.log(` Context: Active (${CODEX_AGENTS_MD_PATH})`);
|
||||
console.log(` Legacy global context: Present (${CODEX_AGENTS_MD_PATH})`);
|
||||
} else {
|
||||
console.log(` Context: AGENTS.md exists but no context tags`);
|
||||
console.log(` Legacy global context: Not active`);
|
||||
}
|
||||
} else {
|
||||
console.log(` Context: No AGENTS.md file`);
|
||||
console.log(` Legacy global context: None`);
|
||||
}
|
||||
|
||||
// Check if ~/.codex/sessions exists (indicates Codex has been used)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user