sessionmem 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -40
- package/dist/adapters/claudeMdInjector.js +17 -1
- package/dist/adapters/generic.js +3 -0
- package/dist/adapters/global/claudeCode.js +8 -3
- package/dist/adapters/ide/cline.js +0 -2
- package/dist/adapters/ide/installer.js +35 -8
- package/dist/cli/commands/browse.js +33 -0
- package/dist/cli/commands/dedupe.js +72 -0
- package/dist/cli/commands/ingestHook.js +104 -0
- package/dist/cli/context.js +1 -4
- package/dist/cli/index.js +21 -0
- package/dist/core/api/contracts.js +7 -0
- package/dist/core/api/memoryCoreService.js +23 -1
- package/dist/core/retrieve/decay.js +11 -4
- package/dist/core/schema/migrations/010_memory_tags.sql +9 -0
- package/dist/core/schema/migrations/011_memory_expiry.sql +9 -0
- package/dist/core/storage/memoryRepo.js +17 -0
- package/dist/core/storage/memorySearchRepo.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/sessionmem) [](https://github.com/catfish-1234/sessionmem/blob/main/LICENSE)
|
|
4
4
|
|
|
5
|
-
**Give your AI coding assistant a memory that lasts beyond one conversation
|
|
5
|
+
**Give your AI coding assistant a memory that lasts beyond one conversation, stored entirely on your own computer.**
|
|
6
6
|
|
|
7
|
-
`sessionmem` is a local-first memory layer for AI coding assistants (Claude Code, Cursor, Codex, Cline, Windsurf, Antigravity, QCoder, and any other tool that speaks [MCP](https://modelcontextprotocol.io)). It watches your coding sessions, writes down the important stuff (decisions, warnings, facts about your project), and quietly reminds the assistant about it the next time you start working
|
|
7
|
+
`sessionmem` is a local-first memory layer for AI coding assistants (Claude Code, Cursor, Codex, Cline, Windsurf, Antigravity, QCoder, and any other tool that speaks [MCP](https://modelcontextprotocol.io)). It watches your coding sessions, writes down the important stuff (decisions, warnings, facts about your project), and quietly reminds the assistant about it the next time you start working, so you never have to repeat yourself.
|
|
8
8
|
|
|
9
9
|
Everything happens on your machine. There is no account to create, no cloud service to trust, and no data that leaves your computer unless you explicitly turn that on.
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ Everything happens on your machine. There is no account to create, no cloud serv
|
|
|
12
12
|
|
|
13
13
|
## Quickstart
|
|
14
14
|
|
|
15
|
-
No programming experience needed
|
|
15
|
+
No programming experience needed. You just need a terminal (Command Prompt, Terminal, or PowerShell) and [Node.js](https://nodejs.org) installed.
|
|
16
16
|
|
|
17
17
|
### 1. Install sessionmem
|
|
18
18
|
|
|
@@ -30,8 +30,8 @@ sessionmem install
|
|
|
30
30
|
|
|
31
31
|
This does three things:
|
|
32
32
|
- Tells your AI tool's MCP host about `sessionmem` so it can be launched automatically.
|
|
33
|
-
- Creates a config file at `~/.sessionmem/config.json` with safe, privacy-respecting defaults
|
|
34
|
-
- Injects instructions into `~/.claude/CLAUDE.md` so Claude Code knows about sessionmem's tools and uses them proactively (idempotent
|
|
33
|
+
- Creates a config file at `~/.sessionmem/config.json` with safe, privacy-respecting defaults, but only if one doesn't already exist.
|
|
34
|
+
- Injects instructions into `~/.claude/CLAUDE.md` so Claude Code knows about sessionmem's tools and uses them proactively (idempotent, so safe to re-run).
|
|
35
35
|
|
|
36
36
|
### 3. Start using your AI tool as normal
|
|
37
37
|
|
|
@@ -39,7 +39,7 @@ This does three things:
|
|
|
39
39
|
sessionmem run
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
(Most of the time you won't run this yourself
|
|
42
|
+
(Most of the time you won't run this yourself. Your AI tool's host starts it automatically once it's registered.)
|
|
43
43
|
|
|
44
44
|
That's it. From here:
|
|
45
45
|
- `sessionmem` watches your sessions in the background.
|
|
@@ -85,7 +85,7 @@ This happens because most AI assistants only "know" what's inside the current co
|
|
|
85
85
|
`sessionmem` fixes this by sitting quietly between your assistant and your project:
|
|
86
86
|
|
|
87
87
|
1. While you work, it **captures** what happens in the session.
|
|
88
|
-
2. When the session ends, it **summarizes** the important parts
|
|
88
|
+
2. When the session ends, it **summarizes** the important parts (decisions made, warnings, useful facts) into short, durable notes.
|
|
89
89
|
3. The next time you start a session, it **reminds** the assistant of the most relevant notes, automatically, in a small amount of text.
|
|
90
90
|
|
|
91
91
|
You don't run any of these steps yourself. Once installed, it just works in the background.
|
|
@@ -99,21 +99,21 @@ There are other "memory for Claude" projects out there (for example, tools like
|
|
|
99
99
|
| | **sessionmem** | Typical cloud/Claude-only memory tools |
|
|
100
100
|
|---|---|---|
|
|
101
101
|
| **Where is data stored?** | A single SQLite file on your computer (`~/.sessionmem/memories.db`) | Often a hosted service, a cloud vector database, or a separate server process you have to run |
|
|
102
|
-
| **Account / sign-up required?** | No
|
|
102
|
+
| **Account / sign-up required?** | No, never | Sometimes |
|
|
103
103
|
| **Which AI tools does it work with?** | Claude Code, Cursor, Codex, Cline, Windsurf, Antigravity, QCoder, and any other MCP-compatible host | Usually just one specific tool (commonly Claude Code only) |
|
|
104
|
-
| **Secret redaction** | Built in
|
|
104
|
+
| **Secret redaction** | Built in, on by default. API keys, tokens, passwords, and private keys are scrubbed before anything is saved. | Often not handled, or left to the user |
|
|
105
105
|
| **Token budget control** | Injected memories are trimmed to a small, fixed token budget so they don't bloat every conversation (see benchmarks below) | Varies, often unbounded |
|
|
106
106
|
| **Old/stale memory cleanup** | Built-in retention policy automatically prunes old memories (configurable, on by default) | Often grows forever ("memory rot") |
|
|
107
|
-
| **Team sharing** | Optional, via a shared folder you already control (network drive, synced directory)
|
|
108
|
-
| **Offline-capable** | Yes, fully
|
|
107
|
+
| **Team sharing** | Optional, via a shared folder you already control (network drive, synced directory). No server needed. | Usually requires a shared hosted backend |
|
|
108
|
+
| **Offline-capable** | Yes, fully. Works with no network connection by default. | Usually requires network access to the memory service |
|
|
109
109
|
|
|
110
|
-
In short: `sessionmem`
|
|
110
|
+
In short: `sessionmem` is the **boring, local, "just a SQLite file" option**: easy to inspect, back up, and delete, with no lock-in to any one vendor's AI tool.
|
|
111
111
|
|
|
112
112
|
---
|
|
113
113
|
|
|
114
114
|
## Benchmark results
|
|
115
115
|
|
|
116
|
-
These numbers come from `npm run benchmark` (`scripts/benchmark.mjs`), which runs the
|
|
116
|
+
These numbers come from `npm run benchmark` (`scripts/benchmark.mjs`), which runs the real production retrieval and injection code over a fixed, synthetic set of test data with no network calls. The results are fully reproducible. See [`docs/benchmark.md`](docs/benchmark.md) for the full report and how to regenerate it.
|
|
117
117
|
|
|
118
118
|
### Token savings
|
|
119
119
|
|
|
@@ -124,11 +124,11 @@ These numbers come from `npm run benchmark` (`scripts/benchmark.mjs`), which run
|
|
|
124
124
|
| Full session history (baseline) | 1,587 |
|
|
125
125
|
| What sessionmem injects at the start of your next session | 228 |
|
|
126
126
|
|
|
127
|
-
In practice: instead of re-reading (or re-explaining)
|
|
127
|
+
In practice: instead of re-reading (or re-explaining) about 1,600 tokens of past context every session, the assistant gets a 230-token summary of just the things that matter: decisions, warnings, and key facts.
|
|
128
128
|
|
|
129
129
|
### Retrieval accuracy
|
|
130
130
|
|
|
131
|
-
> **100% hit-rate
|
|
131
|
+
> **100% hit-rate**: every one of the 10 test queries successfully retrieved the memory it was supposed to.
|
|
132
132
|
|
|
133
133
|
| Metric | Result |
|
|
134
134
|
|---|---|
|
|
@@ -136,9 +136,9 @@ In practice: instead of re-reading (or re-explaining) ~1,600 tokens of past cont
|
|
|
136
136
|
| Recall | 100.0% |
|
|
137
137
|
| Precision | 33.3% |
|
|
138
138
|
|
|
139
|
-
Precision of 33.3% is expected here: each query retrieves the top 3 candidate memories, and only one of those three is the "expected" match for a given test query
|
|
139
|
+
Precision of 33.3% is expected here: each query retrieves the top 3 candidate memories, and only one of those three is the "expected" match for a given test query. The other two are still relevant context for the agent, just not the one being scored. The important number is recall/hit-rate: **the right memory is never missed.**
|
|
140
140
|
|
|
141
|
-
These benchmarks are deterministic and reproducible
|
|
141
|
+
These benchmarks are deterministic and reproducible. Run them yourself:
|
|
142
142
|
|
|
143
143
|
```bash
|
|
144
144
|
npm run build # benchmark imports the compiled code from dist/
|
|
@@ -175,7 +175,7 @@ npm run benchmark # regenerates docs/benchmark.md
|
|
|
175
175
|
└──────────────────────────────────────────────┘
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
- **Adapters** are small pieces that know how to talk to each specific AI tool. This is why sessionmem can support many tools
|
|
178
|
+
- **Adapters** are small pieces that know how to talk to each specific AI tool. This is why sessionmem can support many tools: adding a new one doesn't change how memory itself works.
|
|
179
179
|
- **The core engine** is the same no matter which tool you use. It decides what's worth remembering, how relevant it is later, and how much of it fits in a small "reminder" at the start of your next session.
|
|
180
180
|
- **The database** is just a file. You can back it up, move it, inspect it, or delete it like any other file on your computer.
|
|
181
181
|
|
|
@@ -234,24 +234,24 @@ Full details: [`docs/privacy-and-retention.md`](docs/privacy-and-retention.md).
|
|
|
234
234
|
|
|
235
235
|
## Memory rot: keeping memory accurate over time
|
|
236
236
|
|
|
237
|
-
"Memory rot" is what happens when a memory system keeps accumulating notes forever
|
|
237
|
+
"Memory rot" is what happens when a memory system keeps accumulating notes forever. Eventually it fills up with outdated decisions, duplicate facts, and noise, and the assistant starts surfacing stale information instead of helpful information.
|
|
238
238
|
|
|
239
239
|
`sessionmem` is designed to avoid this in a few ways:
|
|
240
240
|
|
|
241
|
-
1. **Retention pruning
|
|
241
|
+
1. **Retention pruning**: memories older than a configurable window (default 90 days) are automatically eligible for cleanup. This runs as a light check at the end of every session, and can also be run manually:
|
|
242
242
|
|
|
243
243
|
```bash
|
|
244
|
-
sessionmem retention prune # dry run
|
|
244
|
+
sessionmem retention prune # dry run - shows what *would* be deleted
|
|
245
245
|
sessionmem retention prune --force # actually deletes
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
-
2. **Importance-weighted ranking
|
|
248
|
+
2. **Importance-weighted ranking**: when memories are retrieved, they're ranked by a blend of semantic relevance, recency, and importance. Old, low-importance notes naturally sink to the bottom and stop being surfaced even before they're pruned.
|
|
249
249
|
|
|
250
|
-
3. **Token-budgeted injection
|
|
250
|
+
3. **Token-budgeted injection**: only the top-ranked, most relevant memories are injected (trimmed to a small token budget, see [benchmarks](#benchmark-results)), so even a large memory store doesn't produce bloated, noisy context.
|
|
251
251
|
|
|
252
|
-
4. **Conflict resolution in team mode
|
|
252
|
+
4. **Conflict resolution in team mode**: when memories are merged from teammates, the system uses last-write-wins by id (so stale duplicates don't pile up) while preserving the higher importance score (so a critical warning doesn't get silently downgraded).
|
|
253
253
|
|
|
254
|
-
The retrieval benchmark above (100% hit-rate / 100% recall) demonstrates that even with the ranking and trimming in place, the
|
|
254
|
+
The retrieval benchmark above (100% hit-rate / 100% recall) demonstrates that even with the ranking and trimming in place, the right memory still surfaces. Accuracy is not traded away for compactness.
|
|
255
255
|
|
|
256
256
|
You're always in control: export everything first if you want a permanent record before pruning:
|
|
257
257
|
|
|
@@ -263,15 +263,15 @@ sessionmem export
|
|
|
263
263
|
|
|
264
264
|
## Team mode (optional)
|
|
265
265
|
|
|
266
|
-
Want your whole team's AI assistants to share decisions and warnings? Point `sessionmem` at a shared folder (a network drive, a synced directory
|
|
266
|
+
Want your whole team's AI assistants to share decisions and warnings? Point `sessionmem` at a shared folder (a network drive, a synced directory, or any location everyone can read and write):
|
|
267
267
|
|
|
268
268
|
```bash
|
|
269
269
|
sessionmem team enable <shared-path>
|
|
270
270
|
sessionmem sync
|
|
271
271
|
```
|
|
272
272
|
|
|
273
|
-
- **Off by default
|
|
274
|
-
- No server
|
|
273
|
+
- **Off by default**: nothing is shared until you turn it on.
|
|
274
|
+
- No server needed. It's just files in a folder you already control.
|
|
275
275
|
- Teammates' memories show up with an `author:` prefix so you know where they came from.
|
|
276
276
|
- Secrets are re-redacted on every pulled memory, so a teammate's snapshot can't reintroduce something your redaction policy would have stripped.
|
|
277
277
|
|
|
@@ -281,9 +281,9 @@ Full details, including the trust model: [`docs/team-mode.md`](docs/team-mode.md
|
|
|
281
281
|
|
|
282
282
|
## Cloud summarization (optional, off by default)
|
|
283
283
|
|
|
284
|
-
By default, summarization (turning a session into a short memory) happens
|
|
284
|
+
By default, summarization (turning a session into a short memory) happens entirely locally, with no API calls.
|
|
285
285
|
|
|
286
|
-
If you explicitly opt in (`allowCloudSummarization=true`)
|
|
286
|
+
If you explicitly opt in (`allowCloudSummarization=true`) and provide an `ANTHROPIC_API_KEY`, summarization can use Claude's API for higher-quality summaries. If that ever fails, it automatically falls back to local summarization. Your sessions are never left unsummarized.
|
|
287
287
|
|
|
288
288
|
Details: [`docs/cloud-summarization.md`](docs/cloud-summarization.md).
|
|
289
289
|
|
|
@@ -307,19 +307,19 @@ Details: [`docs/cloud-summarization.md`](docs/cloud-summarization.md).
|
|
|
307
307
|
|
|
308
308
|
## Further documentation
|
|
309
309
|
|
|
310
|
-
- [Architecture](docs/architecture.md)
|
|
311
|
-
- [Benchmark](docs/benchmark.md)
|
|
312
|
-
- [Privacy and retention](docs/privacy-and-retention.md)
|
|
313
|
-
- [Team mode](docs/team-mode.md)
|
|
314
|
-
- [Cloud summarization](docs/cloud-summarization.md)
|
|
315
|
-
- [Migration](docs/migration.md)
|
|
316
|
-
- [Troubleshooting](docs/troubleshooting.md)
|
|
310
|
+
- [Architecture](docs/architecture.md): how the core engine, adapters, CLI, and SQLite storage fit together.
|
|
311
|
+
- [Benchmark](docs/benchmark.md): full token-reduction and retrieval-accuracy report, and how to reproduce it.
|
|
312
|
+
- [Privacy and retention](docs/privacy-and-retention.md): secret redaction, retention pruning, and config.
|
|
313
|
+
- [Team mode](docs/team-mode.md): shared-path team memory.
|
|
314
|
+
- [Cloud summarization](docs/cloud-summarization.md): the opt-in cloud summarization path.
|
|
315
|
+
- [Migration](docs/migration.md): the SQLite migration system and version-upgrade policy.
|
|
316
|
+
- [Troubleshooting](docs/troubleshooting.md): install failures, adapter issues, and `better-sqlite3` native-build problems.
|
|
317
317
|
|
|
318
318
|
---
|
|
319
319
|
|
|
320
320
|
## Troubleshooting
|
|
321
321
|
|
|
322
|
-
Run into trouble installing or running `sessionmem`? Start with [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
|
322
|
+
Run into trouble installing or running `sessionmem`? Start with [`docs/troubleshooting.md`](docs/troubleshooting.md). It covers install failures, adapter-specific issues, and native module (`better-sqlite3`) build problems on different platforms.
|
|
323
323
|
|
|
324
324
|
Quick checks:
|
|
325
325
|
|
|
@@ -339,10 +339,10 @@ Install sessionmem (`npm i -g sessionmem`), then run `sessionmem install` in you
|
|
|
339
339
|
Same install. sessionmem also ships as a Claude Code plugin (the `.claude-plugin` file in the repo), so it works with Claude Code's native plugin system too.
|
|
340
340
|
|
|
341
341
|
**Is there a local MCP memory server that works offline with no API key?**
|
|
342
|
-
Yes
|
|
342
|
+
Yes. sessionmem stores everything in a single SQLite file at `~/.sessionmem/memories.db` and works fully offline by default. Nothing leaves your machine unless you explicitly enable the optional cloud summarization path.
|
|
343
343
|
|
|
344
344
|
**How is sessionmem different from claude-mem?**
|
|
345
|
-
sessionmem is not Claude-only. It works with Cursor, Cline, Codex, Windsurf, Antigravity, QCoder, and any other MCP host
|
|
345
|
+
sessionmem is not Claude-only. It works with Cursor, Cline, Codex, Windsurf, Antigravity, QCoder, and any other MCP host, not just Claude Code. It also redacts secrets (API keys, tokens, JWTs) by default, prunes stale memory automatically, and ships reproducible benchmarks you can run yourself.
|
|
346
346
|
|
|
347
347
|
**Does sessionmem send my code to the cloud?**
|
|
348
348
|
No. Nothing leaves your machine by default. The optional cloud summarization path is opt-in and off by default.
|
|
@@ -19,13 +19,24 @@ hook support), call it once before any task work, or call \`retrieveMemories\`
|
|
|
19
19
|
with the current task as the query. Never inject twice if context was already
|
|
20
20
|
provided.
|
|
21
21
|
|
|
22
|
-
###
|
|
22
|
+
### Session event ingestion (auto-ingest path)
|
|
23
|
+
The \`PostToolUse\` hook installed by sessionmem automatically captures Bash, Edit,
|
|
24
|
+
Write, and MultiEdit tool uses as session events. These are summarized by the
|
|
25
|
+
\`SessionEnd\` hook into a durable memory — no manual action required. To ingest
|
|
26
|
+
additional context (e.g. key decisions made mid-session that weren't captured as
|
|
27
|
+
tool use), call \`ingestSessionEvents\` at task boundaries with a description of
|
|
28
|
+
what was accomplished.
|
|
29
|
+
|
|
30
|
+
### When to store memories explicitly (storeMemory / batchStoreMemory)
|
|
23
31
|
- User makes an architectural or design decision
|
|
24
32
|
- You discover a non-obvious project constraint or convention
|
|
25
33
|
- A debugging session reveals an important root cause
|
|
26
34
|
- User states a preference about how they want things done
|
|
27
35
|
- A warning or pitfall is discovered that future sessions should know about
|
|
28
36
|
|
|
37
|
+
Use \`tags\` to categorize memories for better retrieval (e.g. \`tags: ["auth", "security"]\`).
|
|
38
|
+
Use \`expiresAt\` for time-limited context (e.g. a temporary workaround).
|
|
39
|
+
|
|
29
40
|
### When to retrieve memories mid-session (retrieveMemories)
|
|
30
41
|
- Before making architectural decisions (check if prior decisions exist)
|
|
31
42
|
- When the user references something from a previous session
|
|
@@ -37,6 +48,9 @@ informed. Store a concise \`summary\` memory (importance 7) of the key outcomes,
|
|
|
37
48
|
any new decisions, facts, or warnings. Use \`batchStoreMemory\` to write several at
|
|
38
49
|
once. This is what makes context survive across sessions and saves tokens later.
|
|
39
50
|
|
|
51
|
+
Note: The SessionEnd hook also auto-summarizes captured session events — you only
|
|
52
|
+
need to call \`batchStoreMemory\` for decisions and facts NOT already captured.
|
|
53
|
+
|
|
40
54
|
### Memory kinds
|
|
41
55
|
- \`decision\` — architectural or design choices (importance: 7-9)
|
|
42
56
|
- \`fact\` — project constraints, conventions, patterns (importance: 5-7)
|
|
@@ -49,6 +63,7 @@ once. This is what makes context survive across sessions and saves tokens later.
|
|
|
49
63
|
- \`getMemory\` — fetch a specific memory by ID
|
|
50
64
|
- \`forgetMemory\` — delete an outdated or incorrect memory
|
|
51
65
|
- \`batchStoreMemory\` — store multiple memories in one call (use at session end)
|
|
66
|
+
- \`ingestSessionEvents\` — push raw session events for auto-summarization
|
|
52
67
|
- \`stats\` — check memory count and health
|
|
53
68
|
|
|
54
69
|
### Guidelines
|
|
@@ -56,6 +71,7 @@ once. This is what makes context survive across sessions and saves tokens later.
|
|
|
56
71
|
- Don't retrieve memories every single turn — retrieve at task boundaries
|
|
57
72
|
- Keep memory content concise (1-3 sentences) and self-contained
|
|
58
73
|
- Use appropriate importance scores (see kinds above)
|
|
74
|
+
- Use tags to group related memories (\`tags: ["topic"]\`)
|
|
59
75
|
`;
|
|
60
76
|
export function generateClaudeMdBlock() {
|
|
61
77
|
return `${SESSIONMEM_BLOCK_START}\n${BLOCK_CONTENT}\n${SESSIONMEM_BLOCK_END}`;
|
package/dist/adapters/generic.js
CHANGED
|
@@ -68,6 +68,7 @@ const TOOL_DEFINITIONS = [
|
|
|
68
68
|
limit: retrieveMemoriesRequestSchema.shape.limit.describe("Maximum number of memories to return. Integer 1-100, default 20. Increase for broad topic sweeps; keep at default for focused lookups."),
|
|
69
69
|
mode: retrieveMemoriesRequestSchema.shape.mode.describe("'auto' for the standard startup context-load path. 'on-demand' for an explicit mid-session retrieval triggered by a specific task or question."),
|
|
70
70
|
depth: retrieveMemoriesRequestSchema.shape.depth.describe("'default' for standard semantic search. 'deep' for a broader sweep that surfaces less-similar memories — use when the topic is new or unfamiliar."),
|
|
71
|
+
tags: retrieveMemoriesRequestSchema.shape.tags.describe("Optional tag filter. When provided, only return memories that have ALL listed tags."),
|
|
71
72
|
},
|
|
72
73
|
},
|
|
73
74
|
{
|
|
@@ -86,6 +87,8 @@ const TOOL_DEFINITIONS = [
|
|
|
86
87
|
content: storeMemoryRequestSchema.shape.content.describe("The memory text. Must be self-contained and specific — written so it is useful without surrounding conversation context. Avoid vague phrases like 'the user decided to...'."),
|
|
87
88
|
importance: storeMemoryRequestSchema.shape.importance.describe("Integer 1-10 indicating criticality (10 = most important). Directly affects ranking in future retrieveMemories calls. Use 8-10 for decisions that must not be forgotten; 3-5 for useful but non-critical facts."),
|
|
88
89
|
redactionEnabled: storeMemoryRequestSchema.shape.redactionEnabled.describe("If true, PII is stripped from content before storage. Omit to use the project-level redaction setting from config.json."),
|
|
90
|
+
tags: storeMemoryRequestSchema.shape.tags.describe("Optional array of tag strings (max 10, each max 50 chars) for categorizing this memory. Example: ['auth', 'db-schema']. Tags are filterable via retrieveMemories."),
|
|
91
|
+
expiresAt: storeMemoryRequestSchema.shape.expiresAt.describe("Optional UTC ISO timestamp (e.g. '2026-12-31T00:00:00Z') after which this memory is excluded from retrieval. Use for time-limited context like a temporary workaround."),
|
|
89
92
|
},
|
|
90
93
|
},
|
|
91
94
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { GenericMCPAdapter } from "../generic.js";
|
|
4
|
-
import { IDEInstaller, SESSIONMEM_HOOK_COMMAND, SESSIONMEM_SESSION_END_HOOK_COMMAND, } from "../ide/installer.js";
|
|
4
|
+
import { IDEInstaller, SESSIONMEM_HOOK_COMMAND, SESSIONMEM_SESSION_END_HOOK_COMMAND, SESSIONMEM_POST_TOOL_HOOK_COMMAND, SESSIONMEM_POST_TOOL_MATCHER, } from "../ide/installer.js";
|
|
5
5
|
export class ClaudeCodeAdapter extends GenericMCPAdapter {
|
|
6
6
|
name = "Claude Code";
|
|
7
7
|
// Capabilities inherited from GenericMCPAdapter (tools only) so the
|
|
@@ -44,7 +44,11 @@ export class ClaudeCodeAdapter extends GenericMCPAdapter {
|
|
|
44
44
|
// session ends — the deterministic write-side counterpart to the
|
|
45
45
|
// SessionStart read-side hook.
|
|
46
46
|
const endHookOk = await IDEInstaller.injectClaudeHook(this.settingsPath(), SESSIONMEM_SESSION_END_HOOK_COMMAND, "SessionEnd");
|
|
47
|
-
|
|
47
|
+
// Register a PostToolUse hook so high-signal tool uses (Bash/Edit/Write/
|
|
48
|
+
// MultiEdit) are captured as session events automatically, feeding the
|
|
49
|
+
// SessionEnd auto-summarizer without the agent calling ingestSessionEvents.
|
|
50
|
+
const postToolOk = await IDEInstaller.injectClaudeHook(this.settingsPath(), SESSIONMEM_POST_TOOL_HOOK_COMMAND, "PostToolUse", SESSIONMEM_POST_TOOL_MATCHER);
|
|
51
|
+
return mcpOk && startHookOk && endHookOk && postToolOk;
|
|
48
52
|
}
|
|
49
53
|
async uninstall() {
|
|
50
54
|
const configPath = join(homedir(), ".claude.json");
|
|
@@ -53,6 +57,7 @@ export class ClaudeCodeAdapter extends GenericMCPAdapter {
|
|
|
53
57
|
// reported as an uninstall failure rather than silently ignored.
|
|
54
58
|
const startOk = await IDEInstaller.removeClaudeHook(this.settingsPath(), SESSIONMEM_HOOK_COMMAND);
|
|
55
59
|
const endOk = await IDEInstaller.removeClaudeHook(this.settingsPath(), SESSIONMEM_SESSION_END_HOOK_COMMAND, "SessionEnd");
|
|
56
|
-
|
|
60
|
+
const postToolOk = await IDEInstaller.removeClaudeHook(this.settingsPath(), SESSIONMEM_POST_TOOL_HOOK_COMMAND, "PostToolUse");
|
|
61
|
+
return mcpOk && startOk && endOk && postToolOk;
|
|
57
62
|
}
|
|
58
63
|
}
|
|
@@ -50,8 +50,6 @@ export class ClineAdapter extends GenericMCPAdapter {
|
|
|
50
50
|
// "Code" is VS Code stable, but Cline also installs into Insiders, VSCodium,
|
|
51
51
|
// and Cursor. Pick the first variant whose globalStorage dir already exists;
|
|
52
52
|
// fall back to stable "Code" when none is found (fresh install).
|
|
53
|
-
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
54
|
-
// `name` is from the hardcoded VSCODE_EDITOR_VARIANTS constant above — not user input.
|
|
55
53
|
const editorName = VSCODE_EDITOR_VARIANTS.find((name) => existsSync(join(base, name, "User", "globalStorage"))) ?? "Code";
|
|
56
54
|
return join(base, editorName, ...rest);
|
|
57
55
|
}
|
|
@@ -1,17 +1,38 @@
|
|
|
1
|
+
// On Windows, `sessionmem` resolves to a `.cmd` shim which requires cmd.exe to
|
|
2
|
+
// execute. Claude Code spawns hook commands without a shell on Windows, so a
|
|
3
|
+
// bare `sessionmem …` invocation fails silently. Route through `cmd /c` —
|
|
4
|
+
// mirroring the MCP server install fix in claudeCode.ts — so the shim is
|
|
5
|
+
// resolved correctly. On non-Windows platforms the bare command works fine.
|
|
6
|
+
const _isWindows = process.platform === "win32";
|
|
1
7
|
/**
|
|
2
8
|
* The exact command Claude Code runs at session start. Installed as a
|
|
3
9
|
* `SessionStart` hook in ~/.claude/settings.json so prior memories are injected
|
|
4
10
|
* into every session automatically — the deterministic counterpart to the
|
|
5
11
|
* advisory `startup_inject_memories` tool (which the agent must choose to call).
|
|
6
12
|
*/
|
|
7
|
-
export const SESSIONMEM_HOOK_COMMAND =
|
|
13
|
+
export const SESSIONMEM_HOOK_COMMAND = _isWindows
|
|
14
|
+
? "cmd /c sessionmem session-start"
|
|
15
|
+
: "sessionmem session-start";
|
|
8
16
|
/**
|
|
9
17
|
* The command Claude Code runs when a session ends. Installed as a `SessionEnd`
|
|
10
18
|
* hook so the session-end pipeline (light retention prune + auto-summarization
|
|
11
19
|
* of any ingested session events) runs automatically once per session, without
|
|
12
20
|
* relying on the agent choosing to call a tool.
|
|
13
21
|
*/
|
|
14
|
-
export const SESSIONMEM_SESSION_END_HOOK_COMMAND =
|
|
22
|
+
export const SESSIONMEM_SESSION_END_HOOK_COMMAND = _isWindows
|
|
23
|
+
? "cmd /c sessionmem session-end"
|
|
24
|
+
: "sessionmem session-end";
|
|
25
|
+
/**
|
|
26
|
+
* The command Claude Code runs after each tool use. Installed as a `PostToolUse`
|
|
27
|
+
* hook so high-signal tool uses (Bash/Edit/Write/MultiEdit) are captured as
|
|
28
|
+
* session events automatically, giving the SessionEnd auto-summarizer material
|
|
29
|
+
* to work with without the agent choosing to call ingestSessionEvents.
|
|
30
|
+
*/
|
|
31
|
+
export const SESSIONMEM_POST_TOOL_HOOK_COMMAND = _isWindows
|
|
32
|
+
? "cmd /c sessionmem ingest-hook"
|
|
33
|
+
: "sessionmem ingest-hook";
|
|
34
|
+
/** Tool matcher for the PostToolUse auto-ingest hook. */
|
|
35
|
+
export const SESSIONMEM_POST_TOOL_MATCHER = "Bash|Edit|Write|MultiEdit";
|
|
15
36
|
/** Default Claude Code hook event for the legacy 2-arg hook helpers. */
|
|
16
37
|
const DEFAULT_HOOK_EVENT = "SessionStart";
|
|
17
38
|
export class IDEInstaller {
|
|
@@ -166,9 +187,11 @@ export class IDEInstaller {
|
|
|
166
187
|
* every other key (mcpServers, other hook events, user settings). Defaults to
|
|
167
188
|
* the `SessionStart` event; pass `eventName` for another event (e.g.
|
|
168
189
|
* `SessionEnd`). Idempotent: re-injecting the same command on the same event
|
|
169
|
-
* never produces a duplicate entry.
|
|
190
|
+
* never produces a duplicate entry. When `matcher` is provided (e.g. for
|
|
191
|
+
* `PostToolUse`), the pushed entry carries the matcher so Claude Code scopes
|
|
192
|
+
* the hook to the matching tools.
|
|
170
193
|
*/
|
|
171
|
-
static injectClaudeHookBlock(content, command, eventName = DEFAULT_HOOK_EVENT) {
|
|
194
|
+
static injectClaudeHookBlock(content, command, eventName = DEFAULT_HOOK_EVENT, matcher) {
|
|
172
195
|
const config = content.trim()
|
|
173
196
|
? this.parseJsonc(content)
|
|
174
197
|
: {};
|
|
@@ -179,9 +202,13 @@ export class IDEInstaller {
|
|
|
179
202
|
? hooks[eventName]
|
|
180
203
|
: [];
|
|
181
204
|
// Drop any pre-existing sessionmem entry so re-install stays idempotent and
|
|
182
|
-
// a stale command form is replaced rather than duplicated.
|
|
205
|
+
// a stale command form is replaced rather than duplicated. hookEntryHasCommand
|
|
206
|
+
// only inspects entry.hooks, so it dedupes correctly whether or not the entry
|
|
207
|
+
// carries a matcher.
|
|
183
208
|
const filtered = eventEntries.filter((entry) => !this.hookEntryHasCommand(entry, command));
|
|
184
|
-
filtered.push(
|
|
209
|
+
filtered.push(matcher !== undefined
|
|
210
|
+
? { matcher, hooks: [{ type: "command", command }] }
|
|
211
|
+
: { hooks: [{ type: "command", command }] });
|
|
185
212
|
hooks[eventName] = filtered;
|
|
186
213
|
config.hooks = hooks;
|
|
187
214
|
return JSON.stringify(config, null, 2);
|
|
@@ -214,14 +241,14 @@ export class IDEInstaller {
|
|
|
214
241
|
}
|
|
215
242
|
return JSON.stringify(config, null, 2);
|
|
216
243
|
}
|
|
217
|
-
static async injectClaudeHook(filePath, command, eventName = DEFAULT_HOOK_EVENT) {
|
|
244
|
+
static async injectClaudeHook(filePath, command, eventName = DEFAULT_HOOK_EVENT, matcher) {
|
|
218
245
|
try {
|
|
219
246
|
const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import("fs");
|
|
220
247
|
const { dirname } = await import("path");
|
|
221
248
|
const existing = existsSync(filePath)
|
|
222
249
|
? readFileSync(filePath, "utf-8")
|
|
223
250
|
: "{}";
|
|
224
|
-
const updated = this.injectClaudeHookBlock(existing, command, eventName);
|
|
251
|
+
const updated = this.injectClaudeHookBlock(existing, command, eventName, matcher);
|
|
225
252
|
const dir = dirname(filePath);
|
|
226
253
|
if (!existsSync(dir)) {
|
|
227
254
|
mkdirSync(dir, { recursive: true });
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
export async function browseCommand(options = {}, ctx) {
|
|
3
|
+
const context = ctx ?? createCliContext();
|
|
4
|
+
const db = context.db;
|
|
5
|
+
if (options.project) {
|
|
6
|
+
// Show memories for a specific project
|
|
7
|
+
const memories = db.prepare("SELECT id, kind, importance, content, created_at FROM memories WHERE project_id = ? ORDER BY updated_at DESC LIMIT 50").all(options.project);
|
|
8
|
+
if (memories.length === 0) {
|
|
9
|
+
process.stdout.write(`No memories found for project: ${options.project}\n`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
process.stdout.write(`Memories for project: ${options.project} (${memories.length} shown)\n\n`);
|
|
13
|
+
for (const m of memories) {
|
|
14
|
+
const preview = m.content.slice(0, 120).replace(/\n/g, " ");
|
|
15
|
+
process.stdout.write(`[${m.kind}] imp=${m.importance} id=${m.id.slice(0, 8)}…\n ${preview}${m.content.length > 120 ? "…" : ""}\n\n`);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// List all projects
|
|
20
|
+
const projects = db.prepare("SELECT project_id, COUNT(*) as memory_count, MAX(updated_at) as newest FROM memories GROUP BY project_id ORDER BY newest DESC").all();
|
|
21
|
+
if (projects.length === 0) {
|
|
22
|
+
process.stdout.write("No projects with memories yet.\n");
|
|
23
|
+
process.stdout.write(`Current project: ${context.projectId}\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(`Projects with memories (${projects.length} total):\n\n`);
|
|
27
|
+
for (const p of projects) {
|
|
28
|
+
const marker = p.project_id === context.projectId ? " ← current" : "";
|
|
29
|
+
process.stdout.write(`${p.project_id}${marker}\n`);
|
|
30
|
+
process.stdout.write(` memories: ${p.memory_count} last_updated: ${p.newest}\n\n`);
|
|
31
|
+
}
|
|
32
|
+
process.stdout.write(`Use --project <id> to list memories for a specific project.\n`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
import { listMemoriesByProject } from "../../core/storage/memoryRepo.js";
|
|
3
|
+
const SIMILARITY_THRESHOLD = 0.85;
|
|
4
|
+
const MAX_TAG_LEN = 200;
|
|
5
|
+
function cosineSimilarity(a, b) {
|
|
6
|
+
if (a.length !== b.length || a.length === 0)
|
|
7
|
+
return 0;
|
|
8
|
+
let dot = 0, normA = 0, normB = 0;
|
|
9
|
+
for (let i = 0; i < a.length; i++) {
|
|
10
|
+
dot += a[i] * b[i];
|
|
11
|
+
normA += a[i] * a[i];
|
|
12
|
+
normB += b[i] * b[i];
|
|
13
|
+
}
|
|
14
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
15
|
+
return denom === 0 ? 0 : dot / denom;
|
|
16
|
+
}
|
|
17
|
+
export async function dedupeCommand(options = {}, ctx) {
|
|
18
|
+
const context = ctx ?? createCliContext();
|
|
19
|
+
const threshold = options.threshold ? parseFloat(options.threshold) : SIMILARITY_THRESHOLD;
|
|
20
|
+
const dryRun = options.dry !== false;
|
|
21
|
+
const memories = listMemoriesByProject(context.db, context.projectId);
|
|
22
|
+
const withEmbeddings = memories.filter((m) => m.embedding && m.embedding_dim);
|
|
23
|
+
if (withEmbeddings.length < 2) {
|
|
24
|
+
process.stdout.write("Not enough memories with embeddings to compare.\n");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const pairs = [];
|
|
28
|
+
for (let i = 0; i < withEmbeddings.length; i++) {
|
|
29
|
+
const a = withEmbeddings[i];
|
|
30
|
+
const vecA = JSON.parse(a.embedding);
|
|
31
|
+
for (let j = i + 1; j < withEmbeddings.length; j++) {
|
|
32
|
+
const b = withEmbeddings[j];
|
|
33
|
+
const vecB = JSON.parse(b.embedding);
|
|
34
|
+
const sim = cosineSimilarity(vecA, vecB);
|
|
35
|
+
if (sim >= threshold) {
|
|
36
|
+
pairs.push({
|
|
37
|
+
idA: a.id,
|
|
38
|
+
idB: b.id,
|
|
39
|
+
similarity: sim,
|
|
40
|
+
contentA: a.content.slice(0, MAX_TAG_LEN),
|
|
41
|
+
contentB: b.content.slice(0, MAX_TAG_LEN),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (pairs.length === 0) {
|
|
47
|
+
process.stdout.write(`No near-duplicate memories found (threshold: ${threshold}).\n`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
process.stdout.write(`Found ${pairs.length} near-duplicate pair(s) (threshold: ${threshold}):\n\n`);
|
|
51
|
+
for (const pair of pairs) {
|
|
52
|
+
process.stdout.write(`Similarity: ${(pair.similarity * 100).toFixed(1)}%\n`);
|
|
53
|
+
process.stdout.write(` A [${pair.idA}]: ${pair.contentA}${pair.contentA.length >= MAX_TAG_LEN ? "…" : ""}\n`);
|
|
54
|
+
process.stdout.write(` B [${pair.idB}]: ${pair.contentB}${pair.contentB.length >= MAX_TAG_LEN ? "…" : ""}\n`);
|
|
55
|
+
if (!dryRun) {
|
|
56
|
+
// Keep A (higher importance or first by date), delete B.
|
|
57
|
+
const memA = withEmbeddings.find((m) => m.id === pair.idA);
|
|
58
|
+
const memB = withEmbeddings.find((m) => m.id === pair.idB);
|
|
59
|
+
const keepId = memA.importance >= memB.importance ? pair.idA : pair.idB;
|
|
60
|
+
const dropId = keepId === pair.idA ? pair.idB : pair.idA;
|
|
61
|
+
await context.service.call("forgetMemory", {
|
|
62
|
+
projectId: context.projectId,
|
|
63
|
+
memoryId: dropId,
|
|
64
|
+
});
|
|
65
|
+
process.stdout.write(` → Deleted ${dropId} (kept ${keepId})\n`);
|
|
66
|
+
}
|
|
67
|
+
process.stdout.write("\n");
|
|
68
|
+
}
|
|
69
|
+
if (dryRun && pairs.length > 0) {
|
|
70
|
+
process.stdout.write(`Run with --apply to delete the lower-importance duplicate in each pair.\n`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createCliContext } from "../context.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
// High-signal tool types that we capture as session events.
|
|
4
|
+
const CAPTURED_TOOL_TYPES = new Set(["Bash", "Edit", "Write", "MultiEdit"]);
|
|
5
|
+
// Max payload size stored per hook event (chars). The full hook payload can be
|
|
6
|
+
// large (file diffs); cap it so a single tool use can't bloat session_events.
|
|
7
|
+
const MAX_HOOK_PAYLOAD_CHARS = 4000;
|
|
8
|
+
async function readStdinJson() {
|
|
9
|
+
if (process.stdin.isTTY)
|
|
10
|
+
return {};
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
let data = "";
|
|
13
|
+
let settled = false;
|
|
14
|
+
const finish = () => {
|
|
15
|
+
if (settled)
|
|
16
|
+
return;
|
|
17
|
+
settled = true;
|
|
18
|
+
try {
|
|
19
|
+
resolve(JSON.parse(data));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
resolve({});
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
process.stdin.removeAllListeners();
|
|
27
|
+
process.stdin.pause();
|
|
28
|
+
finish();
|
|
29
|
+
}, 500);
|
|
30
|
+
timer.unref?.();
|
|
31
|
+
process.stdin.setEncoding("utf8");
|
|
32
|
+
process.stdin.on("data", (c) => { data += c; });
|
|
33
|
+
process.stdin.on("end", () => { clearTimeout(timer); finish(); });
|
|
34
|
+
process.stdin.on("error", () => { clearTimeout(timer); finish(); });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function resolveSessionId(payload) {
|
|
38
|
+
if (typeof payload.session_id === "string" && payload.session_id.trim()) {
|
|
39
|
+
return payload.session_id.trim();
|
|
40
|
+
}
|
|
41
|
+
return process.env.CLAUDE_CODE_SESSION_ID ?? process.env.SESSION_ID ?? `session-${Date.now()}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `sessionmem ingest-hook` — PostToolUse hook handler.
|
|
45
|
+
* Reads Claude Code's PostToolUse JSON payload from stdin and stores it as a
|
|
46
|
+
* session event so the SessionEnd auto-summarizer has material to work with.
|
|
47
|
+
* Also auto-stores git commits as decision memories (git commit detection).
|
|
48
|
+
*/
|
|
49
|
+
export async function ingestHookCommand(ctx) {
|
|
50
|
+
try {
|
|
51
|
+
const payload = await readStdinJson();
|
|
52
|
+
const toolName = payload.tool_name ?? "";
|
|
53
|
+
if (!CAPTURED_TOOL_TYPES.has(toolName))
|
|
54
|
+
return;
|
|
55
|
+
const context = ctx ?? createCliContext();
|
|
56
|
+
const sessionId = resolveSessionId(payload);
|
|
57
|
+
const projectId = context.projectId;
|
|
58
|
+
// Trim payload to cap storage per event.
|
|
59
|
+
const fullPayload = JSON.stringify({
|
|
60
|
+
tool: toolName,
|
|
61
|
+
input: payload.tool_input,
|
|
62
|
+
cwd: payload.cwd,
|
|
63
|
+
});
|
|
64
|
+
const trimmedPayload = fullPayload.length > MAX_HOOK_PAYLOAD_CHARS
|
|
65
|
+
? fullPayload.slice(0, MAX_HOOK_PAYLOAD_CHARS) + "…"
|
|
66
|
+
: fullPayload;
|
|
67
|
+
// Store as a session event (for SessionEnd auto-summarize).
|
|
68
|
+
await context.service.call("ingestSessionEvents", {
|
|
69
|
+
projectId,
|
|
70
|
+
sessionId,
|
|
71
|
+
events: [{
|
|
72
|
+
id: randomUUID(),
|
|
73
|
+
eventIndex: Date.now(), // monotonic within the session
|
|
74
|
+
eventType: `tool_use:${toolName.toLowerCase()}`,
|
|
75
|
+
payloadJson: trimmedPayload,
|
|
76
|
+
}],
|
|
77
|
+
});
|
|
78
|
+
// Git commit detection: auto-store as a decision memory.
|
|
79
|
+
if (toolName === "Bash") {
|
|
80
|
+
const cmd = typeof payload.tool_input?.command === "string"
|
|
81
|
+
? payload.tool_input.command
|
|
82
|
+
: "";
|
|
83
|
+
const commitMatch = cmd.match(/git\s+commit[^|&;]*(?:-m\s+['"]([^'"]+)['"]|--message=?['"]([^'"]+)['"])/);
|
|
84
|
+
if (commitMatch) {
|
|
85
|
+
const message = (commitMatch[1] ?? commitMatch[2] ?? "").trim();
|
|
86
|
+
if (message) {
|
|
87
|
+
await context.service.call("storeMemory", {
|
|
88
|
+
projectId,
|
|
89
|
+
sessionId,
|
|
90
|
+
memoryId: `gitcommit-${Date.now()}`,
|
|
91
|
+
sourceAdapter: "sessionmem-hook",
|
|
92
|
+
kind: "decision",
|
|
93
|
+
content: `Git commit: ${message}`,
|
|
94
|
+
importance: 5,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
context.db.close();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Never block tool use — swallow all errors silently.
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/cli/context.js
CHANGED
|
@@ -38,10 +38,7 @@ export function expandTilde(p) {
|
|
|
38
38
|
if (p === "~")
|
|
39
39
|
return homedir();
|
|
40
40
|
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
41
|
-
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
42
|
-
// `homedir()` is the fixed base; `p` is SESSIONMEM_DB_PATH, a local CLI
|
|
43
|
-
// config the operator sets for their own filesystem — not external user input.
|
|
44
|
-
return join(homedir(), p.slice(2));
|
|
41
|
+
return join(homedir(), p.slice(2)); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
45
42
|
}
|
|
46
43
|
return p;
|
|
47
44
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -21,6 +21,9 @@ import { configGetCommand, configSetCommand } from "./commands/config.js";
|
|
|
21
21
|
import { teamEnableCommand, teamDisableCommand, teamStatusCommand, } from "./commands/team.js";
|
|
22
22
|
import { syncCommand } from "./commands/sync.js";
|
|
23
23
|
import { reEmbedCommand } from "./commands/reEmbed.js";
|
|
24
|
+
import { ingestHookCommand } from "./commands/ingestHook.js";
|
|
25
|
+
import { dedupeCommand } from "./commands/dedupe.js";
|
|
26
|
+
import { browseCommand } from "./commands/browse.js";
|
|
24
27
|
// Source the version from package.json (single source of truth) so `--version`
|
|
25
28
|
// never drifts from the published manifest. createRequire + resolveJsonModule
|
|
26
29
|
// reads the manifest relative to this module; from dist/cli/index.js the
|
|
@@ -40,6 +43,10 @@ program
|
|
|
40
43
|
.command("session-end")
|
|
41
44
|
.description("Run the session-end pipeline (auto-summarize ingested session events + light retention prune) for the current project (invoked automatically by the SessionEnd hook installed during `sessionmem install`)")
|
|
42
45
|
.action(() => sessionEndCommand());
|
|
46
|
+
program
|
|
47
|
+
.command("ingest-hook")
|
|
48
|
+
.description("Process a Claude Code PostToolUse hook payload from stdin and store it as a session event (invoked automatically by the hook installed during `sessionmem install`)")
|
|
49
|
+
.action(() => ingestHookCommand());
|
|
43
50
|
program
|
|
44
51
|
.command("install")
|
|
45
52
|
.description("Install sessionmem into the current MCP host")
|
|
@@ -167,6 +174,20 @@ program
|
|
|
167
174
|
.command("sync")
|
|
168
175
|
.description("Push local memories and pull teammate memories via the shared path")
|
|
169
176
|
.action(() => syncCommand());
|
|
177
|
+
// dedupe — find near-duplicate memories by embedding cosine similarity.
|
|
178
|
+
// Dry-run by default; --apply deletes the lower-importance duplicate per pair.
|
|
179
|
+
program
|
|
180
|
+
.command("dedupe")
|
|
181
|
+
.description("Find and optionally remove near-duplicate memories")
|
|
182
|
+
.option("--apply", "Delete the lower-importance duplicate in each pair (default: dry-run)")
|
|
183
|
+
.option("--threshold <n>", "Cosine similarity threshold 0-1 (default: 0.85)")
|
|
184
|
+
.action((options) => dedupeCommand({ dry: !options.apply, threshold: options.threshold }));
|
|
185
|
+
// browse — cross-project memory viewer.
|
|
186
|
+
program
|
|
187
|
+
.command("browse")
|
|
188
|
+
.description("List all projects with memories, or browse a specific project")
|
|
189
|
+
.option("--project <id>", "Show memories for a specific project ID")
|
|
190
|
+
.action((options) => browseCommand({ project: options.project }));
|
|
170
191
|
program.parseAsync(process.argv).catch((err) => {
|
|
171
192
|
console.error(err instanceof Error ? err.message : String(err));
|
|
172
193
|
process.exit(1);
|
|
@@ -58,6 +58,8 @@ export const memorySchema = z.object({
|
|
|
58
58
|
accessCount: z.number().int().nonnegative(),
|
|
59
59
|
lastAccessed: z.string().nullable(),
|
|
60
60
|
effectiveImportance: z.number().int().min(1).max(10),
|
|
61
|
+
tags: z.array(z.string()).nullable().optional(),
|
|
62
|
+
expiresAt: z.string().nullable().optional(),
|
|
61
63
|
createdAt: z.string().min(1),
|
|
62
64
|
updatedAt: z.string().min(1),
|
|
63
65
|
});
|
|
@@ -144,6 +146,8 @@ export const storeMemoryRequestSchema = z.object({
|
|
|
144
146
|
// the service layer can fall back to the policy-config redactionEnabled
|
|
145
147
|
// setting (override > config.json > default precedence).
|
|
146
148
|
redactionEnabled: z.boolean().optional(),
|
|
149
|
+
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
150
|
+
expiresAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, "must be UTC ISO timestamp").optional(),
|
|
147
151
|
});
|
|
148
152
|
export const retrieveMemoriesRequestSchema = z.object({
|
|
149
153
|
projectId: z.string().min(1),
|
|
@@ -151,6 +155,7 @@ export const retrieveMemoriesRequestSchema = z.object({
|
|
|
151
155
|
limit: z.number().int().min(1).max(100).default(20),
|
|
152
156
|
mode: z.enum(["auto", "on-demand"]).default("auto"),
|
|
153
157
|
depth: z.enum(["default", "deep"]).default("default"),
|
|
158
|
+
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
154
159
|
});
|
|
155
160
|
export const listMemoriesRequestSchema = z.object({
|
|
156
161
|
projectId: z.string().min(1),
|
|
@@ -380,6 +385,8 @@ export const batchStoreMemoryItemSchema = z.object({
|
|
|
380
385
|
content: z.string().min(1).max(MAX_CONTENT_LENGTH),
|
|
381
386
|
importance: z.number().int().min(1).max(10),
|
|
382
387
|
redactionEnabled: z.boolean().optional(),
|
|
388
|
+
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
389
|
+
expiresAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/, "must be UTC ISO timestamp").optional(),
|
|
383
390
|
});
|
|
384
391
|
export const batchStoreMemoryRequestSchema = z.object({
|
|
385
392
|
projectId: z.string().min(1),
|
|
@@ -75,6 +75,8 @@ function toMemoryDto(record) {
|
|
|
75
75
|
accessCount: record.access_count,
|
|
76
76
|
lastAccessed: record.last_accessed,
|
|
77
77
|
effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
|
|
78
|
+
tags: record.tags ? JSON.parse(record.tags) : null,
|
|
79
|
+
expiresAt: record.expires_at ?? null,
|
|
78
80
|
createdAt: record.created_at,
|
|
79
81
|
updatedAt: record.updated_at,
|
|
80
82
|
};
|
|
@@ -114,6 +116,10 @@ function toRetrievedMemoryDto(record) {
|
|
|
114
116
|
accessCount: record.access_count,
|
|
115
117
|
lastAccessed: null,
|
|
116
118
|
effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
|
|
119
|
+
// The semantic-search candidate projection does not carry tags/expires_at;
|
|
120
|
+
// expired rows are already filtered out by the retrieval SQL.
|
|
121
|
+
tags: null,
|
|
122
|
+
expiresAt: null,
|
|
117
123
|
createdAt: record.created_at,
|
|
118
124
|
updatedAt: record.updated_at,
|
|
119
125
|
semantic: record.semantic,
|
|
@@ -269,6 +275,8 @@ export function createMemoryCoreService(deps) {
|
|
|
269
275
|
// because this row did not come from another project's store.
|
|
270
276
|
author: localAuthor,
|
|
271
277
|
origin_project_id: null,
|
|
278
|
+
tags: JSON.stringify(parsed.tags ?? null),
|
|
279
|
+
expires_at: parsed.expiresAt ?? null,
|
|
272
280
|
});
|
|
273
281
|
const inserted = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
|
|
274
282
|
if (!inserted) {
|
|
@@ -289,12 +297,24 @@ export function createMemoryCoreService(deps) {
|
|
|
289
297
|
async retrieveMemories(request) {
|
|
290
298
|
const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
|
|
291
299
|
const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, DEEP_MODE_RETRIEVAL_CAP) : parsed.limit;
|
|
292
|
-
const
|
|
300
|
+
const rankedAll = retrieveMemories({
|
|
293
301
|
db,
|
|
294
302
|
projectId: parsed.projectId,
|
|
295
303
|
queryText: parsed.query,
|
|
296
304
|
limit,
|
|
297
305
|
});
|
|
306
|
+
// Optional tag filter: keep a memory only when its stored tags contain ALL
|
|
307
|
+
// requested tags. The semantic candidate projection omits tags, so re-read
|
|
308
|
+
// each row's tags by id (this is an explicit, infrequent filter path).
|
|
309
|
+
const ranked = parsed.tags && parsed.tags.length > 0
|
|
310
|
+
? rankedAll.filter((m) => {
|
|
311
|
+
const rec = getMemoryRecordById(db, parsed.projectId, m.id);
|
|
312
|
+
const recTags = rec?.tags
|
|
313
|
+
? JSON.parse(rec.tags)
|
|
314
|
+
: [];
|
|
315
|
+
return parsed.tags.every((t) => recTags.includes(t));
|
|
316
|
+
})
|
|
317
|
+
: rankedAll;
|
|
298
318
|
if (ranked.length > 0 && parsed.mode !== "on-demand") {
|
|
299
319
|
// Only boost access counts for startup injection (mode='auto'), not for
|
|
300
320
|
// explicit on-demand retrieval, so a mid-session lookup does not inflate
|
|
@@ -674,6 +694,8 @@ export function createMemoryCoreService(deps) {
|
|
|
674
694
|
embedding_version: embedding.embeddingVersion,
|
|
675
695
|
author: localAuthor,
|
|
676
696
|
origin_project_id: null,
|
|
697
|
+
tags: JSON.stringify(item.tags ?? null),
|
|
698
|
+
expires_at: item.expiresAt ?? null,
|
|
677
699
|
});
|
|
678
700
|
const inserted = getMemoryRecordById(db, parsed.projectId, item.memoryId);
|
|
679
701
|
if (!inserted) {
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
const DEFAULT_DECAY_THRESHOLD_DAYS = 7;
|
|
2
2
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Apply time-based importance decay. Memories decay by 1 point per week
|
|
5
|
+
* after the first week of inactivity, capping decay at -3 so even old
|
|
6
|
+
* memories remain discoverable (minimum effective importance = stored - 3).
|
|
7
|
+
* This produces a smoother ranking fade-out vs. the previous single -1 step.
|
|
8
|
+
*/
|
|
3
9
|
export function decayOldBoosts(memories, now = new Date(), thresholdDays = DEFAULT_DECAY_THRESHOLD_DAYS) {
|
|
4
10
|
return memories.map((memory) => {
|
|
5
11
|
const updatedAt = new Date(memory.updated_at);
|
|
6
12
|
const ageDays = Math.max(0, (now.getTime() - updatedAt.getTime()) / DAY_IN_MS);
|
|
7
|
-
const
|
|
13
|
+
const weeksOverThreshold = Number.isFinite(ageDays) && ageDays > thresholdDays
|
|
14
|
+
? Math.floor((ageDays - thresholdDays) / 7)
|
|
15
|
+
: 0;
|
|
16
|
+
const decayAmount = Math.min(weeksOverThreshold, 3);
|
|
8
17
|
return {
|
|
9
18
|
...memory,
|
|
10
|
-
decayedImportance:
|
|
11
|
-
? Math.max(1, memory.importance - 1)
|
|
12
|
-
: memory.importance,
|
|
19
|
+
decayedImportance: Math.max(1, memory.importance - decayAmount),
|
|
13
20
|
};
|
|
14
21
|
});
|
|
15
22
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Memory tags: optional JSON-encoded string array for categorizing memories so
|
|
2
|
+
-- they can be filtered at retrieval time. Added to the existing `memories` table
|
|
3
|
+
-- without rewriting it so pre-existing rows survive (they get NULL tags).
|
|
4
|
+
--
|
|
5
|
+
-- Idempotency: SQLite has no `ADD COLUMN IF NOT EXISTS`. Re-running this
|
|
6
|
+
-- migration (only possible if the _migrations record was lost) throws
|
|
7
|
+
-- "duplicate column name", which runMigrations catches and treats as
|
|
8
|
+
-- already-applied. See src/core/schema/runMigrations.ts.
|
|
9
|
+
ALTER TABLE memories ADD COLUMN tags TEXT;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Memory expiry: optional UTC ISO timestamp after which a memory is considered
|
|
2
|
+
-- expired and excluded from retrieval/listing. Added to the existing `memories`
|
|
3
|
+
-- table without rewriting it so pre-existing rows survive (NULL = never expires).
|
|
4
|
+
--
|
|
5
|
+
-- Idempotency: SQLite has no `ADD COLUMN IF NOT EXISTS`. Re-running this
|
|
6
|
+
-- migration (only possible if the _migrations record was lost) throws
|
|
7
|
+
-- "duplicate column name", which runMigrations catches and treats as
|
|
8
|
+
-- already-applied. See src/core/schema/runMigrations.ts.
|
|
9
|
+
ALTER TABLE memories ADD COLUMN expires_at TEXT;
|
|
@@ -6,10 +6,12 @@ const UPSERT_INSERT_HEAD = `
|
|
|
6
6
|
INSERT INTO memories (
|
|
7
7
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
8
8
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
9
|
+
tags, expires_at,
|
|
9
10
|
created_at, updated_at
|
|
10
11
|
) VALUES (
|
|
11
12
|
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
12
13
|
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
14
|
+
@tags, @expires_at,
|
|
13
15
|
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
14
16
|
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
15
17
|
)
|
|
@@ -26,6 +28,8 @@ const UPSERT_INSERT_TAIL = `
|
|
|
26
28
|
embedding_version = excluded.embedding_version,
|
|
27
29
|
author = excluded.author,
|
|
28
30
|
origin_project_id = excluded.origin_project_id,
|
|
31
|
+
tags = excluded.tags,
|
|
32
|
+
expires_at = excluded.expires_at,
|
|
29
33
|
created_at = excluded.created_at,
|
|
30
34
|
updated_at = excluded.updated_at
|
|
31
35
|
`;
|
|
@@ -39,10 +43,12 @@ function getStatements(db) {
|
|
|
39
43
|
INSERT INTO memories (
|
|
40
44
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
41
45
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
46
|
+
tags, expires_at,
|
|
42
47
|
created_at, updated_at
|
|
43
48
|
) VALUES (
|
|
44
49
|
@id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
|
|
45
50
|
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
51
|
+
@tags, @expires_at,
|
|
46
52
|
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
47
53
|
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
48
54
|
)
|
|
@@ -51,10 +57,12 @@ function getStatements(db) {
|
|
|
51
57
|
INSERT INTO memories (
|
|
52
58
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
53
59
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
60
|
+
tags, expires_at,
|
|
54
61
|
created_at, updated_at
|
|
55
62
|
) VALUES (
|
|
56
63
|
@id, @project_id, @session_id, @source_adapter, 'summary', @content, @normalized_content,
|
|
57
64
|
@importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
|
|
65
|
+
@tags, @expires_at,
|
|
58
66
|
COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
59
67
|
COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
60
68
|
)
|
|
@@ -70,6 +78,8 @@ function getStatements(db) {
|
|
|
70
78
|
embedding_version = excluded.embedding_version,
|
|
71
79
|
author = excluded.author,
|
|
72
80
|
origin_project_id = excluded.origin_project_id,
|
|
81
|
+
tags = excluded.tags,
|
|
82
|
+
expires_at = excluded.expires_at,
|
|
73
83
|
updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
74
84
|
ON CONFLICT(id)
|
|
75
85
|
DO UPDATE SET
|
|
@@ -84,15 +94,19 @@ function getStatements(db) {
|
|
|
84
94
|
embedding_version = excluded.embedding_version,
|
|
85
95
|
author = excluded.author,
|
|
86
96
|
origin_project_id = excluded.origin_project_id,
|
|
97
|
+
tags = excluded.tags,
|
|
98
|
+
expires_at = excluded.expires_at,
|
|
87
99
|
updated_at = COALESCE(excluded.updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
88
100
|
`),
|
|
89
101
|
listByProject: db.prepare(`
|
|
90
102
|
SELECT
|
|
91
103
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
92
104
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
105
|
+
tags, expires_at,
|
|
93
106
|
access_count, last_accessed, created_at, updated_at
|
|
94
107
|
FROM memories
|
|
95
108
|
WHERE project_id = ?
|
|
109
|
+
AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
96
110
|
ORDER BY updated_at DESC
|
|
97
111
|
`),
|
|
98
112
|
// Lightweight projection for token-savings accounting: only `content` is
|
|
@@ -110,6 +124,7 @@ function getStatements(db) {
|
|
|
110
124
|
SELECT
|
|
111
125
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
112
126
|
importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
|
|
127
|
+
tags, expires_at,
|
|
113
128
|
access_count, last_accessed, created_at, updated_at
|
|
114
129
|
FROM memories
|
|
115
130
|
WHERE project_id = ? AND id = ?
|
|
@@ -194,6 +209,8 @@ function toParams(input) {
|
|
|
194
209
|
embedding_version: input.embedding_version ?? null,
|
|
195
210
|
author: input.author ?? "",
|
|
196
211
|
origin_project_id: input.origin_project_id ?? null,
|
|
212
|
+
tags: input.tags ?? null,
|
|
213
|
+
expires_at: input.expires_at ?? null,
|
|
197
214
|
created_at: input.created_at ?? null,
|
|
198
215
|
updated_at: input.updated_at ?? null,
|
|
199
216
|
};
|
|
@@ -14,6 +14,7 @@ function getSearchStatements(db) {
|
|
|
14
14
|
embedding, embedding_dim, embedding_version
|
|
15
15
|
FROM memories
|
|
16
16
|
WHERE project_id = ?
|
|
17
|
+
AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
17
18
|
AND (
|
|
18
19
|
importance >= 8
|
|
19
20
|
OR updated_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days')
|
|
@@ -29,6 +30,7 @@ function getSearchStatements(db) {
|
|
|
29
30
|
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
30
31
|
WHERE memories_fts MATCH ?
|
|
31
32
|
AND m.project_id = ?
|
|
33
|
+
AND (m.expires_at IS NULL OR m.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
32
34
|
ORDER BY rank
|
|
33
35
|
LIMIT ?
|
|
34
36
|
`),
|
package/package.json
CHANGED