sessionmem 1.2.0 → 1.4.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.
Files changed (41) hide show
  1. package/.github/sessionmem-logo.svg +57 -0
  2. package/CHANGELOG.md +84 -0
  3. package/CONTRIBUTING.md +130 -0
  4. package/README.md +153 -82
  5. package/SECURITY.md +33 -0
  6. package/dist/cli/commands/dedupe.js +89 -22
  7. package/dist/cli/commands/import.js +15 -44
  8. package/dist/cli/commands/ingestHook.js +107 -18
  9. package/dist/cli/commands/install.js +3 -3
  10. package/dist/cli/commands/list.js +3 -0
  11. package/dist/cli/commands/run.js +10 -12
  12. package/dist/cli/commands/savings.js +3 -1
  13. package/dist/cli/commands/search.js +8 -4
  14. package/dist/cli/commands/stats.js +7 -0
  15. package/dist/cli/commands/uninstall.js +2 -1
  16. package/dist/cli/context.js +8 -6
  17. package/dist/cli/index.js +8 -17
  18. package/dist/cli/projectId.js +48 -2
  19. package/dist/core/api/contracts.js +6 -1
  20. package/dist/core/api/memoryCoreService.js +17 -3
  21. package/dist/core/config/policyConfig.js +5 -1
  22. package/dist/core/injection/tokenBudget.js +10 -2
  23. package/dist/core/retrieve/retrieveMemories.js +1 -1
  24. package/dist/core/schema/runMigrations.js +9 -5
  25. package/dist/core/storage/db.js +31 -9
  26. package/dist/core/storage/memoryRepo.js +1 -1
  27. package/dist/core/storage/memorySearchRepo.js +36 -14
  28. package/dist/core/storage/sessionEventsRepo.js +21 -0
  29. package/dist/core/summarize/cloudSummarizer.js +3 -8
  30. package/dist/core/summarize/redaction.js +70 -10
  31. package/dist/core/summarize/summaryShape.js +50 -20
  32. package/docs/architecture.md +53 -0
  33. package/docs/benchmark.md +50 -0
  34. package/docs/cloud-summarization.md +57 -0
  35. package/docs/migration.md +42 -0
  36. package/docs/privacy-and-retention.md +139 -0
  37. package/docs/team-mode.md +117 -0
  38. package/docs/troubleshooting.md +78 -0
  39. package/docs/typescript-upgrade.md +48 -0
  40. package/package.json +26 -13
  41. package/dist/core/session.js +0 -4
@@ -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(raw.trim());
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
  }
@@ -14,7 +14,7 @@ export async function importCommand(pathArg, options, ctx) {
14
14
  // Read and parse the JSON file
15
15
  let parsed;
16
16
  try {
17
- const raw = readFileSync(inPath, "utf8");
17
+ const raw = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(inPath));
18
18
  parsed = JSON.parse(raw);
19
19
  }
