Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25ccf46ac0 | |||
| 1a6a68cac8 | |||
| a0e895b53b | |||
| 753a993647 | |||
| d0676aa049 | |||
| 7996dfd5cd | |||
| 95889c7b4e | |||
| 25bb93a995 | |||
| c21e49d9fa | |||
| f4570f2a0a | |||
| cbb68ad9e1 | |||
| b8999c1181 | |||
| 052da384b2 | |||
| d8947473b8 | |||
| 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 | |||
| c5129ed016 | |||
| 902db6b2e1 | |||
| c7c68e81f4 | |||
| 21b10b4696 | |||
| 4de417663c | |||
| 190c74492f | |||
| ae6915b88e | |||
| cdffdba97a | |||
| 2495f98496 | |||
| a2ac116aac | |||
| 8265fc7aa1 | |||
| 76a880a3d6 | |||
| 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 | |||
| d06882126f | |||
| ddb57ea598 | |||
| 6885bdb019 | |||
| 0321f4266d | |||
| 80d1deedbe | |||
| 07ab7000a8 | |||
| a48bf89963 | |||
| 368daddd88 | |||
| ed444dfec7 | |||
| 4aa7119d7d | |||
| 5621b67ccd | |||
| 9cfa57d498 | |||
| a656af2bff | |||
| fe8c65a8cd | |||
| 4f6fb9e614 | |||
| 2b60dd2932 | |||
| 88636ec012 | |||
| 031513d723 | |||
| f2cc33b494 | |||
| 3a09c1bb1a | |||
| 85eb796b18 | |||
| b6f9950bb3 | |||
| 4324f6bbc1 | |||
| df1fb8bb89 | |||
| e2a230286d | |||
| 0524fa83cd | |||
| 4d7bec4d05 | |||
| 5b041d6b49 | |||
| abb5940788 | |||
| d88ea71590 | |||
| c80763390b | |||
| 47d6d51030 | |||
| 9f529a30f5 | |||
| e07b13f7de | |||
| 1d48f63b99 | |||
| fb9d917f8a | |||
| b34aff1aa2 | |||
| d54e574251 | |||
| c7abb01dfc | |||
| 7e07210635 | |||
| 648c84804c | |||
| 8c79b99384 | |||
| 9361e33b6d | |||
| 9e7b08445f | |||
| 033c1c4503 | |||
| 8d74031213 | |||
| 3bc3697648 | |||
| 4d7b29786b | |||
| 4c697899e0 | |||
| ef0a07f606 | |||
| 472ed8e1e0 | |||
| 5ccd81b8a3 | |||
| 678ae1e7d3 | |||
| 80a8c90a1a | |||
| 237a4c37f8 | |||
| 626654f816 | |||
| ed5189ebe9 | |||
| e7ba9acaa7 | |||
| ad902bedd9 | |||
| b88566dcdd | |||
| 1fac57535e | |||
| 10e980cd69 | |||
| 38d9ac7adb | |||
| 23058d4b0c | |||
| 503bda4868 | |||
| 4616f7ab1c | |||
| 73113321a1 | |||
| 88be01910b | |||
| 9dbf63f5d4 | |||
| 3651a34e96 | |||
| 79bc3c85b3 | |||
| 6581d2ef45 | |||
| 39db5c4882 | |||
| 3af68b7dfe | |||
| e9b4f75fb2 | |||
| 2af37422da | |||
| a32151a166 | |||
| 97ea9e45fc | |||
| ecb09df420 | |||
| 6c7acfbc1c | |||
| 44a7b2fcb9 | |||
| 7015301d8f | |||
| a5e86ad4ab | |||
| d93bde059e | |||
| d60ae14a9b |
@@ -0,0 +1,7 @@
|
||||
<claude-mem-context>
|
||||
# claude-mem: Cross-Session Memory
|
||||
|
||||
*No context yet. Complete your first session and context will appear here.*
|
||||
|
||||
Use claude-mem's MCP search tools for manual memory queries.
|
||||
</claude-mem-context>
|
||||
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.5.0",
|
||||
"version": "12.0.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.0.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":"e69a1f74-daa5-47f4-a6e8-ee55a9eebeaa","pid":82985,"acquiredAt":1775596215414}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "12.0.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,7 @@
|
||||
<claude-mem-context>
|
||||
# claude-mem: Cross-Session Memory
|
||||
|
||||
*No context yet. Complete your first session and context will appear here.*
|
||||
|
||||
Use claude-mem's MCP search tools for manual memory queries.
|
||||
</claude-mem-context>
|
||||
@@ -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 }}
|
||||
|
||||
+8
-9
@@ -1,7 +1,6 @@
|
||||
datasets/
|
||||
node_modules/
|
||||
dist/
|
||||
!installer/dist/
|
||||
**/_tree-sitter/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -20,7 +19,6 @@ plugin/data.backup/
|
||||
package-lock.json
|
||||
bun.lock
|
||||
private/
|
||||
datasets/
|
||||
Auto Run Docs/
|
||||
|
||||
# Generated UI files (built from viewer-template.html)
|
||||
@@ -30,12 +28,13 @@ src/ui/viewer.html
|
||||
.mcp.json
|
||||
.cursor/
|
||||
|
||||
# Prevent literal tilde directories (path validation bug artifacts)
|
||||
~*/
|
||||
|
||||
# Prevent other malformed path directories
|
||||
http*/
|
||||
https*/
|
||||
|
||||
# Ignore WebStorm project files (for dinosaur IDE users)
|
||||
.idea/
|
||||
|
||||
.claude-octopus/
|
||||
.claude/session-intent.md
|
||||
.claude/session-plan.md
|
||||
.octo/
|
||||
|
||||
# Local contribution analysis (not part of upstream)
|
||||
CONTRIB_NOTES.md
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Source code (dist/ and plugin/ are the shipped artifacts)
|
||||
src/
|
||||
scripts/
|
||||
tests/
|
||||
docs/
|
||||
datasets/
|
||||
private/
|
||||
antipattern-czar/
|
||||
|
||||
# Heavy binaries installed at runtime via smart-install.js
|
||||
plugin/node_modules/
|
||||
plugin/scripts/claude-mem
|
||||
plugin/bun.lock
|
||||
plugin/data/
|
||||
plugin/data.backup/
|
||||
|
||||
# Development files
|
||||
*.ts
|
||||
!*.d.ts
|
||||
tsconfig*.json
|
||||
.eslintrc*
|
||||
.prettierrc*
|
||||
.editorconfig
|
||||
jest.config*
|
||||
vitest.config*
|
||||
|
||||
# Git and CI
|
||||
.git/
|
||||
.github/
|
||||
.gitignore
|
||||
.claude/
|
||||
.cursor/
|
||||
.mcp.json
|
||||
.plan/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
Thumbs.db
|
||||
|
||||
# Misc
|
||||
Auto Run Docs/
|
||||
~*/
|
||||
http*/
|
||||
https*/
|
||||
.idea/
|
||||
@@ -0,0 +1,5 @@
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
*No context yet. Complete your first session and context will appear here.*
|
||||
|
||||
Use claude-mem's MCP search tools for manual memory queries.
|
||||
+4312
-67
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,3 @@
|
||||
<p align="center">
|
||||
Official $CMEM Links:
|
||||
<a href="https://bags.fm/2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS">Bags.fm</a> •
|
||||
<a href="https://jup.ag/tokens/2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS">Jupiter</a> •
|
||||
<a href="https://photon-sol.tinyastro.io/en/lp/6MzFAkWnac6GSK1EdFX93dZeukGfzrFq4UHWarhGSQyd">Photon</a> •
|
||||
<a href="https://dexscreener.com/solana/6mzfakwnac6gsk1edfx93dzeukgfzrfq4uhwarhgsqyd">DEXScreener</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Official CA: 2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS (on Solana)</p>
|
||||
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<a href="https://github.com/thedotmack/claude-mem">
|
||||
@@ -84,13 +74,40 @@
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/thedotmack/claude-mem">
|
||||
<picture>
|
||||
<img src="https://raw.githubusercontent.com/thedotmack/claude-mem/main/docs/public/cm-preview.gif" alt="Claude-Mem Preview" width="800">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/thedotmack/claude-mem">
|
||||
<picture>
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/thedotmack/claude-mem/main/docs/public/cm-preview.gif"
|
||||
alt="Claude-Mem Preview"
|
||||
width="500"
|
||||
>
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.star-history.com/#thedotmack/claude-mem&Date">
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="https://api.star-history.com/image?repos=thedotmack/claude-mem&type=date&theme=dark&legend=top-left"
|
||||
/>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="https://api.star-history.com/image?repos=thedotmack/claude-mem&type=date&legend=top-left"
|
||||
/>
|
||||
<img
|
||||
alt="Star History Chart"
|
||||
src="https://api.star-history.com/image?repos=thedotmack/claude-mem&type=date&legend=top-left"
|
||||
width="500"
|
||||
/>
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
@@ -110,17 +127,29 @@
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start a new Claude Code session in the terminal and enter the following commands:
|
||||
Install with a single command:
|
||||
|
||||
```bash
|
||||
npx claude-mem install
|
||||
```
|
||||
|
||||
Or install for Gemini CLI (auto-detects `~/.gemini`):
|
||||
|
||||
```bash
|
||||
npx claude-mem install --ide gemini-cli
|
||||
```
|
||||
|
||||
Or install from the plugin marketplace inside Claude Code:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add thedotmack/claude-mem
|
||||
|
||||
/plugin install claude-mem
|
||||
```
|
||||
|
||||
Restart Claude Code. Context from previous sessions will automatically appear in new sessions.
|
||||
Restart Claude Code or Gemini CLI. Context from previous sessions will automatically appear in new sessions.
|
||||
|
||||
> **Note:** Claude-Mem is also published on npm, but `npm install -g claude-mem` installs the **SDK/library only** — it does not register the plugin hooks or set up the worker service. To use Claude-Mem as a plugin, always install via the `/plugin` commands above.
|
||||
> **Note:** Claude-Mem is also published on npm, but `npm install -g claude-mem` installs the **SDK/library only** — it does not register the plugin hooks or set up the worker service. Always install via `npx claude-mem install` or the `/plugin` commands above.
|
||||
|
||||
### 🦞 OpenClaw Gateway
|
||||
|
||||
@@ -154,6 +183,7 @@ The installer handles dependencies, plugin setup, AI provider configuration, wor
|
||||
### Getting Started
|
||||
|
||||
- **[Installation Guide](https://docs.claude-mem.ai/installation)** - Quick start & advanced installation
|
||||
- **[Gemini CLI Setup](https://docs.claude-mem.ai/gemini-cli/setup)** - Dedicated guide for Google's Gemini CLI integration
|
||||
- **[Usage Guide](https://docs.claude-mem.ai/usage/getting-started)** - How Claude-Mem works automatically
|
||||
- **[Search Tools](https://docs.claude-mem.ai/usage/search-tools)** - Query your project history with natural language
|
||||
- **[Beta Features](https://docs.claude-mem.ai/beta-features)** - Try experimental features like Endless Mode
|
||||
@@ -340,3 +370,9 @@ See the [LICENSE](LICENSE) file for full details.
|
||||
---
|
||||
|
||||
**Built with Claude Agent SDK** | **Powered by Claude Code** | **Made with TypeScript**
|
||||
|
||||
---
|
||||
|
||||
### What About $CMEM?
|
||||
|
||||
$CMEM is a solana token created by a 3rd party without Claude-Mem's prior consent, but officially embraced by the creator of Claude-Mem (Alex Newman, @thedotmack). The token acts as a community catalyst for growth and a vehicle for bringing real-time agent data to the developers and knowledge workers that need it most. $CMEM: 2TsmuYUrsctE57VLckZBYEEzdokUF8j8e1GavekWBAGS
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<claude-mem-context>
|
||||
# claude-mem: Cross-Session Memory
|
||||
|
||||
*No context yet. Complete your first session and context will appear here.*
|
||||
|
||||
Use claude-mem's MCP search tools for manual memory queries.
|
||||
</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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+10
-1
@@ -57,12 +57,21 @@
|
||||
"cursor/openrouter-setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Gemini CLI Integration",
|
||||
"icon": "terminal",
|
||||
"pages": [
|
||||
"gemini-cli/setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Best Practices",
|
||||
"icon": "lightbulb",
|
||||
"pages": [
|
||||
"context-engineering",
|
||||
"progressive-disclosure"
|
||||
"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.
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: "Gemini CLI Setup"
|
||||
description: "Add persistent memory to Gemini CLI with claude-mem"
|
||||
---
|
||||
|
||||
# Gemini CLI Setup
|
||||
|
||||
> **Give Gemini CLI persistent memory across sessions.**
|
||||
|
||||
Gemini CLI starts every session from scratch. Claude-mem changes that by capturing observations, decisions, and patterns — then injecting relevant context into each new session.
|
||||
|
||||
<Info>
|
||||
**How it works:** Claude-mem installs lifecycle hooks into Gemini CLI that capture tool usage, agent responses, and session events. A local worker service extracts semantic observations and injects relevant history at session start.
|
||||
</Info>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and configured
|
||||
- [Node.js](https://nodejs.org/) 18+
|
||||
- The `~/.gemini` directory must exist (created by Gemini CLI on first run)
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Install claude-mem
|
||||
|
||||
```bash
|
||||
npx claude-mem install
|
||||
```
|
||||
|
||||
The installer will:
|
||||
1. Auto-detect Gemini CLI (checks for `~/.gemini` directory)
|
||||
2. Prompt you to select **Gemini CLI** from the IDE picker
|
||||
3. Install 8 lifecycle hooks into `~/.gemini/settings.json`
|
||||
4. Inject context configuration into `~/.gemini/GEMINI.md`
|
||||
5. Start the worker service
|
||||
|
||||
### Step 2: Configure an AI provider
|
||||
|
||||
Claude-mem needs an AI provider to extract observations from your sessions. Choose one:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Gemini API (Free)">
|
||||
The simplest option — use Gemini's own API for observation extraction:
|
||||
|
||||
1. Get a free API key from [Google AI Studio](https://aistudio.google.com/apikey)
|
||||
2. Add it to your settings:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude-mem
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||
"CLAUDE_MEM_GEMINI_API_KEY": "YOUR_API_KEY"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
<Tip>
|
||||
**Free tier:** 1,500 requests/day with `gemini-2.5-flash-lite`. Enable billing on Google Cloud for 4,000 RPM without charges.
|
||||
</Tip>
|
||||
</Tab>
|
||||
<Tab title="Claude SDK">
|
||||
If you have a Claude API key:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude-mem
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "claude"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Set your API key via environment variable:
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="your-key"
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="OpenRouter">
|
||||
For access to 100+ models:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude-mem
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||
"CLAUDE_MEM_OPENROUTER_API_KEY": "YOUR_KEY"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Step 3: Verify installation
|
||||
|
||||
```bash
|
||||
# Check worker is running
|
||||
npx claude-mem status
|
||||
|
||||
# Check hooks are installed — look for claude-mem entries
|
||||
cat ~/.gemini/settings.json | grep claude-mem
|
||||
```
|
||||
|
||||
Open http://localhost:37777 to see the memory viewer.
|
||||
|
||||
### Step 4: Start using Gemini CLI
|
||||
|
||||
Launch Gemini CLI normally. Claude-mem works in the background:
|
||||
|
||||
```bash
|
||||
gemini
|
||||
```
|
||||
|
||||
On session start, you'll see claude-mem context injected with your recent observations and project history.
|
||||
|
||||
## What gets captured
|
||||
|
||||
Claude-mem registers 8 of Gemini CLI's 11 lifecycle hooks:
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| **SessionStart** | Injects memory context into the session |
|
||||
| **SessionEnd** | Marks session complete, triggers summary |
|
||||
| **PreCompress** | Captures session summary before compression |
|
||||
| **Notification** | Records system events (permissions, etc.) |
|
||||
| **BeforeAgent** | Captures user prompts |
|
||||
| **AfterAgent** | Records full agent responses |
|
||||
| **BeforeTool** | Logs tool invocations before execution |
|
||||
| **AfterTool** | Captures tool results after execution |
|
||||
|
||||
Three model-level hooks (BeforeModel, AfterModel, BeforeToolSelection) are intentionally skipped — they fire per-LLM-call and are too noisy for memory capture.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hooks not firing
|
||||
|
||||
1. Verify hooks exist in settings:
|
||||
```bash
|
||||
cat ~/.gemini/settings.json
|
||||
```
|
||||
You should see entries like `"SessionStart"`, `"AfterTool"`, etc. with claude-mem commands.
|
||||
|
||||
2. Restart Gemini CLI after installation.
|
||||
|
||||
3. Re-run the installer:
|
||||
```bash
|
||||
npx claude-mem install
|
||||
```
|
||||
|
||||
### Worker not running
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
npx claude-mem status
|
||||
|
||||
# View logs
|
||||
npx claude-mem logs
|
||||
|
||||
# Restart worker
|
||||
npx claude-mem restart
|
||||
```
|
||||
|
||||
### No context appearing at session start
|
||||
|
||||
1. Ensure the worker is running (check http://localhost:37777)
|
||||
2. You need at least one previous session with observations for context to appear
|
||||
3. Check your AI provider is configured in `~/.claude-mem/settings.json`
|
||||
|
||||
### Raw escape codes in output
|
||||
|
||||
If you see characters like `[31m` or `[0m` in the session context, your claude-mem version may need updating:
|
||||
|
||||
```bash
|
||||
npx claude-mem install
|
||||
```
|
||||
|
||||
This was fixed in v10.6.3+ — the Gemini CLI adapter now strips ANSI color codes automatically.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
npx claude-mem uninstall
|
||||
```
|
||||
|
||||
This removes hooks from `~/.gemini/settings.json` and cleans up `~/.gemini/GEMINI.md`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Gemini Provider](/usage/gemini-provider) — Configure the Gemini AI provider for observation extraction
|
||||
- [Configuration](/configuration) — All settings options
|
||||
- [Search Tools](/usage/search-tools) — Search your memory from within sessions
|
||||
- [Troubleshooting](/troubleshooting) — Common issues and solutions
|
||||
@@ -7,24 +7,35 @@ description: "Install Claude-Mem plugin for persistent memory across sessions"
|
||||
|
||||
## Quick Start
|
||||
|
||||
Install Claude-Mem directly from the plugin marketplace:
|
||||
### Option 1: npx (Recommended)
|
||||
|
||||
Install and configure Claude-Mem with a single command:
|
||||
|
||||
```bash
|
||||
npx claude-mem install
|
||||
```
|
||||
|
||||
The interactive installer will:
|
||||
- Detect your installed IDEs (Claude Code, Cursor, Gemini CLI, Windsurf, etc.)
|
||||
- Copy plugin files to the correct locations
|
||||
- Register the plugin with Claude Code
|
||||
- Install all dependencies (including Bun and uv)
|
||||
- Auto-start the worker service
|
||||
|
||||
### Option 2: Plugin Marketplace
|
||||
|
||||
Install Claude-Mem directly from the plugin marketplace inside Claude Code:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add thedotmack/claude-mem
|
||||
/plugin install claude-mem
|
||||
```
|
||||
|
||||
That's it! The plugin will automatically:
|
||||
- Download prebuilt binaries (no compilation needed)
|
||||
- Install all dependencies (including SQLite binaries)
|
||||
- Configure hooks for session lifecycle management
|
||||
- Auto-start the worker service on first session
|
||||
|
||||
Start a new Claude Code session and you'll see context from previous sessions automatically loaded.
|
||||
Both methods will automatically configure hooks and start the worker service. Start a new Claude Code session and you'll see context from previous sessions automatically loaded.
|
||||
|
||||
> **Important:** Claude-Mem is published on npm, but running `npm install -g claude-mem` installs the
|
||||
> **SDK/library only**. It does **not** register plugin hooks or start the worker service.
|
||||
> To use Claude-Mem as a persistent memory plugin, always install via the `/plugin` commands above.
|
||||
> Always install via `npx claude-mem install` or the `/plugin` commands above.
|
||||
|
||||
## System Requirements
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@ Claude-Mem seamlessly preserves context across sessions by automatically capturi
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start a new Claude Code session in the terminal and enter the following commands:
|
||||
Install with a single command:
|
||||
|
||||
```bash
|
||||
npx claude-mem install
|
||||
```
|
||||
|
||||
Or install from the plugin marketplace inside Claude Code:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add thedotmack/claude-mem
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: OpenClaw Integration
|
||||
description: Persistent memory for OpenClaw agents — observation recording, MEMORY.md live sync, and real-time observation feeds
|
||||
description: Persistent memory for OpenClaw agents — observation recording, system prompt context injection, and real-time observation feeds
|
||||
icon: dragon
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ icon: dragon
|
||||
The OpenClaw plugin gives claude-mem persistent memory to agents running on the [OpenClaw](https://openclaw.ai) gateway. It handles three things:
|
||||
|
||||
1. **Observation recording** — Captures tool usage from OpenClaw's embedded runner and sends it to the claude-mem worker for AI processing
|
||||
2. **MEMORY.md live sync** — Writes a continuously-updated timeline to each agent's workspace so agents always have context from previous sessions
|
||||
2. **System prompt context injection** — Injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook, keeping `MEMORY.md` free for agent-curated memory
|
||||
3. **Observation feed** — Streams new observations to messaging channels (Telegram, Discord, Slack, etc.) in real-time via SSE
|
||||
|
||||
<Info>
|
||||
@@ -21,10 +21,11 @@ OpenClaw's embedded runner (`pi-embedded`) calls the Anthropic API directly with
|
||||
```plaintext
|
||||
OpenClaw Gateway
|
||||
│
|
||||
├── before_agent_start ──→ Sync MEMORY.md + Init session
|
||||
├── tool_result_persist ──→ Record observation + Re-sync MEMORY.md
|
||||
├── before_agent_start ───→ Init session
|
||||
├── before_prompt_build ──→ Inject context into system prompt
|
||||
├── tool_result_persist ──→ Record observation
|
||||
├── agent_end ────────────→ Summarize + Complete session
|
||||
└── gateway_start ────────→ Reset session tracking
|
||||
└── gateway_start ────────→ Reset session tracking + context cache
|
||||
│
|
||||
▼
|
||||
Claude-Mem Worker (localhost:37777)
|
||||
@@ -32,7 +33,7 @@ OpenClaw Gateway
|
||||
├── POST /api/sessions/observations
|
||||
├── POST /api/sessions/summarize
|
||||
├── POST /api/sessions/complete
|
||||
├── GET /api/context/inject ──→ MEMORY.md content
|
||||
├── GET /api/context/inject ──→ System prompt context
|
||||
└── GET /stream ─────────────→ SSE → Messaging channels
|
||||
```
|
||||
|
||||
@@ -40,21 +41,15 @@ OpenClaw Gateway
|
||||
|
||||
<Steps>
|
||||
<Step title="Agent starts (before_agent_start)">
|
||||
When an OpenClaw agent starts, the plugin does two things:
|
||||
When an OpenClaw agent starts, the plugin initializes a session by sending the user prompt to `POST /api/sessions/init` so the worker can create a new session and start processing.
|
||||
</Step>
|
||||
<Step title="Context injected (before_prompt_build)">
|
||||
Before each LLM call, the plugin fetches the observation timeline from the worker's `/api/context/inject` endpoint and returns it as `appendSystemContext`. This injects cross-session context directly into the agent's system prompt without writing any files.
|
||||
|
||||
1. **Syncs MEMORY.md** — Fetches the latest timeline from the worker's `/api/context/inject` endpoint and writes it to `MEMORY.md` in the agent's workspace directory. This gives the agent context from all previous sessions before it starts working.
|
||||
|
||||
2. **Initializes a session** — Sends the user prompt to `POST /api/sessions/init` so the worker can create a new session and start processing.
|
||||
|
||||
Short prompts (under 10 characters) skip session init but still sync MEMORY.md.
|
||||
The context is cached for 60 seconds to avoid re-fetching on every LLM turn within a session.
|
||||
</Step>
|
||||
<Step title="Tool use recorded (tool_result_persist)">
|
||||
Every time the agent uses a tool (Read, Write, Bash, etc.), the plugin:
|
||||
|
||||
1. **Sends the observation** to `POST /api/sessions/observations` with the tool name, input, and truncated response (max 1000 chars)
|
||||
2. **Re-syncs MEMORY.md** with the latest timeline from the worker
|
||||
|
||||
Both operations are fire-and-forget — they don't block the agent from continuing work. The MEMORY.md file gets progressively richer as the session continues.
|
||||
Every time the agent uses a tool (Read, Write, Bash, etc.), the plugin sends the observation to `POST /api/sessions/observations` with the tool name, input, and truncated response (max 1000 chars). This is fire-and-forget — it doesn't block the agent from continuing work.
|
||||
|
||||
Tools prefixed with `memory_` are skipped to avoid recursive recording.
|
||||
</Step>
|
||||
@@ -62,21 +57,18 @@ OpenClaw Gateway
|
||||
When the agent completes, the plugin extracts the last assistant message and sends it to `POST /api/sessions/summarize`, then calls `POST /api/sessions/complete` to close the session. Both are fire-and-forget.
|
||||
</Step>
|
||||
<Step title="Gateway restarts (gateway_start)">
|
||||
Clears all session tracking (session IDs, workspace directory mappings) so agents get fresh state after a gateway restart.
|
||||
Clears all session tracking (session IDs, context cache) so agents get fresh state after a gateway restart.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### MEMORY.md Live Sync
|
||||
### System Prompt Context Injection
|
||||
|
||||
The plugin writes a `MEMORY.md` file to each agent's workspace directory containing the full timeline of observations and summaries from previous sessions. This file is updated:
|
||||
The plugin injects cross-session observation context into each agent's system prompt via OpenClaw's `before_prompt_build` hook. The content comes from the worker's `GET /api/context/inject?projects=<project>` endpoint, which generates a formatted markdown timeline from the SQLite database.
|
||||
|
||||
- On every `before_agent_start` event (agent gets fresh context before starting)
|
||||
- On every `tool_result_persist` event (context stays current during the session)
|
||||
|
||||
The content comes from the worker's `GET /api/context/inject?projects=<project>` endpoint, which generates a formatted markdown timeline from the SQLite database.
|
||||
This approach keeps `MEMORY.md` under the agent's control for curated long-term memory (decisions, preferences, durable facts), while the observation timeline is delivered through the system prompt where it belongs.
|
||||
|
||||
<Info>
|
||||
MEMORY.md updates are fire-and-forget. They run in the background without blocking the agent. The file reflects whatever the worker has processed so far — it doesn't wait for the current observation to be fully processed before writing.
|
||||
Context is cached for 60 seconds per project to avoid re-fetching on every LLM turn. The cache is cleared on gateway restart. Use `syncMemoryFileExclude` to opt specific agents out of context injection entirely.
|
||||
</Info>
|
||||
|
||||
### Observation Feed (SSE → Messaging)
|
||||
@@ -319,7 +311,11 @@ The claude-mem worker service must be running on the same machine as the OpenCla
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="syncMemoryFile" type="boolean" default={true}>
|
||||
Enable automatic MEMORY.md sync to agent workspaces. Set to `false` if you don't want the plugin writing files to workspace directories.
|
||||
Inject observation context into the agent system prompt via `before_prompt_build` hook. When `true`, agents receive cross-session context automatically. Set to `false` to disable context injection entirely (observations are still recorded).
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="syncMemoryFileExclude" type="string[]" default={[]}>
|
||||
Agent IDs excluded from automatic context injection. Useful for agents that curate their own memory and don't need the observation timeline (e.g., `["snarf", "debugger"]`). Observations are still recorded for excluded agents — only the context injection is skipped.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="workerPort" type="number" default={37777}>
|
||||
@@ -374,9 +370,9 @@ The plugin uses HTTP calls to the already-running claude-mem worker service rath
|
||||
Each OpenClaw agent session gets a unique `contentSessionId` (format: `openclaw-<sessionKey>-<timestamp>`) that maps to a claude-mem session in the worker. The plugin tracks:
|
||||
|
||||
- `sessionIds` — Maps OpenClaw session keys to content session IDs
|
||||
- `workspaceDirsBySessionKey` — Maps session keys to workspace directories so `tool_result_persist` events can sync MEMORY.md even when the event context doesn't include `workspaceDir`
|
||||
- `contextCache` — TTL cache (60s) for context injection responses, keyed by project
|
||||
|
||||
Both maps are cleared on `gateway_start`.
|
||||
Both are cleared on `gateway_start`.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -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,196 @@
|
||||
---
|
||||
title: "Smart Explore Benchmark"
|
||||
description: "Token efficiency comparison between AST-based and traditional code exploration"
|
||||
---
|
||||
|
||||
# Smart Explore Benchmark
|
||||
|
||||
Smart Explore uses tree-sitter AST parsing to provide structural code navigation through three MCP tools: `smart_search`, `smart_outline`, and `smart_unfold`. This report documents a rigorous A/B comparison against the standard Explore agent (which uses Glob, Grep, and Read tools) to quantify the token savings and quality trade-offs.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Metric | Smart Explore | Explore Agent | Advantage |
|
||||
|--------|:---:|:---:|---|
|
||||
| Discovery (cross-file search) | ~14,200 tokens | ~252,500 tokens | **17.8x cheaper** |
|
||||
| Targeted reads (specific symbols) | ~5,650 tokens | ~109,400 tokens | **19.4x cheaper** |
|
||||
| End-to-end (search + read) | ~4,200 tokens | ~45,000 tokens | **10-12x cheaper** |
|
||||
| Completeness | 5/5 full source returned | 4/5 (truncated longest method) | Smart Explore more reliable |
|
||||
| Speed | Under 2s per call | 5-66s per call | **10-30x faster** |
|
||||
|
||||
## Methodology
|
||||
|
||||
### Test Environment
|
||||
|
||||
- **Codebase**: claude-mem (`src/` directory, 194 TypeScript files, 1,206 parsed symbols)
|
||||
- **Model**: Claude Opus 4.6 for both approaches
|
||||
- **Measurement**: Token counts from tool response metadata (`total_tokens` for Explore agents, self-reported `~N tokens for folded view` for Smart Explore)
|
||||
|
||||
### Controls
|
||||
|
||||
The Explore agents were explicitly instructed: *"Do NOT use smart_search, smart_outline, or smart_unfold tools. Only use Glob, Grep, and Read tools."* This was verified necessary after an initial round where agents opportunistically used the Smart Explore tools, invalidating the comparison.
|
||||
|
||||
### Queries
|
||||
|
||||
Five queries were selected to represent common exploration tasks:
|
||||
|
||||
1. **"session processing"** -- Cross-cutting feature spanning multiple services
|
||||
2. **"shutdown"** -- Infrastructure concern touching 6+ files
|
||||
3. **"hook registration"** -- Architecture question about plugin system
|
||||
4. **"sqlite database"** -- Technology-specific search across the data layer
|
||||
5. **"worker-service.ts outline"** -- Single large file (1,225 lines) structural understanding
|
||||
|
||||
## Round 1: Discovery
|
||||
|
||||
*"What exists and where is it?"* -- Finding relevant files and symbols across the codebase.
|
||||
|
||||
### Results
|
||||
|
||||
| Query | Smart Explore | Explore Agent | Ratio | Explore Tool Calls |
|
||||
|-------|:---:|:---:|:---:|:---:|
|
||||
| session processing | ~4,391 t | 51,659 t | **11.8x** | 15 |
|
||||
| shutdown | ~3,852 t | 51,523 t | **13.4x** | 18 |
|
||||
| hook registration | ~1,930 t | 51,688 t | **26.8x** | 37 |
|
||||
| sqlite database | ~2,543 t | 58,633 t | **23.1x** | 16 |
|
||||
| worker-service outline | ~1,500 t | 38,973 t | **26.0x** | 15 |
|
||||
| **Total** | **~14,216 t** | **252,476 t** | **17.8x** | **101** |
|
||||
|
||||
### What Each Returned
|
||||
|
||||
**Smart Explore** (1 tool call each): 10 ranked symbols with signatures, line numbers, and JSDoc summaries, plus folded structural views of all matching files showing every function/class/interface with bodies collapsed.
|
||||
|
||||
**Explore Agent** (15-37 tool calls each): Synthesized narrative reports with architecture diagrams, design pattern analysis, data flow explanations, complete interface dumps, and file structure maps. Significantly more explanatory prose.
|
||||
|
||||
### Analysis
|
||||
|
||||
The token gap is widest for narrowly-scoped queries ("hook registration" at 26.8x) because the Explore agent reads multiple full files to find relatively few relevant symbols. For broad queries ("session processing" at 11.8x), more of the file content is relevant, narrowing the ratio.
|
||||
|
||||
Smart Explore's consistent 1-tool-call pattern means its cost is predictable. The Explore agent's cost varies with how many files it reads and how much it synthesizes -- ranging from 15 to 37 tool calls for comparable scope.
|
||||
|
||||
## Round 2: Targeted Reads
|
||||
|
||||
*"Show me this specific function."* -- Reading the implementation of a known symbol after discovery.
|
||||
|
||||
Based on the Round 1 results, five specific symbols were selected as natural drill-down targets:
|
||||
|
||||
| Target Symbol | File | Lines |
|
||||
|---------------|------|:---:|
|
||||
| `SessionManager.initializeSession` | services/worker/SessionManager.ts | 135 |
|
||||
| `performGracefulShutdown` | services/infrastructure/GracefulShutdown.ts | 48 |
|
||||
| `hookCommand` | cli/hook-command.ts | 45 |
|
||||
| `DatabaseManager.initialize` | services/sqlite/Database.ts | 27 |
|
||||
| `WorkerService.startSessionProcessor` | services/worker-service.ts | 158 |
|
||||
|
||||
### Results
|
||||
|
||||
| Symbol | Smart Unfold | Explore Agent | Ratio | Completeness |
|
||||
|--------|:---:|:---:|:---:|---|
|
||||
| initializeSession (135 lines) | ~1,800 t | 27,816 t | **15.5x** | Both returned full source |
|
||||
| performGracefulShutdown (48 lines) | ~700 t | 19,621 t | **28.0x** | Both returned full source |
|
||||
| hookCommand (45 lines) | ~650 t | 18,680 t | **28.7x** | Both returned full source |
|
||||
| DatabaseManager.initialize (27 lines) | ~400 t | 22,334 t | **55.8x** | Both returned full source |
|
||||
| startSessionProcessor (158 lines) | ~2,100 t | 20,906 t | **10.0x** | Smart Unfold: complete. Explore: **truncated** |
|
||||
| **Total** | **~5,650 t** | **109,357 t** | **19.4x** | |
|
||||
|
||||
### Analysis
|
||||
|
||||
**The ratio scales inversely with symbol size.** The smallest function (`initialize`, 27 lines) shows the biggest gap at 55.8x because the Explore agent still reads the entire 235-line file to extract 27 lines. The largest method (`startSessionProcessor`, 158 lines) narrows to 10x since more of the file is "useful."
|
||||
|
||||
**Smart Unfold returned more complete code.** For the longest method (158 lines), the Explore agent truncated the error handling section with "... error handling continues ...", while `smart_unfold` returned the complete implementation. This is because smart_unfold extracts by AST node boundaries, guaranteeing completeness regardless of symbol size.
|
||||
|
||||
**Explore agents add zero unique information for targeted reads.** When you already know the file path and symbol name, the agent's overhead is pure waste -- it reads the file, locates the function, and echoes it back. The only addition is a brief explanatory paragraph.
|
||||
|
||||
## Combined Workflow
|
||||
|
||||
The realistic workflow is discovery followed by targeted reading. Here is the end-to-end cost comparison for understanding a single function:
|
||||
|
||||
### Smart Explore: search + unfold
|
||||
|
||||
```
|
||||
smart_search("shutdown", path="./src") ~3,852 tokens
|
||||
smart_unfold("GracefulShutdown.ts", "performGracefulShutdown") ~700 tokens
|
||||
────────────────────────────────────────────────────────────────
|
||||
Total: ~4,552 tokens (2 tool calls, under 3 seconds)
|
||||
```
|
||||
|
||||
### Explore Agent: single query
|
||||
|
||||
```
|
||||
"Find and explain the shutdown logic" ~51,523 tokens
|
||||
────────────────────────────────────────────────────────────────
|
||||
Total: ~51,523 tokens (18 tool calls, ~43 seconds)
|
||||
```
|
||||
|
||||
**End-to-end ratio: 11.3x** -- and the Smart Explore workflow gives you the actual source code, while the Explore agent gives you a prose summary that may paraphrase or truncate.
|
||||
|
||||
## Quality Assessment
|
||||
|
||||
Neither approach is universally better. They optimize for different outcomes.
|
||||
|
||||
### Smart Explore Strengths
|
||||
|
||||
- **Predictable cost**: 1 tool call per operation, consistent token ranges
|
||||
- **Complete source code**: AST-based extraction guarantees full symbol bodies
|
||||
- **Structural context**: Folded views show every symbol in matching files
|
||||
- **Speed**: Sub-second responses enable rapid iteration
|
||||
- **Composability**: Search, outline, and unfold chain naturally
|
||||
|
||||
### Explore Agent Strengths
|
||||
|
||||
- **Synthesized understanding**: Produces architecture narratives, data flow diagrams, and design pattern analysis
|
||||
- **Cross-cutting explanation**: Connects concepts across files that individual symbol reads cannot
|
||||
- **Onboarding quality**: Output reads like documentation, not raw code
|
||||
- **Error handling insight**: Identifies edge cases and design decisions that require reading multiple related functions
|
||||
- **No prior knowledge needed**: Can answer open-ended questions without knowing file paths or symbol names
|
||||
|
||||
### Quality by Task Type
|
||||
|
||||
| Task | Better Tool | Why |
|
||||
|------|-------------|-----|
|
||||
| "Where is X defined?" | Smart Explore | One call, exact answer |
|
||||
| "What functions are in this file?" | Smart Explore | Outline returns complete structural map |
|
||||
| "Show me this function" | Smart Explore | Unfold returns exact source, never truncates |
|
||||
| "How does feature X work end-to-end?" | Explore Agent | Reads multiple files and synthesizes narrative |
|
||||
| "What design patterns are used here?" | Explore Agent | Requires reading and interpreting, not just extracting |
|
||||
| "Help me understand this codebase" | Explore Agent | Produces onboarding-quality documentation |
|
||||
|
||||
## When to Use Which
|
||||
|
||||
**Use Smart Explore when:**
|
||||
- You know what you are looking for (function name, concept, file)
|
||||
- You need source code, not explanation
|
||||
- You are iterating quickly (read, modify, read again)
|
||||
- Token budget matters (large codebases, long sessions)
|
||||
- You need file structure at a glance
|
||||
|
||||
**Use the Explore Agent when:**
|
||||
- You need synthesized cross-cutting understanding
|
||||
- The question is open-ended ("how does this system work?")
|
||||
- You are writing documentation or architecture reviews
|
||||
- You need to understand *why*, not just *what*
|
||||
- You are onboarding to an unfamiliar codebase
|
||||
|
||||
**Use both when:**
|
||||
- Start with Smart Explore for discovery and navigation
|
||||
- Escalate to Explore Agent only for deep analysis that requires multi-file synthesis
|
||||
- This hybrid approach captures most of the token savings while preserving access to deep understanding when needed
|
||||
|
||||
## Token Economics Reference
|
||||
|
||||
| Operation | Tokens | Use Case |
|
||||
|-----------|:---:|----------|
|
||||
| `smart_search` | 2,000-6,000 | Cross-file symbol discovery |
|
||||
| `smart_outline` | 1,000-2,000 | Single file structural map |
|
||||
| `smart_unfold` | 400-2,100 | Single symbol full source |
|
||||
| `smart_search` + `smart_unfold` | 3,000-8,000 | End-to-end: find and read |
|
||||
| Explore Agent (targeted) | 18,000-28,000 | Single function with explanation |
|
||||
| Explore Agent (cross-cutting) | 39,000-59,000 | Architecture-level understanding |
|
||||
| Read (full file) | 8,000-15,000+ | Complete file contents |
|
||||
|
||||
### Savings by Workflow
|
||||
|
||||
| Workflow | Smart Explore | Traditional | Savings |
|
||||
|----------|:---:|:---:|:---:|
|
||||
| Understand one file | outline + unfold (~3,100 t) | Read full file (~12,000 t) | **4x** |
|
||||
| Find a function across codebase | search (~3,500 t) | Explore agent (~50,000 t) | **14x** |
|
||||
| Find and read a specific function | search + unfold (~4,500 t) | Explore agent (~50,000 t) | **11x** |
|
||||
| Navigate a 1,200-line file | outline (~1,500 t) | Read full file (~12,000 t) | **8x** |
|
||||
+15
-49
@@ -1,59 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# claude-mem installer bootstrap
|
||||
# Usage: curl -fsSL https://install.cmem.ai | bash
|
||||
# or: curl -fsSL https://install.cmem.ai | bash -s -- --provider=gemini --api-key=YOUR_KEY
|
||||
|
||||
INSTALLER_URL="https://install.cmem.ai/installer.js"
|
||||
# claude-mem installer redirect
|
||||
# The old curl-pipe-bash installer has been replaced by npx claude-mem.
|
||||
# This script now redirects users to the new install method.
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
error() { echo -e "${RED}Error: $1${NC}" >&2; exit 1; }
|
||||
info() { echo -e "${CYAN}$1${NC}"; }
|
||||
|
||||
# Check Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
error "Node.js is required but not found. Install from https://nodejs.org"
|
||||
fi
|
||||
|
||||
NODE_VERSION=$(node -v | sed 's/v//')
|
||||
NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1)
|
||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
||||
error "Node.js >= 18 required. Current: v${NODE_VERSION}"
|
||||
fi
|
||||
|
||||
info "claude-mem installer (Node.js v${NODE_VERSION})"
|
||||
|
||||
# Create temp file for installer
|
||||
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/claude-mem-installer.XXXXXX.mjs")
|
||||
|
||||
# Cleanup on exit
|
||||
cleanup() {
|
||||
rm -f "$TMPFILE"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Download installer
|
||||
info "Downloading installer..."
|
||||
if command -v curl &> /dev/null; then
|
||||
curl -fsSL "$INSTALLER_URL" -o "$TMPFILE"
|
||||
elif command -v wget &> /dev/null; then
|
||||
wget -q "$INSTALLER_URL" -O "$TMPFILE"
|
||||
else
|
||||
error "curl or wget required to download installer"
|
||||
fi
|
||||
|
||||
# Run installer with TTY access
|
||||
# When piped (curl | bash), stdin is the script. We need to reconnect to the terminal.
|
||||
if [ -t 0 ]; then
|
||||
# Already have TTY (script was downloaded and run directly)
|
||||
node "$TMPFILE" "$@"
|
||||
else
|
||||
# Piped execution -- reconnect stdin to terminal
|
||||
node "$TMPFILE" "$@" </dev/tty
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${YELLOW}The curl-pipe-bash installer has been replaced.${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}Install claude-mem with a single command:${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}npx claude-mem install${NC}"
|
||||
echo ""
|
||||
echo -e "This requires Node.js >= 18. Get it from ${CYAN}https://nodejs.org${NC}"
|
||||
echo ""
|
||||
echo -e "For more info, visit: ${CYAN}https://docs.claude-mem.ai/installation${NC}"
|
||||
echo ""
|
||||
|
||||
+13
-2103
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
import { build } from 'esbuild';
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outfile: 'dist/index.js',
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
external: [],
|
||||
});
|
||||
|
||||
console.log('Build complete: dist/index.js');
|
||||
Vendored
-2107
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "claude-mem-installer",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"bin": { "claude-mem-installer": "./dist/index.js" },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs && node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.1",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"engines": { "node": ">=18.0.0" }
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import { runWelcome } from './steps/welcome.js';
|
||||
import { runDependencyChecks } from './steps/dependencies.js';
|
||||
import { runIdeSelection } from './steps/ide-selection.js';
|
||||
import { runProviderConfiguration } from './steps/provider.js';
|
||||
import { runSettingsConfiguration } from './steps/settings.js';
|
||||
import { writeSettings } from './utils/settings-writer.js';
|
||||
import { runInstallation } from './steps/install.js';
|
||||
import { runWorkerStartup } from './steps/worker.js';
|
||||
import { runCompletion } from './steps/complete.js';
|
||||
|
||||
async function runInstaller(): Promise<void> {
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error('Error: This installer requires an interactive terminal.');
|
||||
console.error('Run directly: npx claude-mem-installer');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const installMode = await runWelcome();
|
||||
|
||||
// Dependency checks (all modes)
|
||||
await runDependencyChecks();
|
||||
|
||||
// IDE and provider selection
|
||||
const selectedIDEs = await runIdeSelection();
|
||||
const providerConfig = await runProviderConfiguration();
|
||||
|
||||
// Settings configuration
|
||||
const settingsConfig = await runSettingsConfiguration();
|
||||
|
||||
// Write settings file
|
||||
writeSettings(providerConfig, settingsConfig);
|
||||
p.log.success('Settings saved.');
|
||||
|
||||
// Installation (fresh or upgrade)
|
||||
if (installMode !== 'configure') {
|
||||
await runInstallation(selectedIDEs);
|
||||
await runWorkerStartup(settingsConfig.workerPort, settingsConfig.dataDir);
|
||||
}
|
||||
|
||||
// Completion summary
|
||||
runCompletion(providerConfig, settingsConfig, selectedIDEs);
|
||||
}
|
||||
|
||||
runInstaller().catch((error) => {
|
||||
p.cancel('Installation failed.');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import type { ProviderConfig } from './provider.js';
|
||||
import type { SettingsConfig } from './settings.js';
|
||||
import type { IDE } from './ide-selection.js';
|
||||
|
||||
function getProviderLabel(config: ProviderConfig): string {
|
||||
switch (config.provider) {
|
||||
case 'claude':
|
||||
return config.claudeAuthMethod === 'api' ? 'Claude (API Key)' : 'Claude (CLI subscription)';
|
||||
case 'gemini':
|
||||
return `Gemini (${config.model ?? 'gemini-2.5-flash-lite'})`;
|
||||
case 'openrouter':
|
||||
return `OpenRouter (${config.model ?? 'xiaomi/mimo-v2-flash:free'})`;
|
||||
}
|
||||
}
|
||||
|
||||
function getIDELabels(ides: IDE[]): string {
|
||||
return ides.map((ide) => {
|
||||
switch (ide) {
|
||||
case 'claude-code': return 'Claude Code';
|
||||
case 'cursor': return 'Cursor';
|
||||
}
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
export function runCompletion(
|
||||
providerConfig: ProviderConfig,
|
||||
settingsConfig: SettingsConfig,
|
||||
selectedIDEs: IDE[],
|
||||
): void {
|
||||
const summaryLines = [
|
||||
`Provider: ${pc.cyan(getProviderLabel(providerConfig))}`,
|
||||
`IDEs: ${pc.cyan(getIDELabels(selectedIDEs))}`,
|
||||
`Data dir: ${pc.cyan(settingsConfig.dataDir)}`,
|
||||
`Port: ${pc.cyan(settingsConfig.workerPort)}`,
|
||||
`Chroma: ${settingsConfig.chromaEnabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
||||
];
|
||||
|
||||
p.note(summaryLines.join('\n'), 'Configuration Summary');
|
||||
|
||||
const nextStepsLines: string[] = [];
|
||||
|
||||
if (selectedIDEs.includes('claude-code')) {
|
||||
nextStepsLines.push('Open Claude Code and start a conversation — memory is automatic!');
|
||||
}
|
||||
if (selectedIDEs.includes('cursor')) {
|
||||
nextStepsLines.push('Open Cursor — hooks are active in your projects.');
|
||||
}
|
||||
nextStepsLines.push(`View your memories: ${pc.underline(`http://localhost:${settingsConfig.workerPort}`)}`);
|
||||
nextStepsLines.push(`Search past work: use ${pc.bold('/mem-search')} in Claude Code`);
|
||||
|
||||
p.note(nextStepsLines.join('\n'), 'Next Steps');
|
||||
|
||||
p.outro(pc.green('claude-mem installed successfully!'));
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { findBinary, compareVersions, installBun, installUv } from '../utils/dependencies.js';
|
||||
import { detectOS } from '../utils/system.js';
|
||||
|
||||
const BUN_EXTRA_PATHS = ['~/.bun/bin/bun', '/usr/local/bin/bun', '/opt/homebrew/bin/bun'];
|
||||
const UV_EXTRA_PATHS = ['~/.local/bin/uv', '~/.cargo/bin/uv'];
|
||||
|
||||
interface DependencyStatus {
|
||||
nodeOk: boolean;
|
||||
gitOk: boolean;
|
||||
bunOk: boolean;
|
||||
uvOk: boolean;
|
||||
bunPath: string | null;
|
||||
uvPath: string | null;
|
||||
}
|
||||
|
||||
export async function runDependencyChecks(): Promise<DependencyStatus> {
|
||||
const status: DependencyStatus = {
|
||||
nodeOk: false,
|
||||
gitOk: false,
|
||||
bunOk: false,
|
||||
uvOk: false,
|
||||
bunPath: null,
|
||||
uvPath: null,
|
||||
};
|
||||
|
||||
await p.tasks([
|
||||
{
|
||||
title: 'Checking Node.js',
|
||||
task: async () => {
|
||||
const version = process.version.slice(1); // remove 'v'
|
||||
if (compareVersions(version, '18.0.0')) {
|
||||
status.nodeOk = true;
|
||||
return `Node.js ${process.version} ${pc.green('✓')}`;
|
||||
}
|
||||
return `Node.js ${process.version} — requires >= 18.0.0 ${pc.red('✗')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Checking git',
|
||||
task: async () => {
|
||||
const info = findBinary('git');
|
||||
if (info.found) {
|
||||
status.gitOk = true;
|
||||
return `git ${info.version ?? ''} ${pc.green('✓')}`;
|
||||
}
|
||||
return `git not found ${pc.red('✗')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Checking Bun',
|
||||
task: async () => {
|
||||
const info = findBinary('bun', BUN_EXTRA_PATHS);
|
||||
if (info.found && info.version && compareVersions(info.version, '1.1.14')) {
|
||||
status.bunOk = true;
|
||||
status.bunPath = info.path;
|
||||
return `Bun ${info.version} ${pc.green('✓')}`;
|
||||
}
|
||||
if (info.found && info.version) {
|
||||
return `Bun ${info.version} — requires >= 1.1.14 ${pc.yellow('⚠')}`;
|
||||
}
|
||||
return `Bun not found ${pc.yellow('⚠')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Checking uv',
|
||||
task: async () => {
|
||||
const info = findBinary('uv', UV_EXTRA_PATHS);
|
||||
if (info.found) {
|
||||
status.uvOk = true;
|
||||
status.uvPath = info.path;
|
||||
return `uv ${info.version ?? ''} ${pc.green('✓')}`;
|
||||
}
|
||||
return `uv not found ${pc.yellow('⚠')}`;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Handle missing dependencies
|
||||
if (!status.gitOk) {
|
||||
const os = detectOS();
|
||||
p.log.error('git is required but not found.');
|
||||
if (os === 'macos') {
|
||||
p.log.info('Install with: xcode-select --install');
|
||||
} else if (os === 'linux') {
|
||||
p.log.info('Install with: sudo apt install git (or your distro equivalent)');
|
||||
} else {
|
||||
p.log.info('Download from: https://git-scm.com/downloads');
|
||||
}
|
||||
p.cancel('Please install git and try again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!status.nodeOk) {
|
||||
p.log.error(`Node.js >= 18.0.0 is required. Current: ${process.version}`);
|
||||
p.cancel('Please upgrade Node.js and try again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!status.bunOk) {
|
||||
const shouldInstall = await p.confirm({
|
||||
message: 'Bun is required but not found. Install it now?',
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldInstall)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (shouldInstall) {
|
||||
const s = p.spinner();
|
||||
s.start('Installing Bun...');
|
||||
try {
|
||||
installBun();
|
||||
const recheck = findBinary('bun', BUN_EXTRA_PATHS);
|
||||
if (recheck.found) {
|
||||
status.bunOk = true;
|
||||
status.bunPath = recheck.path;
|
||||
s.stop(`Bun installed ${pc.green('✓')}`);
|
||||
} else {
|
||||
s.stop(`Bun installed but not found in PATH. You may need to restart your shell.`);
|
||||
}
|
||||
} catch {
|
||||
s.stop(`Bun installation failed. Install manually: curl -fsSL https://bun.sh/install | bash`);
|
||||
}
|
||||
} else {
|
||||
p.log.warn('Bun is required for claude-mem. Install manually: curl -fsSL https://bun.sh/install | bash');
|
||||
p.cancel('Cannot continue without Bun.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!status.uvOk) {
|
||||
const shouldInstall = await p.confirm({
|
||||
message: 'uv (Python package manager) is recommended for Chroma. Install it now?',
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldInstall)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (shouldInstall) {
|
||||
const s = p.spinner();
|
||||
s.start('Installing uv...');
|
||||
try {
|
||||
installUv();
|
||||
const recheck = findBinary('uv', UV_EXTRA_PATHS);
|
||||
if (recheck.found) {
|
||||
status.uvOk = true;
|
||||
status.uvPath = recheck.path;
|
||||
s.stop(`uv installed ${pc.green('✓')}`);
|
||||
} else {
|
||||
s.stop('uv installed but not found in PATH. You may need to restart your shell.');
|
||||
}
|
||||
} catch {
|
||||
s.stop('uv installation failed. Install manually: curl -fsSL https://astral.sh/uv/install.sh | sh');
|
||||
}
|
||||
} else {
|
||||
p.log.warn('Skipping uv — Chroma vector search will not be available.');
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
export type IDE = 'claude-code' | 'cursor';
|
||||
|
||||
export async function runIdeSelection(): Promise<IDE[]> {
|
||||
const result = await p.multiselect({
|
||||
message: 'Which IDEs do you use?',
|
||||
options: [
|
||||
{ value: 'claude-code' as const, label: 'Claude Code', hint: 'recommended' },
|
||||
{ value: 'cursor' as const, label: 'Cursor' },
|
||||
// Windsurf coming soon - not yet selectable
|
||||
],
|
||||
initialValues: ['claude-code'],
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(result)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const selectedIDEs = result as IDE[];
|
||||
|
||||
if (selectedIDEs.includes('claude-code')) {
|
||||
p.log.info('Claude Code: Plugin will be registered via marketplace.');
|
||||
}
|
||||
if (selectedIDEs.includes('cursor')) {
|
||||
p.log.info('Cursor: Hooks will be configured for your projects.');
|
||||
}
|
||||
|
||||
return selectedIDEs;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir, tmpdir } from 'os';
|
||||
import type { IDE } from './ide-selection.js';
|
||||
|
||||
const MARKETPLACE_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
const PLUGINS_DIR = join(homedir(), '.claude', 'plugins');
|
||||
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
||||
|
||||
function ensureDir(directoryPath: string): void {
|
||||
if (!existsSync(directoryPath)) {
|
||||
mkdirSync(directoryPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(filepath: string): any {
|
||||
if (!existsSync(filepath)) return {};
|
||||
return JSON.parse(readFileSync(filepath, 'utf-8'));
|
||||
}
|
||||
|
||||
function writeJsonFile(filepath: string, data: any): void {
|
||||
ensureDir(join(filepath, '..'));
|
||||
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function registerMarketplace(): void {
|
||||
const knownMarketplacesPath = join(PLUGINS_DIR, 'known_marketplaces.json');
|
||||
const knownMarketplaces = readJsonFile(knownMarketplacesPath);
|
||||
|
||||
knownMarketplaces['thedotmack'] = {
|
||||
source: {
|
||||
source: 'github',
|
||||
repo: 'thedotmack/claude-mem',
|
||||
},
|
||||
installLocation: MARKETPLACE_DIR,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
autoUpdate: true,
|
||||
};
|
||||
|
||||
ensureDir(PLUGINS_DIR);
|
||||
writeJsonFile(knownMarketplacesPath, knownMarketplaces);
|
||||
}
|
||||
|
||||
function registerPlugin(version: string): void {
|
||||
const installedPluginsPath = join(PLUGINS_DIR, 'installed_plugins.json');
|
||||
const installedPlugins = readJsonFile(installedPluginsPath);
|
||||
|
||||
if (!installedPlugins.version) installedPlugins.version = 2;
|
||||
if (!installedPlugins.plugins) installedPlugins.plugins = {};
|
||||
|
||||
const pluginCachePath = join(PLUGINS_DIR, 'cache', 'thedotmack', 'claude-mem', version);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
installedPlugins.plugins['claude-mem@thedotmack'] = [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: pluginCachePath,
|
||||
version,
|
||||
installedAt: now,
|
||||
lastUpdated: now,
|
||||
},
|
||||
];
|
||||
|
||||
writeJsonFile(installedPluginsPath, installedPlugins);
|
||||
|
||||
// Copy built plugin to cache directory
|
||||
ensureDir(pluginCachePath);
|
||||
const pluginSourceDir = join(MARKETPLACE_DIR, 'plugin');
|
||||
if (existsSync(pluginSourceDir)) {
|
||||
cpSync(pluginSourceDir, pluginCachePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function enablePluginInClaudeSettings(): void {
|
||||
const settings = readJsonFile(CLAUDE_SETTINGS_PATH);
|
||||
|
||||
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
||||
settings.enabledPlugins['claude-mem@thedotmack'] = true;
|
||||
|
||||
writeJsonFile(CLAUDE_SETTINGS_PATH, settings);
|
||||
}
|
||||
|
||||
function getPluginVersion(): string {
|
||||
const pluginJsonPath = join(MARKETPLACE_DIR, 'plugin', '.claude-plugin', 'plugin.json');
|
||||
if (existsSync(pluginJsonPath)) {
|
||||
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
||||
return pluginJson.version ?? '1.0.0';
|
||||
}
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
export async function runInstallation(selectedIDEs: IDE[]): Promise<void> {
|
||||
const tempDir = join(tmpdir(), `claude-mem-install-${Date.now()}`);
|
||||
|
||||
await p.tasks([
|
||||
{
|
||||
title: 'Cloning claude-mem repository',
|
||||
task: async (message) => {
|
||||
message('Downloading latest release...');
|
||||
execSync(
|
||||
`git clone --depth 1 https://github.com/thedotmack/claude-mem.git "${tempDir}"`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
return `Repository cloned ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Installing dependencies',
|
||||
task: async (message) => {
|
||||
message('Running npm install...');
|
||||
execSync('npm install', { cwd: tempDir, stdio: 'pipe' });
|
||||
return `Dependencies installed ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Building plugin',
|
||||
task: async (message) => {
|
||||
message('Compiling TypeScript and bundling...');
|
||||
execSync('npm run build', { cwd: tempDir, stdio: 'pipe' });
|
||||
return `Plugin built ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Registering plugin',
|
||||
task: async (message) => {
|
||||
message('Copying files to marketplace directory...');
|
||||
ensureDir(MARKETPLACE_DIR);
|
||||
|
||||
// Sync from cloned repo to marketplace dir, excluding .git and lock files
|
||||
execSync(
|
||||
`rsync -a --delete --exclude=.git --exclude=package-lock.json --exclude=bun.lock "${tempDir}/" "${MARKETPLACE_DIR}/"`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
|
||||
message('Registering marketplace...');
|
||||
registerMarketplace();
|
||||
|
||||
message('Installing marketplace dependencies...');
|
||||
execSync('npm install', { cwd: MARKETPLACE_DIR, stdio: 'pipe' });
|
||||
|
||||
message('Registering plugin in Claude Code...');
|
||||
const version = getPluginVersion();
|
||||
registerPlugin(version);
|
||||
|
||||
message('Enabling plugin...');
|
||||
enablePluginInClaudeSettings();
|
||||
|
||||
return `Plugin registered (v${getPluginVersion()}) ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Cleanup temp directory (non-critical if it fails)
|
||||
try {
|
||||
execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
|
||||
} catch {
|
||||
// Temp dir will be cleaned by OS eventually
|
||||
}
|
||||
|
||||
if (selectedIDEs.includes('cursor')) {
|
||||
p.log.info('Cursor hook configuration will be available after first launch.');
|
||||
p.log.info('Run: claude-mem cursor-setup (coming soon)');
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
|
||||
export type ProviderType = 'claude' | 'gemini' | 'openrouter';
|
||||
export type ClaudeAuthMethod = 'cli' | 'api';
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider: ProviderType;
|
||||
claudeAuthMethod?: ClaudeAuthMethod;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
rateLimitingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export async function runProviderConfiguration(): Promise<ProviderConfig> {
|
||||
const provider = await p.select({
|
||||
message: 'Which AI provider should claude-mem use for memory compression?',
|
||||
options: [
|
||||
{ value: 'claude' as const, label: 'Claude', hint: 'uses your Claude subscription' },
|
||||
{ value: 'gemini' as const, label: 'Gemini', hint: 'free tier available' },
|
||||
{ value: 'openrouter' as const, label: 'OpenRouter', hint: 'free models available' },
|
||||
],
|
||||
});
|
||||
|
||||
if (p.isCancel(provider)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const config: ProviderConfig = { provider };
|
||||
|
||||
if (provider === 'claude') {
|
||||
const authMethod = await p.select({
|
||||
message: 'How should Claude authenticate?',
|
||||
options: [
|
||||
{ value: 'cli' as const, label: 'CLI (Max Plan subscription)', hint: 'no API key needed' },
|
||||
{ value: 'api' as const, label: 'API Key', hint: 'uses Anthropic API credits' },
|
||||
],
|
||||
});
|
||||
|
||||
if (p.isCancel(authMethod)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
config.claudeAuthMethod = authMethod;
|
||||
|
||||
if (authMethod === 'api') {
|
||||
const apiKey = await p.password({
|
||||
message: 'Enter your Anthropic API key:',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) return 'API key is required';
|
||||
if (!value.startsWith('sk-ant-')) return 'Anthropic API keys start with sk-ant-';
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(apiKey)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
config.apiKey = apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = await p.password({
|
||||
message: 'Enter your Gemini API key:',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) return 'API key is required';
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(apiKey)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
config.apiKey = apiKey;
|
||||
|
||||
const model = await p.select({
|
||||
message: 'Which Gemini model?',
|
||||
options: [
|
||||
{ value: 'gemini-2.5-flash-lite' as const, label: 'Gemini 2.5 Flash Lite', hint: 'fastest, highest free RPM' },
|
||||
{ value: 'gemini-2.5-flash' as const, label: 'Gemini 2.5 Flash', hint: 'balanced' },
|
||||
{ value: 'gemini-3-flash-preview' as const, label: 'Gemini 3 Flash Preview', hint: 'latest' },
|
||||
],
|
||||
});
|
||||
|
||||
if (p.isCancel(model)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
config.model = model;
|
||||
|
||||
const rateLimiting = await p.confirm({
|
||||
message: 'Enable rate limiting? (recommended for free tier)',
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(rateLimiting)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
config.rateLimitingEnabled = rateLimiting;
|
||||
}
|
||||
|
||||
if (provider === 'openrouter') {
|
||||
const apiKey = await p.password({
|
||||
message: 'Enter your OpenRouter API key:',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) return 'API key is required';
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(apiKey)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
config.apiKey = apiKey;
|
||||
|
||||
const model = await p.text({
|
||||
message: 'Which OpenRouter model?',
|
||||
defaultValue: 'xiaomi/mimo-v2-flash:free',
|
||||
placeholder: 'xiaomi/mimo-v2-flash:free',
|
||||
});
|
||||
|
||||
if (p.isCancel(model)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
config.model = model;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
|
||||
export interface SettingsConfig {
|
||||
workerPort: string;
|
||||
dataDir: string;
|
||||
contextObservations: string;
|
||||
logLevel: string;
|
||||
pythonVersion: string;
|
||||
chromaEnabled: boolean;
|
||||
chromaMode?: 'local' | 'remote';
|
||||
chromaHost?: string;
|
||||
chromaPort?: string;
|
||||
chromaSsl?: boolean;
|
||||
}
|
||||
|
||||
export async function runSettingsConfiguration(): Promise<SettingsConfig> {
|
||||
const useDefaults = await p.confirm({
|
||||
message: 'Use default settings? (recommended for most users)',
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(useDefaults)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (useDefaults) {
|
||||
return {
|
||||
workerPort: '37777',
|
||||
dataDir: '~/.claude-mem',
|
||||
contextObservations: '50',
|
||||
logLevel: 'INFO',
|
||||
pythonVersion: '3.13',
|
||||
chromaEnabled: true,
|
||||
chromaMode: 'local',
|
||||
};
|
||||
}
|
||||
|
||||
// Custom settings
|
||||
const workerPort = await p.text({
|
||||
message: 'Worker service port:',
|
||||
defaultValue: '37777',
|
||||
placeholder: '37777',
|
||||
validate: (value = '') => {
|
||||
const port = parseInt(value, 10);
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
return 'Port must be between 1024 and 65535';
|
||||
}
|
||||
},
|
||||
});
|
||||
if (p.isCancel(workerPort)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
|
||||
const dataDir = await p.text({
|
||||
message: 'Data directory:',
|
||||
defaultValue: '~/.claude-mem',
|
||||
placeholder: '~/.claude-mem',
|
||||
});
|
||||
if (p.isCancel(dataDir)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
|
||||
const contextObservations = await p.text({
|
||||
message: 'Number of context observations per session:',
|
||||
defaultValue: '50',
|
||||
placeholder: '50',
|
||||
validate: (value = '') => {
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 1 || num > 200) {
|
||||
return 'Must be between 1 and 200';
|
||||
}
|
||||
},
|
||||
});
|
||||
if (p.isCancel(contextObservations)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
|
||||
const logLevel = await p.select({
|
||||
message: 'Log level:',
|
||||
options: [
|
||||
{ value: 'DEBUG', label: 'DEBUG', hint: 'verbose' },
|
||||
{ value: 'INFO', label: 'INFO', hint: 'default' },
|
||||
{ value: 'WARN', label: 'WARN' },
|
||||
{ value: 'ERROR', label: 'ERROR', hint: 'errors only' },
|
||||
],
|
||||
initialValue: 'INFO',
|
||||
});
|
||||
if (p.isCancel(logLevel)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
|
||||
const pythonVersion = await p.text({
|
||||
message: 'Python version (for Chroma):',
|
||||
defaultValue: '3.13',
|
||||
placeholder: '3.13',
|
||||
});
|
||||
if (p.isCancel(pythonVersion)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
|
||||
const chromaEnabled = await p.confirm({
|
||||
message: 'Enable Chroma vector search?',
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(chromaEnabled)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
|
||||
let chromaMode: 'local' | 'remote' | undefined;
|
||||
let chromaHost: string | undefined;
|
||||
let chromaPort: string | undefined;
|
||||
let chromaSsl: boolean | undefined;
|
||||
|
||||
if (chromaEnabled) {
|
||||
const mode = await p.select({
|
||||
message: 'Chroma mode:',
|
||||
options: [
|
||||
{ value: 'local' as const, label: 'Local', hint: 'starts local Chroma server' },
|
||||
{ value: 'remote' as const, label: 'Remote', hint: 'connect to existing server' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(mode)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
chromaMode = mode;
|
||||
|
||||
if (mode === 'remote') {
|
||||
const host = await p.text({
|
||||
message: 'Chroma host:',
|
||||
defaultValue: '127.0.0.1',
|
||||
placeholder: '127.0.0.1',
|
||||
});
|
||||
if (p.isCancel(host)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
chromaHost = host;
|
||||
|
||||
const port = await p.text({
|
||||
message: 'Chroma port:',
|
||||
defaultValue: '8000',
|
||||
placeholder: '8000',
|
||||
validate: (value = '') => {
|
||||
const portNum = parseInt(value, 10);
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return 'Port must be between 1 and 65535';
|
||||
},
|
||||
});
|
||||
if (p.isCancel(port)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
chromaPort = port;
|
||||
|
||||
const ssl = await p.confirm({
|
||||
message: 'Use SSL for Chroma connection?',
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(ssl)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
||||
chromaSsl = ssl;
|
||||
}
|
||||
}
|
||||
|
||||
const config: SettingsConfig = {
|
||||
workerPort,
|
||||
dataDir,
|
||||
contextObservations,
|
||||
logLevel,
|
||||
pythonVersion,
|
||||
chromaEnabled,
|
||||
chromaMode,
|
||||
chromaHost,
|
||||
chromaPort,
|
||||
chromaSsl,
|
||||
};
|
||||
|
||||
// Show summary
|
||||
const summaryLines = [
|
||||
`Worker port: ${pc.cyan(workerPort)}`,
|
||||
`Data directory: ${pc.cyan(dataDir)}`,
|
||||
`Context observations: ${pc.cyan(contextObservations)}`,
|
||||
`Log level: ${pc.cyan(logLevel)}`,
|
||||
`Python version: ${pc.cyan(pythonVersion)}`,
|
||||
`Chroma: ${chromaEnabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
||||
];
|
||||
if (chromaEnabled && chromaMode) {
|
||||
summaryLines.push(`Chroma mode: ${pc.cyan(chromaMode)}`);
|
||||
}
|
||||
|
||||
p.note(summaryLines.join('\n'), 'Settings Summary');
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { existsSync } from 'fs';
|
||||
import { expandHome } from '../utils/system.js';
|
||||
|
||||
export type InstallMode = 'fresh' | 'upgrade' | 'configure';
|
||||
|
||||
export async function runWelcome(): Promise<InstallMode> {
|
||||
p.intro(pc.bgCyan(pc.black(' claude-mem installer ')));
|
||||
|
||||
p.log.info(`Version: 1.0.0`);
|
||||
p.log.info(`Platform: ${process.platform} (${process.arch})`);
|
||||
|
||||
const settingsExist = existsSync(expandHome('~/.claude-mem/settings.json'));
|
||||
const pluginExist = existsSync(expandHome('~/.claude/plugins/marketplaces/thedotmack/'));
|
||||
|
||||
const alreadyInstalled = settingsExist && pluginExist;
|
||||
|
||||
if (alreadyInstalled) {
|
||||
p.log.warn('Existing claude-mem installation detected.');
|
||||
}
|
||||
|
||||
const installMode = await p.select({
|
||||
message: 'What would you like to do?',
|
||||
options: alreadyInstalled
|
||||
? [
|
||||
{ value: 'upgrade' as const, label: 'Upgrade', hint: 'update to latest version' },
|
||||
{ value: 'configure' as const, label: 'Configure', hint: 'change settings only' },
|
||||
{ value: 'fresh' as const, label: 'Fresh Install', hint: 'reinstall from scratch' },
|
||||
]
|
||||
: [
|
||||
{ value: 'fresh' as const, label: 'Fresh Install', hint: 'recommended' },
|
||||
{ value: 'configure' as const, label: 'Configure Only', hint: 'set up settings without installing' },
|
||||
],
|
||||
});
|
||||
|
||||
if (p.isCancel(installMode)) {
|
||||
p.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return installMode;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { expandHome } from '../utils/system.js';
|
||||
import { findBinary } from '../utils/dependencies.js';
|
||||
|
||||
const MARKETPLACE_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
|
||||
const HEALTH_CHECK_INTERVAL_MS = 1000;
|
||||
const HEALTH_CHECK_MAX_ATTEMPTS = 30;
|
||||
|
||||
async function pollHealthEndpoint(port: string, maxAttempts: number = HEALTH_CHECK_MAX_ATTEMPTS): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
if (response.ok) return true;
|
||||
} catch {
|
||||
// Expected during startup — worker not listening yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runWorkerStartup(workerPort: string, dataDir: string): Promise<void> {
|
||||
const bunInfo = findBinary('bun', ['~/.bun/bin/bun', '/usr/local/bin/bun', '/opt/homebrew/bin/bun']);
|
||||
|
||||
if (!bunInfo.found || !bunInfo.path) {
|
||||
p.log.error('Bun is required to start the worker but was not found.');
|
||||
p.log.info('Install Bun: curl -fsSL https://bun.sh/install | bash');
|
||||
return;
|
||||
}
|
||||
|
||||
const workerScript = join(MARKETPLACE_DIR, 'plugin', 'scripts', 'worker-service.cjs');
|
||||
const expandedDataDir = expandHome(dataDir);
|
||||
const logPath = join(expandedDataDir, 'logs');
|
||||
|
||||
const s = p.spinner();
|
||||
s.start('Starting worker service...');
|
||||
|
||||
// Start worker as a detached background process
|
||||
const child = spawn(bunInfo.path, [workerScript], {
|
||||
cwd: MARKETPLACE_DIR,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_MEM_WORKER_PORT: workerPort,
|
||||
CLAUDE_MEM_DATA_DIR: expandedDataDir,
|
||||
},
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
// Poll the health endpoint until the worker is responsive
|
||||
const workerIsHealthy = await pollHealthEndpoint(workerPort);
|
||||
|
||||
if (workerIsHealthy) {
|
||||
s.stop(`Worker running on port ${pc.cyan(workerPort)} ${pc.green('OK')}`);
|
||||
} else {
|
||||
s.stop(`Worker may still be starting. Check logs at: ${logPath}`);
|
||||
p.log.warn('Health check timed out. The worker might need more time to initialize.');
|
||||
p.log.info(`Check status: curl http://127.0.0.1:${workerPort}/api/health`);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { commandExists, runCommand, expandHome, detectOS } from './system.js';
|
||||
|
||||
export interface BinaryInfo {
|
||||
found: boolean;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export function findBinary(name: string, extraPaths: string[] = []): BinaryInfo {
|
||||
// Check PATH first
|
||||
if (commandExists(name)) {
|
||||
const result = runCommand('which', [name]);
|
||||
const versionResult = runCommand(name, ['--version']);
|
||||
return {
|
||||
found: true,
|
||||
path: result.stdout,
|
||||
version: parseVersion(versionResult.stdout) || parseVersion(versionResult.stderr),
|
||||
};
|
||||
}
|
||||
|
||||
// Check extra known locations
|
||||
for (const extraPath of extraPaths) {
|
||||
const fullPath = expandHome(extraPath);
|
||||
if (existsSync(fullPath)) {
|
||||
const versionResult = runCommand(fullPath, ['--version']);
|
||||
return {
|
||||
found: true,
|
||||
path: fullPath,
|
||||
version: parseVersion(versionResult.stdout) || parseVersion(versionResult.stderr),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false, path: null, version: null };
|
||||
}
|
||||
|
||||
function parseVersion(output: string): string | null {
|
||||
if (!output) return null;
|
||||
const match = output.match(/(\d+\.\d+(\.\d+)?)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function compareVersions(current: string, minimum: string): boolean {
|
||||
const currentParts = current.split('.').map(Number);
|
||||
const minimumParts = minimum.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(currentParts.length, minimumParts.length); i++) {
|
||||
const a = currentParts[i] || 0;
|
||||
const b = minimumParts[i] || 0;
|
||||
if (a > b) return true;
|
||||
if (a < b) return false;
|
||||
}
|
||||
return true; // equal
|
||||
}
|
||||
|
||||
export function installBun(): void {
|
||||
const os = detectOS();
|
||||
if (os === 'windows') {
|
||||
execSync('powershell -c "irm bun.sh/install.ps1 | iex"', { stdio: 'inherit' });
|
||||
} else {
|
||||
execSync('curl -fsSL https://bun.sh/install | bash', { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
|
||||
export function installUv(): void {
|
||||
const os = detectOS();
|
||||
if (os === 'windows') {
|
||||
execSync('powershell -c "irm https://astral.sh/uv/install.ps1 | iex"', { stdio: 'inherit' });
|
||||
} else {
|
||||
execSync('curl -fsSL https://astral.sh/uv/install.sh | sh', { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { ProviderConfig } from '../steps/provider.js';
|
||||
import type { SettingsConfig } from '../steps/settings.js';
|
||||
|
||||
export function expandDataDir(dataDir: string): string {
|
||||
if (dataDir.startsWith('~')) {
|
||||
return join(homedir(), dataDir.slice(1));
|
||||
}
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
export function buildSettingsObject(
|
||||
providerConfig: ProviderConfig,
|
||||
settingsConfig: SettingsConfig,
|
||||
): Record<string, string> {
|
||||
const settings: Record<string, string> = {
|
||||
CLAUDE_MEM_WORKER_PORT: settingsConfig.workerPort,
|
||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
||||
CLAUDE_MEM_DATA_DIR: expandDataDir(settingsConfig.dataDir),
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: settingsConfig.contextObservations,
|
||||
CLAUDE_MEM_LOG_LEVEL: settingsConfig.logLevel,
|
||||
CLAUDE_MEM_PYTHON_VERSION: settingsConfig.pythonVersion,
|
||||
CLAUDE_MEM_PROVIDER: providerConfig.provider,
|
||||
};
|
||||
|
||||
// Provider-specific settings
|
||||
if (providerConfig.provider === 'claude') {
|
||||
settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD = providerConfig.claudeAuthMethod ?? 'cli';
|
||||
}
|
||||
|
||||
if (providerConfig.provider === 'gemini') {
|
||||
if (providerConfig.apiKey) settings.CLAUDE_MEM_GEMINI_API_KEY = providerConfig.apiKey;
|
||||
if (providerConfig.model) settings.CLAUDE_MEM_GEMINI_MODEL = providerConfig.model;
|
||||
settings.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED = providerConfig.rateLimitingEnabled !== false ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (providerConfig.provider === 'openrouter') {
|
||||
if (providerConfig.apiKey) settings.CLAUDE_MEM_OPENROUTER_API_KEY = providerConfig.apiKey;
|
||||
if (providerConfig.model) settings.CLAUDE_MEM_OPENROUTER_MODEL = providerConfig.model;
|
||||
}
|
||||
|
||||
// Chroma settings
|
||||
if (settingsConfig.chromaEnabled) {
|
||||
settings.CLAUDE_MEM_CHROMA_MODE = settingsConfig.chromaMode ?? 'local';
|
||||
if (settingsConfig.chromaMode === 'remote') {
|
||||
if (settingsConfig.chromaHost) settings.CLAUDE_MEM_CHROMA_HOST = settingsConfig.chromaHost;
|
||||
if (settingsConfig.chromaPort) settings.CLAUDE_MEM_CHROMA_PORT = settingsConfig.chromaPort;
|
||||
if (settingsConfig.chromaSsl !== undefined) settings.CLAUDE_MEM_CHROMA_SSL = String(settingsConfig.chromaSsl);
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function writeSettings(
|
||||
providerConfig: ProviderConfig,
|
||||
settingsConfig: SettingsConfig,
|
||||
): void {
|
||||
const dataDir = expandDataDir(settingsConfig.dataDir);
|
||||
const settingsPath = join(dataDir, 'settings.json');
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Merge with existing settings if upgrading
|
||||
let existingSettings: Record<string, string> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
const raw = readFileSync(settingsPath, 'utf-8');
|
||||
existingSettings = JSON.parse(raw);
|
||||
}
|
||||
|
||||
const newSettings = buildSettingsObject(providerConfig, settingsConfig);
|
||||
|
||||
// Merge: new settings override existing ones
|
||||
const merged = { ...existingSettings, ...newSettings };
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
export type OSType = 'macos' | 'linux' | 'windows';
|
||||
|
||||
export function detectOS(): OSType {
|
||||
switch (process.platform) {
|
||||
case 'darwin': return 'macos';
|
||||
case 'win32': return 'windows';
|
||||
default: return 'linux';
|
||||
}
|
||||
}
|
||||
|
||||
export function commandExists(command: string): boolean {
|
||||
try {
|
||||
execSync(`which ${command}`, { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export function runCommand(command: string, args: string[] = []): CommandResult {
|
||||
try {
|
||||
const fullCommand = [command, ...args].join(' ');
|
||||
const stdout = execSync(fullCommand, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return { stdout: stdout.trim(), stderr: '', exitCode: 0 };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
stdout: error.stdout?.toString().trim() ?? '',
|
||||
stderr: error.stderr?.toString().trim() ?? '',
|
||||
exitCode: error.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function expandHome(filepath: string): string {
|
||||
if (filepath.startsWith('~')) {
|
||||
return join(homedir(), filepath.slice(1));
|
||||
}
|
||||
return filepath;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
+24
-20
@@ -1,6 +1,6 @@
|
||||
# Claude-Mem OpenClaw Plugin — Setup Guide
|
||||
|
||||
This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions, a live-updating MEMORY.md in their workspace, and optionally a real-time observation feed streaming to a messaging channel.
|
||||
This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions via system prompt context injection, and optionally a real-time observation feed streaming to a messaging channel.
|
||||
|
||||
## Quick Install (Recommended)
|
||||
|
||||
@@ -138,7 +138,9 @@ Add the `claude-mem` plugin to your OpenClaw gateway configuration:
|
||||
|
||||
- **`project`** (string, default: `"openclaw"`) — The project name that scopes all observations in the memory database. Use a unique name per gateway/use-case so observations don't mix. For example, if this gateway runs a coding bot, use `"coding-bot"`.
|
||||
|
||||
- **`syncMemoryFile`** (boolean, default: `true`) — When enabled, the plugin writes a `MEMORY.md` file to each agent's workspace directory. This file contains the full timeline of observations and summaries from previous sessions, and it updates on every tool use so agents always have fresh context. Set to `false` only if you don't want the plugin writing files to agent workspaces.
|
||||
- **`syncMemoryFile`** (boolean, default: `true`) — When enabled, the plugin injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook. This gives agents cross-session context without writing to MEMORY.md. Set to `false` to disable context injection entirely (observations are still recorded).
|
||||
|
||||
- **`syncMemoryFileExclude`** (string[], default: `[]`) — Agent IDs excluded from automatic context injection. Useful for agents that curate their own memory. Observations are still recorded for excluded agents.
|
||||
|
||||
- **`workerPort`** (number, default: `37777`) — The port where the claude-mem worker service is listening. Only change this if you configured the worker to use a different port.
|
||||
|
||||
@@ -168,13 +170,14 @@ The observation feed shows `disconnected` because we haven't configured it yet.
|
||||
|
||||
Have an agent do some work. The plugin automatically records observations through these OpenClaw events:
|
||||
|
||||
1. **`before_agent_start`** — Initializes a claude-mem session when the agent starts, syncs MEMORY.md to the workspace
|
||||
2. **`tool_result_persist`** — Records each tool use (Read, Write, Bash, etc.) as an observation, re-syncs MEMORY.md
|
||||
3. **`agent_end`** — Summarizes the session and marks it complete
|
||||
1. **`before_agent_start`** — Initializes a claude-mem session when the agent starts
|
||||
2. **`before_prompt_build`** — Injects the observation timeline into the agent's system prompt (cached for 60s)
|
||||
3. **`tool_result_persist`** — Records each tool use (Read, Write, Bash, etc.) as an observation
|
||||
4. **`agent_end`** — Summarizes the session and marks it complete
|
||||
|
||||
All of this happens automatically. No additional configuration needed.
|
||||
|
||||
To verify it's working, check the agent's workspace directory for a `MEMORY.md` file after the agent runs. It should contain a formatted timeline of observations.
|
||||
To verify it's working, check the worker's viewer UI at http://localhost:37777 to see observations appearing after the agent runs.
|
||||
|
||||
You can also check the worker's viewer UI at http://localhost:37777 to see observations appearing in real time.
|
||||
|
||||
@@ -372,10 +375,11 @@ Shows observation feed status. Accepts optional `on`/`off` argument.
|
||||
```
|
||||
OpenClaw Gateway
|
||||
│
|
||||
├── before_agent_start ──→ Sync MEMORY.md + Init session
|
||||
├── tool_result_persist ──→ Record observation + Re-sync MEMORY.md
|
||||
├── before_agent_start ───→ Init session
|
||||
├── before_prompt_build ──→ Inject context into system prompt
|
||||
├── tool_result_persist ──→ Record observation
|
||||
├── agent_end ────────────→ Summarize + Complete session
|
||||
└── gateway_start ────────→ Reset session tracking
|
||||
└── gateway_start ────────→ Reset session tracking + context cache
|
||||
│
|
||||
▼
|
||||
Claude-Mem Worker (localhost:37777)
|
||||
@@ -383,17 +387,15 @@ OpenClaw Gateway
|
||||
├── POST /api/sessions/observations
|
||||
├── POST /api/sessions/summarize
|
||||
├── POST /api/sessions/complete
|
||||
├── GET /api/context/inject ──→ MEMORY.md content
|
||||
├── GET /api/context/inject ──→ System prompt context
|
||||
└── GET /stream ─────────────→ SSE → Messaging channels
|
||||
```
|
||||
|
||||
### MEMORY.md live sync
|
||||
### System prompt context injection
|
||||
|
||||
The plugin writes `MEMORY.md` to each agent's workspace with the full observation timeline. It updates:
|
||||
- On every `before_agent_start` — agent gets fresh context before starting
|
||||
- On every `tool_result_persist` — context stays current as the agent works
|
||||
The plugin injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook. The content comes from the worker's `GET /api/context/inject` endpoint. Context is cached for 60 seconds per project to avoid re-fetching on every LLM turn. The cache is cleared on gateway restart.
|
||||
|
||||
Updates are fire-and-forget (non-blocking). The agent is never held up waiting for MEMORY.md to write.
|
||||
This keeps MEMORY.md under the agent's control for curated long-term memory, while the observation timeline is delivered through the system prompt.
|
||||
|
||||
### Observation recording
|
||||
|
||||
@@ -401,10 +403,11 @@ Every tool use (Read, Write, Bash, etc.) is sent to the claude-mem worker as an
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
- **`before_agent_start`** — Creates a session in the worker, syncs MEMORY.md. Short prompts (under 10 chars) skip session init but still sync.
|
||||
- **`tool_result_persist`** — Records observation (fire-and-forget), re-syncs MEMORY.md (fire-and-forget). Tool responses are truncated to 1000 characters.
|
||||
- **`before_agent_start`** — Creates a session in the worker.
|
||||
- **`before_prompt_build`** — Fetches the observation timeline and returns it as `appendSystemContext`. Cached for 60s.
|
||||
- **`tool_result_persist`** — Records observation (fire-and-forget). Tool responses are truncated to 1000 characters.
|
||||
- **`agent_end`** — Sends the last assistant message for summarization, then completes the session. Both fire-and-forget.
|
||||
- **`gateway_start`** — Clears all session tracking (session IDs, workspace mappings) so agents start fresh.
|
||||
- **`gateway_start`** — Clears all session tracking (session IDs, context cache) so agents start fresh.
|
||||
|
||||
### Observation feed
|
||||
|
||||
@@ -417,7 +420,7 @@ A background service connects to the worker's SSE stream and forwards `new_obser
|
||||
| Worker health check fails | Is bun installed? (`bun --version`). Is something else on port 37777? (`lsof -i :37777`). Try running directly: `bun plugin/scripts/worker-service.cjs start` |
|
||||
| Worker started from Claude Code install but not responding | Check `cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:status`. May need `npm run worker:restart`. |
|
||||
| Worker started from cloned repo but not responding | Check `cd /path/to/claude-mem && npm run worker:status`. Make sure you ran `npm install && npm run build` first. |
|
||||
| No MEMORY.md appearing | Check that `syncMemoryFile` is not set to `false`. Verify the agent's event context includes `workspaceDir`. |
|
||||
| No context in agent system prompt | Check that `syncMemoryFile` is not set to `false`. Check that the agent's ID is not in `syncMemoryFileExclude`. Verify the worker is running and has observations. |
|
||||
| Observations not being recorded | Check gateway logs for `[claude-mem]` messages. The worker must be running and reachable on localhost:37777. |
|
||||
| Feed shows `disconnected` | Worker's `/stream` endpoint not reachable. Check `workerPort` matches the actual worker port. |
|
||||
| Feed shows `reconnecting` | Connection dropped. The plugin auto-reconnects — wait up to 30 seconds. |
|
||||
@@ -451,7 +454,8 @@ A background service connects to the worker's SSE stream and forwards `new_obser
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `project` | string | `"openclaw"` | Project name scoping observations in the database |
|
||||
| `syncMemoryFile` | boolean | `true` | Write MEMORY.md to agent workspaces |
|
||||
| `syncMemoryFile` | boolean | `true` | Inject observation context into agent system prompt |
|
||||
| `syncMemoryFileExclude` | string[] | `[]` | Agent IDs excluded from context injection |
|
||||
| `workerPort` | number | `37777` | Claude-mem worker service port |
|
||||
| `observationFeed.enabled` | boolean | `false` | Stream observations to a messaging channel |
|
||||
| `observationFeed.channel` | string | — | Channel type: `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `line` |
|
||||
|
||||
+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',
|
||||
|
||||
@@ -14,13 +14,24 @@
|
||||
"syncMemoryFile": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically sync MEMORY.md on session start"
|
||||
"description": "Inject observation context into the agent system prompt via before_prompt_build hook. When true, agents receive cross-session context without MEMORY.md being overwritten."
|
||||
},
|
||||
"syncMemoryFileExclude": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"default": [],
|
||||
"description": "Agent IDs excluded from automatic context injection (observations are still recorded, only prompt injection is skipped)"
|
||||
},
|
||||
"workerPort": {
|
||||
"type": "number",
|
||||
"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",
|
||||
|
||||
+115
-96
@@ -87,9 +87,11 @@ function createMockApi(pluginConfigOverride: Record<string, any> = {}) {
|
||||
getEventHandlers: (event: string) => eventHandlers.get(event) || [],
|
||||
fireEvent: async (event: string, data: any, ctx: any = {}) => {
|
||||
const handlers = eventHandlers.get(event) || [];
|
||||
let lastResult: any;
|
||||
for (const handler of handlers) {
|
||||
await handler(data, ctx);
|
||||
lastResult = await handler(data, ctx);
|
||||
}
|
||||
return lastResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -106,6 +108,7 @@ describe("claudeMemPlugin", () => {
|
||||
assert.ok(getEventHandlers("session_start").length > 0, "session_start handler registered");
|
||||
assert.ok(getEventHandlers("after_compaction").length > 0, "after_compaction handler registered");
|
||||
assert.ok(getEventHandlers("before_agent_start").length > 0, "before_agent_start handler registered");
|
||||
assert.ok(getEventHandlers("before_prompt_build").length > 0, "before_prompt_build handler registered");
|
||||
assert.ok(getEventHandlers("tool_result_persist").length > 0, "tool_result_persist handler registered");
|
||||
assert.ok(getEventHandlers("agent_end").length > 0, "agent_end handler registered");
|
||||
assert.ok(getEventHandlers("gateway_start").length > 0, "gateway_start handler registered");
|
||||
@@ -535,11 +538,10 @@ describe("Observation I/O event handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MEMORY.md context sync", () => {
|
||||
describe("before_prompt_build context injection", () => {
|
||||
let workerServer: Server;
|
||||
let workerPort: number;
|
||||
let receivedRequests: Array<{ method: string; url: string; body: any }> = [];
|
||||
let tmpDir: string;
|
||||
let contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
|
||||
|
||||
function startWorkerMock(): Promise<number> {
|
||||
@@ -586,21 +588,20 @@ describe("MEMORY.md context sync", () => {
|
||||
receivedRequests = [];
|
||||
contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
|
||||
workerPort = await startWorkerMock();
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
workerServer?.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes MEMORY.md to workspace on before_agent_start", async () => {
|
||||
it("returns appendSystemContext from before_prompt_build", async () => {
|
||||
const { api, logs, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "sync-test", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
@@ -608,142 +609,143 @@ describe("MEMORY.md context sync", () => {
|
||||
assert.ok(contextRequest, "should request context from worker");
|
||||
assert.ok(contextRequest!.url!.includes("projects=openclaw"));
|
||||
|
||||
const memoryContent = await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
assert.ok(memoryContent.includes("Claude-Mem Context"), "MEMORY.md should contain context");
|
||||
assert.ok(memoryContent.includes("Session 1"), "MEMORY.md should contain timeline");
|
||||
assert.ok(logs.some((l) => l.includes("MEMORY.md synced")));
|
||||
assert.ok(result, "should return a result");
|
||||
assert.ok(result.appendSystemContext, "should return appendSystemContext");
|
||||
assert.ok(result.appendSystemContext.includes("Claude-Mem Context"), "should contain context");
|
||||
assert.ok(result.appendSystemContext.includes("Session 1"), "should contain timeline");
|
||||
assert.ok(logs.some((l) => l.includes("Context injected via system prompt")));
|
||||
});
|
||||
|
||||
it("syncs MEMORY.md on every before_agent_start call", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
it("does not write MEMORY.md on before_agent_start", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
||||
try {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "First prompt for this agent",
|
||||
}, { sessionKey: "agent-a", workspaceDir: tmpDir });
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "sync-test", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const firstContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(firstContextRequests.length, 1, "first call should fetch context");
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Second prompt for same agent",
|
||||
}, { sessionKey: "agent-a", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const allContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(allContextRequests.length, 2, "should re-fetch context on every call");
|
||||
let memoryExists = true;
|
||||
try {
|
||||
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
} catch {
|
||||
memoryExists = false;
|
||||
}
|
||||
assert.ok(!memoryExists, "MEMORY.md should not be created by before_agent_start");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("syncs MEMORY.md on tool_result_persist via fire-and-forget", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
it("does not sync MEMORY.md on tool_result_persist", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
||||
try {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
// Init session to register workspace dir
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "tool-sync", workspaceDir: tmpDir });
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "tool-sync", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const preToolContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(preToolContextRequests.length, 1, "before_agent_start should sync once");
|
||||
await fireEvent("tool_result_persist", {
|
||||
toolName: "Read",
|
||||
params: { file_path: "/src/app.ts" },
|
||||
message: { content: [{ type: "text", text: "file contents" }] },
|
||||
}, { sessionKey: "tool-sync" });
|
||||
|
||||
// Fire tool result — should trigger another MEMORY.md sync
|
||||
await fireEvent("tool_result_persist", {
|
||||
toolName: "Read",
|
||||
params: { file_path: "/src/app.ts" },
|
||||
message: { content: [{ type: "text", text: "file contents" }] },
|
||||
}, { sessionKey: "tool-sync" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const contextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(contextRequests.length, 0, "tool_result_persist should not fetch context");
|
||||
|
||||
const postToolContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(postToolContextRequests.length, 2, "tool_result_persist should trigger another sync");
|
||||
|
||||
const memoryContent = await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
assert.ok(memoryContent.includes("Claude-Mem Context"), "MEMORY.md should be updated");
|
||||
let memoryExists = true;
|
||||
try {
|
||||
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
||||
} catch {
|
||||
memoryExists = false;
|
||||
}
|
||||
assert.ok(!memoryExists, "MEMORY.md should not be written by tool_result_persist");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips MEMORY.md sync when syncMemoryFile is false", async () => {
|
||||
it("skips context injection when syncMemoryFile is false", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFile: false });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "no-sync", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.ok(!contextRequest, "should not fetch context when sync disabled");
|
||||
assert.ok(!contextRequest, "should not fetch context when injection disabled");
|
||||
assert.equal(result, undefined, "should return undefined when injection disabled");
|
||||
});
|
||||
|
||||
it("skips MEMORY.md sync when no workspaceDir in context", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
it("skips context injection for excluded agents", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "no-workspace" });
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me",
|
||||
messages: [],
|
||||
}, { agentId: "snarf" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.ok(!contextRequest, "should not fetch context without workspaceDir");
|
||||
assert.ok(!contextRequest, "should not fetch context for excluded agent");
|
||||
assert.equal(result, undefined, "should return undefined for excluded agent");
|
||||
});
|
||||
|
||||
it("skips writing MEMORY.md when context is empty", async () => {
|
||||
it("injects context for non-excluded agents", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me",
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
assert.ok(result, "should return a result for non-excluded agent");
|
||||
assert.ok(result.appendSystemContext, "should inject context for non-excluded agent");
|
||||
});
|
||||
|
||||
it("returns undefined when context is empty", async () => {
|
||||
contextResponse = " ";
|
||||
const { api, logs, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
const result = await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "empty-ctx", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
assert.ok(!logs.some((l) => l.includes("MEMORY.md synced")), "should not log sync for empty context");
|
||||
});
|
||||
|
||||
it("gateway_start resets sync tracking so next agent re-syncs", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
// First sync
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "agent-1", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const firstContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(firstContextRequests.length, 1);
|
||||
|
||||
// Gateway restart
|
||||
await fireEvent("gateway_start", {}, {});
|
||||
|
||||
// Second sync after gateway restart — same workspace should re-sync
|
||||
await fireEvent("before_agent_start", {
|
||||
prompt: "Help me after gateway restart",
|
||||
}, { sessionKey: "agent-1", workspaceDir: tmpDir });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const allContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.equal(allContextRequests.length, 2, "should re-fetch context after gateway restart");
|
||||
assert.equal(result, undefined, "should return undefined for empty context");
|
||||
assert.ok(!logs.some((l) => l.includes("Context injected")), "should not log injection for empty context");
|
||||
});
|
||||
|
||||
it("uses custom project name in context inject URL", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort, project: "my-bot" });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_agent_start", {
|
||||
await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me write a function",
|
||||
}, { sessionKey: "proj-test", workspaceDir: tmpDir });
|
||||
messages: [],
|
||||
}, { agentId: "main" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
@@ -751,6 +753,23 @@ describe("MEMORY.md context sync", () => {
|
||||
assert.ok(contextRequest, "should request context");
|
||||
assert.ok(contextRequest!.url!.includes("projects=my-bot"), "should use custom project name");
|
||||
});
|
||||
|
||||
it("includes agent-scoped project in context request", async () => {
|
||||
const { api, fireEvent } = createMockApi({ workerPort });
|
||||
claudeMemPlugin(api);
|
||||
|
||||
await fireEvent("before_prompt_build", {
|
||||
prompt: "Help me",
|
||||
messages: [],
|
||||
}, { agentId: "debugger" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
||||
assert.ok(contextRequest, "should request context");
|
||||
const url = decodeURIComponent(contextRequest!.url!);
|
||||
assert.ok(url.includes("openclaw,openclaw-debugger"), "should include both base and agent-scoped projects");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE stream integration", () => {
|
||||
|
||||
+205
-71
@@ -1,5 +1,5 @@
|
||||
import { writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
// No file-system imports needed — context is injected via system prompt hook,
|
||||
// not by writing to MEMORY.md.
|
||||
|
||||
// Minimal type declarations for the OpenClaw Plugin SDK.
|
||||
// These match the real OpenClawPluginApi provided by the gateway at runtime.
|
||||
@@ -35,6 +35,18 @@ interface BeforeAgentStartEvent {
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
interface BeforePromptBuildEvent {
|
||||
prompt: string;
|
||||
messages: unknown[];
|
||||
}
|
||||
|
||||
interface BeforePromptBuildResult {
|
||||
systemPrompt?: string;
|
||||
prependContext?: string;
|
||||
prependSystemContext?: string;
|
||||
appendSystemContext?: string;
|
||||
}
|
||||
|
||||
interface ToolResultPersistEvent {
|
||||
toolName?: string;
|
||||
params?: Record<string, unknown>;
|
||||
@@ -87,6 +99,7 @@ interface MessageContext {
|
||||
}
|
||||
|
||||
type EventCallback<T> = (event: T, ctx: EventContext) => void | Promise<void>;
|
||||
type PromptBuildCallback = (event: BeforePromptBuildEvent, ctx: EventContext) => BeforePromptBuildResult | Promise<BeforePromptBuildResult | void> | void;
|
||||
type MessageEventCallback<T> = (event: T, ctx: MessageContext) => void | Promise<void>;
|
||||
|
||||
interface OpenClawPluginApi {
|
||||
@@ -109,7 +122,8 @@ interface OpenClawPluginApi {
|
||||
requireAuth?: boolean;
|
||||
handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise<PluginCommandResult>;
|
||||
}) => void;
|
||||
on: ((event: "before_agent_start", callback: EventCallback<BeforeAgentStartEvent>) => void) &
|
||||
on: ((event: "before_prompt_build", callback: PromptBuildCallback) => void) &
|
||||
((event: "before_agent_start", callback: EventCallback<BeforeAgentStartEvent>) => void) &
|
||||
((event: "tool_result_persist", callback: EventCallback<ToolResultPersistEvent>) => void) &
|
||||
((event: "agent_end", callback: EventCallback<AgentEndEvent>) => void) &
|
||||
((event: "session_start", callback: EventCallback<SessionStartEvent>) => void) &
|
||||
@@ -166,8 +180,10 @@ interface FeedEmojiConfig {
|
||||
|
||||
interface ClaudeMemPluginConfig {
|
||||
syncMemoryFile?: boolean;
|
||||
syncMemoryFileExclude?: string[];
|
||||
project?: string;
|
||||
workerPort?: number;
|
||||
workerHost?: string;
|
||||
observationFeed?: {
|
||||
enabled?: boolean;
|
||||
channel?: string;
|
||||
@@ -183,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.
|
||||
@@ -241,8 +258,10 @@ 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}`;
|
||||
}
|
||||
|
||||
async function workerPost(
|
||||
@@ -518,6 +537,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);
|
||||
|
||||
@@ -532,8 +552,16 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
// Session tracking for observation I/O
|
||||
// ------------------------------------------------------------------
|
||||
const sessionIds = new Map<string, string>();
|
||||
const workspaceDirsBySessionKey = new Map<string, string>();
|
||||
const 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 || []);
|
||||
|
||||
function getContentSessionId(sessionKey?: string): string {
|
||||
const key = sessionKey || "default";
|
||||
@@ -543,99 +571,195 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
return sessionIds.get(key)!;
|
||||
}
|
||||
|
||||
async function syncMemoryToWorkspace(workspaceDir: string, ctx?: EventContext): Promise<void> {
|
||||
function shouldInjectContext(ctx?: EventContext): boolean {
|
||||
if (!syncMemoryFile) return false;
|
||||
const agentId = ctx?.agentId;
|
||||
if (agentId && syncMemoryFileExclude.has(agentId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
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.
|
||||
const CONTEXT_CACHE_TTL_MS = 60_000;
|
||||
const contextCache = new Map<string, { text: string; fetchedAt: number }>();
|
||||
|
||||
async function getContextForPrompt(ctx?: EventContext): Promise<string | null> {
|
||||
// Include both the base project and agent-scoped project (e.g. "openclaw" + "openclaw-main")
|
||||
const projects = [baseProjectName];
|
||||
const agentProject = ctx ? getProjectName(ctx) : null;
|
||||
if (agentProject && agentProject !== baseProjectName) {
|
||||
projects.push(agentProject);
|
||||
}
|
||||
const cacheKey = projects.join(",");
|
||||
|
||||
// Return cached context if still fresh
|
||||
const cached = contextCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CONTEXT_CACHE_TTL_MS) {
|
||||
return cached.text;
|
||||
}
|
||||
|
||||
const contextText = await workerGetText(
|
||||
workerPort,
|
||||
`/api/context/inject?projects=${encodeURIComponent(projects.join(","))}`,
|
||||
`/api/context/inject?projects=${encodeURIComponent(cacheKey)}`,
|
||||
api.logger
|
||||
);
|
||||
if (contextText && contextText.trim().length > 0) {
|
||||
try {
|
||||
await writeFile(join(workspaceDir, "MEMORY.md"), contextText, "utf-8");
|
||||
api.logger.info(`[claude-mem] MEMORY.md synced to ${workspaceDir}`);
|
||||
} catch (writeError: unknown) {
|
||||
const msg = writeError instanceof Error ? writeError.message : String(writeError);
|
||||
api.logger.warn(`[claude-mem] Failed to write MEMORY.md: ${msg}`);
|
||||
}
|
||||
const trimmed = contextText.trim();
|
||||
contextCache.set(cacheKey, { text: trimmed, fetchedAt: Date.now() });
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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 + sync MEMORY.md + track workspace
|
||||
// Event: before_agent_start — single init point with dedup guard
|
||||
// ------------------------------------------------------------------
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
// Track workspace dir so tool_result_persist can sync MEMORY.md later
|
||||
if (ctx.workspaceDir) {
|
||||
workspaceDirsBySessionKey.set(ctx.sessionKey || "default", ctx.workspaceDir);
|
||||
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);
|
||||
|
||||
// Sync MEMORY.md before agent runs (provides context to agent)
|
||||
if (syncMemoryFile && ctx.workspaceDir) {
|
||||
await syncMemoryToWorkspace(ctx.workspaceDir, ctx);
|
||||
api.logger.info(`[claude-mem] Session initialized via before_agent_start: contentSessionId=${contentSessionId} project=${projectName}`);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: before_prompt_build — inject context into system prompt
|
||||
//
|
||||
// Instead of writing to MEMORY.md (which conflicts with agent-curated
|
||||
// memory), inject the observation timeline via appendSystemContext.
|
||||
// This keeps MEMORY.md under the agent's control while still providing
|
||||
// cross-session context to the LLM.
|
||||
// ------------------------------------------------------------------
|
||||
api.on("before_prompt_build", async (_event, ctx) => {
|
||||
if (!shouldInjectContext(ctx)) return;
|
||||
|
||||
const contextText = await getContextForPrompt(ctx);
|
||||
if (contextText) {
|
||||
api.logger.info(`[claude-mem] Context injected via system prompt for agent=${ctx.agentId ?? "unknown"}`);
|
||||
return { appendSystemContext: contextText };
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Event: tool_result_persist — record tool observations + sync MEMORY.md
|
||||
// Event: tool_result_persist — record tool observations
|
||||
// ------------------------------------------------------------------
|
||||
api.on("tool_result_persist", (event, ctx) => {
|
||||
api.logger.info(`[claude-mem] tool_result_persist fired: tool=${event.toolName ?? "unknown"} agent=${ctx.agentId ?? "none"} session=${ctx.sessionKey ?? "none"}`);
|
||||
@@ -645,7 +769,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 = "";
|
||||
@@ -663,26 +787,31 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||
}
|
||||
|
||||
// Fire-and-forget: send observation + sync MEMORY.md in parallel
|
||||
// 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);
|
||||
|
||||
const workspaceDir = ctx.workspaceDir || workspaceDirsBySessionKey.get(ctx.sessionKey || "default");
|
||||
if (syncMemoryFile && workspaceDir) {
|
||||
syncMemoryToWorkspace(workspaceDir, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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 = "";
|
||||
@@ -711,26 +840,31 @@ 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);
|
||||
workspaceDirsBySessionKey.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 () => {
|
||||
workspaceDirsBySessionKey.clear();
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -1012,5 +1146,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"
|
||||
|
||||
+36
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.5.0",
|
||||
"version": "12.0.0",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -26,6 +26,9 @@
|
||||
"url": "https://github.com/thedotmack/claude-mem/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"claude-mem": "./dist/npx-cli/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -39,7 +42,17 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"plugin"
|
||||
"plugin/.claude-plugin",
|
||||
"plugin/CLAUDE.md",
|
||||
"plugin/package.json",
|
||||
"plugin/hooks",
|
||||
"plugin/modes",
|
||||
"plugin/scripts/*.js",
|
||||
"plugin/scripts/*.cjs",
|
||||
"plugin/scripts/CLAUDE.md",
|
||||
"plugin/skills",
|
||||
"plugin/ui",
|
||||
"openclaw"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
@@ -47,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",
|
||||
@@ -97,18 +110,26 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||
"@clack/prompts": "^0.9.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"dompurify": "^3.3.1",
|
||||
"express": "^4.18.2",
|
||||
"glob": "^11.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"picocolors": "^1.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"yaml": "^2.8.2",
|
||||
"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",
|
||||
@@ -117,17 +138,29 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.5.0",
|
||||
"version": "12.0.0",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+44
-8
@@ -1,18 +1,35 @@
|
||||
{
|
||||
"description": "Claude-mem memory system hooks",
|
||||
"hooks": {
|
||||
"Setup": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(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\"; \"$_R/scripts/setup.sh\"",
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/smart-install.js\"",
|
||||
"command": "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
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
|
||||
"command": "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/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; for i in 1 2 3 4 5 6 7 8; 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 || exit 1; echo '{\"continue\":true,\"suppressOutput\":true}'",
|
||||
"timeout": 60
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"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\"; for i in 1 2 3 4 5 6 7 8; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
@@ -23,7 +40,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
|
||||
"command": "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/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
@@ -31,27 +48,46 @@
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
|
||||
"command": "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/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Read",
|
||||
"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 file-context",
|
||||
"timeout": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
|
||||
"command": "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/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
|
||||
"timeout": 120
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/thedotmack/plugin}\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
|
||||
"timeout": 120
|
||||
"command": "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/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,7 @@
|
||||
{
|
||||
"name": "Law Study (Chill)",
|
||||
"prompts": {
|
||||
"recording_focus": "WHAT TO RECORD (HIGH SIGNAL ONLY)\n----------------------------------\nOnly record what would be painful to reconstruct later:\n- Issue-spotting triggers: specific fact patterns that signal a testable issue\n- Professor's explicit emphasis, frameworks, or exam tips\n- Counterintuitive holdings or gotchas that contradict intuition\n- Cross-case connections that reframe how a doctrine works\n- A synthesized rule only if it distills something non-obvious from multiple sources\n\nSkip anything that could be looked up in a casebook in under 60 seconds.\n\nUse verbs like: held, established, revealed, distinguished, flagged",
|
||||
"skip_guidance": "WHEN TO SKIP (LIBERAL — WHEN IN DOUBT, SKIP)\n---------------------------------------------\nSkip freely:\n- All case briefs, even condensed ones, unless the holding is counterintuitive\n- Any rule or doctrine stated plainly in the casebook without nuance\n- Definitions of standard legal terms\n- Procedural history\n- Any fact pattern or case that wasn't specifically emphasized by the professor\n- Anything you could find again in under 60 seconds\n- **No output necessary if skipping.**"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Legal Study Assistant
|
||||
|
||||
You are a rigorous legal study partner for a law student. Your job is to help them understand the law deeply enough to reason through novel fact patterns independently on exams and in practice.
|
||||
|
||||
---
|
||||
|
||||
## Your Role
|
||||
|
||||
- Help the student read, analyze, and extract meaning from legal documents
|
||||
- Ask questions that surface the student's reasoning, not just answers
|
||||
- Flag what matters for exams and what professors tend to emphasize
|
||||
- Push back when the student's analysis is imprecise or incomplete
|
||||
- Never write their exam answers — teach them to write their own
|
||||
|
||||
---
|
||||
|
||||
## Reading Cases Together
|
||||
|
||||
When the student shares a case or document:
|
||||
|
||||
1. Read it fully before saying anything. No skimming.
|
||||
2. Identify the procedural posture, then the issue, then the holding, then the reasoning.
|
||||
3. Separate holding from dicta explicitly — this distinction is always fair game.
|
||||
4. Surface ambiguity when the court was evasive. That ambiguity is often the exam question.
|
||||
5. Ask: "Which facts were outcome-determinative? What if those facts changed?"
|
||||
|
||||
**Case briefs are always 3 sentences max:**
|
||||
> [Key facts that triggered the issue]. The court held [holding + extracted rule]. [Why this rule exists or how it fits the doctrine — only if non-obvious.]
|
||||
|
||||
---
|
||||
|
||||
## Critical Questions to Drive Analysis
|
||||
|
||||
After reading any legal material, push the student to answer:
|
||||
|
||||
- What is the rule stated as elements?
|
||||
- What did the dissent argue and why does it matter?
|
||||
- How does this fit — or conflict with — earlier cases?
|
||||
- What fact pattern on an exam triggers this rule?
|
||||
- What does the professor emphasize about this? Their framing is the exam framing.
|
||||
- Is the law settled or contested here?
|
||||
|
||||
---
|
||||
|
||||
## Issue Spotting
|
||||
|
||||
When working through a fact pattern:
|
||||
|
||||
1. Read the entire hypo before naming any issues.
|
||||
2. List every potential claim and defense — err toward inclusion.
|
||||
3. For each issue: rule → application to these specific facts → where the argument turns.
|
||||
4. Treat "irrelevant" facts as planted triggers. Nothing in an exam hypo is accidental.
|
||||
5. Calibrate to the professor's emphasis — they wrote the exam.
|
||||
|
||||
---
|
||||
|
||||
## Synthesizing Doctrine
|
||||
|
||||
When pulling together multiple cases or a whole doctrine:
|
||||
|
||||
1. Find the common principle across all the cases.
|
||||
2. Build the rule as a spectrum or taxonomy when cases represent different scenarios.
|
||||
3. State the limiting principle — where does this rule stop and why.
|
||||
4. Majority rule first, then minority positions with their rationale.
|
||||
5. Identify the live tension — what the courts haven't resolved yet.
|
||||
|
||||
---
|
||||
|
||||
## Tone and Pace
|
||||
|
||||
- Be direct. Law school trains precision — model it.
|
||||
- When the student is vague, say so and ask them to be specific.
|
||||
- Celebrate when they spot something sharp. Legal reasoning is hard.
|
||||
- Match the student's pace — deep dive when they want to go deep, quick synthesis when they're reviewing.
|
||||
|
||||
---
|
||||
|
||||
## Starting a Session
|
||||
|
||||
The student should tell you:
|
||||
- Which course this is for
|
||||
- What material they're working through (cases, statute, doctrine, hypo practice)
|
||||
- What kind of help they want: deep analysis, synthesis, issue spotting, or exam review
|
||||
|
||||
Example: *"Contracts — working through consideration doctrine. Here are four cases. Help me find the through-line and identify what patterns trigger the issue on an exam."*
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"name": "Law Study",
|
||||
"description": "Legal study and exam preparation for law students",
|
||||
"version": "1.0.0",
|
||||
"observation_types": [
|
||||
{
|
||||
"id": "case-holding",
|
||||
"label": "Case Holding",
|
||||
"description": "Case brief (2-3 sentences: key facts + holding) with extracted legal rule",
|
||||
"emoji": "⚖️",
|
||||
"work_emoji": "📖"
|
||||
},
|
||||
{
|
||||
"id": "issue-pattern",
|
||||
"label": "Issue Pattern",
|
||||
"description": "Exam trigger or fact pattern that signals a legal issue to spot",
|
||||
"emoji": "🎯",
|
||||
"work_emoji": "🔍"
|
||||
},
|
||||
{
|
||||
"id": "prof-framework",
|
||||
"label": "Prof Framework",
|
||||
"description": "Professor's analytical lens, emphasis, or approach to a topic or doctrine",
|
||||
"emoji": "🧑🏫",
|
||||
"work_emoji": "📝"
|
||||
},
|
||||
{
|
||||
"id": "doctrine-rule",
|
||||
"label": "Doctrine / Rule",
|
||||
"description": "Legal test, standard, or doctrine synthesized from cases, statutes, or restatements",
|
||||
"emoji": "📜",
|
||||
"work_emoji": "🔍"
|
||||
},
|
||||
{
|
||||
"id": "argument-structure",
|
||||
"label": "Argument Structure",
|
||||
"description": "Legal argument or counter-argument worked through with analytical steps",
|
||||
"emoji": "🗣️",
|
||||
"work_emoji": "⚖️"
|
||||
},
|
||||
{
|
||||
"id": "cross-case-connection",
|
||||
"label": "Cross-Case Connection",
|
||||
"description": "Insight linking multiple cases, doctrines, or topics that reveals a deeper principle",
|
||||
"emoji": "🔗",
|
||||
"work_emoji": "🔍"
|
||||
}
|
||||
],
|
||||
"observation_concepts": [
|
||||
{
|
||||
"id": "exam-relevant",
|
||||
"label": "Exam Relevant",
|
||||
"description": "Flagged by professor or likely to appear on exams based on emphasis"
|
||||
},
|
||||
{
|
||||
"id": "minority-position",
|
||||
"label": "Minority Position",
|
||||
"description": "Dissent, minority rule, or alternative jurisdictional approach worth knowing"
|
||||
},
|
||||
{
|
||||
"id": "gotcha",
|
||||
"label": "Gotcha",
|
||||
"description": "Subtle nuance, counterintuitive result, or common mistake students get wrong"
|
||||
},
|
||||
{
|
||||
"id": "unsettled-law",
|
||||
"label": "Unsettled Law",
|
||||
"description": "Circuit split, open question, or evolving area of law"
|
||||
},
|
||||
{
|
||||
"id": "policy-rationale",
|
||||
"label": "Policy Rationale",
|
||||
"description": "Normative or policy argument underlying a rule or holding"
|
||||
},
|
||||
{
|
||||
"id": "course-theme",
|
||||
"label": "Course Theme",
|
||||
"description": "How this case or rule connects to the overarching narrative or theory of the course"
|
||||
}
|
||||
],
|
||||
"prompts": {
|
||||
"system_identity": "You are Claude-Mem, a specialized observer tool for creating searchable memory FOR FUTURE SESSIONS.\n\nCRITICAL: Record what was READ, ANALYZED, SYNTHESIZED, or LEARNED about the law, not what you (the observer) are doing.\n\nYou do not have access to tools. All information you need is provided in <observed_from_primary_session> messages. Create observations from what you observe - no investigation needed.",
|
||||
"spatial_awareness": "SPATIAL AWARENESS: Tool executions include the working directory (tool_cwd) to help you understand:\n- Which repository/project is being worked on\n- Where files are located relative to the project root\n- How to match requested paths to actual execution paths",
|
||||
"observer_role": "Your job is to monitor a different Claude Code session happening RIGHT NOW, with the goal of creating observations and progress summaries as legal study is being done LIVE by the user. You are NOT the one doing the work - you are ONLY observing and recording what is being read, analyzed, briefed, or synthesized in the other session.",
|
||||
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on legal knowledge and exam-ready insights:\n- Case holdings distilled to 2-3 sentences (key facts + holding + rule)\n- Legal tests, elements, and standards extracted from cases or statutes\n- Issue-spotting triggers: what fact patterns signal which legal issues\n- Professor's framing, emphasis, or analytical approach to a doctrine\n- Arguments and counter-arguments worked through\n- Connections across cases or doctrines that reveal underlying principles\n\nUse verbs like: held, established, synthesized, identified, distinguished, analyzed, revealed, connected\n\n✅ GOOD EXAMPLES (describes what was learned about the law):\n- \"Palsgraf established proximate cause requires the harm be foreseeable to the defendant at the time of conduct\"\n- \"Prof frames consideration doctrine around the bargain theory, not benefit-detriment — exam answers should reflect this\"\n- \"When fact pattern shows concurrent causation, issue-spot both but-for AND substantial factor tests\"\n\n❌ BAD EXAMPLES (describes observation process - DO NOT DO THIS):\n- \"Analyzed the case and recorded findings about proximate cause\"\n- \"Tracked professor's comments and stored the framework\"\n- \"Monitored discussion of consideration and noted the approach\"",
|
||||
"skip_guidance": "WHEN TO SKIP\n------------\nSkip these — not worth recording:\n- Full case briefs (only record the 2-3 sentence distilled version with the rule)\n- Re-reading the same case or passage without new insight\n- Definitions of basic terms the student already knows\n- Routine case brief formatting with no analytical content\n- Simple fact summaries that don't extract a rule or pattern\n- Procedural history details not relevant to the legal rule\n- **No output necessary if skipping.**",
|
||||
"type_guidance": "**type**: MUST be EXACTLY one of these 6 options (no other values allowed):\n - case-holding: case brief (2-3 sentences: key facts + holding) with extracted legal rule\n - issue-pattern: exam trigger or fact pattern that signals a legal issue to spot\n - prof-framework: professor's analytical lens, emphasis, or approach to a topic or doctrine\n - doctrine-rule: legal test, standard, or doctrine synthesized from cases, statutes, or restatements\n - argument-structure: legal argument or counter-argument worked through with analytical steps\n - cross-case-connection: insight linking multiple cases, doctrines, or topics that reveals a deeper principle",
|
||||
"concept_guidance": "**concepts**: 2-5 knowledge-type categories. MUST use ONLY these exact keywords:\n - exam-relevant: flagged by professor or likely to appear on exams\n - minority-position: dissent, minority rule, or alternative jurisdictional approach\n - gotcha: subtle nuance, counterintuitive result, or common mistake\n - unsettled-law: circuit split, open question, or evolving area\n - policy-rationale: normative or policy argument underlying a rule\n - course-theme: connects to the overarching narrative or theory of the course\n\n IMPORTANT: Do NOT include the observation type (case-holding/issue-pattern/etc.) as a concept.\n Types and concepts are separate dimensions.",
|
||||
"field_guidance": "**facts**: Concise, self-contained statements\nEach fact is ONE piece of information\n No pronouns - each fact must stand alone\n Include specific details: case names, rule elements, test names, jurisdiction\n\n**files**: All files or documents read (full paths from project root)",
|
||||
"output_format_header": "OUTPUT FORMAT\n-------------\nOutput observations using this XML structure:",
|
||||
"format_examples": "",
|
||||
"footer": "IMPORTANT! DO NOT do any work right now other than generating this OBSERVATIONS from tool use messages - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the observation content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful observations.\n\nRemember that we record these observations as a way of helping us stay on track with our progress, and to help us keep important decisions and changes at the forefront of our minds! :) Thank you so much for your help!",
|
||||
|
||||
"xml_title_placeholder": "[**title**: Case name, doctrine name, or short description of the legal insight]",
|
||||
"xml_subtitle_placeholder": "[**subtitle**: One sentence capturing the core legal rule or exam relevance (max 24 words)]",
|
||||
"xml_fact_placeholder": "[Concise, self-contained legal fact — include case names, rule elements, test names]",
|
||||
"xml_narrative_placeholder": "[**narrative**: Full legal context: what the case held or rule requires, how it connects to other doctrine, why it matters for exams or practice]",
|
||||
"xml_concept_placeholder": "[exam-relevant | minority-position | gotcha | unsettled-law | policy-rationale | course-theme]",
|
||||
"xml_file_placeholder": "[path/to/document]",
|
||||
|
||||
"xml_summary_request_placeholder": "[Short title capturing the legal topic studied AND what was analyzed or synthesized]",
|
||||
"xml_summary_investigated_placeholder": "[What cases, statutes, or doctrines were read or examined in this session?]",
|
||||
"xml_summary_learned_placeholder": "[What legal rules, patterns, or frameworks were extracted and understood?]",
|
||||
"xml_summary_completed_placeholder": "[What study work was completed? Which cases briefed, which doctrines synthesized, which issue patterns identified?]",
|
||||
"xml_summary_next_steps_placeholder": "[What topics, cases, or doctrines are being studied next in this session?]",
|
||||
"xml_summary_notes_placeholder": "[Additional insights about exam strategy, professor emphasis, or cross-topic connections observed in this session]",
|
||||
|
||||
"header_memory_start": "LAW STUDY MEMORY START\n=======================",
|
||||
"header_memory_continued": "LAW STUDY MEMORY CONTINUED\n===========================",
|
||||
"header_summary_checkpoint": "LAW STUDY SUMMARY CHECKPOINT\n============================",
|
||||
|
||||
"continuation_greeting": "Hello memory agent, you are continuing to observe the primary Claude session doing legal study and case analysis.",
|
||||
"continuation_instruction": "IMPORTANT: Continue generating observations from tool use messages using the XML structure below.",
|
||||
|
||||
"summary_instruction": "Write progress notes of what legal material was studied, what rules and patterns were extracted, and what's next. This is a checkpoint to capture study progress so far. The session is ongoing - more cases or doctrines may be analyzed after this summary. Write \"next_steps\" as the current study trajectory (what topics or cases are actively being worked through), not as post-session plans. Always write at least a minimal summary explaining current progress, even if study is still early, so that users see a summary output tied to each study block.",
|
||||
"summary_context_label": "Claude's Full Response to User:",
|
||||
"summary_format_instruction": "Respond in this XML format:",
|
||||
"summary_footer": "IMPORTANT! DO NOT do any work right now other than generating this next PROGRESS SUMMARY - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the summary content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful summary content.\n\nThank you, this summary will be very useful for keeping track of legal study progress!"
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "10.5.0",
|
||||
"version": "12.0.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",
|
||||
|
||||
@@ -55,6 +55,13 @@ function findBun() {
|
||||
});
|
||||
|
||||
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 +159,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 +192,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
+2045
-56
File diff suppressed because one or more lines are too long
@@ -220,14 +220,14 @@ function installBun() {
|
||||
// Windows: Use PowerShell installer
|
||||
console.error(' Installing via PowerShell...');
|
||||
execSync('powershell -c "irm bun.sh/install.ps1 | iex"', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
} else {
|
||||
// Unix/macOS: Use curl installer
|
||||
console.error(' Installing via curl...');
|
||||
execSync('curl -fsSL https://bun.sh/install | bash', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
}
|
||||
@@ -285,14 +285,14 @@ function installUv() {
|
||||
// Windows: Use PowerShell installer
|
||||
console.error(' Installing via PowerShell...');
|
||||
execSync('powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
} else {
|
||||
// Unix/macOS: Use curl installer
|
||||
console.error(' Installing via curl...');
|
||||
execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', {
|
||||
stdio: 'inherit',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
}
|
||||
@@ -426,14 +426,18 @@ function installDeps() {
|
||||
// Quote path for Windows paths with spaces
|
||||
const bunCmd = IS_WINDOWS && bunPath.includes(' ') ? `"${bunPath}"` : bunPath;
|
||||
|
||||
// Use pipe for stdout to prevent non-JSON output leaking to Claude Code hooks.
|
||||
// stderr is inherited so progress/errors are still visible to the user.
|
||||
const installStdio = ['pipe', 'pipe', 'inherit'];
|
||||
|
||||
let bunSucceeded = false;
|
||||
try {
|
||||
execSync(`${bunCmd} install`, { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync(`${bunCmd} install`, { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
bunSucceeded = true;
|
||||
} catch {
|
||||
// First attempt failed, try with force flag
|
||||
try {
|
||||
execSync(`${bunCmd} install --force`, { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync(`${bunCmd} install --force`, { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
bunSucceeded = true;
|
||||
} catch {
|
||||
// Bun failed completely, will try npm fallback
|
||||
@@ -445,7 +449,7 @@ function installDeps() {
|
||||
console.error('⚠️ Bun install failed, falling back to npm...');
|
||||
console.error(' (This can happen with npm alias packages like *-cjs)');
|
||||
try {
|
||||
execSync('npm install', { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync('npm install --legacy-peer-deps', { cwd: ROOT, stdio: installStdio, shell: IS_WINDOWS });
|
||||
} catch (npmError) {
|
||||
throw new Error('Both bun and npm install failed: ' + npmError.message);
|
||||
}
|
||||
@@ -506,7 +510,7 @@ try {
|
||||
console.error(`⚠️ Bun ${currentVersion} is outdated. Minimum required: ${MIN_BUN_VERSION}`);
|
||||
console.error(' Upgrading bun...');
|
||||
try {
|
||||
execSync('bun upgrade', { stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync('bun upgrade', { stdio: ['pipe', 'pipe', 'inherit'], shell: IS_WINDOWS });
|
||||
if (!isBunVersionSufficient()) {
|
||||
console.error(`❌ Bun upgrade failed. Please manually upgrade: bun upgrade`);
|
||||
process.exit(1);
|
||||
@@ -542,7 +546,7 @@ try {
|
||||
if (!verifyCriticalModules()) {
|
||||
console.error('⚠️ Retrying install with npm...');
|
||||
try {
|
||||
execSync('npm install --production', { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS });
|
||||
execSync('npm install --production --legacy-peer-deps', { cwd: ROOT, stdio: ['pipe', 'pipe', 'inherit'], shell: IS_WINDOWS });
|
||||
} catch {
|
||||
// npm also failed
|
||||
}
|
||||
@@ -577,7 +581,12 @@ try {
|
||||
|
||||
// Step 4: Install CLI to PATH
|
||||
installCLI();
|
||||
|
||||
// Output valid JSON for Claude Code hook contract
|
||||
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||
} catch (e) {
|
||||
console.error('❌ Installation failed:', e.message);
|
||||
// Still output valid JSON so Claude Code doesn't show a confusing error
|
||||
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+482
-273
File diff suppressed because one or more lines are too long
@@ -125,3 +125,51 @@ 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
|
||||
|
||||
## Smart-Explore Language Support
|
||||
|
||||
Smart-explore tools (`smart_search`, `smart_outline`, `smart_unfold`) use tree-sitter AST parsing. The following languages are supported out of the box.
|
||||
|
||||
### 24 Bundled Languages
|
||||
|
||||
JS, TS, Python, Go, Rust, Ruby, Java, C, C++, Kotlin, Swift, PHP, Elixir, Lua, Scala, Bash, Haskell, Zig, CSS, SCSS, TOML, YAML, SQL, Markdown
|
||||
|
||||
### Markdown Special Support
|
||||
|
||||
Markdown files get structure-aware parsing beyond generic tree-sitter:
|
||||
|
||||
- **Heading hierarchy** -- `#`/`##`/`###` headings are extracted as nested symbols (sections contain subsections)
|
||||
- **Code block detection** -- fenced code blocks are surfaced as `code` symbols with language annotation
|
||||
- **Section-aware unfold** -- `smart_unfold` on a heading returns the full section content (heading through all subsections until the next heading of equal or higher level)
|
||||
|
||||
### User-Installable Grammars via `.claude-mem.json`
|
||||
|
||||
Add custom tree-sitter grammars for languages not in the bundled set. Place `.claude-mem.json` in the project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"grammars": {
|
||||
"gleam": {
|
||||
"package": "tree-sitter-gleam",
|
||||
"extensions": [".gleam"]
|
||||
},
|
||||
"protobuf": {
|
||||
"package": "tree-sitter-proto",
|
||||
"extensions": [".proto"],
|
||||
"query": ".claude-mem/queries/proto.scm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `package` (string, required) -- npm package name for the tree-sitter grammar
|
||||
- `extensions` (array of strings, required) -- file extensions to associate with this language
|
||||
- `query` (string, optional) -- path to a custom `.scm` query file for symbol extraction. If omitted, a generic query is used.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- User grammars do NOT override bundled languages. If a language is already bundled, the entry is ignored.
|
||||
- The npm package must be installed in the project (`npm install tree-sitter-gleam`).
|
||||
- Config is cached per project root. Changes to `.claude-mem.json` take effect on next worker restart.
|
||||
|
||||
@@ -7,6 +7,8 @@ description: Token-optimized structural code search using tree-sitter AST parsin
|
||||
|
||||
Structural code exploration using AST parsing. **This skill overrides your default exploration behavior.** While this skill is active, use smart_search/smart_outline/smart_unfold as your primary tools instead of Read, Grep, and Glob.
|
||||
|
||||
**Core principle:** Index first, fetch on demand. Give yourself a map of the code before loading implementation details. The question before every file read should be: "do I need to see all of this, or can I get a structural overview first?" The answer is almost always: get the map.
|
||||
|
||||
## Your Next Tool Call
|
||||
|
||||
This skill only loads instructions. You must call the MCP tools yourself. Your next action should be one of:
|
||||
@@ -71,7 +73,7 @@ Review symbols from Steps 1-2. Pick the ones you need. Unfold only those:
|
||||
smart_unfold(file_path="services/worker-service.ts", symbol_name="shutdown")
|
||||
```
|
||||
|
||||
**Returns:** Full source code of the specified symbol including JSDoc, decorators, and complete implementation (~1-7k tokens depending on symbol size)
|
||||
**Returns:** Full source code of the specified symbol including JSDoc, decorators, and complete implementation (~400-2,100 tokens depending on symbol size). AST node boundaries guarantee completeness regardless of symbol size — unlike Read + agent summarization, which may truncate long methods.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
@@ -85,6 +87,7 @@ Use these only when smart_* tools are the wrong fit:
|
||||
- **Grep:** Exact string/regex search ("find all TODO comments", "where is `ensureWorkerStarted` defined?")
|
||||
- **Read:** Small files under ~100 lines, non-code files (JSON, markdown, config)
|
||||
- **Glob:** File path patterns ("find all test files")
|
||||
- **Explore agent:** When you need synthesized understanding across 6+ files, architecture narratives, or answers to open-ended questions like "how does this entire system work end-to-end?" Smart-explore is a scalpel — it answers "where is this?" and "show me that." It doesn't synthesize cross-file data flows, design decisions, or edge cases across an entire feature.
|
||||
|
||||
For code files over ~100 lines, prefer smart_outline + smart_unfold over Read.
|
||||
|
||||
@@ -132,10 +135,11 @@ Use smart_* tools for code exploration, Read for non-code files. Mix freely.
|
||||
|
||||
| Approach | Tokens | Use Case |
|
||||
|----------|--------|----------|
|
||||
| smart_outline | ~1,500 | "What's in this file?" |
|
||||
| smart_unfold | ~1,600 | "Show me this function" |
|
||||
| smart_search | ~2,000-6,000 | "How does X work?" |
|
||||
| smart_outline | ~1,000-2,000 | "What's in this file?" |
|
||||
| smart_unfold | ~400-2,100 | "Show me this function" |
|
||||
| smart_search | ~2,000-6,000 | "Find all X across the codebase" |
|
||||
| search + unfold | ~3,000-8,000 | End-to-end: find and read (the primary workflow) |
|
||||
| Read (full file) | ~12,000+ | When you truly need everything |
|
||||
| Explore agent | ~20,000-40,000 | Same as smart_search, 6-12x more expensive |
|
||||
| Explore agent | ~39,000-59,000 | Cross-file synthesis with narrative |
|
||||
|
||||
**8x savings** on file understanding (outline + unfold vs Read). **6-12x savings** on exploration vs Explore agent.
|
||||
**4-8x savings** on file understanding (outline + unfold vs Read). **11-18x savings** on codebase exploration vs Explore agent. The narrower the query, the wider the gap — a 27-line function costs 55x less to read via unfold than via an Explore agent, because the agent still reads the entire file.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: timeline-report
|
||||
description: Generate a "Journey Into [Project]" narrative report analyzing a project's entire development history from claude-mem's timeline. Use when asked for a timeline report, project history analysis, development journey, or full project report.
|
||||
---
|
||||
|
||||
# Timeline Report
|
||||
|
||||
Generate a comprehensive narrative analysis of a project's entire development history using claude-mem's persistent memory timeline.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use when users ask for:
|
||||
|
||||
- "Write a timeline report"
|
||||
- "Journey into [project]"
|
||||
- "Analyze my project history"
|
||||
- "Full project report"
|
||||
- "Summarize the entire development history"
|
||||
- "What's the story of this project?"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The claude-mem worker must be running on localhost:37777. The project must have claude-mem observations recorded.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Determine the Project Name
|
||||
|
||||
Ask the user which project to analyze if not obvious from context. The project name is typically the directory name of the project (e.g., "tokyo", "my-app"). If the user says "this project", use the current working directory's basename.
|
||||
|
||||
**Worktree Detection:** Before using the directory basename, check if the current directory is a git worktree. In a worktree, the data source is the **parent project**, not the worktree directory itself. Run:
|
||||
|
||||
```bash
|
||||
git_dir=$(git rev-parse --git-dir 2>/dev/null)
|
||||
git_common_dir=$(git rev-parse --git-common-dir 2>/dev/null)
|
||||
if [ "$git_dir" != "$git_common_dir" ]; then
|
||||
# We're in a worktree — resolve the parent project name
|
||||
parent_project=$(basename "$(dirname "$git_common_dir")")
|
||||
echo "Worktree detected. Parent project: $parent_project"
|
||||
else
|
||||
parent_project=$(basename "$PWD")
|
||||
fi
|
||||
echo "$parent_project"
|
||||
```
|
||||
|
||||
If a worktree is detected, use `$parent_project` (the basename of the parent repo) as the project name for all API calls. Inform the user: "Detected git worktree. Using parent project '[name]' as the data source."
|
||||
|
||||
### Step 2: Fetch the Full Timeline
|
||||
|
||||
Use Bash to fetch the complete timeline from the claude-mem worker API:
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:37777/api/context/inject?project=PROJECT_NAME&full=true"
|
||||
```
|
||||
|
||||
This returns the entire compressed timeline -- every observation, session boundary, and summary across the project's full history. The response is pre-formatted markdown optimized for LLM consumption.
|
||||
|
||||
**Token estimates:** The full timeline size depends on the project's history:
|
||||
- Small project (< 1,000 observations): ~20-50K tokens
|
||||
- Medium project (1,000-10,000 observations): ~50-300K tokens
|
||||
- Large project (10,000-35,000 observations): ~300-750K tokens
|
||||
|
||||
If the response is empty or returns an error, the worker may not be running or the project name may be wrong. Try `curl -s "http://localhost:37777/api/search?query=*&limit=1"` to verify the worker is healthy.
|
||||
|
||||
### Step 3: Estimate Token Count
|
||||
|
||||
Before proceeding, estimate the token count of the fetched timeline (roughly 1 token per 4 characters). Report this to the user:
|
||||
|
||||
```
|
||||
Timeline fetched: ~X observations, estimated ~Yk tokens.
|
||||
This analysis will consume approximately Yk input tokens + ~5-10k output tokens.
|
||||
Proceed? (y/n)
|
||||
```
|
||||
|
||||
Wait for user confirmation before continuing if the timeline exceeds 100K tokens.
|
||||
|
||||
### Step 4: Analyze with a Subagent
|
||||
|
||||
Deploy an Agent (using the Task tool) with the full timeline and the following analysis prompt. Pass the ENTIRE timeline as context to the agent. The agent should also be instructed to query the SQLite database at `~/.claude-mem/claude-mem.db` for the Token Economics section.
|
||||
|
||||
**Agent prompt:**
|
||||
|
||||
```
|
||||
You are a technical historian analyzing a software project's complete development timeline from claude-mem's persistent memory system. The timeline below contains every observation, session boundary, and summary recorded across the project's entire history.
|
||||
|
||||
You also have access to the claude-mem SQLite database at ~/.claude-mem/claude-mem.db. Use it to run queries for the Token Economics & Memory ROI section. The database has an "observations" table with columns: id, memory_session_id, project, text, type, title, subtitle, facts, narrative, concepts, files_read, files_modified, prompt_number, discovery_tokens, created_at, created_at_epoch, source_tool, source_input_summary.
|
||||
|
||||
Write a comprehensive narrative report titled "Journey Into [PROJECT_NAME]" that covers:
|
||||
|
||||
## Required Sections
|
||||
|
||||
1. **Project Genesis** -- When and how the project started. What were the first commits, the initial vision, the founding technical decisions? What problem was being solved?
|
||||
|
||||
2. **Architectural Evolution** -- How did the architecture change over time? What were the major pivots? Why did they happen? Trace the evolution from initial design through each significant restructuring.
|
||||
|
||||
3. **Key Breakthroughs** -- Identify the "aha" moments: when a difficult problem was finally solved, when a new approach unlocked progress, when a prototype first worked. These are the observations where the tone shifts from investigation to resolution.
|
||||
|
||||
4. **Work Patterns** -- Analyze the rhythm of development. Identify debugging cycles (clusters of bug fixes), feature sprints (rapid observation sequences), refactoring phases (architectural changes without new features), and exploration phases (many discoveries without changes).
|
||||
|
||||
5. **Technical Debt** -- Track where shortcuts were taken and when they were paid back. Identify patterns of accumulation (rapid feature work) and resolution (dedicated refactoring sessions).
|
||||
|
||||
6. **Challenges and Debugging Sagas** -- The hardest problems encountered. Multi-session debugging efforts, architectural dead-ends that required backtracking, platform-specific issues that took days to resolve.
|
||||
|
||||
7. **Memory and Continuity** -- How did persistent memory (claude-mem itself, if applicable) affect the development process? Were there moments where recalled context from prior sessions saved significant time or prevented repeated mistakes?
|
||||
|
||||
8. **Token Economics & Memory ROI** -- Quantitative analysis of how memory recall saved work:
|
||||
- Query the database directly for these metrics using `sqlite3 ~/.claude-mem/claude-mem.db`
|
||||
- Count total discovery_tokens across all observations (the original cost of all work)
|
||||
- Count sessions that had context injection available (sessions after the first)
|
||||
- Calculate the compression ratio: average discovery_tokens vs average read_tokens per observation
|
||||
- Identify the highest-value observations (highest discovery_tokens -- these are the most expensive decisions, bugs, and discoveries that memory prevents re-doing)
|
||||
- Identify explicit recall events (observations where source_tool contains "search", "smart_search", "get_observations", "timeline", or where narrative mentions "recalled", "from memory", "previous session")
|
||||
- Estimate passive recall savings: each session with context injection receives ~50 observations. Use a 30% relevance factor (conservative estimate that 30% of injected context prevents re-work). Savings = sessions_with_context × avg_discovery_value_of_50_obs_window × 0.30
|
||||
- Estimate explicit recall savings: ~10K tokens per explicit recall query
|
||||
- Calculate net ROI: total_savings / total_read_tokens_invested
|
||||
- Present as a table with monthly breakdown
|
||||
- Highlight the top 5 most expensive observations by discovery_tokens -- these represent the highest-value memories in the system (architecture decisions, hard bugs, implementation plans that cost 100K+ tokens to produce originally)
|
||||
|
||||
Use these SQL queries as a starting point:
|
||||
```sql
|
||||
-- Total discovery tokens
|
||||
SELECT SUM(discovery_tokens) FROM observations WHERE project = 'PROJECT_NAME';
|
||||
|
||||
-- Sessions with context available (not the first session)
|
||||
SELECT COUNT(DISTINCT memory_session_id) FROM observations WHERE project = 'PROJECT_NAME';
|
||||
|
||||
-- Average tokens per observation
|
||||
SELECT AVG(discovery_tokens) as avg_discovery, AVG(LENGTH(title || COALESCE(subtitle,'') || COALESCE(narrative,'') || COALESCE(facts,'')) / 4) as avg_read FROM observations WHERE project = 'PROJECT_NAME' AND discovery_tokens > 0;
|
||||
|
||||
-- Top 5 most expensive observations (highest-value memories)
|
||||
SELECT id, title, discovery_tokens FROM observations WHERE project = 'PROJECT_NAME' ORDER BY discovery_tokens DESC LIMIT 5;
|
||||
|
||||
-- Monthly breakdown
|
||||
SELECT strftime('%Y-%m', created_at) as month, COUNT(*) as obs, SUM(discovery_tokens) as total_discovery, COUNT(DISTINCT memory_session_id) as sessions FROM observations WHERE project = 'PROJECT_NAME' GROUP BY month ORDER BY month;
|
||||
|
||||
-- Explicit recall events
|
||||
SELECT COUNT(*) FROM observations WHERE project = 'PROJECT_NAME' AND (source_tool LIKE '%search%' OR source_tool LIKE '%timeline%' OR source_tool LIKE '%get_observations%' OR narrative LIKE '%recalled%' OR narrative LIKE '%from memory%' OR narrative LIKE '%previous session%');
|
||||
```
|
||||
|
||||
9. **Timeline Statistics** -- Quantitative summary:
|
||||
- Date range (first observation to last)
|
||||
- Total observations and sessions
|
||||
- Breakdown by observation type (features, bug fixes, discoveries, decisions, changes)
|
||||
- Most active days/weeks
|
||||
- Longest debugging sessions
|
||||
|
||||
10. **Lessons and Meta-Observations** -- What patterns emerge from the full history? What would a new developer learn about this codebase from reading the timeline? What recurring themes or principles guided development?
|
||||
|
||||
## Writing Style
|
||||
|
||||
- Write as a technical narrative, not a list of bullet points
|
||||
- Use specific observation IDs and timestamps when referencing events (e.g., "On Dec 14 (#26766), the root cause was finally identified...")
|
||||
- Connect events across time -- show how early decisions created later consequences
|
||||
- Be honest about struggles and dead ends, not just successes
|
||||
- Target 3,000-6,000 words depending on project size
|
||||
- Use markdown formatting with headers, emphasis, and code references where appropriate
|
||||
|
||||
## Important
|
||||
|
||||
- Analyze the ENTIRE timeline chronologically -- do not skip early history
|
||||
- Look for narrative arcs: problem -> investigation -> solution
|
||||
- Identify turning points where the project's direction fundamentally changed
|
||||
- Note any observations about the development process itself (tooling, workflow, collaboration patterns)
|
||||
|
||||
Here is the complete project timeline:
|
||||
|
||||
[TIMELINE CONTENT GOES HERE]
|
||||
```
|
||||
|
||||
### Step 5: Save the Report
|
||||
|
||||
Save the agent's output as a markdown file. Default location:
|
||||
|
||||
```
|
||||
./journey-into-PROJECT_NAME.md
|
||||
```
|
||||
|
||||
Or if the user specified a different output path, use that instead.
|
||||
|
||||
### Step 6: Report Completion
|
||||
|
||||
Tell the user:
|
||||
- Where the report was saved
|
||||
- The approximate token cost (input timeline + output report)
|
||||
- The date range covered
|
||||
- Number of observations analyzed
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Empty timeline:** "No observations found for project 'X'. Check the project name with: `curl -s 'http://localhost:37777/api/search?query=*&limit=1'`"
|
||||
- **Worker not running:** "The claude-mem worker is not responding on port 37777. Start it with your usual method or check `ps aux | grep worker-service`."
|
||||
- **Timeline too large:** For projects with 50,000+ observations, the timeline may exceed context limits. Suggest using date range filtering: `curl -s "http://localhost:37777/api/context/inject?project=X&full=true"` -- the current endpoint returns all observations; for extremely large projects, the user may want to analyze in time-windowed segments.
|
||||
|
||||
## Example
|
||||
|
||||
User: "Write a journey report for the tokyo project"
|
||||
|
||||
1. Fetch: `curl -s "http://localhost:37777/api/context/inject?project=tokyo&full=true"`
|
||||
2. Estimate: "Timeline fetched: ~34,722 observations, estimated ~718K tokens. Proceed?"
|
||||
3. User confirms
|
||||
4. Deploy analysis agent with full timeline
|
||||
5. Save to `./journey-into-tokyo.md`
|
||||
6. Report: "Report saved. Analyzed 34,722 observations spanning Oct 2025 - Mar 2026 (~718K input tokens, ~8K output tokens)."
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: claude-code-plugin-release
|
||||
description: Automated semantic versioning and release workflow for Claude Code plugins. Handles version increments across package.json, marketplace.json, and plugin.json, build verification, git tagging, GitHub releases, and changelog generation.
|
||||
---
|
||||
|
||||
# Version Bump & Release Workflow
|
||||
|
||||
**IMPORTANT:** You must first plan and write detailed release notes before starting the version bump workflow.
|
||||
|
||||
**CRITICAL:** ALWAYS commit EVERYTHING (including build artifacts). At the end of this workflow, NOTHING should be left uncommitted or unpushed. Run `git status` at the end to verify.
|
||||
|
||||
## Preparation
|
||||
|
||||
1. **Analyze**: Determine if the change is a **PATCH** (bug fixes), **MINOR** (features), or **MAJOR** (breaking) update.
|
||||
2. **Environment**: Identify the repository owner and name (e.g., from `git remote -v`).
|
||||
3. **Paths**: Verify existence of `package.json`, `.claude-plugin/marketplace.json`, and `plugin/.claude-plugin/plugin.json`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Update**: Increment version strings in all configuration files.
|
||||
2. **Verify**: Use `grep` to ensure all files match the new version.
|
||||
3. **Build**: Run `npm run build` to generate fresh artifacts.
|
||||
4. **Commit**: Stage all changes including artifacts: `git add -A && git commit -m "chore: bump version to X.Y.Z"`.
|
||||
5. **Tag**: Create an annotated tag: `git tag -a vX.Y.Z -m "Version X.Y.Z"`.
|
||||
6. **Push**: `git push origin main && git push origin vX.Y.Z`.
|
||||
7. **Release**: `gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES"`.
|
||||
8. **Changelog**: Regenerate `CHANGELOG.md` using the GitHub API and the provided script:
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/releases --paginate | ./scripts/generate_changelog.js > CHANGELOG.md
|
||||
```
|
||||
9. **Sync**: Commit and push the updated `CHANGELOG.md`.
|
||||
10. **Notify**: Run `npm run discord:notify vX.Y.Z` if applicable.
|
||||
11. **Finalize**: Run `git status` to ensure a clean working tree.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] All config files have matching versions
|
||||
- [ ] `npm run build` succeeded
|
||||
- [ ] Git tag created and pushed
|
||||
- [ ] GitHub release created with notes
|
||||
- [ ] `CHANGELOG.md` updated and pushed
|
||||
- [ ] `git status` shows clean tree
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Processes GitHub release JSON from stdin and outputs a formatted CHANGELOG.md
|
||||
*/
|
||||
function generate() {
|
||||
try {
|
||||
const input = fs.readFileSync(0, 'utf8');
|
||||
if (!input || input.trim() === '') {
|
||||
process.stderr.write('No input received on stdin
|
||||
');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releases = JSON.parse(input);
|
||||
const lines = ['# Changelog', '', 'All notable changes to this project.', ''];
|
||||
|
||||
releases.slice(0, 50).forEach(r => {
|
||||
const date = r.published_at.split('T')[0];
|
||||
lines.push(`## [${r.tag_name}] - ${date}`);
|
||||
lines.push('');
|
||||
if (r.body) lines.push(r.body.trim());
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
process.stdout.write(lines.join('
|
||||
') + '
|
||||
');
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error generating changelog: ${err.message}
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generate();
|
||||
+10
-10
File diff suppressed because one or more lines are too long
+124
-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;
|
||||
@@ -1483,6 +1570,10 @@
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1491,6 +1582,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 +1640,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 +1850,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 +2996,4 @@
|
||||
<script src="viewer-bundle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+182
-3
@@ -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',
|
||||
@@ -116,10 +173,17 @@ async function buildHooks() {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
banner: {
|
||||
js: '#!/usr/bin/env bun'
|
||||
js: [
|
||||
'#!/usr/bin/env bun',
|
||||
'var __filename = require("node:url").fileURLToPath(import.meta.url);',
|
||||
'var __dirname = require("node:path").dirname(__filename);'
|
||||
].join('\n')
|
||||
}
|
||||
});
|
||||
|
||||
// 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`);
|
||||
@@ -148,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}"`
|
||||
@@ -157,6 +236,9 @@ 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`);
|
||||
@@ -176,12 +258,99 @@ async function buildHooks() {
|
||||
external: ['bun:sqlite'],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
}
|
||||
},
|
||||
// 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)`);
|
||||
|
||||
// Build NPX CLI (pure Node.js — no Bun dependency)
|
||||
console.log(`\n🔧 Building NPX CLI...`);
|
||||
const npxCliOutDir = 'dist/npx-cli';
|
||||
if (!fs.existsSync(npxCliOutDir)) {
|
||||
fs.mkdirSync(npxCliOutDir, { recursive: true });
|
||||
}
|
||||
await build({
|
||||
entryPoints: ['src/npx-cli/index.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'esm',
|
||||
outfile: `${npxCliOutDir}/index.js`,
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
minify: true,
|
||||
logLevel: 'error',
|
||||
external: [
|
||||
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||
'buffer', 'querystring', 'readline', 'tty', 'assert',
|
||||
],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
});
|
||||
|
||||
// Make NPX CLI executable
|
||||
fs.chmodSync(`${npxCliOutDir}/index.js`, 0o755);
|
||||
const npxCliStats = fs.statSync(`${npxCliOutDir}/index.js`);
|
||||
console.log(`✓ npx-cli built (${(npxCliStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Build OpenClaw plugin (self-contained, only Node builtins external)
|
||||
if (fs.existsSync('openclaw/src/index.ts')) {
|
||||
console.log(`\n🔧 Building OpenClaw plugin...`);
|
||||
const openclawOutDir = 'openclaw/dist';
|
||||
if (!fs.existsSync(openclawOutDir)) {
|
||||
fs.mkdirSync(openclawOutDir, { recursive: true });
|
||||
}
|
||||
await build({
|
||||
entryPoints: ['openclaw/src/index.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'esm',
|
||||
outfile: `${openclawOutDir}/index.js`,
|
||||
minify: true,
|
||||
logLevel: 'error',
|
||||
external: [
|
||||
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||
],
|
||||
});
|
||||
|
||||
const openclawStats = fs.statSync(`${openclawOutDir}/index.js`);
|
||||
console.log(`✓ openclaw plugin built (${(openclawStats.size / 1024).toFixed(2)} KB)`);
|
||||
}
|
||||
|
||||
// Build OpenCode plugin (self-contained, Node.js ESM — Bun-compatible)
|
||||
if (fs.existsSync('src/integrations/opencode-plugin/index.ts')) {
|
||||
console.log(`\n🔧 Building OpenCode plugin...`);
|
||||
const opencodeOutDir = 'dist/opencode-plugin';
|
||||
if (!fs.existsSync(opencodeOutDir)) {
|
||||
fs.mkdirSync(opencodeOutDir, { recursive: true });
|
||||
}
|
||||
await build({
|
||||
entryPoints: ['src/integrations/opencode-plugin/index.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'esm',
|
||||
outfile: `${opencodeOutDir}/index.js`,
|
||||
minify: true,
|
||||
logLevel: 'error',
|
||||
external: [
|
||||
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||
],
|
||||
});
|
||||
|
||||
const opencodeStats = fs.statSync(`${opencodeOutDir}/index.js`);
|
||||
console.log(`✓ opencode plugin built (${(opencodeStats.size / 1024).toFixed(2)} KB)`);
|
||||
}
|
||||
|
||||
// Verify critical distribution files exist (skills are source files, not build outputs)
|
||||
console.log('\n📋 Verifying distribution files...');
|
||||
const requiredDistributionFiles = [
|
||||
@@ -197,11 +366,21 @@ async function buildHooks() {
|
||||
}
|
||||
console.log('✓ All required distribution files present');
|
||||
|
||||
console.log('\n✅ Worker service, MCP server, and context generator built successfully!');
|
||||
console.log('\n✅ All build targets compiled successfully!');
|
||||
console.log(` Output: ${hooksDir}/`);
|
||||
console.log(` - Worker: worker-service.cjs`);
|
||||
console.log(` - MCP Server: mcp-server.cjs`);
|
||||
console.log(` - Context Generator: context-generator.cjs`);
|
||||
console.log(` Output: ${npxCliOutDir}/`);
|
||||
console.log(` - NPX CLI: index.js`);
|
||||
if (fs.existsSync('openclaw/dist/index.js')) {
|
||||
console.log(` Output: openclaw/dist/`);
|
||||
console.log(` - OpenClaw Plugin: index.js`);
|
||||
}
|
||||
if (fs.existsSync('dist/opencode-plugin/index.js')) {
|
||||
console.log(` Output: dist/opencode-plugin/`);
|
||||
console.log(` - OpenCode Plugin: index.js`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Build failed:', error.message);
|
||||
|
||||
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
|
||||
@@ -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();
|
||||
@@ -16,13 +16,20 @@ export const claudeCodeAdapter: PlatformAdapter = {
|
||||
};
|
||||
},
|
||||
formatOutput(result) {
|
||||
if (result.hookSpecificOutput) {
|
||||
const r = result ?? ({} as HookResult);
|
||||
if (r.hookSpecificOutput) {
|
||||
const output: Record<string, unknown> = { hookSpecificOutput: result.hookSpecificOutput };
|
||||
if (result.systemMessage) {
|
||||
output.systemMessage = result.systemMessage;
|
||||
if (r.systemMessage) {
|
||||
output.systemMessage = r.systemMessage;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return { continue: result.continue ?? true, suppressOutput: result.suppressOutput ?? true };
|
||||
// Only emit fields in the Claude Code hook contract — unrecognized fields
|
||||
// cause "JSON validation failed" in Stop hooks.
|
||||
const output: Record<string, unknown> = {};
|
||||
if (r.systemMessage) {
|
||||
output.systemMessage = r.systemMessage;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PlatformAdapter } from '../types.js';
|
||||
|
||||
/**
|
||||
* Gemini CLI Platform Adapter
|
||||
*
|
||||
* Normalizes Gemini CLI's hook JSON to NormalizedHookInput.
|
||||
* Gemini CLI supports 11 lifecycle hooks; we register 8:
|
||||
*
|
||||
* Lifecycle:
|
||||
* SessionStart → context (inject memory context)
|
||||
* SessionEnd → session-complete
|
||||
* PreCompress → summarize
|
||||
* Notification → observation (system events like ToolPermission)
|
||||
*
|
||||
* Agent:
|
||||
* BeforeAgent → user-message (captures user prompt)
|
||||
* AfterAgent → observation (full agent response)
|
||||
*
|
||||
* Tool:
|
||||
* BeforeTool → observation (tool intent before execution)
|
||||
* AfterTool → observation (tool result after execution)
|
||||
*
|
||||
* Unmapped (not useful for memory):
|
||||
* BeforeModel, AfterModel, BeforeToolSelection — model-level events
|
||||
* that fire per-LLM-call, too chatty for observation capture.
|
||||
*
|
||||
* Base fields (all events): session_id, transcript_path, cwd, hook_event_name, timestamp
|
||||
*
|
||||
* Output format: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
|
||||
* Advisory hooks (SessionStart, SessionEnd, PreCompress, Notification) ignore flow-control fields.
|
||||
*/
|
||||
export const geminiCliAdapter: PlatformAdapter = {
|
||||
normalizeInput(raw) {
|
||||
const r = (raw ?? {}) as any;
|
||||
|
||||
// CWD resolution chain: JSON field → env vars → process.cwd()
|
||||
const cwd = r.cwd
|
||||
?? process.env.GEMINI_CWD
|
||||
?? process.env.GEMINI_PROJECT_DIR
|
||||
?? process.env.CLAUDE_PROJECT_DIR
|
||||
?? process.cwd();
|
||||
|
||||
const sessionId = r.session_id
|
||||
?? process.env.GEMINI_SESSION_ID
|
||||
?? undefined;
|
||||
|
||||
const hookEventName: string | undefined = r.hook_event_name;
|
||||
|
||||
// Tool fields — present in BeforeTool, AfterTool
|
||||
let toolName: string | undefined = r.tool_name;
|
||||
let toolInput: unknown = r.tool_input;
|
||||
let toolResponse: unknown = r.tool_response;
|
||||
|
||||
// AfterAgent: synthesize observation shape from the full agent response
|
||||
if (hookEventName === 'AfterAgent' && r.prompt_response) {
|
||||
toolName = toolName ?? 'GeminiAgent';
|
||||
toolInput = toolInput ?? { prompt: r.prompt };
|
||||
toolResponse = toolResponse ?? { response: r.prompt_response };
|
||||
}
|
||||
|
||||
// BeforeTool: has tool_name and tool_input but no tool_response yet
|
||||
// Synthesize a marker so observation handler knows this is pre-execution
|
||||
if (hookEventName === 'BeforeTool' && toolName && !toolResponse) {
|
||||
toolResponse = { _preExecution: true };
|
||||
}
|
||||
|
||||
// Notification: capture as an observation with notification details
|
||||
if (hookEventName === 'Notification') {
|
||||
toolName = toolName ?? 'GeminiNotification';
|
||||
toolInput = toolInput ?? {
|
||||
notification_type: r.notification_type,
|
||||
message: r.message,
|
||||
};
|
||||
toolResponse = toolResponse ?? { details: r.details };
|
||||
}
|
||||
|
||||
// Collect platform-specific metadata
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (r.source) metadata.source = r.source; // SessionStart: startup|resume|clear
|
||||
if (r.reason) metadata.reason = r.reason; // SessionEnd: exit|clear|logout|...
|
||||
if (r.trigger) metadata.trigger = r.trigger; // PreCompress: auto|manual
|
||||
if (r.mcp_context) metadata.mcp_context = r.mcp_context; // Tool hooks: MCP server context
|
||||
if (r.notification_type) metadata.notification_type = r.notification_type;
|
||||
if (r.stop_hook_active !== undefined) metadata.stop_hook_active = r.stop_hook_active;
|
||||
if (r.original_request_name) metadata.original_request_name = r.original_request_name;
|
||||
if (hookEventName) metadata.hook_event_name = hookEventName;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
cwd,
|
||||
prompt: r.prompt,
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResponse,
|
||||
transcriptPath: r.transcript_path,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
formatOutput(result) {
|
||||
// Gemini CLI expects: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
// Flow control — always include `continue` to prevent accidental agent termination
|
||||
output.continue = result.continue ?? true;
|
||||
|
||||
if (result.suppressOutput !== undefined) {
|
||||
output.suppressOutput = result.suppressOutput;
|
||||
}
|
||||
|
||||
if (result.systemMessage) {
|
||||
// Strip ANSI escape sequences: matches colors, text formatting, and terminal control codes
|
||||
// Gemini CLI often has issues with ANSI escape sequences in tool output (showing them as raw text)
|
||||
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
output.systemMessage = result.systemMessage.replace(ansiRegex, '');
|
||||
}
|
||||
|
||||
// hookSpecificOutput is a first-class Gemini CLI field — pass through directly
|
||||
// This includes additionalContext for context injection in SessionStart, BeforeAgent, AfterTool
|
||||
if (result.hookSpecificOutput) {
|
||||
output.hookSpecificOutput = {
|
||||
additionalContext: result.hookSpecificOutput.additionalContext,
|
||||
};
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
};
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { PlatformAdapter } from '../types.js';
|
||||
import { claudeCodeAdapter } from './claude-code.js';
|
||||
import { cursorAdapter } from './cursor.js';
|
||||
import { geminiCliAdapter } from './gemini-cli.js';
|
||||
import { rawAdapter } from './raw.js';
|
||||
import { windsurfAdapter } from './windsurf.js';
|
||||
|
||||
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
switch (platform) {
|
||||
case 'claude-code': return claudeCodeAdapter;
|
||||
case 'cursor': return cursorAdapter;
|
||||
case 'gemini':
|
||||
case 'gemini-cli': return geminiCliAdapter;
|
||||
case 'windsurf': return windsurfAdapter;
|
||||
case 'raw': return rawAdapter;
|
||||
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
|
||||
default: return rawAdapter;
|
||||
}
|
||||
}
|
||||
|
||||
export { claudeCodeAdapter, cursorAdapter, rawAdapter };
|
||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
|
||||
|
||||
// Maps Windsurf stdin format — JSON envelope with agent_action_name + tool_info payload
|
||||
//
|
||||
// Common envelope (all hooks):
|
||||
// { agent_action_name, trajectory_id, execution_id, timestamp, tool_info: { ... } }
|
||||
//
|
||||
// Event-specific tool_info payloads:
|
||||
// pre_user_prompt: { user_prompt: string }
|
||||
// post_write_code: { file_path, edits: [{ old_string, new_string }] }
|
||||
// post_run_command: { command_line, cwd }
|
||||
// post_mcp_tool_use: { mcp_server_name, mcp_tool_name, mcp_tool_arguments, mcp_result }
|
||||
// post_cascade_response: { response }
|
||||
export const windsurfAdapter: PlatformAdapter = {
|
||||
normalizeInput(raw) {
|
||||
const r = (raw ?? {}) as any;
|
||||
const toolInfo = r.tool_info ?? {};
|
||||
const actionName: string = r.agent_action_name ?? '';
|
||||
|
||||
const base: NormalizedHookInput = {
|
||||
sessionId: r.trajectory_id ?? r.execution_id,
|
||||
cwd: toolInfo.cwd ?? process.cwd(),
|
||||
platform: 'windsurf',
|
||||
};
|
||||
|
||||
switch (actionName) {
|
||||
case 'pre_user_prompt':
|
||||
return {
|
||||
...base,
|
||||
prompt: toolInfo.user_prompt,
|
||||
};
|
||||
|
||||
case 'post_write_code':
|
||||
return {
|
||||
...base,
|
||||
toolName: 'Write',
|
||||
filePath: toolInfo.file_path,
|
||||
edits: toolInfo.edits,
|
||||
toolInput: {
|
||||
file_path: toolInfo.file_path,
|
||||
edits: toolInfo.edits,
|
||||
},
|
||||
};
|
||||
|
||||
case 'post_run_command':
|
||||
return {
|
||||
...base,
|
||||
cwd: toolInfo.cwd ?? base.cwd,
|
||||
toolName: 'Bash',
|
||||
toolInput: { command: toolInfo.command_line },
|
||||
};
|
||||
|
||||
case 'post_mcp_tool_use':
|
||||
return {
|
||||
...base,
|
||||
toolName: toolInfo.mcp_tool_name ?? 'mcp_tool',
|
||||
toolInput: toolInfo.mcp_tool_arguments,
|
||||
toolResponse: toolInfo.mcp_result,
|
||||
};
|
||||
|
||||
case 'post_cascade_response':
|
||||
return {
|
||||
...base,
|
||||
toolName: 'cascade_response',
|
||||
toolResponse: toolInfo.response,
|
||||
};
|
||||
|
||||
default:
|
||||
// Unknown action — pass through what we can
|
||||
return base;
|
||||
}
|
||||
},
|
||||
|
||||
formatOutput(result) {
|
||||
// Windsurf exit codes: 0 = success, 2 = block (pre-hooks only)
|
||||
// The CLI layer handles exit codes; here we just return a simple continue flag
|
||||
return { continue: result.continue ?? true };
|
||||
},
|
||||
};
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, getWorkerPort, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
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,16 +40,16 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
// Pass all projects (parent + worktree if applicable) for unified timeline
|
||||
const projectsParam = context.allProjects.join(',');
|
||||
const url = `http://127.0.0.1:${port}/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&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)
|
||||
// Worker service has its own timeouts, so client-side timeout is redundant
|
||||
try {
|
||||
// Fetch markdown (for Claude context) and optionally colored (for user display)
|
||||
const colorUrl = `${url}&colors=true`;
|
||||
const [response, colorResponse] = await Promise.all([
|
||||
fetch(url),
|
||||
showTerminalOutput ? fetch(colorUrl).catch(() => null) : Promise.resolve(null)
|
||||
workerHttpRequest(apiPath),
|
||||
showTerminalOutput ? workerHttpRequest(colorApiPath).catch(() => null) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -66,9 +68,15 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
const additionalContext = contextResult.trim();
|
||||
const coloredTimeline = colorResult.trim();
|
||||
const platform = input.platform;
|
||||
|
||||
const systemMessage = showTerminalOutput && coloredTimeline
|
||||
? `${coloredTimeline}\n\nView Observations Live @ http://localhost:${port}`
|
||||
// Use colored timeline for display if available, otherwise fall back to
|
||||
// plain markdown context (especially useful for platforms like Gemini
|
||||
// where we want to ensure visibility even if colors aren't fetched).
|
||||
const displayContent = coloredTimeline || (platform === 'gemini-cli' || platform === 'gemini' ? additionalContext : '');
|
||||
|
||||
const systemMessage = showTerminalOutput && displayContent
|
||||
? `${displayContent}\n\nView Observations Live @ http://localhost:${port}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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): 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 lines: string[] = [
|
||||
`Current: ${currentDate} ${currentTime} ${currentTimezone}`,
|
||||
`This file has prior observations. Only line 1 was read to save tokens.`,
|
||||
`- **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 };
|
||||
}
|
||||
|
||||
// Skip gate for files below the token-economics threshold — timeline (~370 tokens)
|
||||
// costs more than reading small files directly.
|
||||
try {
|
||||
const statPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(input.cwd || process.cwd(), filePath);
|
||||
const stat = statSync(statPath);
|
||||
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
} 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 };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// Allow the read with limit: 1 line — just enough for Edit's "file must be read"
|
||||
// check to pass, while keeping token cost near zero. The observation timeline
|
||||
// gives Claude full context about prior work on this file.
|
||||
const timeline = formatFileTimeline(dedupedObservations, filePath);
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: timeline,
|
||||
permissionDecision: 'allow',
|
||||
updatedInput: {
|
||||
file_path: filePath,
|
||||
limit: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('HOOK', 'File context fetch error, skipping', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -6,9 +6,10 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const fileEditHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -20,15 +21,13 @@ export const fileEditHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId, cwd, filePath, edits } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('fileEditHandler requires filePath');
|
||||
}
|
||||
|
||||
const port = getWorkerPort();
|
||||
|
||||
logger.dataIn('HOOK', `FileEdit: ${filePath}`, {
|
||||
workerPort: port,
|
||||
editCount: edits?.length ?? 0
|
||||
});
|
||||
|
||||
@@ -40,17 +39,17 @@ export const fileEditHandler: EventHandler = {
|
||||
// Send to worker as an observation with file edit metadata
|
||||
// The observation handler on the worker will process this appropriately
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
const response = await workerHttpRequest('/api/sessions/observations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
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,19 +23,16 @@ 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
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const port = getWorkerPort();
|
||||
|
||||
const toolStr = logger.formatTool(toolName, toolInput);
|
||||
|
||||
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
|
||||
workerPort: port
|
||||
});
|
||||
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {});
|
||||
|
||||
// Validate required fields before sending to worker
|
||||
if (!cwd) {
|
||||
@@ -50,17 +48,17 @@ export const observationHandler: EventHandler = {
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
const response = await workerHttpRequest('/api/sessions/observations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -23,7 +24,7 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId } = input;
|
||||
const port = getWorkerPort();
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||
@@ -31,17 +32,17 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
}
|
||||
|
||||
logger.info('HOOK', '→ session-complete: Removing session from active map', {
|
||||
workerPort: port,
|
||||
contentSessionId: sessionId
|
||||
});
|
||||
|
||||
try {
|
||||
// Call the session complete endpoint by contentSessionId
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/complete`, {
|
||||
const response = await workerHttpRequest('/api/sessions/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId
|
||||
contentSessionId: sessionId,
|
||||
platformSource
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { getProjectName } from '../../utils/project-name.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
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> {
|
||||
@@ -42,20 +43,20 @@ export const sessionInitHandler: EventHandler = {
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
|
||||
const project = getProjectName(cwd);
|
||||
const port = getWorkerPort();
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
|
||||
|
||||
// Initialize session via HTTP - handles DB operations and privacy checks
|
||||
const initResponse = await fetch(`http://127.0.0.1:${port}/api/sessions/init`, {
|
||||
const initResponse = await workerHttpRequest('/api/sessions/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
project,
|
||||
prompt
|
||||
prompt,
|
||||
platformSource
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
@@ -89,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;
|
||||
@@ -107,25 +109,68 @@ export const sessionInitHandler: EventHandler = {
|
||||
logger.debug('HOOK', 'session-init: Calling /sessions/{sessionDbId}/init', { sessionDbId, promptNumber });
|
||||
|
||||
// Initialize SDK agent session via HTTP (starts the agent!)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
|
||||
const response = await workerHttpRequest(`/sessions/${sessionDbId}/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userPrompt: cleanedPrompt, promptNumber })
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* Summarize Handler - Stop
|
||||
*
|
||||
* Extracted from summary-hook.ts - sends summary request to worker.
|
||||
* Transcript parsing stays in the hook because only the hook has access to
|
||||
* the transcript file path.
|
||||
* Runs in the Stop hook (120s timeout, not capped like SessionEnd).
|
||||
* This is the ONLY place where we can reliably wait for async work.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Queue summarize request to worker
|
||||
* 2. Poll worker until summary processing completes
|
||||
* 3. Call /api/sessions/complete to clean up session
|
||||
*
|
||||
* SessionEnd (1.5s cap from Claude Code) is just a lightweight fallback —
|
||||
* all real work must happen here in Stop.
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort, fetchWithTimeout } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { extractLastMessage } from '../../shared/transcript-parser.js';
|
||||
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
||||
|
||||
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120s timeout
|
||||
|
||||
export const summarizeHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -25,8 +34,6 @@ export const summarizeHandler: EventHandler = {
|
||||
|
||||
const { sessionId, transcriptPath } = input;
|
||||
|
||||
const port = getWorkerPort();
|
||||
|
||||
// Validate required fields before processing
|
||||
if (!transcriptPath) {
|
||||
// No transcript available - skip summary gracefully (not an error)
|
||||
@@ -37,33 +44,83 @@ export const summarizeHandler: EventHandler = {
|
||||
// Extract last assistant message from transcript (the work Claude did)
|
||||
// Note: "user" messages in transcripts are mostly tool_results, not actual user input.
|
||||
// The user's original request is already stored in user_prompts table.
|
||||
const lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
let lastAssistantMessage = '';
|
||||
try {
|
||||
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
} catch (err) {
|
||||
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
// 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', {
|
||||
workerPort: port,
|
||||
hasLastAssistantMessage: !!lastAssistantMessage
|
||||
});
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/sessions/summarize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
}),
|
||||
},
|
||||
SUMMARIZE_TIMEOUT_MS
|
||||
);
|
||||
// 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
|
||||
}),
|
||||
timeoutMs: SUMMARIZE_TIMEOUT_MS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Return standard response even on failure (matches original behavior)
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'Summary request sent successfully');
|
||||
logger.debug('HOOK', 'Summary request queued, waiting for completion');
|
||||
|
||||
// 2. Poll worker until pending work for this session is done.
|
||||
// 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();
|
||||
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', {
|
||||
waitedMs: Date.now() - waitStart
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Worker may be busy — keep polling
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Complete the session — clean up active sessions map.
|
||||
// This runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
|
||||
// so it reliably fires after summary work is done.
|
||||
try {
|
||||
await workerHttpRequest('/api/sessions/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contentSessionId: sessionId }),
|
||||
timeoutMs: 10_000
|
||||
});
|
||||
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
|
||||
} catch (err) {
|
||||
logger.warn('HOOK', `Stop hook: session-complete failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { basename } from 'path';
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, getWorkerPort, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
|
||||
export const userMessageHandler: EventHandler = {
|
||||
@@ -23,11 +23,11 @@ export const userMessageHandler: EventHandler = {
|
||||
const project = basename(input.cwd ?? process.cwd());
|
||||
|
||||
// Fetch formatted context directly from worker API
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
// Only request ANSI colors for platforms that render them (claude-code)
|
||||
const colorsParam = input.platform === 'claude-code' ? '&colors=true' : '';
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
|
||||
{ method: 'GET' }
|
||||
const response = await workerHttpRequest(
|
||||
`/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
+10
-2
@@ -1,7 +1,7 @@
|
||||
export interface NormalizedHookInput {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
platform?: string; // 'claude-code' or 'cursor'
|
||||
platform?: string; // 'claude-code', 'cursor', 'gemini-cli', etc.
|
||||
prompt?: string;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
@@ -10,12 +10,20 @@ export interface NormalizedHookInput {
|
||||
// Cursor-specific fields
|
||||
filePath?: string; // afterFileEdit
|
||||
edits?: unknown[]; // afterFileEdit
|
||||
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Observation metadata constants
|
||||
* Shared across hooks, worker service, and UI components
|
||||
*
|
||||
* Note: These are fallback defaults for the code mode.
|
||||
* Actual observation types and concepts are defined per-mode in the modes/ directory.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default observation types (comma-separated string for settings)
|
||||
* Uses code mode defaults as fallback
|
||||
*/
|
||||
export const DEFAULT_OBSERVATION_TYPES_STRING = 'bugfix,feature,refactor,discovery,decision,change';
|
||||
|
||||
/**
|
||||
* Default observation concepts (comma-separated string for settings)
|
||||
* Uses code mode defaults as fallback
|
||||
*/
|
||||
export const DEFAULT_OBSERVATION_CONCEPTS_STRING = 'how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off';
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* OpenCode Plugin for claude-mem
|
||||
*
|
||||
* Integrates claude-mem persistent memory with OpenCode (110k+ stars).
|
||||
* Runs inside OpenCode's Bun-based plugin runtime.
|
||||
*
|
||||
* Plugin hooks:
|
||||
* - tool.execute.after: Captures tool execution observations
|
||||
* - Bus events: session.created, message.updated, session.compacted,
|
||||
* file.edited, session.deleted
|
||||
*
|
||||
* Custom tool:
|
||||
* - claude_mem_search: Search memory database from within OpenCode
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Minimal type declarations for OpenCode Plugin SDK
|
||||
// These match the runtime API provided by @opencode-ai/plugin
|
||||
// ============================================================================
|
||||
|
||||
interface OpenCodeProject {
|
||||
name?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface OpenCodePluginContext {
|
||||
client: unknown;
|
||||
project: OpenCodeProject;
|
||||
directory: string;
|
||||
worktree: string;
|
||||
serverUrl: URL;
|
||||
$: unknown; // BunShell
|
||||
}
|
||||
|
||||
interface ToolExecuteAfterInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ToolExecuteAfterOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ToolDefinition {
|
||||
description: string;
|
||||
args: Record<string, unknown>;
|
||||
execute: (args: Record<string, unknown>, context: unknown) => Promise<string>;
|
||||
}
|
||||
|
||||
// Bus event payloads
|
||||
interface SessionCreatedEvent {
|
||||
event: {
|
||||
sessionID: string;
|
||||
directory?: string;
|
||||
project?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MessageUpdatedEvent {
|
||||
event: {
|
||||
sessionID: string;
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionCompactedEvent {
|
||||
event: {
|
||||
sessionID: string;
|
||||
summary?: string;
|
||||
messageCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface FileEditedEvent {
|
||||
event: {
|
||||
sessionID: string;
|
||||
path: string;
|
||||
diff?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionDeletedEvent {
|
||||
event: {
|
||||
sessionID: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const WORKER_BASE_URL = "http://127.0.0.1:37777";
|
||||
const MAX_TOOL_RESPONSE_LENGTH = 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Worker HTTP Client
|
||||
// ============================================================================
|
||||
|
||||
async function workerPost(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const response = await fetch(`${WORKER_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
} catch (error: unknown) {
|
||||
// Gracefully handle ECONNREFUSED — worker may not be running
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("ECONNREFUSED")) {
|
||||
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function workerPostFireAndForget(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): void {
|
||||
fetch(`${WORKER_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("ECONNREFUSED")) {
|
||||
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function workerGetText(path: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${WORKER_BASE_URL}${path}`);
|
||||
if (!response.ok) {
|
||||
console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
return await response.text();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("ECONNREFUSED")) {
|
||||
console.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session tracking
|
||||
// ============================================================================
|
||||
|
||||
const contentSessionIdsByOpenCodeSessionId = new Map<string, string>();
|
||||
|
||||
const MAX_SESSION_MAP_ENTRIES = 1000;
|
||||
|
||||
function getOrCreateContentSessionId(openCodeSessionId: string): string {
|
||||
if (!contentSessionIdsByOpenCodeSessionId.has(openCodeSessionId)) {
|
||||
// Evict oldest entries when the map exceeds the cap (Map preserves insertion order)
|
||||
while (contentSessionIdsByOpenCodeSessionId.size >= MAX_SESSION_MAP_ENTRIES) {
|
||||
const oldestKey = contentSessionIdsByOpenCodeSessionId.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
contentSessionIdsByOpenCodeSessionId.delete(oldestKey);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
contentSessionIdsByOpenCodeSessionId.set(
|
||||
openCodeSessionId,
|
||||
`opencode-${openCodeSessionId}-${Date.now()}`,
|
||||
);
|
||||
}
|
||||
return contentSessionIdsByOpenCodeSessionId.get(openCodeSessionId)!;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Entry Point
|
||||
// ============================================================================
|
||||
|
||||
export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => {
|
||||
const projectName = ctx.project?.name || "opencode";
|
||||
|
||||
console.log(`[claude-mem] OpenCode plugin loading (project: ${projectName})`);
|
||||
|
||||
return {
|
||||
// ------------------------------------------------------------------
|
||||
// Direct interceptor hooks
|
||||
// ------------------------------------------------------------------
|
||||
hooks: {
|
||||
tool: {
|
||||
execute: {
|
||||
after: (
|
||||
input: ToolExecuteAfterInput,
|
||||
output: ToolExecuteAfterOutput,
|
||||
) => {
|
||||
const contentSessionId = getOrCreateContentSessionId(input.sessionID);
|
||||
|
||||
// Truncate long tool output
|
||||
let toolResponseText = output.output || "";
|
||||
if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) {
|
||||
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||
}
|
||||
|
||||
workerPostFireAndForget("/api/sessions/observations", {
|
||||
contentSessionId,
|
||||
tool_name: input.tool,
|
||||
tool_input: input.args || {},
|
||||
tool_response: toolResponseText,
|
||||
cwd: ctx.directory,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Bus event handlers
|
||||
// ------------------------------------------------------------------
|
||||
event: (eventName: string, payload: unknown) => {
|
||||
switch (eventName) {
|
||||
case "session.created": {
|
||||
const { event } = payload as SessionCreatedEvent;
|
||||
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||
|
||||
workerPostFireAndForget("/api/sessions/init", {
|
||||
contentSessionId,
|
||||
project: projectName,
|
||||
prompt: "",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "message.updated": {
|
||||
const { event } = payload as MessageUpdatedEvent;
|
||||
|
||||
// Only capture assistant messages as observations
|
||||
if (event.role !== "assistant") break;
|
||||
|
||||
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||
|
||||
let messageText = event.content || "";
|
||||
if (messageText.length > MAX_TOOL_RESPONSE_LENGTH) {
|
||||
messageText = messageText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||
}
|
||||
|
||||
workerPostFireAndForget("/api/sessions/observations", {
|
||||
contentSessionId,
|
||||
tool_name: "assistant_message",
|
||||
tool_input: {},
|
||||
tool_response: messageText,
|
||||
cwd: ctx.directory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.compacted": {
|
||||
const { event } = payload as SessionCompactedEvent;
|
||||
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||
|
||||
workerPostFireAndForget("/api/sessions/summarize", {
|
||||
contentSessionId,
|
||||
last_assistant_message: event.summary || "",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "file.edited": {
|
||||
const { event } = payload as FileEditedEvent;
|
||||
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||
|
||||
workerPostFireAndForget("/api/sessions/observations", {
|
||||
contentSessionId,
|
||||
tool_name: "file_edit",
|
||||
tool_input: { path: event.path },
|
||||
tool_response: event.diff
|
||||
? event.diff.slice(0, MAX_TOOL_RESPONSE_LENGTH)
|
||||
: `File edited: ${event.path}`,
|
||||
cwd: ctx.directory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.deleted": {
|
||||
const { event } = payload as SessionDeletedEvent;
|
||||
const contentSessionId = contentSessionIdsByOpenCodeSessionId.get(
|
||||
event.sessionID,
|
||||
);
|
||||
|
||||
if (contentSessionId) {
|
||||
workerPostFireAndForget("/api/sessions/complete", {
|
||||
contentSessionId,
|
||||
});
|
||||
contentSessionIdsByOpenCodeSessionId.delete(event.sessionID);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Custom tools
|
||||
// ------------------------------------------------------------------
|
||||
tool: {
|
||||
claude_mem_search: {
|
||||
description:
|
||||
"Search claude-mem memory database for past observations, sessions, and context",
|
||||
args: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Search query for memory observations",
|
||||
},
|
||||
},
|
||||
async execute(
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const query = String(args.query || "");
|
||||
if (!query) {
|
||||
return "Please provide a search query.";
|
||||
}
|
||||
|
||||
const text = await workerGetText(
|
||||
`/api/search/observations?query=${encodeURIComponent(query)}&limit=10`,
|
||||
);
|
||||
|
||||
if (!text) {
|
||||
return "claude-mem worker is not running. Start it with: npx claude-mem start";
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
if (items.length === 0) {
|
||||
return `No results found for "${query}".`;
|
||||
}
|
||||
|
||||
return items
|
||||
.slice(0, 10)
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
const title = String(item.title || item.subtitle || "Untitled");
|
||||
const project = item.project ? ` [${String(item.project)}]` : "";
|
||||
return `${index + 1}. ${title}${project}`;
|
||||
})
|
||||
.join("\n");
|
||||
} catch {
|
||||
return "Failed to parse search results.";
|
||||
}
|
||||
},
|
||||
} satisfies ToolDefinition,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default ClaudeMemPlugin;
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* IDE Auto-Detection
|
||||
*
|
||||
* Detects which AI coding IDEs / tools are installed on the system by
|
||||
* probing known config directories and checking for binaries in PATH.
|
||||
*
|
||||
* Pure Node.js — no Bun APIs used.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { IS_WINDOWS } from '../utils/paths.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IDE type and metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IDEInfo {
|
||||
/** Machine-readable identifier. */
|
||||
id: string;
|
||||
/** Human-readable label for display in prompts. */
|
||||
label: string;
|
||||
/** Whether the IDE was detected on this system. */
|
||||
detected: boolean;
|
||||
/** Whether claude-mem has implemented setup for this IDE. */
|
||||
supported: boolean;
|
||||
/** Short hint text shown in the multi-select. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATH helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isCommandInPath(command: string): boolean {
|
||||
try {
|
||||
const whichCommand = IS_WINDOWS ? 'where' : 'which';
|
||||
execSync(`${whichCommand} ${command}`, { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VS Code extension directory scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hasVscodeExtension(extensionNameFragment: string): boolean {
|
||||
const extensionsDirectory = join(homedir(), '.vscode', 'extensions');
|
||||
if (!existsSync(extensionsDirectory)) return false;
|
||||
try {
|
||||
const entries = readdirSync(extensionsDirectory);
|
||||
return entries.some((entry) => entry.toLowerCase().includes(extensionNameFragment.toLowerCase()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect all known IDEs and return an array of `IDEInfo` objects.
|
||||
* Each entry indicates whether the IDE was found and whether claude-mem
|
||||
* currently supports setting it up.
|
||||
*/
|
||||
export function detectInstalledIDEs(): IDEInfo[] {
|
||||
const home = homedir();
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'claude-code',
|
||||
label: 'Claude Code',
|
||||
detected: existsSync(join(home, '.claude')),
|
||||
supported: true,
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
id: 'gemini-cli',
|
||||
label: 'Gemini CLI',
|
||||
detected: existsSync(join(home, '.gemini')),
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
detected:
|
||||
existsSync(join(home, '.config', 'opencode')) || isCommandInPath('opencode'),
|
||||
supported: true,
|
||||
hint: 'plugin-based integration',
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
label: 'OpenClaw',
|
||||
detected: existsSync(join(home, '.openclaw')),
|
||||
supported: true,
|
||||
hint: 'plugin-based integration',
|
||||
},
|
||||
{
|
||||
id: 'windsurf',
|
||||
label: 'Windsurf',
|
||||
detected: existsSync(join(home, '.codeium', 'windsurf')),
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
id: 'codex-cli',
|
||||
label: 'Codex CLI',
|
||||
detected: existsSync(join(home, '.codex')),
|
||||
supported: true,
|
||||
hint: 'transcript-based integration',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
detected: existsSync(join(home, '.cursor')),
|
||||
supported: true,
|
||||
hint: 'hooks + MCP integration',
|
||||
},
|
||||
{
|
||||
id: 'copilot-cli',
|
||||
label: 'Copilot CLI',
|
||||
detected: isCommandInPath('copilot'),
|
||||
supported: true,
|
||||
hint: 'MCP-based integration',
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
detected: existsSync(join(home, '.gemini', 'antigravity')),
|
||||
supported: true,
|
||||
hint: 'MCP-based integration',
|
||||
},
|
||||
{
|
||||
id: 'goose',
|
||||
label: 'Goose',
|
||||
detected:
|
||||
existsSync(join(home, '.config', 'goose')) || isCommandInPath('goose'),
|
||||
supported: true,
|
||||
hint: 'MCP-based integration',
|
||||
},
|
||||
{
|
||||
id: 'crush',
|
||||
label: 'Crush',
|
||||
detected: isCommandInPath('crush'),
|
||||
supported: true,
|
||||
hint: 'MCP-based integration',
|
||||
},
|
||||
{
|
||||
id: 'roo-code',
|
||||
label: 'Roo Code',
|
||||
detected: hasVscodeExtension('roo-code'),
|
||||
supported: true,
|
||||
hint: 'MCP-based integration',
|
||||
},
|
||||
{
|
||||
id: 'warp',
|
||||
label: 'Warp',
|
||||
detected: existsSync(join(home, '.warp')) || isCommandInPath('warp'),
|
||||
supported: true,
|
||||
hint: 'MCP-based integration',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the IDEs that were detected on this system.
|
||||
*/
|
||||
export function getDetectedIDEs(): IDEInfo[] {
|
||||
return detectInstalledIDEs().filter((ide) => ide.detected);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Install command for `npx claude-mem install`.
|
||||
*
|
||||
* 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 * 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 {
|
||||
/** When provided, skip the interactive IDE multi-select and use this IDE. */
|
||||
ide?: string;
|
||||
}
|
||||
|
||||
export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
|
||||
const version = readPluginVersion();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Runtime command routing for `npx claude-mem start|stop|restart|status|search|transcript`.
|
||||
*
|
||||
* These commands delegate to the installed plugin's worker-service.cjs via Bun,
|
||||
* or hit the worker's HTTP API directly (for `search`).
|
||||
*
|
||||
* Pure Node.js — no Bun APIs used.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import pc from 'picocolors';
|
||||
import { resolveBunBinaryPath } from '../utils/bun-resolver.js';
|
||||
import { isPluginInstalled, marketplaceDirectory } from '../utils/paths.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installation guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureInstalledOrExit(): void {
|
||||
if (!isPluginInstalled()) {
|
||||
console.error(pc.red('claude-mem is not installed.'));
|
||||
console.error(`Run: ${pc.bold('npx claude-mem install')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bun guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveBunOrExit(): string {
|
||||
const bunPath = resolveBunBinaryPath();
|
||||
if (!bunPath) {
|
||||
console.error(pc.red('Bun not found.'));
|
||||
console.error('Install Bun: https://bun.sh');
|
||||
console.error('After installation, restart your terminal.');
|
||||
process.exit(1);
|
||||
}
|
||||
return bunPath;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker-service path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function workerServiceScriptPath(): string {
|
||||
return join(marketplaceDirectory(), 'plugin', 'scripts', 'worker-service.cjs');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnBunWorkerCommand(command: string, 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);
|
||||
}
|
||||
|
||||
const args = [workerScript, command, ...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);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function runStartCommand(): void {
|
||||
spawnBunWorkerCommand('start');
|
||||
}
|
||||
|
||||
export function runStopCommand(): void {
|
||||
spawnBunWorkerCommand('stop');
|
||||
}
|
||||
|
||||
export function runRestartCommand(): void {
|
||||
spawnBunWorkerCommand('restart');
|
||||
}
|
||||
|
||||
export function runStatusCommand(): void {
|
||||
spawnBunWorkerCommand('status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the worker API at `GET /api/search?q=<query>`.
|
||||
*/
|
||||
export async function runSearchCommand(queryParts: string[]): Promise<void> {
|
||||
ensureInstalledOrExit();
|
||||
|
||||
const query = queryParts.join(' ').trim();
|
||||
if (!query) {
|
||||
console.error(pc.red('Usage: npx claude-mem search <query>'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?q=${encodeURIComponent(query)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(searchUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
console.error(pc.red('Search endpoint not found. Is the worker running?'));
|
||||
console.error(`Try: ${pc.bold('npx claude-mem start')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(pc.red(`Search failed: HTTP ${response.status}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.cause?.code === 'ECONNREFUSED' || error?.message?.includes('ECONNREFUSED')) {
|
||||
console.error(pc.red('Worker is not running.'));
|
||||
console.error(`Start it with: ${pc.bold('npx claude-mem start')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(pc.red(`Search failed: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the transcript watcher via Bun.
|
||||
*/
|
||||
export function runTranscriptWatchCommand(): void {
|
||||
ensureInstalledOrExit();
|
||||
const bunPath = resolveBunOrExit();
|
||||
|
||||
const transcriptWatcherPath = join(
|
||||
marketplaceDirectory(),
|
||||
'plugin',
|
||||
'scripts',
|
||||
'transcript-watcher.cjs',
|
||||
);
|
||||
|
||||
if (!existsSync(transcriptWatcherPath)) {
|
||||
// Fall back to worker-service with transcript subcommand
|
||||
spawnBunWorkerCommand('transcript', ['watch']);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(bunPath, [transcriptWatcherPath, 'watch'], {
|
||||
stdio: 'inherit',
|
||||
cwd: marketplaceDirectory(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(pc.red(`Failed to start transcript watcher: ${error.message}`));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('close', (exitCode) => {
|
||||
process.exit(exitCode ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Uninstall command for `npx claude-mem uninstall`.
|
||||
*
|
||||
* Removes the plugin from the marketplace directory, cache, plugin
|
||||
* registrations, and Claude settings. Optionally cleans up IDE-specific
|
||||
* configurations.
|
||||
*
|
||||
* Pure Node.js — no Bun APIs used.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
claudeSettingsPath,
|
||||
installedPluginsPath,
|
||||
isPluginInstalled,
|
||||
knownMarketplacesPath,
|
||||
marketplaceDirectory,
|
||||
pluginsDirectory,
|
||||
writeJsonFileAtomic,
|
||||
} from '../utils/paths.js';
|
||||
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function removeMarketplaceDirectory(): boolean {
|
||||
const marketplaceDir = marketplaceDirectory();
|
||||
if (existsSync(marketplaceDir)) {
|
||||
rmSync(marketplaceDir, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function removeCacheDirectory(): boolean {
|
||||
const cacheDirectory = join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem');
|
||||
if (existsSync(cacheDirectory)) {
|
||||
rmSync(cacheDirectory, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function removeFromKnownMarketplaces(): void {
|
||||
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||
if (knownMarketplaces['thedotmack']) {
|
||||
delete knownMarketplaces['thedotmack'];
|
||||
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromInstalledPlugins(): void {
|
||||
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
|
||||
if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
|
||||
delete installedPlugins.plugins['claude-mem@thedotmack'];
|
||||
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromClaudeSettings(): void {
|
||||
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
|
||||
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
|
||||
delete settings.enabledPlugins['claude-mem@thedotmack'];
|
||||
writeJsonFileAtomic(claudeSettingsPath(), settings);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runUninstallCommand(): Promise<void> {
|
||||
p.intro(pc.bgRed(pc.white(' claude-mem uninstall ')));
|
||||
|
||||
if (!isPluginInstalled()) {
|
||||
p.log.warn('claude-mem does not appear to be installed.');
|
||||
|
||||
// Still offer to clean up partial state
|
||||
if (process.stdin.isTTY) {
|
||||
const shouldCleanup = await p.confirm({
|
||||
message: 'Clean up any remaining registration data anyway?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldCleanup) || !shouldCleanup) {
|
||||
p.outro('Nothing to do.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
p.outro('Nothing to do.');
|
||||
return;
|
||||
}
|
||||
} else if (process.stdin.isTTY) {
|
||||
const shouldContinue = await p.confirm({
|
||||
message: 'Are you sure you want to uninstall claude-mem?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
||||
p.cancel('Uninstall cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the worker and wait for it to exit before deleting files
|
||||
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${workerPort}/api/admin/shutdown`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
// Poll health endpoint until worker is gone (max 10s)
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${workerPort}/api/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
// Still alive — keep waiting
|
||||
} catch {
|
||||
break; // Connection refused = worker is gone
|
||||
}
|
||||
}
|
||||
p.log.info('Worker service stopped.');
|
||||
} catch {
|
||||
// Worker may not be running — that is fine
|
||||
}
|
||||
|
||||
await p.tasks([
|
||||
{
|
||||
title: 'Removing marketplace directory',
|
||||
task: async () => {
|
||||
const removed = removeMarketplaceDirectory();
|
||||
return removed
|
||||
? `Marketplace directory removed ${pc.green('OK')}`
|
||||
: `Marketplace directory not found ${pc.dim('skipped')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Removing cache directory',
|
||||
task: async () => {
|
||||
const removed = removeCacheDirectory();
|
||||
return removed
|
||||
? `Cache directory removed ${pc.green('OK')}`
|
||||
: `Cache directory not found ${pc.dim('skipped')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Removing marketplace registration',
|
||||
task: async () => {
|
||||
removeFromKnownMarketplaces();
|
||||
return `Marketplace registration removed ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Removing plugin registration',
|
||||
task: async () => {
|
||||
removeFromInstalledPlugins();
|
||||
return `Plugin registration removed ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Removing from Claude settings',
|
||||
task: async () => {
|
||||
removeFromClaudeSettings();
|
||||
return `Claude settings updated ${pc.green('OK')}`;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Remove IDE-specific hooks and config (best-effort, each is independent)
|
||||
const ideCleanups: Array<{ label: string; fn: () => Promise<number> | number }> = [
|
||||
{ label: 'Gemini CLI hooks', fn: async () => {
|
||||
const { uninstallGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
|
||||
return uninstallGeminiCliHooks();
|
||||
}},
|
||||
{ label: 'Windsurf hooks', fn: async () => {
|
||||
const { uninstallWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
|
||||
return uninstallWindsurfHooks();
|
||||
}},
|
||||
{ label: 'OpenCode plugin', fn: async () => {
|
||||
const { uninstallOpenCodePlugin } = await import('../../services/integrations/OpenCodeInstaller.js');
|
||||
return uninstallOpenCodePlugin();
|
||||
}},
|
||||
{ label: 'OpenClaw plugin', fn: async () => {
|
||||
const { uninstallOpenClawPlugin } = await import('../../services/integrations/OpenClawInstaller.js');
|
||||
return uninstallOpenClawPlugin();
|
||||
}},
|
||||
{ label: 'Codex CLI', fn: async () => {
|
||||
const { uninstallCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
||||
return uninstallCodexCli();
|
||||
}},
|
||||
];
|
||||
|
||||
for (const { label, fn } of ideCleanups) {
|
||||
try {
|
||||
const result = await fn();
|
||||
if (result === 0) {
|
||||
p.log.info(`${label}: removed.`);
|
||||
}
|
||||
} catch {
|
||||
// IDE not configured or uninstaller errored — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
p.note(
|
||||
[
|
||||
`Your data directory at ${pc.cyan('~/.claude-mem')} was preserved.`,
|
||||
'To remove it manually: rm -rf ~/.claude-mem',
|
||||
].join('\n'),
|
||||
'Note',
|
||||
);
|
||||
|
||||
p.outro(pc.green('claude-mem has been uninstalled.'));
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* NPX CLI entry point for claude-mem.
|
||||
*
|
||||
* Usage:
|
||||
* npx claude-mem → interactive install
|
||||
* npx claude-mem install → interactive install
|
||||
* npx claude-mem install --ide <id> → direct IDE setup
|
||||
* npx claude-mem update → update to latest version
|
||||
* npx claude-mem uninstall → remove plugin and IDE configs
|
||||
* npx claude-mem version → print version
|
||||
* npx claude-mem start → start worker service
|
||||
* npx claude-mem stop → stop worker service
|
||||
* npx claude-mem restart → restart worker service
|
||||
* npx claude-mem status → show worker status
|
||||
* npx claude-mem search <query> → search observations
|
||||
* npx claude-mem transcript watch → start transcript watcher
|
||||
*
|
||||
* This file is pure Node.js — Bun is NOT required for install commands.
|
||||
* Runtime commands (`start`, `stop`, etc.) delegate to Bun via the installed plugin.
|
||||
*/
|
||||
import pc from 'picocolors';
|
||||
import { readPluginVersion } from './utils/paths.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0]?.toLowerCase() ?? '';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Help text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printHelp(): void {
|
||||
const version = readPluginVersion();
|
||||
|
||||
console.log(`
|
||||
${pc.bold('claude-mem')} v${version} — persistent memory for AI coding assistants
|
||||
|
||||
${pc.bold('Install Commands')} (no Bun required):
|
||||
${pc.cyan('npx claude-mem')} Interactive install
|
||||
${pc.cyan('npx claude-mem install')} Interactive install
|
||||
${pc.cyan('npx claude-mem install --ide <id>')} Install for specific IDE
|
||||
${pc.cyan('npx claude-mem update')} Update to latest version
|
||||
${pc.cyan('npx claude-mem uninstall')} Remove plugin and configs
|
||||
${pc.cyan('npx claude-mem version')} Print version
|
||||
|
||||
${pc.bold('Runtime Commands')} (requires Bun, delegates to installed plugin):
|
||||
${pc.cyan('npx claude-mem start')} Start worker service
|
||||
${pc.cyan('npx claude-mem stop')} Stop worker service
|
||||
${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 transcript watch')} Start transcript watcher
|
||||
|
||||
${pc.bold('IDE Identifiers')}:
|
||||
claude-code, cursor, gemini-cli, opencode, openclaw,
|
||||
windsurf, codex-cli, copilot-cli, antigravity, goose,
|
||||
crush, roo-code, warp
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
switch (command) {
|
||||
// -- No command: default to install ------------------------------------
|
||||
case '': {
|
||||
const { runInstallCommand } = await import('./commands/install.js');
|
||||
await runInstallCommand();
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Install -----------------------------------------------------------
|
||||
case 'install': {
|
||||
const ideIndex = args.indexOf('--ide');
|
||||
const ideValue = ideIndex !== -1 ? args[ideIndex + 1] : undefined;
|
||||
|
||||
const { runInstallCommand } = await import('./commands/install.js');
|
||||
await runInstallCommand({ ide: ideValue });
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Update (alias for install — overwrite with latest) ----------------
|
||||
case 'update':
|
||||
case 'upgrade': {
|
||||
const { runInstallCommand } = await import('./commands/install.js');
|
||||
await runInstallCommand();
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Uninstall ---------------------------------------------------------
|
||||
case 'uninstall':
|
||||
case 'remove': {
|
||||
const { runUninstallCommand } = await import('./commands/uninstall.js');
|
||||
await runUninstallCommand();
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Version -----------------------------------------------------------
|
||||
case 'version':
|
||||
case '--version':
|
||||
case '-v': {
|
||||
console.log(readPluginVersion());
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Help --------------------------------------------------------------
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h': {
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Runtime: start / stop / restart / status --------------------------
|
||||
case 'start': {
|
||||
const { runStartCommand } = await import('./commands/runtime.js');
|
||||
runStartCommand();
|
||||
break;
|
||||
}
|
||||
case 'stop': {
|
||||
const { runStopCommand } = await import('./commands/runtime.js');
|
||||
runStopCommand();
|
||||
break;
|
||||
}
|
||||
case 'restart': {
|
||||
const { runRestartCommand } = await import('./commands/runtime.js');
|
||||
runRestartCommand();
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const { runStatusCommand } = await import('./commands/runtime.js');
|
||||
runStatusCommand();
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Search ------------------------------------------------------------
|
||||
case 'search': {
|
||||
const { runSearchCommand } = await import('./commands/runtime.js');
|
||||
await runSearchCommand(args.slice(1));
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Transcript --------------------------------------------------------
|
||||
case 'transcript': {
|
||||
const subCommand = args[1]?.toLowerCase();
|
||||
if (subCommand === 'watch') {
|
||||
const { runTranscriptWatchCommand } = await import('./commands/runtime.js');
|
||||
runTranscriptWatchCommand();
|
||||
} else {
|
||||
console.error(pc.red(`Unknown transcript subcommand: ${subCommand ?? '(none)'}`));
|
||||
console.error(`Usage: npx claude-mem transcript watch`);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Unknown -----------------------------------------------------------
|
||||
default: {
|
||||
console.error(pc.red(`Unknown command: ${command}`));
|
||||
console.error(`Run ${pc.bold('npx claude-mem --help')} for usage information.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(pc.red('Fatal error:'), error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Bun binary resolution utility.
|
||||
*
|
||||
* Extracted from `plugin/scripts/bun-runner.js` so that the NPX CLI
|
||||
* can locate Bun without duplicating the search logic.
|
||||
*
|
||||
* Pure Node.js — no Bun APIs used.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { IS_WINDOWS } from './paths.js';
|
||||
|
||||
/**
|
||||
* Well-known locations where Bun might be installed, beyond PATH.
|
||||
* Order matches the search priority in bun-runner.js and smart-install.js.
|
||||
*/
|
||||
function bunCandidatePaths(): string[] {
|
||||
if (IS_WINDOWS) {
|
||||
return [
|
||||
join(homedir(), '.bun', 'bin', 'bun.exe'),
|
||||
join(process.env.USERPROFILE || homedir(), '.bun', 'bin', 'bun.exe'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
join(homedir(), '.bun', 'bin', 'bun'),
|
||||
'/usr/local/bin/bun',
|
||||
'/opt/homebrew/bin/bun',
|
||||
'/home/linuxbrew/.linuxbrew/bin/bun',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to locate the Bun executable.
|
||||
*
|
||||
* 1. Check PATH via `which` / `where`.
|
||||
* 2. Probe well-known installation directories.
|
||||
*
|
||||
* Returns the absolute path to the binary, `'bun'` if it is in PATH,
|
||||
* or `null` if Bun cannot be found.
|
||||
*/
|
||||
export function resolveBunBinaryPath(): string | null {
|
||||
// Try PATH first
|
||||
const whichCommand = IS_WINDOWS ? 'where' : 'which';
|
||||
const pathCheck = spawnSync(whichCommand, ['bun'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: IS_WINDOWS,
|
||||
});
|
||||
|
||||
if (pathCheck.status === 0 && pathCheck.stdout.trim()) {
|
||||
return 'bun'; // Available in PATH — use short name
|
||||
}
|
||||
|
||||
// Probe known install locations
|
||||
for (const candidatePath of bunCandidatePaths()) {
|
||||
if (existsSync(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installed Bun version string (e.g. `"1.2.3"`), or `null`
|
||||
* if Bun is not available.
|
||||
*/
|
||||
export function getBunVersionString(): string | null {
|
||||
const bunPath = resolveBunBinaryPath();
|
||||
if (!bunPath) return null;
|
||||
|
||||
try {
|
||||
const result = spawnSync(bunPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: IS_WINDOWS,
|
||||
});
|
||||
return result.status === 0 ? result.stdout.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Shared path utilities for the NPX CLI.
|
||||
*
|
||||
* All platform-specific path logic is centralized here so that every command
|
||||
* resolves directories in exactly the same way, regardless of OS.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Root of the Claude Code config directory. */
|
||||
export function claudeConfigDirectory(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||
}
|
||||
|
||||
/** Marketplace install directory for thedotmack. */
|
||||
export function marketplaceDirectory(): string {
|
||||
return join(claudeConfigDirectory(), 'plugins', 'marketplaces', 'thedotmack');
|
||||
}
|
||||
|
||||
/** Top-level plugins directory. */
|
||||
export function pluginsDirectory(): string {
|
||||
return join(claudeConfigDirectory(), 'plugins');
|
||||
}
|
||||
|
||||
/** Path to `known_marketplaces.json`. */
|
||||
export function knownMarketplacesPath(): string {
|
||||
return join(pluginsDirectory(), 'known_marketplaces.json');
|
||||
}
|
||||
|
||||
/** Path to `installed_plugins.json`. */
|
||||
export function installedPluginsPath(): string {
|
||||
return join(pluginsDirectory(), 'installed_plugins.json');
|
||||
}
|
||||
|
||||
/** Path to `~/.claude/settings.json`. */
|
||||
export function claudeSettingsPath(): string {
|
||||
return join(claudeConfigDirectory(), 'settings.json');
|
||||
}
|
||||
|
||||
/** Plugin cache directory for a specific version. */
|
||||
export function pluginCacheDirectory(version: string): string {
|
||||
return join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem', version);
|
||||
}
|
||||
|
||||
/** claude-mem data directory (default `~/.claude-mem`). */
|
||||
export function claudeMemDataDirectory(): string {
|
||||
return join(homedir(), '.claude-mem');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NPM package root (where the NPX package lives on disk)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the root of the installed npm package.
|
||||
*
|
||||
* After bundling, the CLI entry point lives at `<pkg>/dist/npx-cli/index.js`.
|
||||
* Walking up 2 levels from `import.meta.url` reaches the package root
|
||||
* where `plugin/` and `package.json` can be found.
|
||||
*/
|
||||
export function npmPackageRootDirectory(): string {
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
// <pkg>/dist/npx-cli/index.js -> up 2 levels -> <pkg>
|
||||
const root = join(dirname(currentFilePath), '..', '..');
|
||||
if (!existsSync(join(root, 'package.json'))) {
|
||||
throw new Error(
|
||||
`npmPackageRootDirectory: expected package.json at ${root}. ` +
|
||||
`Bundle structure may have changed — update the path walk.`,
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the `plugin/` directory bundled inside the npm package.
|
||||
*/
|
||||
export function npmPackagePluginDirectory(): string {
|
||||
return join(npmPackageRootDirectory(), 'plugin');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the current plugin version from the npm package's
|
||||
* `plugin/.claude-plugin/plugin.json` (preferred) or from `package.json`.
|
||||
*/
|
||||
export function readPluginVersion(): string {
|
||||
// Try plugin.json first (authoritative for plugin version)
|
||||
const pluginJsonPath = join(npmPackagePluginDirectory(), '.claude-plugin', 'plugin.json');
|
||||
if (existsSync(pluginJsonPath)) {
|
||||
try {
|
||||
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
||||
if (pluginJson.version) return pluginJson.version;
|
||||
} catch {
|
||||
// Fall through to package.json
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to package.json at package root
|
||||
const packageJsonPath = join(npmPackageRootDirectory(), 'package.json');
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (packageJson.version) return packageJson.version;
|
||||
} catch {
|
||||
// Unable to read
|
||||
}
|
||||
}
|
||||
|
||||
return '0.0.0';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installation detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns true if the plugin appears to be installed in the marketplace dir. */
|
||||
export function isPluginInstalled(): boolean {
|
||||
const marketplaceDir = marketplaceDirectory();
|
||||
return existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON file helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ensureDirectoryExists(directoryPath: string): void {
|
||||
if (!existsSync(directoryPath)) {
|
||||
mkdirSync(directoryPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `readJsonSafe` from `../../utils/json-utils.js` instead.
|
||||
* Kept as re-export for backward compatibility.
|
||||
*/
|
||||
export { readJsonSafe } from '../../utils/json-utils.js';
|
||||
|
||||
export function writeJsonFileAtomic(filepath: string, data: any): void {
|
||||
ensureDirectoryExists(dirname(filepath));
|
||||
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
+16
-2
@@ -75,7 +75,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,
|
||||
@@ -120,6 +120,11 @@ export function parseSummary(text: string, sessionId?: number): ParsedSummary |
|
||||
const summaryMatch = summaryRegex.exec(text);
|
||||
|
||||
if (!summaryMatch) {
|
||||
// Log when the response contains <observation> instead of <summary>
|
||||
// to help diagnose prompt conditioning issues (see #1312)
|
||||
if (/<observation>/.test(text)) {
|
||||
logger.warn('PARSER', 'Summary response contained <observation> tags instead of <summary> — prompt conditioning may need strengthening', { sessionId });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -133,7 +138,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)
|
||||
@@ -149,6 +154,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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user