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 CHANGED
@@ -1,12 +1,35 @@
1
1
  # sessionmem
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/sessionmem.svg)](https://www.npmjs.com/package/sessionmem) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/catfish-1234/sessionmem/blob/main/LICENSE)
3
+ <p align="center">
4
+ <img src=".github/sessionmem-logo.svg" alt="sessionmem" width="480">
5
+ </p>
4
6
 
5
- **Give your AI coding assistant a memory that lasts beyond one conversation, stored entirely on your own computer.**
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
- `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.
12
+ **85.6% fewer tokens. Every session starts knowing your codebase. Stored entirely on your machine.**
8
13
 
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.
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 MAX_TAG_LEN = 200;
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 = options.threshold ? parseFloat(options.threshold) : SIMILARITY_THRESHOLD;
50
+ const threshold = resolveThreshold(options.threshold);
20
51
  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) {
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 < 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);
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, MAX_TAG_LEN),
41
- contentB: b.content.slice(0, MAX_TAG_LEN),
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 >= MAX_TAG_LEN ? "…" : ""}\n`);
54
- process.stdout.write(` B [${pair.idB}]: ${pair.contentB}${pair.contentB.length >= MAX_TAG_LEN ? "…" : ""}\n`);
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
- // 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);
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
- process.stdout.write(` → Deleted ${dropId} (kept ${keepId})\n`);
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 && pairs.length > 0) {
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
- const context = ctx ?? createCliContext();
132
+ }
133
+ context = ctx ?? createCliContext();
56
134
  const sessionId = resolveSessionId(payload);
57
135
  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", {
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: trimmedPayload,
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 — swallow all errors silently.
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
- " Tip: call ingestSessionEvents during sessions to track token usage.",
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
- // teammate snapshot back. syncCommand declares a trailing `ctx?` test seam, so
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")
@@ -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 `process.cwd()`.
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
- return projectIdFromCwd(process.cwd());
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
- eventIndex: z.number().int().nonnegative(),
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: event.eventIndex,
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
- const ingested = ingest();
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 > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
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 > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days')
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 > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
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(projectId);
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(sanitized, projectId, FTS_CANDIDATE_LIMIT);
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
- // Config/connection-string assignments: key=value where key is a known
53
- // secret-bearing name. The key is kept and the value redacted. Covers
54
- // password/secret plus api_key/apikey/token/access_token/pwd in URL query
55
- // strings and config files. The `=` separator may carry surrounding spaces.
56
- (input) => input.replace(/\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|token)\s*=\s*([^\s"'&;]+)/gi, "$1=[REDACTED]"),
57
- // JSON-form secret assignments: "key": "value" for known secret-bearing
58
- // keys. Complements the key=value rule above so secrets embedded in JSON
59
- // payloads (config files, logged request bodies) are redacted too. The key
60
- // is preserved; the quoted value is collapsed.
61
- (input) => input.replace(/"(password|secret|api_key|token|access_token|auth_token|client_secret|private_key|pwd|passwd)"\s*:\s*"[^"]{4,}"/gi, '"$1": "[REDACTED]"'),
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
- function sectionLines(events, fallback) {
14
- const lines = events.map(extractEventText).filter((line) => line.length > 0);
15
- if (lines.length === 0) {
16
- return [fallback];
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.slice(0, 3);
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 sharedLines = sectionLines(events, "No relevant events captured.");
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
- "goals",
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
- "blockers",
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
- .map((event) => `- [${event.event_type}] ${extractEventText(event)}`)
40
- .slice(0, 5);
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.2.0",
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
- "postversion": "node -e \"const v=require('./package.json').version; ['package/package.json','.claude-plugin/plugin.json','server.json'].forEach(f=>{const o=require('./'+f);o.version=v;if(o.packages&&o.packages[0])o.packages[0].version=v;require('fs').writeFileSync('./'+f,JSON.stringify(o,null,2)+'\\n')})\" && npm run build"
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.105.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",
@@ -1,4 +0,0 @@
1
- export function onSessionDisconnect(adapterName, error) {
2
- const reason = error ? error.message : "unknown cause";
3
- console.warn(`[sessionmem] ${adapterName} disconnected mid-session (${reason}). Continuing.`);
4
- }