sessionmem 1.2.0 → 1.3.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 +37 -5
- package/dist/cli/commands/dedupe.js +89 -22
- package/dist/cli/commands/ingestHook.js +107 -18
- package/dist/cli/commands/savings.js +3 -1
- package/dist/cli/commands/stats.js +7 -0
- package/dist/cli/index.js +2 -11
- package/dist/cli/projectId.js +48 -2
- package/dist/core/api/contracts.js +6 -1
- package/dist/core/api/memoryCoreService.js +17 -3
- package/dist/core/retrieve/retrieveMemories.js +1 -1
- package/dist/core/storage/memoryRepo.js +1 -1
- package/dist/core/storage/memorySearchRepo.js +36 -14
- package/dist/core/storage/sessionEventsRepo.js +21 -0
- package/dist/core/summarize/redaction.js +70 -10
- package/dist/core/summarize/summaryShape.js +50 -20
- package/package.json +4 -3
- package/dist/core/session.js +0 -4
package/README.md
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
# sessionmem
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src=".github/sessionmem-logo.svg" alt="sessionmem" width="480">
|
|
5
|
+
</p>
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/sessionmem"><img src="https://img.shields.io/npm/v/sessionmem.svg" alt="npm version"></a>
|
|
9
|
+
<a href="https://github.com/catfish-1234/sessionmem/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
10
|
+
</p>
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
**85.6% fewer tokens. Every session starts knowing your codebase. Stored entirely on your machine.**
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
```
|
|
15
|
+
New session. Claude starts fresh.
|
|
16
|
+
|
|
17
|
+
WITHOUT sessionmem:
|
|
18
|
+
You explain the stack. The JWT bug from last week.
|
|
19
|
+
The Stripe migration that's halfway done. The billing code to stay away from.
|
|
20
|
+
Same questions. Different day.
|
|
21
|
+
|
|
22
|
+
WITH sessionmem:
|
|
23
|
+
[warning] JWT blacklist must be checked before issuing new tokens. Fixed PR #47.
|
|
24
|
+
[decision] Stripe migration ~50% done. Do NOT use /lib/billing-v1.
|
|
25
|
+
[fact] Stack: TypeScript, Next.js, Postgres.
|
|
26
|
+
|
|
27
|
+
Claude: "Looks like you're mid-Stripe migration. Where do you want to pick up?"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`sessionmem` is an MCP server that watches your coding sessions and stores what actually mattered -- decisions, warnings, things that would bite you if Claude forgot them. At the start of each new session it injects the relevant bits automatically. Works with Claude Code, Cursor, Cline, Codex, Windsurf, and anything that speaks [MCP](https://modelcontextprotocol.io).
|
|
31
|
+
|
|
32
|
+
Everything stays on your machine. No account, no cloud, no data leaving your computer unless you explicitly turn that on.
|
|
10
33
|
|
|
11
34
|
---
|
|
12
35
|
|
|
@@ -319,7 +342,16 @@ Details: [`docs/cloud-summarization.md`](docs/cloud-summarization.md).
|
|
|
319
342
|
|
|
320
343
|
## Troubleshooting
|
|
321
344
|
|
|
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.
|
|
345
|
+
Run into trouble installing or running `sessionmem`? Start with [`docs/troubleshooting.md`](docs/troubleshooting.md). It covers install failures, adapter-specific issues, missing session data, and native module (`better-sqlite3`) build problems on different platforms.
|
|
346
|
+
|
|
347
|
+
Two quick checks that resolve most reports:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
sessionmem install # idempotent — re-registers the MCP server and all three hooks
|
|
351
|
+
sessionmem stats # memories, sessions, and session_events for the current project
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
If `stats` shows `sessions: 0` after real work, see [“0 sessions” / no session data recorded](docs/troubleshooting.md#0-sessions--no-session-data-recorded). Memories are keyed to the **repository root**, so every directory inside one repo shares a bucket; outside a repo the working directory itself is the key.
|
|
323
355
|
|
|
324
356
|
Quick checks:
|
|
325
357
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCliContext } from "../context.js";
|
|
2
2
|
import { listMemoriesByProject } from "../../core/storage/memoryRepo.js";
|
|
3
3
|
const SIMILARITY_THRESHOLD = 0.85;
|
|
4
|
-
const
|
|
4
|
+
const MAX_PREVIEW_LEN = 200;
|
|
5
5
|
function cosineSimilarity(a, b) {
|
|
6
6
|
if (a.length !== b.length || a.length === 0)
|
|
7
7
|
return 0;
|
|
@@ -14,31 +14,82 @@ function cosineSimilarity(a, b) {
|
|
|
14
14
|
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
15
15
|
return denom === 0 ? 0 : dot / denom;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse and validate the `--threshold` flag. An unparseable or out-of-range
|
|
19
|
+
* value used to flow through as NaN, and every `sim >= NaN` comparison is
|
|
20
|
+
* false — so `--threshold banana` reported "no duplicates found" rather than
|
|
21
|
+
* telling the user their input was rejected.
|
|
22
|
+
*/
|
|
23
|
+
function resolveThreshold(raw) {
|
|
24
|
+
if (raw === undefined)
|
|
25
|
+
return SIMILARITY_THRESHOLD;
|
|
26
|
+
const parsed = Number.parseFloat(raw);
|
|
27
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) {
|
|
28
|
+
console.error(`Invalid --threshold "${raw}": expected a number greater than 0 and at most 1.`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
/** Parse a stored embedding, returning null when it is absent or malformed. */
|
|
34
|
+
function parseVector(embedding) {
|
|
35
|
+
if (!embedding)
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(embedding);
|
|
39
|
+
if (!Array.isArray(parsed) || !parsed.every((n) => Number.isFinite(n))) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
17
48
|
export async function dedupeCommand(options = {}, ctx) {
|
|
18
49
|
const context = ctx ?? createCliContext();
|
|
19
|
-
const threshold =
|
|
50
|
+
const threshold = resolveThreshold(options.threshold);
|
|
20
51
|
const dryRun = options.dry !== false;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
// Parse each embedding ONCE. The inner loop used to re-parse the same
|
|
53
|
+
// candidate's JSON for every outer element — O(n²) JSON.parse calls over a
|
|
54
|
+
// list that only has n distinct vectors.
|
|
55
|
+
const candidates = [];
|
|
56
|
+
for (const memory of listMemoriesByProject(context.db, context.projectId)) {
|
|
57
|
+
const vector = parseVector(memory.embedding);
|
|
58
|
+
if (!vector || !memory.embedding_dim)
|
|
59
|
+
continue;
|
|
60
|
+
candidates.push({
|
|
61
|
+
id: memory.id,
|
|
62
|
+
content: memory.content,
|
|
63
|
+
importance: memory.importance,
|
|
64
|
+
vector,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (candidates.length < 2) {
|
|
24
68
|
process.stdout.write("Not enough memories with embeddings to compare.\n");
|
|
25
69
|
return;
|
|
26
70
|
}
|
|
71
|
+
// Highest importance first, id as the tie-break. Pairs are emitted in this
|
|
72
|
+
// order and the first element of a pair is the one kept, so the survivor of
|
|
73
|
+
// any duplicate cluster is deterministic.
|
|
74
|
+
//
|
|
75
|
+
// Without this the order came from `ORDER BY updated_at DESC`, which is not a
|
|
76
|
+
// total order — memories written in the same clock tick tie, and the winner
|
|
77
|
+
// varied by platform. The same three memories could collapse to a different
|
|
78
|
+
// survivor on Windows than on Linux, and `--apply` is a hard delete.
|
|
79
|
+
candidates.sort((left, right) => right.importance - left.importance || left.id.localeCompare(right.id));
|
|
27
80
|
const pairs = [];
|
|
28
|
-
for (let i = 0; i <
|
|
29
|
-
const a =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const vecB = JSON.parse(b.embedding);
|
|
34
|
-
const sim = cosineSimilarity(vecA, vecB);
|
|
81
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
82
|
+
const a = candidates[i];
|
|
83
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
84
|
+
const b = candidates[j];
|
|
85
|
+
const sim = cosineSimilarity(a.vector, b.vector);
|
|
35
86
|
if (sim >= threshold) {
|
|
36
87
|
pairs.push({
|
|
37
88
|
idA: a.id,
|
|
38
89
|
idB: b.id,
|
|
39
90
|
similarity: sim,
|
|
40
|
-
contentA: a.content.slice(0,
|
|
41
|
-
contentB: b.content.slice(0,
|
|
91
|
+
contentA: a.content.slice(0, MAX_PREVIEW_LEN),
|
|
92
|
+
contentB: b.content.slice(0, MAX_PREVIEW_LEN),
|
|
42
93
|
});
|
|
43
94
|
}
|
|
44
95
|
}
|
|
@@ -47,26 +98,42 @@ export async function dedupeCommand(options = {}, ctx) {
|
|
|
47
98
|
process.stdout.write(`No near-duplicate memories found (threshold: ${threshold}).\n`);
|
|
48
99
|
return;
|
|
49
100
|
}
|
|
101
|
+
const byId = new Map(candidates.map((c) => [c.id, c]));
|
|
102
|
+
const deleted = new Set();
|
|
50
103
|
process.stdout.write(`Found ${pairs.length} near-duplicate pair(s) (threshold: ${threshold}):\n\n`);
|
|
51
104
|
for (const pair of pairs) {
|
|
105
|
+
// Skip a pair whose partner is already gone. Similarity is not transitive:
|
|
106
|
+
// in a chain A~B, B~C with A≁C, deleting B for the (A,B) pair left the
|
|
107
|
+
// (B,C) pair still to process, and that pass could delete C — a memory
|
|
108
|
+
// whose only duplicate no longer existed. Nothing kept C from being
|
|
109
|
+
// dropped on the strength of an already-deleted row.
|
|
110
|
+
if (deleted.has(pair.idA) || deleted.has(pair.idB))
|
|
111
|
+
continue;
|
|
52
112
|
process.stdout.write(`Similarity: ${(pair.similarity * 100).toFixed(1)}%\n`);
|
|
53
|
-
process.stdout.write(` A [${pair.idA}]: ${pair.contentA}${pair.contentA.length >=
|
|
54
|
-
process.stdout.write(` B [${pair.idB}]: ${pair.contentB}${pair.contentB.length >=
|
|
113
|
+
process.stdout.write(` A [${pair.idA}]: ${pair.contentA}${pair.contentA.length >= MAX_PREVIEW_LEN ? "…" : ""}\n`);
|
|
114
|
+
process.stdout.write(` B [${pair.idB}]: ${pair.contentB}${pair.contentB.length >= MAX_PREVIEW_LEN ? "…" : ""}\n`);
|
|
55
115
|
if (!dryRun) {
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
116
|
+
const memA = byId.get(pair.idA);
|
|
117
|
+
const memB = byId.get(pair.idB);
|
|
118
|
+
if (!memA || !memB)
|
|
119
|
+
continue;
|
|
59
120
|
const keepId = memA.importance >= memB.importance ? pair.idA : pair.idB;
|
|
60
121
|
const dropId = keepId === pair.idA ? pair.idB : pair.idA;
|
|
61
|
-
await context.service.call("forgetMemory", {
|
|
122
|
+
const result = await context.service.call("forgetMemory", {
|
|
62
123
|
projectId: context.projectId,
|
|
63
124
|
memoryId: dropId,
|
|
64
125
|
});
|
|
65
|
-
|
|
126
|
+
if (result.ok) {
|
|
127
|
+
deleted.add(dropId);
|
|
128
|
+
process.stdout.write(` → Deleted ${dropId} (kept ${keepId})\n`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
process.stdout.write(` → Failed to delete ${dropId}: ${result.error.message}\n`);
|
|
132
|
+
}
|
|
66
133
|
}
|
|
67
134
|
process.stdout.write("\n");
|
|
68
135
|
}
|
|
69
|
-
if (dryRun
|
|
136
|
+
if (dryRun) {
|
|
70
137
|
process.stdout.write(`Run with --apply to delete the lower-importance duplicate in each pair.\n`);
|
|
71
138
|
}
|
|
72
139
|
}
|
|
@@ -5,6 +5,22 @@ const CAPTURED_TOOL_TYPES = new Set(["Bash", "Edit", "Write", "MultiEdit"]);
|
|
|
5
5
|
// Max payload size stored per hook event (chars). The full hook payload can be
|
|
6
6
|
// large (file diffs); cap it so a single tool use can't bloat session_events.
|
|
7
7
|
const MAX_HOOK_PAYLOAD_CHARS = 4000;
|
|
8
|
+
// Per-field cap applied BEFORE serialization. Truncating the serialized JSON
|
|
9
|
+
// instead would produce a string that is no longer valid JSON, which the
|
|
10
|
+
// `payloadJson` contract rejects (it parses the value) — the event would then be
|
|
11
|
+
// dropped, silently, by the catch-all below. Trim the inputs, not the output.
|
|
12
|
+
const MAX_FIELD_CHARS = 1200;
|
|
13
|
+
// One-line human summary shown in session summaries (summaryShape reads `text`).
|
|
14
|
+
const MAX_TEXT_CHARS = 300;
|
|
15
|
+
/** Emit a hook diagnostic to stderr when SESSIONMEM_DEBUG=1. */
|
|
16
|
+
function debug(message) {
|
|
17
|
+
if (process.env.SESSIONMEM_DEBUG === "1") {
|
|
18
|
+
process.stderr.write(`[sessionmem] ingest-hook: ${message}\n`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function truncate(value, max) {
|
|
22
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
23
|
+
}
|
|
8
24
|
async function readStdinJson() {
|
|
9
25
|
if (process.stdin.isTTY)
|
|
10
26
|
return {};
|
|
@@ -40,6 +56,65 @@ function resolveSessionId(payload) {
|
|
|
40
56
|
}
|
|
41
57
|
return process.env.CLAUDE_CODE_SESSION_ID ?? process.env.SESSION_ID ?? `session-${Date.now()}`;
|
|
42
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Shorten every string in a tool input to MAX_FIELD_CHARS, recursively. Keeps
|
|
61
|
+
* the object's shape (so it still serializes to valid JSON) while bounding the
|
|
62
|
+
* size contributed by large fields such as a Write tool's `content` or an Edit
|
|
63
|
+
* tool's replacement text.
|
|
64
|
+
*/
|
|
65
|
+
function shrinkValue(value, depth = 0) {
|
|
66
|
+
if (typeof value === "string")
|
|
67
|
+
return truncate(value, MAX_FIELD_CHARS);
|
|
68
|
+
if (depth >= 4)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return value.slice(0, 20).map((item) => shrinkValue(item, depth + 1));
|
|
72
|
+
}
|
|
73
|
+
if (value && typeof value === "object") {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
76
|
+
out[key] = shrinkValue(inner, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
// Numbers/booleans/null pass through; functions/undefined/symbols drop out.
|
|
81
|
+
return value === undefined || typeof value === "function" || typeof value === "symbol"
|
|
82
|
+
? undefined
|
|
83
|
+
: value;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A short human-readable line describing the tool use. Stored alongside the
|
|
87
|
+
* structured input as `text`, which the local summarizer prefers over the raw
|
|
88
|
+
* payload — so session summaries read as prose instead of dumped JSON.
|
|
89
|
+
*/
|
|
90
|
+
function describeToolUse(toolName, input) {
|
|
91
|
+
const str = (key) => typeof input?.[key] === "string" ? input[key] : "";
|
|
92
|
+
if (toolName === "Bash") {
|
|
93
|
+
const command = str("command").replace(/\s+/g, " ").trim();
|
|
94
|
+
return truncate(command ? `Ran: ${command}` : "Ran a shell command", MAX_TEXT_CHARS);
|
|
95
|
+
}
|
|
96
|
+
const filePath = str("file_path") || str("path") || str("notebook_path");
|
|
97
|
+
const verb = toolName === "Write" ? "Wrote" : "Edited";
|
|
98
|
+
return truncate(filePath ? `${verb} ${filePath}` : `${verb} a file`, MAX_TEXT_CHARS);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Serialize the event payload, guaranteeing valid JSON within
|
|
102
|
+
* MAX_HOOK_PAYLOAD_CHARS. Per-field shrinking handles the common case; if the
|
|
103
|
+
* result is still over budget (many fields, or one huge key set), drop the
|
|
104
|
+
* structured input and keep only the human-readable summary rather than
|
|
105
|
+
* emitting truncated — and therefore unparseable — JSON.
|
|
106
|
+
*/
|
|
107
|
+
function buildPayloadJson(toolName, text, input, cwd) {
|
|
108
|
+
const full = JSON.stringify({
|
|
109
|
+
tool: toolName,
|
|
110
|
+
text,
|
|
111
|
+
input: shrinkValue(input),
|
|
112
|
+
cwd,
|
|
113
|
+
});
|
|
114
|
+
if (full.length <= MAX_HOOK_PAYLOAD_CHARS)
|
|
115
|
+
return full;
|
|
116
|
+
return JSON.stringify({ tool: toolName, text, cwd, truncated: true });
|
|
117
|
+
}
|
|
43
118
|
/**
|
|
44
119
|
* `sessionmem ingest-hook` — PostToolUse hook handler.
|
|
45
120
|
* Reads Claude Code's PostToolUse JSON payload from stdin and stores it as a
|
|
@@ -47,34 +122,36 @@ function resolveSessionId(payload) {
|
|
|
47
122
|
* Also auto-stores git commits as decision memories (git commit detection).
|
|
48
123
|
*/
|
|
49
124
|
export async function ingestHookCommand(ctx) {
|
|
125
|
+
let context;
|
|
50
126
|
try {
|
|
51
127
|
const payload = await readStdinJson();
|
|
52
128
|
const toolName = payload.tool_name ?? "";
|
|
53
|
-
if (!CAPTURED_TOOL_TYPES.has(toolName))
|
|
129
|
+
if (!CAPTURED_TOOL_TYPES.has(toolName)) {
|
|
130
|
+
debug(`skipped: tool "${toolName}" is not captured`);
|
|
54
131
|
return;
|
|
55
|
-
|
|
132
|
+
}
|
|
133
|
+
context = ctx ?? createCliContext();
|
|
56
134
|
const sessionId = resolveSessionId(payload);
|
|
57
135
|
const projectId = context.projectId;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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", {
|
|
136
|
+
const text = describeToolUse(toolName, payload.tool_input);
|
|
137
|
+
// eventIndex is intentionally omitted: the service assigns the next free
|
|
138
|
+
// index per session inside an immediate transaction, so parallel tool calls
|
|
139
|
+
// cannot collide (see nextSessionEventIndex).
|
|
140
|
+
const result = await context.service.call("ingestSessionEvents", {
|
|
69
141
|
projectId,
|
|
70
142
|
sessionId,
|
|
71
143
|
events: [{
|
|
72
144
|
id: randomUUID(),
|
|
73
|
-
eventIndex: Date.now(), // monotonic within the session
|
|
74
145
|
eventType: `tool_use:${toolName.toLowerCase()}`,
|
|
75
|
-
payloadJson:
|
|
146
|
+
payloadJson: buildPayloadJson(toolName, text, payload.tool_input, payload.cwd),
|
|
76
147
|
}],
|
|
77
148
|
});
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
debug(`ingest failed: ${result.error.message}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
debug(`ingested ${result.ingested} event(s) for session ${sessionId} (project ${projectId})`);
|
|
154
|
+
}
|
|
78
155
|
// Git commit detection: auto-store as a decision memory.
|
|
79
156
|
if (toolName === "Bash") {
|
|
80
157
|
const cmd = typeof payload.tool_input?.command === "string"
|
|
@@ -96,9 +173,21 @@ export async function ingestHookCommand(ctx) {
|
|
|
96
173
|
}
|
|
97
174
|
}
|
|
98
175
|
}
|
|
99
|
-
context.db.close();
|
|
100
176
|
}
|
|
101
|
-
catch {
|
|
102
|
-
// Never block tool use —
|
|
177
|
+
catch (err) {
|
|
178
|
+
// Never block tool use — a hook failure must not surface as a tool error.
|
|
179
|
+
debug(err instanceof Error ? err.message : String(err));
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
// Close only a context this command opened; an injected ctx belongs to the
|
|
183
|
+
// caller (tests reuse it across assertions). Previously the close was
|
|
184
|
+
// inside the try block, so it was skipped on every error path and leaked
|
|
185
|
+
// the sqlite handle.
|
|
186
|
+
if (!ctx && context) {
|
|
187
|
+
try {
|
|
188
|
+
context.db.close();
|
|
189
|
+
}
|
|
190
|
+
catch { /* already closed */ }
|
|
191
|
+
}
|
|
103
192
|
}
|
|
104
193
|
}
|
|
@@ -63,7 +63,9 @@ export function savingsCommand(ctx, options) {
|
|
|
63
63
|
? [
|
|
64
64
|
"Storage compression:",
|
|
65
65
|
" No session data ingested yet.",
|
|
66
|
-
"
|
|
66
|
+
" Session events are captured automatically by the PostToolUse hook.",
|
|
67
|
+
" If this stays empty, run `sessionmem install` to (re)register the hook,",
|
|
68
|
+
" then check `sessionmem stats` for the session_events counter.",
|
|
67
69
|
]
|
|
68
70
|
: [
|
|
69
71
|
"Storage compression:",
|
|
@@ -2,6 +2,7 @@ import { statSync } from "fs";
|
|
|
2
2
|
import { createCliContext } from "../context.js";
|
|
3
3
|
import { countTokens } from "../../core/injection/tokenBudget.js";
|
|
4
4
|
import { listMemoriesByProject } from "../../core/storage/memoryRepo.js";
|
|
5
|
+
import { countDistinctSessions } from "../../core/storage/tokenSavingsRepo.js";
|
|
5
6
|
import { configFilePath, readPolicyConfig, } from "../../core/config/policyConfig.js";
|
|
6
7
|
export async function statsCommand(ctx, options) {
|
|
7
8
|
const context = ctx ?? createCliContext();
|
|
@@ -38,7 +39,13 @@ export async function statsCommand(ctx, options) {
|
|
|
38
39
|
retentionLine = `Retention: ${retentionDays} days (${eligible} memories eligible for pruning)`;
|
|
39
40
|
}
|
|
40
41
|
const redactionLine = `Redaction: ${redactionEnabled ? "enabled" : "disabled"}`;
|
|
42
|
+
// Surface the session-event counters. The auto-ingest pipeline is silent by
|
|
43
|
+
// design (a hook must never fail a tool call), so without these lines a
|
|
44
|
+
// pipeline that captures nothing looks identical to one that works.
|
|
45
|
+
const sessions = countDistinctSessions(context.db, context.projectId);
|
|
41
46
|
process.stdout.write(`memories: ${result.totalMemories}\n` +
|
|
47
|
+
`sessions: ${sessions}\n` +
|
|
48
|
+
`session_events: ${result.totalSessionEvents}\n` +
|
|
42
49
|
`db_size_bytes: ${sizeBytes}\n` +
|
|
43
50
|
`total_content_tokens: ${totalTokens}\n` +
|
|
44
51
|
`${retentionLine}\n` +
|
package/dist/cli/index.js
CHANGED
|
@@ -126,8 +126,6 @@ retention
|
|
|
126
126
|
.option("--days <n>", "Override the retention window in days")
|
|
127
127
|
.action((options) => retentionPruneCommand(options));
|
|
128
128
|
// config command group — generic get/set over ~/.sessionmem/config.json.
|
|
129
|
-
// config get/set are synchronous and take no CliContext, so the arrow-wrap here
|
|
130
|
-
// only drops commander's trailing Command argument.
|
|
131
129
|
const config = program
|
|
132
130
|
.command("config")
|
|
133
131
|
.description("Read and write sessionmem policy config");
|
|
@@ -140,10 +138,6 @@ config
|
|
|
140
138
|
.description("Persist a config key to ~/.sessionmem/config.json")
|
|
141
139
|
.action((key, value) => configSetCommand(key, value));
|
|
142
140
|
// team command group — turn shared-memory mode on/off and inspect it.
|
|
143
|
-
// enable/status take no CliContext (config-only), so their arrow-wraps just drop
|
|
144
|
-
// commander's trailing Command argument. disable accepts an optional CliContext
|
|
145
|
-
// seam for the --remove-team-memories DB delete; the arrow-wrap forwards only
|
|
146
|
-
// options so production falls through to createCliContext() (NOTE above).
|
|
147
141
|
const team = program
|
|
148
142
|
.command("team")
|
|
149
143
|
.description("Manage shared-path team memory mode");
|
|
@@ -161,15 +155,12 @@ team
|
|
|
161
155
|
.description("Show team mode state and shared-path availability")
|
|
162
156
|
.action(() => teamStatusCommand());
|
|
163
157
|
// re-embed — bulk-update stale embeddings to the current version.
|
|
164
|
-
// reEmbedCommand declares a trailing `ctx?` test seam, so arrow-wrap to drop
|
|
165
|
-
// commander's trailing Command argument (NOTE above).
|
|
166
158
|
program
|
|
167
159
|
.command("re-embed")
|
|
168
160
|
.description("Re-embed all memories with stale embedding versions")
|
|
169
161
|
.action(() => reEmbedCommand());
|
|
170
|
-
// sync — push a local snapshot to the shared path and pull every
|
|
171
|
-
//
|
|
172
|
-
// arrow-wrap to drop commander's trailing Command argument (NOTE above).
|
|
162
|
+
// sync — push a local snapshot to the shared path and pull every teammate
|
|
163
|
+
// snapshot back.
|
|
173
164
|
program
|
|
174
165
|
.command("sync")
|
|
175
166
|
.description("Push local memories and pull teammate memories via the shared path")
|
package/dist/cli/projectId.js
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
/** Safety bound on the upward walk in {@link findProjectRoot}. */
|
|
5
|
+
const MAX_PARENT_WALK = 64;
|
|
6
|
+
/**
|
|
7
|
+
* Walk up from `startDir` to the nearest directory containing `.git`, and
|
|
8
|
+
* return it. Returns null when there is no repository above `startDir`.
|
|
9
|
+
*
|
|
10
|
+
* Memory is keyed by project, and a project is the repository — not whichever
|
|
11
|
+
* directory a command happened to run in. Hashing the raw cwd meant every
|
|
12
|
+
* subdirectory got its OWN bucket: the Claude Code hooks fire at the repo root
|
|
13
|
+
* and write there, then `sessionmem stats` run from `src/` reported an empty
|
|
14
|
+
* store, because it was reading a different project entirely.
|
|
15
|
+
*
|
|
16
|
+
* `.git` is matched as a path entry rather than a directory so linked worktrees
|
|
17
|
+
* and submodules (where `.git` is a FILE pointing at the real gitdir) anchor
|
|
18
|
+
* correctly too.
|
|
19
|
+
*/
|
|
20
|
+
export function findProjectRoot(startDir) {
|
|
21
|
+
let current = resolve(startDir);
|
|
22
|
+
for (let depth = 0; depth < MAX_PARENT_WALK; depth += 1) {
|
|
23
|
+
try {
|
|
24
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
25
|
+
if (existsSync(join(current, ".git"))) {
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Unreadable directory (permissions, race): stop rather than throw — the
|
|
31
|
+
// caller falls back to the cwd-derived id.
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const parent = dirname(current);
|
|
35
|
+
if (parent === current)
|
|
36
|
+
break; // filesystem root
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
2
41
|
/**
|
|
3
42
|
* Sanitize a raw token to the filename-safe character set used across
|
|
4
43
|
* sessionmem (mirrors localUsername). Any character outside [A-Za-z0-9._-]
|
|
@@ -45,7 +84,13 @@ export function projectIdFromCwd(cwd) {
|
|
|
45
84
|
/**
|
|
46
85
|
* Resolve the effective project id: the `SESSIONMEM_PROJECT_ID` env override
|
|
47
86
|
* (operator-controlled test/migration seam; also lets a user pin a legacy
|
|
48
|
-
* basename-only id) wins, otherwise derive from
|
|
87
|
+
* basename-only id) wins, otherwise derive from the enclosing repository root,
|
|
88
|
+
* falling back to `process.cwd()` when the cwd is not inside a repository.
|
|
89
|
+
*
|
|
90
|
+
* Anchoring to the repository keeps every command — hooks firing at the root,
|
|
91
|
+
* a `sessionmem stats` typed three directories down — on the same memory
|
|
92
|
+
* bucket. The id is unchanged for anyone already running at the repo root,
|
|
93
|
+
* since the root IS the cwd in that case.
|
|
49
94
|
*/
|
|
50
95
|
export function deriveProjectId() {
|
|
51
96
|
const envProjectId = process.env.SESSIONMEM_PROJECT_ID;
|
|
@@ -65,5 +110,6 @@ export function deriveProjectId() {
|
|
|
65
110
|
return sanitized;
|
|
66
111
|
}
|
|
67
112
|
}
|
|
68
|
-
|
|
113
|
+
const cwd = process.cwd();
|
|
114
|
+
return projectIdFromCwd(findProjectRoot(cwd) ?? cwd);
|
|
69
115
|
}
|
|
@@ -65,7 +65,12 @@ export const memorySchema = z.object({
|
|
|
65
65
|
});
|
|
66
66
|
export const ingestSessionEventSchema = z.object({
|
|
67
67
|
id: z.string().min(1).max(200),
|
|
68
|
-
|
|
68
|
+
// Optional: callers that already track an ordering key (batch ingestion of a
|
|
69
|
+
// transcript) supply it; callers that fire one event at a time from separate
|
|
70
|
+
// processes (the PostToolUse hook) omit it and the service assigns the next
|
|
71
|
+
// free index per session. See nextSessionEventIndex for why inventing a
|
|
72
|
+
// wall-clock index silently loses events.
|
|
73
|
+
eventIndex: z.number().int().nonnegative().optional(),
|
|
69
74
|
eventType: z.string().min(1).max(100),
|
|
70
75
|
payloadJson: z
|
|
71
76
|
.string()
|
|
@@ -8,7 +8,7 @@ import { applyRedaction } from "../summarize/redaction.js";
|
|
|
8
8
|
import { countAllMemoriesByProject, countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, deleteMemoryById, getMemoryOwnerProjectId, getMemoryRecordById, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertImportedMemory, upsertPulledMemory, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
|
|
9
9
|
import { SESSION_WRITE_SOFT_LIMIT, configFilePath, DEEP_MODE_RETRIEVAL_CAP, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
|
|
10
10
|
import { insertMemoryFeedbackEvent } from "../storage/memoryFeedbackRepo.js";
|
|
11
|
-
import { countAllSessionEvents, insertSessionEvent } from "../storage/sessionEventsRepo.js";
|
|
11
|
+
import { countAllSessionEvents, insertSessionEvent, nextSessionEventIndex, } from "../storage/sessionEventsRepo.js";
|
|
12
12
|
import { batchStoreMemoryItemSchema, batchStoreMemoryRequestSchema, exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, LIST_MEMORIES_DEFAULT_LIMIT, listMemoriesRequestSchema, pruneMemoriesRequestSchema, redactExistingRequestSchema, resetAccessCountsRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.js";
|
|
13
13
|
import { DomainError, toErrorEnvelope } from "./errors.js";
|
|
14
14
|
import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
|
|
@@ -186,15 +186,25 @@ export function createMemoryCoreService(deps) {
|
|
|
186
186
|
const redactionEnabled = resolveRedactionEnabled(undefined);
|
|
187
187
|
const ingest = db.transaction(() => {
|
|
188
188
|
let written = 0;
|
|
189
|
+
// Events that omit eventIndex are appended after the session's current
|
|
190
|
+
// maximum. Read it once inside the transaction and increment locally so
|
|
191
|
+
// a multi-event batch stays contiguous.
|
|
192
|
+
let nextIndex;
|
|
189
193
|
for (const event of parsed.events) {
|
|
190
194
|
const redactedPayload = applyRedaction(event.payloadJson, {
|
|
191
195
|
redactionEnabled,
|
|
192
196
|
}).text;
|
|
197
|
+
let eventIndex = event.eventIndex;
|
|
198
|
+
if (eventIndex === undefined) {
|
|
199
|
+
nextIndex ??= nextSessionEventIndex(db, parsed.projectId, parsed.sessionId);
|
|
200
|
+
eventIndex = nextIndex;
|
|
201
|
+
nextIndex += 1;
|
|
202
|
+
}
|
|
193
203
|
written += insertSessionEvent(db, {
|
|
194
204
|
id: event.id,
|
|
195
205
|
project_id: parsed.projectId,
|
|
196
206
|
session_id: parsed.sessionId,
|
|
197
|
-
event_index:
|
|
207
|
+
event_index: eventIndex,
|
|
198
208
|
event_type: event.eventType,
|
|
199
209
|
payload_json: redactedPayload,
|
|
200
210
|
created_at: event.createdAt,
|
|
@@ -202,7 +212,11 @@ export function createMemoryCoreService(deps) {
|
|
|
202
212
|
}
|
|
203
213
|
return written;
|
|
204
214
|
});
|
|
205
|
-
|
|
215
|
+
// `immediate` takes the write lock up front so two concurrent hook
|
|
216
|
+
// processes cannot both read the same next index and have one insert
|
|
217
|
+
// silently ignored. busy_timeout (db.ts) makes the loser wait rather
|
|
218
|
+
// than fail.
|
|
219
|
+
const ingested = ingest.immediate();
|
|
206
220
|
return {
|
|
207
221
|
ok: true,
|
|
208
222
|
ingested,
|
|
@@ -43,7 +43,7 @@ export function retrieveMemories(input) {
|
|
|
43
43
|
const now = input.now ?? new Date();
|
|
44
44
|
// Use FTS5 pre-filtering to limit cosine similarity computation to ~50 candidates.
|
|
45
45
|
// queryText is guaranteed non-empty by the Zod schema (z.string().min(1)) upstream.
|
|
46
|
-
const candidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText);
|
|
46
|
+
const candidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText, now);
|
|
47
47
|
const decayedCandidates = decayOldBoosts(candidates, now);
|
|
48
48
|
const dimension = resolveEmbeddingDimension(candidates);
|
|
49
49
|
const queryVector = deterministicEmbed(queryText, dimension).vector;
|
|
@@ -107,7 +107,7 @@ function getStatements(db) {
|
|
|
107
107
|
FROM memories
|
|
108
108
|
WHERE project_id = ?
|
|
109
109
|
AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
110
|
-
ORDER BY updated_at DESC
|
|
110
|
+
ORDER BY updated_at DESC, id ASC
|
|
111
111
|
`),
|
|
112
112
|
// Lightweight projection for token-savings accounting: only `content` is
|
|
113
113
|
// needed to count tokens, so we deliberately avoid pulling the (potentially
|
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
import { EMBEDDING_VERSION } from "../embed/embeddingVersion.js";
|
|
2
2
|
const FTS_CANDIDATE_LIMIT = 50;
|
|
3
3
|
const FTS_FALLBACK_THRESHOLD = 5;
|
|
4
|
+
/** Recency window for the fallback candidate scan. */
|
|
5
|
+
const FALLBACK_RECENCY_DAYS = 90;
|
|
6
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
7
|
+
/** Storage timestamp format: ISO-8601 UTC with millisecond precision. */
|
|
8
|
+
function toStorageTimestamp(date) {
|
|
9
|
+
return date.toISOString();
|
|
10
|
+
}
|
|
4
11
|
const searchStmtCache = new WeakMap();
|
|
5
12
|
function getSearchStatements(db) {
|
|
6
13
|
let stmts = searchStmtCache.get(db);
|
|
7
14
|
if (stmts)
|
|
8
15
|
return stmts;
|
|
9
16
|
stmts = {
|
|
17
|
+
// `now` and the recency cutoff are bound parameters rather than
|
|
18
|
+
// strftime('now') so the caller's clock drives both filters. With SQL's own
|
|
19
|
+
// clock, a caller that passes an explicit `now` (retrieveMemories, and every
|
|
20
|
+
// test that pins a date) silently got a different window than it asked for,
|
|
21
|
+
// and fixed-date fixtures aged out of the 90-day band over real calendar
|
|
22
|
+
// time — turning deterministic tests into time bombs.
|
|
10
23
|
searchCandidates: db.prepare(`
|
|
11
24
|
SELECT
|
|
12
25
|
id, project_id, session_id, source_adapter, kind, content, normalized_content,
|
|
13
26
|
importance, author, origin_project_id, access_count, created_at, updated_at,
|
|
14
27
|
embedding, embedding_dim, embedding_version
|
|
15
28
|
FROM memories
|
|
16
|
-
WHERE project_id =
|
|
17
|
-
AND (expires_at IS NULL OR expires_at >
|
|
29
|
+
WHERE project_id = @projectId
|
|
30
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
18
31
|
AND (
|
|
19
32
|
importance >= 8
|
|
20
|
-
OR updated_at >
|
|
33
|
+
OR updated_at > @recencyCutoff
|
|
21
34
|
)
|
|
22
35
|
`),
|
|
23
36
|
searchCandidatesFTS: db.prepare(`
|
|
@@ -28,11 +41,11 @@ function getSearchStatements(db) {
|
|
|
28
41
|
m.embedding, m.embedding_dim, m.embedding_version
|
|
29
42
|
FROM memories_fts
|
|
30
43
|
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
31
|
-
WHERE memories_fts MATCH
|
|
32
|
-
AND m.project_id =
|
|
33
|
-
AND (m.expires_at IS NULL OR m.expires_at >
|
|
44
|
+
WHERE memories_fts MATCH @match
|
|
45
|
+
AND m.project_id = @projectId
|
|
46
|
+
AND (m.expires_at IS NULL OR m.expires_at > @now)
|
|
34
47
|
ORDER BY rank
|
|
35
|
-
LIMIT
|
|
48
|
+
LIMIT @limit
|
|
36
49
|
`),
|
|
37
50
|
};
|
|
38
51
|
searchStmtCache.set(db, stmts);
|
|
@@ -74,8 +87,12 @@ function mapRows(rows) {
|
|
|
74
87
|
};
|
|
75
88
|
});
|
|
76
89
|
}
|
|
77
|
-
export function searchMemoryCandidates(db, projectId) {
|
|
78
|
-
const rows = getSearchStatements(db).searchCandidates.all(
|
|
90
|
+
export function searchMemoryCandidates(db, projectId, now = new Date()) {
|
|
91
|
+
const rows = getSearchStatements(db).searchCandidates.all({
|
|
92
|
+
projectId,
|
|
93
|
+
now: toStorageTimestamp(now),
|
|
94
|
+
recencyCutoff: toStorageTimestamp(new Date(now.getTime() - FALLBACK_RECENCY_DAYS * MS_PER_DAY)),
|
|
95
|
+
});
|
|
79
96
|
return mapRows(rows);
|
|
80
97
|
}
|
|
81
98
|
/**
|
|
@@ -107,23 +124,28 @@ function sanitizeFtsQuery(queryText) {
|
|
|
107
124
|
* (<8) — exactly the rows the fallback's filter excludes — returning zero
|
|
108
125
|
* candidates for a query that matched only such rows.
|
|
109
126
|
*/
|
|
110
|
-
export function searchMemoryCandidatesFTS(db, projectId, queryText) {
|
|
127
|
+
export function searchMemoryCandidatesFTS(db, projectId, queryText, now = new Date()) {
|
|
111
128
|
const sanitized = sanitizeFtsQuery(queryText);
|
|
112
129
|
if (!sanitized) {
|
|
113
|
-
return searchMemoryCandidates(db, projectId);
|
|
130
|
+
return searchMemoryCandidates(db, projectId, now);
|
|
114
131
|
}
|
|
115
132
|
let rows;
|
|
116
133
|
try {
|
|
117
|
-
rows = getSearchStatements(db).searchCandidatesFTS.all(
|
|
134
|
+
rows = getSearchStatements(db).searchCandidatesFTS.all({
|
|
135
|
+
match: sanitized,
|
|
136
|
+
projectId,
|
|
137
|
+
now: toStorageTimestamp(now),
|
|
138
|
+
limit: FTS_CANDIDATE_LIMIT,
|
|
139
|
+
});
|
|
118
140
|
}
|
|
119
141
|
catch {
|
|
120
142
|
// FTS5 MATCH can throw on malformed queries — fall back to full scan
|
|
121
|
-
return searchMemoryCandidates(db, projectId);
|
|
143
|
+
return searchMemoryCandidates(db, projectId, now);
|
|
122
144
|
}
|
|
123
145
|
if (rows.length >= FTS_FALLBACK_THRESHOLD) {
|
|
124
146
|
return dedupById(mapRows(rows));
|
|
125
147
|
}
|
|
126
|
-
const fallback = searchMemoryCandidates(db, projectId);
|
|
148
|
+
const fallback = searchMemoryCandidates(db, projectId, now);
|
|
127
149
|
if (rows.length === 0) {
|
|
128
150
|
return fallback;
|
|
129
151
|
}
|
|
@@ -22,6 +22,11 @@ function getSessionEventsStatements(db) {
|
|
|
22
22
|
ORDER BY event_index ASC
|
|
23
23
|
`),
|
|
24
24
|
countAll: db.prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?"),
|
|
25
|
+
nextEventIndex: db.prepare(`
|
|
26
|
+
SELECT COALESCE(MAX(event_index), -1) + 1 AS next
|
|
27
|
+
FROM session_events
|
|
28
|
+
WHERE project_id = ? AND session_id = ?
|
|
29
|
+
`),
|
|
25
30
|
};
|
|
26
31
|
sessionEventsStmtCache.set(db, stmts);
|
|
27
32
|
return stmts;
|
|
@@ -34,6 +39,22 @@ function getSessionEventsStatements(db) {
|
|
|
34
39
|
export function insertSessionEvent(db, input) {
|
|
35
40
|
return getSessionEventsStatements(db).insertEvent.run(input).changes;
|
|
36
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Next free `event_index` for a session (max + 1, or 0 when the session has no
|
|
44
|
+
* events yet).
|
|
45
|
+
*
|
|
46
|
+
* Callers that have no natural ordering key of their own (the PostToolUse hook
|
|
47
|
+
* fires once per tool use, in separate processes) use this instead of inventing
|
|
48
|
+
* one. A wall-clock index collides whenever two tool uses land in the same
|
|
49
|
+
* millisecond — which parallel tool calls routinely do — and the UNIQUE index
|
|
50
|
+
* on (project_id, session_id, event_index) then makes `INSERT OR IGNORE` drop
|
|
51
|
+
* the loser silently. Must be called inside the same immediate transaction as
|
|
52
|
+
* the insert so concurrent writers cannot read the same value.
|
|
53
|
+
*/
|
|
54
|
+
export function nextSessionEventIndex(db, projectId, sessionId) {
|
|
55
|
+
const row = getSessionEventsStatements(db).nextEventIndex.get(projectId, sessionId);
|
|
56
|
+
return row.next;
|
|
57
|
+
}
|
|
37
58
|
export function countAllSessionEvents(db, projectId) {
|
|
38
59
|
const row = getSessionEventsStatements(db).countAll.get(projectId);
|
|
39
60
|
return row.count;
|
|
@@ -9,6 +9,43 @@
|
|
|
9
9
|
// applyRedaction invocation — a measurable saving in batch/import/pull loops.
|
|
10
10
|
// The regex literals carry no `lastIndex` state because every pattern is used
|
|
11
11
|
// with String.prototype.replace (not stateful .test()/.exec() on a shared regex).
|
|
12
|
+
/**
|
|
13
|
+
* Key names that mark an assignment's value as a secret. Matched
|
|
14
|
+
* case-insensitively and allowed to carry bounded affixes on either side, so
|
|
15
|
+
* `AWS_SECRET_ACCESS_KEY` and `SECRET_TOKEN_VALUE` match as readily as `secret`.
|
|
16
|
+
*/
|
|
17
|
+
const SECRET_KEY_NAMES = [
|
|
18
|
+
"password",
|
|
19
|
+
"passwd",
|
|
20
|
+
"pwd",
|
|
21
|
+
"secret",
|
|
22
|
+
"api[_-]?key",
|
|
23
|
+
"apikey",
|
|
24
|
+
"access[_-]?token",
|
|
25
|
+
"refresh[_-]?token",
|
|
26
|
+
"auth[_-]?token",
|
|
27
|
+
"client[_-]?secret",
|
|
28
|
+
"private[_-]?key",
|
|
29
|
+
"credentials?",
|
|
30
|
+
"token",
|
|
31
|
+
].join("|");
|
|
32
|
+
/**
|
|
33
|
+
* `<key><separator><value>` where the key names a secret. Capture groups are
|
|
34
|
+
* (key, separator, value) so the replacement can keep the key and separator
|
|
35
|
+
* verbatim and swap only the value.
|
|
36
|
+
*
|
|
37
|
+
* Both `:` and `=` are accepted (YAML, JSON, `.env`, query strings, log lines),
|
|
38
|
+
* and the value may be backslash-escaped-quoted, double-quoted, single-quoted,
|
|
39
|
+
* or bare. Every quantifier is bounded, so the pattern is ReDoS-safe.
|
|
40
|
+
*
|
|
41
|
+
* The escaped-quote branch comes first and the bare branch excludes `\`, which
|
|
42
|
+
* together keep a JSON payload parseable after redaction. Session events store
|
|
43
|
+
* JSON, so a secret inside a stringified field arrives as `password=\"a b\"`;
|
|
44
|
+
* letting the bare branch match the lone backslash replaced it with
|
|
45
|
+
* `[REDACTED]` and left the rest of the quoted run stranded, producing
|
|
46
|
+
* unparseable JSON.
|
|
47
|
+
*/
|
|
48
|
+
const SECRET_ASSIGNMENT_PATTERN = new RegExp(String.raw `\b([A-Za-z0-9_.-]{0,40}(?:${SECRET_KEY_NAMES})[A-Za-z0-9_.-]{0,40}\\?"?)(\s*[:=]\s*)(\\"(?:\\.|[^"\\]){0,4096}\\"|"[^"\n]{1,4096}"|'[^'\n]{1,4096}'|[^\s"'\\,;&}]{1,4096})`, "gi");
|
|
12
49
|
const DEFAULT_RULES = createDefaultRules();
|
|
13
50
|
function createDefaultRules() {
|
|
14
51
|
return [
|
|
@@ -49,16 +86,39 @@ function createDefaultRules() {
|
|
|
49
86
|
(input) => input.replace(/\bsk-(?:[a-zA-Z0-9]{1,32}-){0,4}[a-zA-Z0-9]{12,200}\b/g, "[REDACTED_API_KEY]"),
|
|
50
87
|
// Bearer token header value: "Bearer <token>" (case-insensitive), token redacted.
|
|
51
88
|
(input) => input.replace(/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, "Bearer [REDACTED_BEARER_TOKEN]"),
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
(input) => input.replace(
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
89
|
+
// HTTP Basic auth header: the base64 blob encodes user:password verbatim.
|
|
90
|
+
(input) => input.replace(/\bBasic\s+[A-Za-z0-9+/]{12,}={0,2}/g, "Basic [REDACTED_BASIC_AUTH]"),
|
|
91
|
+
// Slack incoming-webhook URLs — the path IS the credential, so the whole
|
|
92
|
+
// path is collapsed rather than a token-shaped substring of it.
|
|
93
|
+
(input) => input.replace(/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]{10,200}/g, "https://hooks.slack.com/services/[REDACTED_SLACK_WEBHOOK]"),
|
|
94
|
+
// Secret-bearing assignments: `<key><sep><value>` for a known secret-ish key
|
|
95
|
+
// name, covering config files, .env exports, YAML, JSON bodies, log lines
|
|
96
|
+
// and URL query strings in one rule.
|
|
97
|
+
//
|
|
98
|
+
// Three things the narrower predecessors missed, each a real leak:
|
|
99
|
+
// - `:` as a separator (YAML, JSON, `Header: value` log lines). Only `=`
|
|
100
|
+
// was handled, so `aws_secret_access_key: wJalr…` passed through.
|
|
101
|
+
// - Affixed key names. The keyword had to sit on a \b boundary, and `_` is
|
|
102
|
+
// a word character, so `AWS_SECRET_ACCESS_KEY` and `SECRET_TOKEN_VALUE`
|
|
103
|
+
// never matched. Allow bounded affixes on both sides.
|
|
104
|
+
// - Quoted values. The value class excluded quotes, so `password="a b c"`
|
|
105
|
+
// matched nothing at all.
|
|
106
|
+
//
|
|
107
|
+
// Quoted values keep their delimiters so a redacted JSON payload stays
|
|
108
|
+
// parseable. All quantifiers are bounded and the alternation is
|
|
109
|
+
// non-backtracking on failure, so the rule stays ReDoS-safe.
|
|
110
|
+
(input) => input.replace(SECRET_ASSIGNMENT_PATTERN, (_match, key, separator, value) => {
|
|
111
|
+
// Preserve the value's quote style so a redacted JSON payload stays
|
|
112
|
+
// parseable and a redacted YAML line stays well-formed.
|
|
113
|
+
const quote = value.startsWith('\\"')
|
|
114
|
+
? '\\"'
|
|
115
|
+
: value.startsWith('"')
|
|
116
|
+
? '"'
|
|
117
|
+
: value.startsWith("'")
|
|
118
|
+
? "'"
|
|
119
|
+
: "";
|
|
120
|
+
return `${key}${separator}${quote}[REDACTED]${quote}`;
|
|
121
|
+
}),
|
|
62
122
|
];
|
|
63
123
|
}
|
|
64
124
|
export function applyRedaction(input, options) {
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/** Max distinct lines rendered per section. */
|
|
2
|
+
const MAX_SECTION_LINES = 5;
|
|
3
|
+
/** Max `- [type] text` fact lines in the facts section. */
|
|
4
|
+
const MAX_FACT_LINES = 8;
|
|
5
|
+
/**
|
|
6
|
+
* Human-readable text for an event. The auto-ingest hook writes a `text` field
|
|
7
|
+
* carrying a one-line description ("Ran: npm test", "Edited src/foo.ts"); fall
|
|
8
|
+
* back to the raw payload for events written by other producers.
|
|
9
|
+
*/
|
|
1
10
|
function extractEventText(event) {
|
|
2
11
|
try {
|
|
3
12
|
const parsed = JSON.parse(event.payload_json);
|
|
@@ -10,34 +19,55 @@ function extractEventText(event) {
|
|
|
10
19
|
}
|
|
11
20
|
return event.payload_json.trim();
|
|
12
21
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
/** Distinct, non-empty lines, preserving first-seen order. */
|
|
23
|
+
function distinctLines(events) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const lines = [];
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
const line = extractEventText(event);
|
|
28
|
+
if (line.length === 0 || seen.has(line))
|
|
29
|
+
continue;
|
|
30
|
+
seen.add(line);
|
|
31
|
+
lines.push(line);
|
|
17
32
|
}
|
|
18
|
-
return lines
|
|
33
|
+
return lines;
|
|
19
34
|
}
|
|
35
|
+
function section(title, lines, fallback) {
|
|
36
|
+
const body = lines.length > 0 ? lines : [fallback];
|
|
37
|
+
return [title, ...body.map((line) => `- ${line}`)];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Render the session's events as a structured summary.
|
|
41
|
+
*
|
|
42
|
+
* Sections carry DIFFERENT slices of the session rather than the same lines
|
|
43
|
+
* repeated under every heading: `actions` is what happened in order, `outcomes`
|
|
44
|
+
* is where the session ended up, and `decisions` surfaces the events that
|
|
45
|
+
* recorded a choice (git commits). Repeating one slice under all five headings
|
|
46
|
+
* produced a summary that cost five times the tokens and carried no extra
|
|
47
|
+
* signal — and, once stored, was what every later session got injected with.
|
|
48
|
+
*/
|
|
20
49
|
export function buildStructuredSummary(events, options) {
|
|
21
|
-
const
|
|
50
|
+
const lines = distinctLines(events);
|
|
51
|
+
// Ordered narrative of the session, capped from the front.
|
|
52
|
+
const actions = lines.slice(0, MAX_SECTION_LINES);
|
|
53
|
+
// Where the session ended up: the tail, which is the most recent state.
|
|
54
|
+
const outcomes = lines.length > MAX_SECTION_LINES
|
|
55
|
+
? lines.slice(-Math.min(MAX_SECTION_LINES, lines.length - MAX_SECTION_LINES))
|
|
56
|
+
: lines.slice(-1);
|
|
57
|
+
// Commits are the durable decisions a session leaves behind.
|
|
58
|
+
const decisions = lines
|
|
59
|
+
.filter((line) => /\bgit\s+commit\b/i.test(line))
|
|
60
|
+
.slice(0, MAX_SECTION_LINES);
|
|
22
61
|
const summarySection = [
|
|
23
|
-
"
|
|
24
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
25
|
-
"",
|
|
26
|
-
"actions",
|
|
27
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
28
|
-
"",
|
|
29
|
-
"decisions",
|
|
30
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
62
|
+
...section("actions", actions, "No relevant events captured."),
|
|
31
63
|
"",
|
|
32
|
-
"
|
|
33
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
64
|
+
...section("decisions", decisions, "No explicit decisions recorded."),
|
|
34
65
|
"",
|
|
35
|
-
"outcomes",
|
|
36
|
-
...sharedLines.map((line) => `- ${line}`),
|
|
66
|
+
...section("outcomes", outcomes, "No outcomes captured."),
|
|
37
67
|
].join("\n");
|
|
38
68
|
const facts = events
|
|
39
|
-
.
|
|
40
|
-
.
|
|
69
|
+
.slice(0, MAX_FACT_LINES)
|
|
70
|
+
.map((event) => `- [${event.event_type}] ${extractEventText(event)}`);
|
|
41
71
|
const factSection = ["facts", ...facts].join("\n");
|
|
42
72
|
if (options.factMode === "summary-only") {
|
|
43
73
|
return summarySection;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sessionmem",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Local-first MCP memory layer for coding agents across Claude Code, Codex, Cursor, Cline, Windsurf, and other MCP-compatible hosts.",
|
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
"lint": "eslint .",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
30
|
"benchmark": "node scripts/benchmark.mjs",
|
|
31
|
-
"
|
|
31
|
+
"version": "node scripts/sync-version.mjs && git add package/package.json .claude-plugin/plugin.json server.json",
|
|
32
|
+
"postversion": "npm run build"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@anthropic-ai/sdk": "^0.
|
|
35
|
+
"@anthropic-ai/sdk": "^0.107.0",
|
|
35
36
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
36
37
|
"better-sqlite3": "^12.4.1",
|
|
37
38
|
"commander": "^15.0.0",
|
package/dist/core/session.js
DELETED