20
20
  catch (err) {
@@ -27,61 +27,32 @@ export async function importCommand(pathArg, options, ctx) {
27
27
  console.error("Import file must be a JSON array of memory records.");
28
28
  process.exit(1);
29
29
  }
30
- const records = parsed;
31
- let toImport = records;
32
- let skippedCount = 0;
33
- if (!options.merge) {
34
- // By default, skip existing IDs (pre-filter). `id` is a
35
- // globally-unique PRIMARY KEY (not scoped by project), so the duplicate
36
- // check must consider every project's ids -- otherwise a record whose id
37
- // collides with another project's memory would not be pre-filtered here
38
- // and could fall through to the service's ON CONFLICT(id) upsert.
39
- const existingIds = listAllMemoryIds(context.db);
40
- const filtered = records.filter((r) => {
41
- const id = typeof r.id === "string" ? r.id : undefined;
42
- return id === undefined || !existingIds.has(id);
43
- });
44
- skippedCount = records.length - filtered.length;
45
- toImport = filtered;
46
- }
47
- if (toImport.length === 0 && skippedCount > 0) {
48
- console.log(`Imported 0, skipped ${skippedCount} duplicates.`);
49
- return;
50
- }
51
- // Map to the expected shape, then validate each record before sending to the service
52
- const mapped = toImport.map((r) => ({
53
- id: r.id,
54
- projectId: r.projectId ?? context.projectId,
55
- sessionId: r.sessionId,
56
- sourceAdapter: r.sourceAdapter,
57
- kind: r.kind,
58
- content: r.content,
59
- importance: r.importance,
60
- createdAt: r.createdAt,
61
- updatedAt: r.updatedAt,
62
- }));
63
- // Validate each record but skip-and-warn on individual invalid
64
- // records (consistent with the duplicate-skip UX) instead of aborting the
65
- // entire import on the first invalid record, which would discard earlier
66
- // valid records with no partial import.
30
+ const existingIds = options.merge ? undefined : listAllMemoryIds(context.db);
67
31
  const validMemories = [];
32
+ let skippedCount = 0;
68
33
  let invalidCount = 0;
69
- for (let i = 0; i < mapped.length; i++) {
70
- const check = importMemoryRecordSchema.safeParse(mapped[i]);
34
+ for (const [index, record] of parsed.entries()) {
35
+ const isRecord = record !== null && typeof record === "object" && !Array.isArray(record);
36
+ if (isRecord && typeof record.id === "string" && existingIds?.has(record.id)) {
37
+ skippedCount += 1;
38
+ continue;
39
+ }
40
+ // Validate unknown values before accessing fields. Keep the source index in
41
+ // diagnostics even when earlier records were duplicates, and preserve the
42
+ // optional provenance fields carried by exports.
43
+ const check = importMemoryRecordSchema.safeParse(isRecord ? { ...record, projectId: record.projectId ?? context.projectId } : record);
71
44
  if (!check.success) {
72
- console.error(`Record at index ${i} is invalid, skipping: ${check.error.message}`);
45
+ console.error(`Record at index ${index} is invalid, skipping: ${check.error.message}`);
73
46
  invalidCount += 1;
74
47
  continue;
75
48
  }
76
- // Use the parsed/transformed record so `kind` is narrowed to the
77
- // canonical enum (memoryKindSchema maps legacy values like `architecture`).
78
49
  validMemories.push(check.data);
79
50
  }
80
51
  if (validMemories.length === 0) {
81
52
  if (invalidCount > 0) {
82
53
  console.log(`Imported 0, skipped ${invalidCount} invalid record(s).`);
83
54
  }
84
- else if (skippedCount > 0) {
55
+ else {
85
56
  console.log(`Imported 0, skipped ${skippedCount} duplicates.`);
86
57
  }
87
58
  return;
@@ -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
  }
@@ -3,7 +3,7 @@ import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import { AdapterFactory } from "../../adapters/factory.js";
5
5
  import { injectGuidanceBlock } from "../../adapters/claudeMdInjector.js";
6
- import { createCliContext } from "../context.js";
6
+ import { createCliContext, resolveDbPath } from "../context.js";
7
7
  /**
8
8
  * Resolve the adapter to install into: an explicit `--adapter` flag wins, then
9
9
  * the SESSIONMEM_ADAPTER env override, else auto-detection from the host env.
@@ -53,8 +53,8 @@ export async function installCommand(options, contextOverrides) {
53
53
  dbPath = ctx.dbPath;
54
54
  }
55
55
  catch (err) {
56
- console.error(`✗ DB init failed (~/.sessionmem/memories.db): ${err instanceof Error ? err.message : String(err)}`);
57
- console.error("Hint: ensure ~/.sessionmem directory is writable and run `sessionmem install` again.");
56
+ console.error(`✗ DB init failed (${resolveDbPath(contextOverrides?.dbPath)}): ${err instanceof Error ? err.message : String(err)}`);
57
+ console.error("Hint: ensure the database parent directory is writable and run `sessionmem install` again.");
58
58
  process.exit(1);
59
59
  }
60
60
  console.log(`✓ DB initialized (${dbPath})`);
@@ -10,4 +10,7 @@ export async function listCommand(ctx) {
10
10
  process.exit(1);
11
11
  }
12
12
  console.log(formatTable(result.memories));
13
+ if (result.total > result.memories.length) {
14
+ console.error(`Showing ${result.memories.length} of ${result.total} memories. Use \`sessionmem export\` for the full project.`);
15
+ }
13
16
  }
@@ -1,19 +1,15 @@
1
1
  import { AdapterFactory } from "../../adapters/factory.js";
2
2
  import { mkdirSync, writeFileSync } from "fs";
3
- import { join } from "path";
4
- import { homedir } from "os";
3
+ import { join, dirname } from "path";
5
4
  import { deriveProjectId } from "../projectId.js";
6
- import { expandTilde } from "../context.js";
5
+ import { resolveDbPath } from "../context.js";
7
6
  export async function runMcpServer() {
8
7
  const adapter = AdapterFactory.detectAdapter();
9
- // Startup diagnostics: written to ~/.sessionmem/logs/mcp.log
10
- const logDir = join(homedir(), ".sessionmem", "logs");
8
+ // Keep diagnostics beside the selected database so an override never
9
+ // creates the default home directory just to write a log.
10
+ const dbPath = resolveDbPath();
11
+ const logDir = join(dirname(dbPath), "logs");
11
12
  const logPath = join(logDir, "mcp.log");
12
- // Derive db path for diagnostics (mirrors context.ts defaultDbPath)
13
- const envDbPath = process.env.SESSIONMEM_DB_PATH;
14
- const dbPath = envDbPath && envDbPath.trim() !== ""
15
- ? expandTilde(envDbPath)
16
- : join(homedir(), ".sessionmem", "memories.db");
17
13
  // Derive project ID for diagnostics. Shares the exact derivation the server
18
14
  // uses (createCliContext → deriveProjectId) so the logged id never diverges
19
15
  // from the bucket the server actually serves.
@@ -21,8 +17,10 @@ export async function runMcpServer() {
21
17
  const adapterName = adapter.name;
22
18
  const logMessage = `[${new Date().toISOString()}] Started sessionmem | adapter=${adapterName} db=${dbPath} project=${projectId}\n`;
23
19
  try {
24
- mkdirSync(logDir, { recursive: true });
25
- writeFileSync(logPath, logMessage, { flag: "a" });
20
+ if (dbPath !== ":memory:") {
21
+ mkdirSync(logDir, { recursive: true });
22
+ writeFileSync(logPath, logMessage, { flag: "a" });
23
+ }
26
24
  }
27
25
  catch {
28
26
  // best-effort logging; ignore failures
@@ -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:",
@@ -1,18 +1,22 @@
1
1
  import { createCliContext } from "../context.js";
2
2
  import { formatTable } from "../output.js";
3
- const DEFAULT_LIMIT = 20;
3
+ const DEFAULT_LIMIT = 10;
4
4
  function coerceLimit(value) {
5
5
  if (value === undefined)
6
6
  return DEFAULT_LIMIT;
7
- const parsed = Number.parseInt(String(value), 10);
8
- return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
7
+ const parsed = Number(value);
8
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
9
+ throw new Error(`Invalid --limit "${value}": expected an integer from 1 to 100.`);
10
+ }
11
+ return parsed;
9
12
  }
10
13
  export async function searchCommand(query, options = {}, ctx) {
14
+ const limit = coerceLimit(options.limit);
11
15
  const context = ctx ?? createCliContext();
12
16
  const result = await context.service.call("retrieveMemories", {
13
17
  projectId: context.projectId,
14
18
  query,
15
- limit: coerceLimit(options.limit),
19
+ limit,
16
20
  mode: "auto",
17
21
  depth: "default",
18
22
  });
@@ -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` +
@@ -1,6 +1,7 @@
1
1
  import { existsSync, rmSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
+ import { resolveDbPath } from "../context.js";
4
5
  import { AdapterFactory } from "../../adapters/factory.js";
5
6
  import { removeClaudeMdBlock } from "../../adapters/claudeMdInjector.js";
6
7
  /**
@@ -51,7 +52,7 @@ export async function uninstallCommand(options = {}) {
51
52
  }
52
53
  }
53
54
  // Resolve dbPath: use injected override (for tests) or the default location
54
- const dbPath = options.dbPath ?? join(homedir(), ".sessionmem", "memories.db");
55
+ const dbPath = resolveDbPath(options.dbPath);
55
56
  if (options.purge) {
56
57
  // --purge: delete only memories.db, not logs or the ~/.sessionmem directory
57
58
  if (existsSync(dbPath)) {
@@ -42,19 +42,21 @@ export function expandTilde(p) {
42
42
  }
43
43
  return p;
44
44
  }
45
- function defaultDbPath(dir) {
45
+ export function resolveDbPath(override) {
46
+ if (override !== undefined)
47
+ return expandTilde(override);
46
48
  // Env override seam (see deriveProjectId). Defaults to ~/.sessionmem/memories.db.
47
49
  const envDbPath = process.env.SESSIONMEM_DB_PATH;
48
50
  if (envDbPath && envDbPath.trim() !== "")
49
51
  return expandTilde(envDbPath);
50
- // `dir` is the fixed ~/.sessionmem dir computed above, not user input.
51
52
  // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
52
- return join(dir, "memories.db");
53
+ return join(homedir(), ".sessionmem", "memories.db");
53
54
  }
54
55
  export function createCliContext(overrides = {}) {
55
- const dir = join(homedir(), ".sessionmem");
56
- mkdirSync(dir, { recursive: true });
57
- const dbPath = overrides.dbPath ?? defaultDbPath(dir);
56
+ const dbPath = resolveDbPath(overrides.dbPath);
57
+ if (!overrides.db && dbPath !== ":memory:") {
58
+ mkdirSync(dirname(dbPath), { recursive: true });
59
+ }
58
60
  const here = dirname(fileURLToPath(import.meta.url));
59
61
  const migrationsDir = overrides.migrationsDir ?? join(here, "..", "core", "schema", "migrations");
60
62
  const username = overrides.username ?? localUsername();