sessionmem 1.6.0 → 1.7.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/dist/cli/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { autostartCommand } from "./commands/autostart.js";
2
3
  import { Command } from "commander";
3
4
  import { createRequire } from "node:module";
4
5
  import { runMcpServer } from "./commands/run.js";
@@ -69,22 +70,28 @@ program
69
70
  .description("Poll Claude Code, Codex, and Cursor transcripts and capture sessions offline")
70
71
  .option("--transcripts-dir <path>", "Override transcript roots (Cursor: User directory or state.vscdb)")
71
72
  .option("--host <host>", "Select a transcript host: claude-code, codex, or cursor")
73
+ .option("--db-path <path>", "Database file for headless capture")
74
+ .option("--log-file <path>", "Append timestamped stdout, stderr and startup failures to a file")
72
75
  .option("--once", "Run one sweep and exit")
73
76
  .option("--interval <seconds>", "Seconds between sweeps", "5")
74
77
  .action((options) => watchCommand(options));
75
78
  program
76
79
  .command("install")
77
- .description("Install sessionmem into every detected agent host")
80
+ .description("Install integrations and enable login transcript capture")
78
81
  .option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
79
82
  .action((options) => installCommand(options));
83
+ program
84
+ .command("autostart <action>")
85
+ .description("Control login capture: enable, disable, or status")
86
+ .action((action) => autostartCommand(action));
80
87
  program
81
88
  .command("doctor")
82
- .description("Check storage, transcript discovery, hooks, MCP wiring, and recall offline")
89
+ .description("Check login capture, storage, transcripts, hooks, MCP wiring, and recall offline")
83
90
  .option("--adapter <name>", "Check a specific host instead of installed-host detection")
84
91
  .action(async (options) => { await doctorCommand(options); });
85
92
  program
86
93
  .command("uninstall")
87
- .description("Remove sessionmem from the current MCP host")
94
+ .description("Stop login capture and remove sessionmem from the current MCP host")
88
95
  .option("--purge", "Also delete the local memories database")
89
96
  .option("--adapter <name>", "Force a host adapter instead of auto-detecting (claude-code, cursor, windsurf, cline, codex, antigravity, qcoder, generic)")
90
97
  .action((options) => uninstallCommand(options));
@@ -0,0 +1,35 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ export const watchCheckpointPath = (dbPath) => join(dirname(resolve(dbPath)), `${basename(dbPath)}.watch-checkpoint.json`);
4
+ export function saveWatchCheckpoint(context, summary, intervalSeconds) {
5
+ if (context.dbPath === ":memory:")
6
+ return;
7
+ const path = watchCheckpointPath(context.dbPath);
8
+ const checkpoint = {
9
+ dbPath: resolve(context.dbPath), completedAt: new Date().toISOString(), intervalSeconds,
10
+ checkpointCount: context.db.prepare("SELECT count(*) AS n FROM transcript_checkpoints").get().n,
11
+ summary,
12
+ };
13
+ const temp = `${path}.${process.pid}.tmp`;
14
+ writeFileSync(temp, JSON.stringify(checkpoint), { mode: 0o600 });
15
+ renameSync(temp, path);
16
+ }
17
+ export function inspectWatchCheckpoint(dbPath, now = Date.now()) {
18
+ const checkpoint = JSON.parse(readFileSync(watchCheckpointPath(dbPath), "utf8"));
19
+ const age = now - Date.parse(checkpoint.completedAt);
20
+ if (checkpoint.dbPath !== resolve(dbPath))
21
+ throw new Error("Watcher checkpoint belongs to a different database");
22
+ if (!Number.isFinite(checkpoint.intervalSeconds) || checkpoint.intervalSeconds <= 0 || checkpoint.intervalSeconds > 86400)
23
+ throw new Error("Invalid watcher checkpoint interval");
24
+ if (!Number.isFinite(age) || age < -60000 || age > Math.max(120000, checkpoint.intervalSeconds * 3000))
25
+ throw new Error(`Watcher checkpoint is stale or invalid (${checkpoint.completedAt})`);
26
+ if (!checkpoint.summary || Object.values(checkpoint.summary).length !== 5 || Object.values(checkpoint.summary).some(value => !Number.isInteger(value) || value < 0))
27
+ throw new Error("Invalid watcher sweep counters");
28
+ if (checkpoint.summary.errors !== 0)
29
+ throw new Error(`Last watcher sweep had ${checkpoint.summary.errors} capture error(s)`);
30
+ if (!Number.isInteger(checkpoint.checkpointCount) || checkpoint.checkpointCount < 0)
31
+ throw new Error("Invalid transcript checkpoint count");
32
+ if (checkpoint.summary.filesFound > 0 && checkpoint.checkpointCount === 0)
33
+ throw new Error("Transcripts found but none checkpointed yet");
34
+ return `Sweep completed ${Math.max(0, Math.round(age / 1000))}s ago; ${checkpoint.checkpointCount} transcript checkpoint(s); ${checkpoint.summary.filesFound} source file(s)`;
35
+ }
@@ -0,0 +1,23 @@
1
+ import { mkdirSync, openSync, closeSync, writeSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ /** Redirect both streams before opening storage so headless startup failures land
4
+ * in the same log as per-file capture failures. Never reads stdin or uses a TTY. */
5
+ export function openWatchLog(path) {
6
+ if (!path)
7
+ return () => { };
8
+ const absolute = resolve(path);
9
+ mkdirSync(dirname(absolute), { recursive: true });
10
+ const fd = openSync(absolute, "a", 0o600);
11
+ const stdout = process.stdout.write;
12
+ const stderr = process.stderr.write;
13
+ const write = ((chunk, encodingOrCallback, callback) => {
14
+ const value = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
15
+ writeSync(fd, `${new Date().toISOString()} ${value}`);
16
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
17
+ done?.();
18
+ return true;
19
+ });
20
+ process.stdout.write = write;
21
+ process.stderr.write = write;
22
+ return () => { process.stdout.write = stdout; process.stderr.write = stderr; closeSync(fd); };
23
+ }
@@ -1,6 +1,8 @@
1
1
  import { userInfo } from "node:os";
2
2
  import { ZodError } from "zod";
3
3
  import { deterministicEmbed } from "../embed/deterministicEmbed.js";
4
+ import { loadLocalSemanticEmbedder } from "../embed/localSemanticEmbedder.js";
5
+ import { indexSemanticMemories, searchSemanticMemories } from "../storage/semanticSearchRepo.js";
4
6
  import { retrieveMemories } from "../retrieve/retrieveMemories.js";
5
7
  import { computeEffectiveImportance } from "../retrieve/score.js";
6
8
  import { formatStartupInjection } from "../injection/formatStartupInjection.js";
@@ -150,6 +152,21 @@ export function createMemoryCoreService(deps) {
150
152
  // Resolve the local author identity once per service instance.
151
153
  const localAuthor = resolveServiceUsername(deps.username);
152
154
  const policyConfigPath = deps.policyConfigPath ?? configFilePath();
155
+ let semanticWarningShown = false;
156
+ async function semanticEmbedder() {
157
+ if (!readPolicyConfig(policyConfigPath).semanticSearch)
158
+ return null;
159
+ const embedder = deps.semanticEmbedder === undefined ? await loadLocalSemanticEmbedder() : deps.semanticEmbedder;
160
+ if (!embedder)
161
+ semanticWarning();
162
+ return embedder;
163
+ }
164
+ function semanticWarning() {
165
+ if (!semanticWarningShown) {
166
+ console.error("Local semantic retrieval unavailable; using BM25.");
167
+ semanticWarningShown = true;
168
+ }
169
+ }
153
170
  /**
154
171
  * Resolve the effective redactionEnabled flag using precedence
155
172
  * override (explicit per-request value) > config.json > default,
@@ -299,6 +316,15 @@ export function createMemoryCoreService(deps) {
299
316
  if (!inserted) {
300
317
  throw new DomainError("INTERNAL", "Memory insert did not persist");
301
318
  }
319
+ // Optional indexing never turns a successful durable capture into a failure.
320
+ try {
321
+ const embedder = await semanticEmbedder();
322
+ if (embedder)
323
+ await indexSemanticMemories(db, parsed.projectId, embedder, parsed.memoryId);
324
+ }
325
+ catch {
326
+ semanticWarning();
327
+ }
302
328
  return {
303
329
  ok: true,
304
330
  // Single-record write echo-back: return the FULL stored body (uncapped),
@@ -314,6 +340,15 @@ export function createMemoryCoreService(deps) {
314
340
  async retrieveMemories(request) {
315
341
  const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
316
342
  const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, DEEP_MODE_RETRIEVAL_CAP) : parsed.limit;
343
+ let semanticCandidates;
344
+ try {
345
+ const embedder = await semanticEmbedder();
346
+ if (embedder)
347
+ semanticCandidates = await searchSemanticMemories(db, parsed.projectId, parsed.query, deps.now?.() ?? new Date(), parsed.includeHistory ?? false, embedder);
348
+ }
349
+ catch {
350
+ semanticWarning();
351
+ }
317
352
  const rankedAll = retrieveMemories({
318
353
  db,
319
354
  projectId: parsed.projectId,
@@ -321,6 +356,7 @@ export function createMemoryCoreService(deps) {
321
356
  includeHistory: parsed.includeHistory,
322
357
  limit,
323
358
  now: deps.now?.(),
359
+ semanticCandidates,
324
360
  });
325
361
  // Optional tag filter: keep a memory only when its stored tags contain ALL
326
362
  // requested tags. The semantic candidate projection omits tags, so re-read
@@ -53,6 +53,7 @@ const policyConfigShape = {
53
53
  // built-in DEFAULT_INJECTION_CAP used by formatStartupInjection and the
54
54
  // `savings` analytics. Omitted by default so existing configs keep the default.
55
55
  injectionCap: z.number().int().min(100).max(10000).optional(),
56
+ semanticSearch: z.boolean().optional(),
56
57
  team: teamConfigShape.default({ enabled: false }),
57
58
  };
58
59
  /**
@@ -107,11 +108,13 @@ export function readPolicyConfig(filePath) {
107
108
  const rd = s.retentionDays.safeParse(obj.retentionDays);
108
109
  const re = s.redactionEnabled.safeParse(obj.redactionEnabled);
109
110
  const ic = s.injectionCap.safeParse(obj.injectionCap);
111
+ const ss = s.semanticSearch.safeParse(obj.semanticSearch);
110
112
  const tm = s.team.safeParse(obj.team);
111
113
  return {
112
114
  retentionDays: rd.success ? rd.data : DEFAULT_POLICY_CONFIG.retentionDays,
113
115
  redactionEnabled: re.success ? re.data : DEFAULT_POLICY_CONFIG.redactionEnabled,
114
116
  injectionCap: ic.success ? ic.data : undefined,
117
+ semanticSearch: ss.success ? ss.data : undefined,
115
118
  team: tm.success ? tm.data : { ...DEFAULT_POLICY_CONFIG.team },
116
119
  };
117
120
  }
@@ -0,0 +1,62 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ const MODEL_SHA = "6fd5d72fe4589f189f8ebc006442dbb529bb7ce38f8082112682524616046452";
5
+ const TOKENIZER_SHA = "be50c3628f2bf5bb5e3a7f17b1f74611b2561a3a27eeab05e5aa30f411572037";
6
+ export const LOCAL_SEMANTIC_MODEL = `minilm-l6-v2:${MODEL_SHA}:${TOKENIZER_SHA}:mean256-v1`;
7
+ let loaded;
8
+ /** Only local package files are opened. No download or remote provider path. */
9
+ export function loadLocalSemanticEmbedder() {
10
+ return loaded ??= load().catch(() => null);
11
+ }
12
+ async function load() {
13
+ const require = createRequire(import.meta.url);
14
+ const model = require.resolve("@xcidos/genesis-memory-model/onnx/model.onnx");
15
+ const tokenizerPath = require.resolve("@xcidos/genesis-memory-model/tokenizer.json");
16
+ for (const [file, expected] of [[model, MODEL_SHA], [tokenizerPath, TOKENIZER_SHA]]) {
17
+ if (createHash("sha256").update(readFileSync(file)).digest("hex") !== expected) {
18
+ throw new Error("Local semantic model checksum mismatch");
19
+ }
20
+ }
21
+ // ORT 1.30 can initialize a native HTTP telemetry uploader on POSIX.
22
+ // Its full opt-out must be set before the native environment is created;
23
+ // JavaScript network guards cannot intercept native library sockets.
24
+ process.env.ORT_DISABLE_TELEMETRY = "1";
25
+ const ort = await import("onnxruntime-node");
26
+ const { Tokenizer } = await import("@huggingface/tokenizers");
27
+ const tokenizer = new Tokenizer(JSON.parse(readFileSync(tokenizerPath, "utf8")), {});
28
+ const session = await ort.InferenceSession.create(model, {
29
+ executionProviders: ["cpu"], intraOpNumThreads: 1, interOpNumThreads: 1,
30
+ logSeverityLevel: 3,
31
+ });
32
+ return {
33
+ id: LOCAL_SEMANTIC_MODEL, dimension: 384,
34
+ async embed(text) {
35
+ let ids = tokenizer.encode(text).ids;
36
+ if (ids.length > 256)
37
+ ids = [...ids.slice(0, 255), 102]; // retain BERT's SEP
38
+ const input = new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), [1, ids.length]);
39
+ const ones = new ort.Tensor("int64", new BigInt64Array(ids.length).fill(1n), [1, ids.length]);
40
+ const zeros = new ort.Tensor("int64", new BigInt64Array(ids.length), [1, ids.length]);
41
+ let output;
42
+ try {
43
+ output = await session.run({ input_ids: input, attention_mask: ones, token_type_ids: zeros });
44
+ const hidden = output.last_hidden_state.data;
45
+ const vector = new Array(384).fill(0);
46
+ for (let token = 0; token < ids.length; token++) {
47
+ for (let dim = 0; dim < 384; dim++)
48
+ vector[dim] += hidden[token * 384 + dim] / ids.length;
49
+ }
50
+ const norm = Math.hypot(...vector);
51
+ return vector.map(v => v / (norm || 1));
52
+ }
53
+ finally {
54
+ input.dispose();
55
+ ones.dispose();
56
+ zeros.dispose();
57
+ for (const tensor of Object.values(output ?? {}))
58
+ tensor.dispose();
59
+ }
60
+ },
61
+ };
62
+ }
@@ -47,7 +47,16 @@ export function retrieveMemories(input) {
47
47
  const now = input.now ?? new Date();
48
48
  // FTS5 retrieves up to 50 BM25 matches; sparse matches retain the existing
49
49
  // recency/importance fallback. The public request schema validates queryText.
50
- const candidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText, now, input.includeHistory);
50
+ const lexicalCandidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText, now, input.includeHistory);
51
+ const candidates = [...lexicalCandidates];
52
+ const ids = new Set(candidates.map(c => c.id));
53
+ for (const { candidate } of input.semanticCandidates ?? []) {
54
+ if (!ids.has(candidate.id)) {
55
+ candidates.push(candidate);
56
+ ids.add(candidate.id);
57
+ }
58
+ }
59
+ const dense = new Map(input.semanticCandidates?.map(c => [c.candidate.id, c.similarity]));
51
60
  const decayedCandidates = decayOldBoosts(candidates, now);
52
61
  const dimension = resolveEmbeddingDimension(candidates);
53
62
  const queryVector = deterministicEmbed(queryText, dimension).vector;
@@ -68,9 +77,16 @@ export function retrieveMemories(input) {
68
77
  : cosineSimilarity(queryVector, candidate.embedding);
69
78
  // Hash cosine is useful for identical text, but adds noise to lexical
70
79
  // matches. Keep it as a diagnostic and as the no-match fallback only.
71
- const baseRelevance = maxLexicalScore > 0
80
+ const lexicalRelevance = maxLexicalScore > 0
72
81
  ? candidate.lexical_score / maxLexicalScore
73
82
  : semantic;
83
+ // The local dense variant was weaker alone. Preserve lexical dominance
84
+ // while admitting candidates absent from FTS, using a fixed 2:1 blend.
85
+ const baseRelevance = input.semanticCandidates?.length
86
+ ? maxLexicalScore > 0
87
+ ? (2 * lexicalRelevance + Math.max(0, dense.get(candidate.id) ?? 0)) / 3
88
+ : Math.max(0, dense.get(candidate.id) ?? 0)
89
+ : lexicalRelevance;
74
90
  const relevance = overlap.size > 0
75
91
  ? (1 - ENTITY_RELEVANCE_WEIGHT) * baseRelevance + ENTITY_RELEVANCE_WEIGHT * (overlap.get(candidate.id) ?? 0)
76
92
  : baseRelevance;
@@ -0,0 +1,12 @@
1
+ -- Separate learned vectors from legacy hash embeddings. Models never mix.
2
+ CREATE TABLE semantic_embeddings (
3
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE ON UPDATE CASCADE,
4
+ model TEXT NOT NULL,
5
+ dimension INTEGER NOT NULL CHECK (dimension > 0),
6
+ vector BLOB NOT NULL,
7
+ PRIMARY KEY (memory_id, model)
8
+ );
9
+ CREATE TRIGGER semantic_embeddings_content_update AFTER UPDATE OF content ON memories
10
+ WHEN old.content IS NOT new.content BEGIN
11
+ DELETE FROM semantic_embeddings WHERE memory_id = new.id;
12
+ END;
@@ -91,6 +91,17 @@ function mapRows(rows) {
91
91
  };
92
92
  });
93
93
  }
94
+ /** Hydrate a bounded semantic candidate list under the same visibility rules. */
95
+ export function searchMemoryCandidatesByIds(db, projectId, ids, now, includeHistory = false) {
96
+ if (!ids.length)
97
+ return [];
98
+ const rows = db.prepare(`SELECT * FROM memories WHERE project_id = ?
99
+ AND id IN (${ids.map(() => "?").join(",")})
100
+ AND (expires_at IS NULL OR expires_at > ?)
101
+ AND (? = 1 OR id NOT IN (SELECT memory_id FROM superseded_memories))`)
102
+ .all(projectId, ...ids, now.toISOString(), Number(includeHistory));
103
+ return mapRows(rows);
104
+ }
94
105
  export function searchMemoryCandidates(db, projectId, now = new Date(), includeHistory = false) {
95
106
  const rows = getSearchStatements(db).searchCandidates.all({
96
107
  projectId,
@@ -0,0 +1,56 @@
1
+ import { searchMemoryCandidatesByIds } from "./memorySearchRepo.js";
2
+ function normalize(vector, dimension) {
3
+ if (vector.length !== dimension || !vector.every(Number.isFinite))
4
+ throw new Error("Invalid local embedding");
5
+ const norm = Math.hypot(...vector);
6
+ if (!norm)
7
+ throw new Error("Empty local embedding");
8
+ return vector.map(v => v / norm);
9
+ }
10
+ /** Backfill from stored text after normal capture policy; never raw capture input. */
11
+ export async function indexSemanticMemories(db, projectId, embedder, memoryId) {
12
+ const rows = db.prepare(`SELECT m.id, m.content FROM memories m
13
+ WHERE m.project_id = ? AND (? IS NULL OR m.id = ?) AND NOT EXISTS (SELECT 1 FROM semantic_embeddings s
14
+ WHERE s.memory_id = m.id AND s.model = ? AND s.dimension = ?)`)
15
+ .all(projectId, memoryId ?? null, memoryId ?? null, embedder.id, embedder.dimension);
16
+ const insert = db.prepare(`INSERT OR REPLACE INTO semantic_embeddings(memory_id, model, dimension, vector)
17
+ SELECT id, @model, @dimension, @vector FROM memories
18
+ WHERE id = @id AND project_id = @project AND content = @content`);
19
+ for (const row of rows) {
20
+ const vector = normalize(await embedder.embed(row.content), embedder.dimension);
21
+ const bytes = Buffer.alloc(vector.length * 4);
22
+ vector.forEach((v, i) => bytes.writeFloatLE(v, i * 4));
23
+ // A concurrent edit/delete during inference must not install stale vectors.
24
+ insert.run({ ...row, model: embedder.id, dimension: embedder.dimension, vector: bytes, project: projectId });
25
+ }
26
+ }
27
+ /** Exact local cosine scan. Appropriate to small project stores; no ANN service. */
28
+ export async function searchSemanticMemories(db, projectId, query, now, includeHistory, embedder) {
29
+ await indexSemanticMemories(db, projectId, embedder);
30
+ const vector = normalize(await embedder.embed(query), embedder.dimension);
31
+ const rows = db.prepare(`SELECT s.memory_id, s.vector FROM semantic_embeddings s JOIN memories m ON m.id = s.memory_id
32
+ WHERE m.project_id = @project AND s.model = @model AND s.dimension = @dimension
33
+ AND (m.expires_at IS NULL OR m.expires_at > @now)
34
+ AND (@history = 1 OR m.id NOT IN (SELECT memory_id FROM superseded_memories))`)
35
+ .all({ project: projectId, model: embedder.id, dimension: embedder.dimension, now: now.toISOString(), history: Number(includeHistory) });
36
+ const scored = [];
37
+ for (const row of rows) {
38
+ if (row.vector.length !== vector.length * 4)
39
+ continue;
40
+ let similarity = 0, norm = 0;
41
+ for (let i = 0; i < vector.length; i++) {
42
+ const value = row.vector.readFloatLE(i * 4);
43
+ similarity += value * vector[i];
44
+ norm += value * value;
45
+ }
46
+ if (Number.isFinite(similarity) && norm > 0)
47
+ scored.push({ id: row.memory_id, similarity: Math.max(-1, Math.min(1, similarity / Math.sqrt(norm))) });
48
+ }
49
+ scored.sort((a, b) => b.similarity - a.similarity || a.id.localeCompare(b.id));
50
+ const top = scored.slice(0, 50);
51
+ const candidates = new Map(searchMemoryCandidatesByIds(db, projectId, top.map(r => r.id), now, includeHistory).map(c => [c.id, c]));
52
+ return top.flatMap(row => {
53
+ const candidate = candidates.get(row.id);
54
+ return candidate ? [{ candidate, similarity: row.similarity }] : [];
55
+ });
56
+ }
@@ -70,7 +70,19 @@ When an agent or `sessionmem search` submits a query, the core engine searches t
70
70
 
71
71
  The ranker divides each positive BM25 score by the best score for that query. If the query contains known entity names in this project, relevance is `0.90 * base relevance + 0.10 * entity overlap`. Overlap is the fraction of distinct matched query names linked to the candidate memory; names are matched as literal token sequences (up to eight tokens), including lowercase questions. With no known query entities, relevance is unchanged. This reranks the existing candidates; it does not expand the FTS candidate pool or traverse relations. The outer relevance, recency, and importance weights stay 0.60, 0.25, and 0.15. Sparse lexical matches retain the recency/importance fallback union. If there are no lexical matches, base relevance uses the previous hash-vector ranking. Project scope and expiry checks apply to both paths. Automatic migrations preserve memory bodies and IDs.
72
72
 
73
- Existing public request fields and all response fields are unchanged; `includeHistory` is an optional addition. The `semantic` field still reports hash cosine similarity. The legacy `score.raw.semantic` and `score.weighted.semantic` fields carry the relevance component used for ranking, including entity overlap when query entities match. Scores are relative to the current query's candidates, not calibrated probabilities. Retrieval needs no API keys, downloaded models, or new dependencies.
73
+ Existing public request fields and all response fields are unchanged; `includeHistory` is an optional addition. With default retrieval, the `semantic` field reports hash cosine similarity. The legacy `score.raw.semantic` and `score.weighted.semantic` fields carry the relevance component used for ranking, including entity overlap when query entities match. Scores are relative to the current query's candidates, not calibrated probabilities. Default retrieval needs no API keys, downloaded models, or new dependencies.
74
+
75
+ With `semanticSearch=true` and the optional local packages installed, migration
76
+ 016 stores versioned learned vectors separately from hash embeddings. Retrieval
77
+ unions up to 50 exact-cosine semantic candidates with lexical candidates, blends
78
+ normalized BM25 and positive cosine 2:1, and retains the existing entity and outer
79
+ scoring weights. With no lexical match it uses cosine relevance. The `semantic`
80
+ field remains the legacy hash diagnostic, matching `embeddingDim` and
81
+ `embeddingVersion`; learned cosine contributes to ranking. The same project,
82
+ expiry, and supersession checks apply. Edits invalidate vectors and deletes
83
+ cascade; missing vectors are backfilled from stored redacted content. This path
84
+ does not change contradiction rules. Model loading/inference errors restore the
85
+ default retrieval path. See [installation and limitations](local-semantic.md).
74
86
 
75
87
  ## Injection flow
76
88
 
package/docs/benchmark.md CHANGED
@@ -5,6 +5,15 @@
5
5
  [benchmarks/README.md](../benchmarks/README.md) for the corpus license, selection
6
6
  rules, metric definitions, and detailed reproduction instructions.
7
7
 
8
+ ## Local semantic and competitor comparison
9
+
10
+ The [measured comparison](../COMPARISON.md) records a new unchanged-main run,
11
+ local raw-turn retrieval variants from claude-mem, Mem0 OSS, Graphiti and cognee,
12
+ and the optional MiniLM hybrid. It includes exact commands and commits, complete
13
+ per-question archives, latency costs and regressions. These component scores do
14
+ not evaluate full capture/graph/answer pipelines. The comparisons below are
15
+ earlier changes on earlier commits; their timings are not the new baseline.
16
+
8
17
  ## Entity overlap comparison
9
18
 
10
19
  The baseline was run first on current main at `81f987c`. A fixed 10% entity
@@ -0,0 +1,56 @@
1
+ # Optional local semantic retrieval
2
+
3
+ The default is BM25 plus entity overlap. To add a learned semantic candidate pool,
4
+ install the optional packages beside sessionmem and enable it:
5
+
6
+ ```bash
7
+ ONNXRUNTIME_NODE_INSTALL=skip npm install sessionmem @huggingface/tokenizers@0.2.0 onnxruntime-node@1.30.0 @xcidos/genesis-memory-model@0.1.0-alpha.1
8
+ npx sessionmem config set semanticSearch true
9
+ ```
10
+
11
+ For a global sessionmem installation, use `npm install -g` for the same packages.
12
+ `ONNXRUNTIME_NODE_INSTALL=skip` skips optional CUDA downloads; the npm package
13
+ already contains the CPU runtime. On PowerShell set
14
+ `$env:ONNXRUNTIME_NODE_INSTALL = 'skip'` before the install command.
15
+ Developers should also use that variable with `npm ci` to avoid CUDA downloads.
16
+ Disable semantic retrieval with `sessionmem config set semanticSearch false`.
17
+
18
+ Installation fetches packages and weights. Capture and retrieval only load files
19
+ from these installed packages; they never download a model, contact a model
20
+ server, or send memory text over a network. Missing packages, checksum mismatches,
21
+ unsupported native runtimes, and inference errors fall back to BM25 and emit one
22
+ diagnostic per service instance. Restart after installing or repairing packages.
23
+ Before loading ONNX Runtime, sessionmem sets the process-wide
24
+ `ORT_DISABLE_TELEMETRY=1` to suppress its native POSIX telemetry uploader.
25
+ The benchmark's `--semantic` mode instead fails if the model cannot load, to
26
+ prevent accidentally publishing fallback scores as semantic results.
27
+
28
+ The encoder is sentence-transformers/all-MiniLM-L6-v2: 384 dimensions, mean pooling,
29
+ L2 normalization, and at most 256 WordPiece tokens. Its weights and tokenizer come
30
+ from the pinned npm distribution above, whose provenance names upstream revision
31
+ `c9745ed1d9f207416be6d2e6f8de32d1f16199bf`. Both files are SHA-256 checked before
32
+ loading. The model is by the sentence-transformers contributors, distributed under
33
+ [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0); see
34
+ [model information](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2)
35
+ and the package's provenance README. No competitor code or prompt is included.
36
+
37
+ Learned vectors are a separate SQLite BLOB index, keyed by memory and complete
38
+ model/tokenizer/pooling version. Content edits invalidate vectors; deletes cascade.
39
+ Only stored text is embedded, after the configured capture redaction policy.
40
+ Capture through `storeMemory` indexes it immediately. Existing memories are
41
+ embedded as stored; other write paths are backfilled
42
+ on the next semantic query, so the first query on a large existing store can be
43
+ slow. Model initialization and indexing cost are distinct from warm retrieval.
44
+
45
+ Queries perform an exact local cosine scan, take up to 50 semantic candidates,
46
+ and union them with FTS candidates. Project, expiry, and supersession filters
47
+ apply to both channels; `includeHistory` explicitly includes superseded rows.
48
+ Normalized BM25 relevance and positive cosine are blended 2:1 before the existing
49
+ entity, recency, importance, and access signals. With no lexical match, cosine
50
+ supplies relevance. This weighting follows the measured weakness of dense-only
51
+ retrieval; it is not a held-out optimum. Read the measured regressions in
52
+ [the comparison](../COMPARISON.md).
53
+
54
+ Exact scans are intended for small project stores and scale linearly with the
55
+ number of vectors. Long memories are truncated to the encoder's token window;
56
+ no new chunking or model-based contradiction resolution is implemented.
@@ -0,0 +1,62 @@
1
+ # Automatic transcript capture
2
+
3
+ Research reviewed on October 18, 2026, before implementation. This change adopts
4
+ approaches only. No competitor source code was copied or adapted.
5
+
6
+ | Project and primary sources | Observed approach | Takeaway for sessionmem |
7
+ | --- | --- | --- |
8
+ | [claude-mem hooks](https://github.com/thedotmack/claude-mem/blob/main/plugin/hooks/hooks.json), [worker service](https://github.com/thedotmack/claude-mem/blob/main/docs/public/architecture/worker-service.mdx) | SessionStart starts a worker; async tool and stop hooks enqueue capture. The worker exposes health checks and separate start, stop, status and log controls. | Start capture automatically, keep explicit controls, and diagnose capture independently of registration. Keep the existing Claude Code hooks. |
9
+ | [Mem0 Claude Code plugin](https://github.com/mem0ai/mem0/blob/main/integrations/claude-code-plugin/README.md), [OpenClaw integration](https://github.com/mem0ai/mem0/blob/main/integrations/openclaw/README.md) | Hooks save local evidence; detached workers flush batches and survive host exit. Capture has pause/resume controls. The OpenClaw integration filters and redacts automatic capture. The previously indexed `mem0-plugin/scripts/auto_capture.py` path is now absent; the current plugin README was reviewed instead. | Keep capture local and redacted, preserve restart checkpoints, and expose enable/disable/status. This change keeps the existing offline summarizer. |
10
+ | [Cognee Claude Code integration](https://github.com/topoteretes/cognee-integrations/blob/main/integrations/claude-code/README.md) | SessionStart bootstraps a background watcher, write hooks stage capture, and detached final-sync workers handle exit. Persisted markers coordinate retries and avoid duplicate work. Logs and diagnostics expose failures. | Reuse the existing persistent transcript checkpoints and event IDs. Log failures and make stale capture visible in doctor. |
11
+ | [Engram installation](https://github.com/Gentleman-Programming/engram/blob/main/docs/INSTALLATION.md), [daemon control discussion](https://github.com/Gentleman-Programming/engram/issues/1462) | Local memory tooling also uses a background service. The daemon discussion describes stale executable paths and service/session process collisions as operational problems. The discussion is a reported problem, not an implemented guarantee. | Pin absolute runtime and entry-point paths, refresh one stable registration, and validate the registered paths. |
12
+
13
+ Sessionmem uses OS login supervision rather than requiring a host hook to start
14
+ capture. This covers Codex and Cursor installations without wired capture hooks.
15
+ The OS implementations share an `enable`, `disable`, `status` interface. They
16
+ update one stable per-user registration and start it immediately. The watcher
17
+ records completed sweeps separately from the OS registration so doctor can report
18
+ capture that has stopped or is failing.
19
+
20
+ The npm binary is a JavaScript entry point. Services invoke its absolute path
21
+ through the absolute Node executable running install. They do not use npm shims,
22
+ `npx`, an interactive shell, or PATH lookup. Changing Node installations requires
23
+ `sessionmem autostart enable` again.
24
+
25
+ Native definitions were also checked with `systemd-analyze verify`, Python's
26
+ plist parser, and the [Microsoft Task Scheduler schema](https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-schema).
27
+ The task uses schema version 1.3, unlimited execution time, and the schema's
28
+ maximum 255 restart attempts at one-minute intervals. Windows status and stop
29
+ checks use [numeric HRESULTs](https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-error-and-success-constants)
30
+ rather than localized error text.
31
+
32
+ ## Validation boundaries
33
+
34
+ CI mocks the OS supervisor commands, tests all three native definitions and
35
+ command sequences, and runs the real watcher against synthetic transcripts.
36
+ The packaged-install smoke test uses that same test-only supervisor. It does not
37
+ validate the live login behavior of launchd, systemd or Task Scheduler.
38
+
39
+ Manual verification required before release:
40
+
41
+ - macOS: install from a normal GUI login as an ordinary user. Confirm the plist
42
+ in `~/Library/LaunchAgents`, `launchctl print gui/<uid>/io.sessionmem.watch`,
43
+ immediate capture, and capture after logout/login with the terminal closed.
44
+ Repeat install and confirm one loaded job. Check paths containing spaces.
45
+ Disable, enable and uninstall; verify the job stops and the plist is removed.
46
+ - Linux: use a desktop/login with systemd's user manager. Confirm
47
+ `systemctl --user is-enabled sessionmem-watch.service` and `is-active`, capture
48
+ after login with no terminal, and restart after killing the watcher. Repeat
49
+ install and check one unit. Disable/enable and uninstall must stop capture and
50
+ remove the unit. Also verify install reports a useful failure without a user
51
+ bus; it must not claim capture succeeded.
52
+ - Windows: install without elevation as the current user. Confirm one Scheduled
53
+ Task with that user's logon trigger, immediate capture, capture after logging
54
+ out/in, paths with spaces/non-ASCII characters, and no interactive prompt.
55
+ Confirm unlimited runtime, restart after a failing process, and battery
56
+ settings. Repeat install, disable/enable, and uninstall; verify no old task or
57
+ watcher remains. Test on a non-English Windows installation as well.
58
+ - Each OS: append a real Codex or Cursor conversation and confirm imported
59
+ memories, a fresh checkpoint and timestamped `logs/watch.log` output. Move or
60
+ remove the registered runtime/entry point and check doctor's binary-path fix
61
+ hint. Stop capture long enough to stale the checkpoint and check the capture
62
+ failure hint. Uninstall must preserve memories unless `--purge` is supplied.
@@ -13,6 +13,8 @@ Doctor prints PASS or FAIL and a fix hint for each problem. Any failed check
13
13
  returns a nonzero exit status. It checks:
14
14
 
15
15
  - **Memory store:** the selected database exists, opens, and passes SQLite's quick integrity check. It does not create a missing database.
16
+ - **Auto-start:** this OS's per-user login registration is present and enabled; the registered Node executable and sessionmem entry point still exist. Run `sessionmem autostart enable` after moving the package or changing Node versions.
17
+ - **Watcher capture:** a recent completed sweep checkpoint has no capture errors and transcript checkpoints exist when source files were found. Check `logs/watch.log` beside the database. Unchanged transcripts do not require new imports. Missing or stale checkpoints fail even if an OS service is registered.
16
18
  - **Schema:** all bundled migrations have been applied. It reports pending or unrecognized migrations without changing the schema.
17
19
  - **Hooks, per host:** Claude Code's SessionStart, SessionEnd, and PostToolUse commands, types, matchers, and enabled state match the current version's definitions. Other hosts report that lifecycle hooks are not required.
18
20
  - **Adapter wiring, per host:** the installed MCP entry matches the installer, its local command starts, the server version matches, and the expected tools and stats response work. This tests the configured server process without starting the coding agent itself.
@@ -21,6 +23,8 @@ returns a nonzero exit status. It checks:
21
23
  For Codex, the wiring check supports the installer-generated TOML command and
22
24
  args. Custom Codex launch settings require manual verification.
23
25
 
26
+ Codex and Cursor transcript capture uses the login watcher; install does not wire lifecycle capture hooks for those hosts. Claude Code also has its installed hooks.
27
+
24
28
  All checks work offline, including when cloud summarization is enabled. Doctor
25
29
  does not summarize sessions or sync shared memories. It honors `SESSIONMEM_DB_PATH`
26
30
  and the same host overrides as install. Keep those environment settings when
@@ -29,6 +33,7 @@ running a suggested repair.
29
33
  Safe repair commands printed by doctor include:
30
34
 
31
35
  ```bash
36
+ sessionmem autostart enable # update paths and restart login capture
32
37
  sessionmem ping # initialize a missing store / apply pending migrations
33
38
  sessionmem install --adapter claude-code # repair MCP registration and hook definitions
34
39
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessionmem",
3
- "version": "1.6.0",
3
+ "version": "1.7.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.",
@@ -46,9 +46,12 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^10.0.1",
49
+ "@huggingface/tokenizers": "0.2.0",
49
50
  "@types/better-sqlite3": "^9.6.0",
51
+ "@xcidos/genesis-memory-model": "0.1.0-alpha.1",
50
52
  "eslint": "^10.12.0",
51
53
  "globals": "^17.13.0",
54
+ "onnxruntime-node": "1.30.0",
52
55
  "typescript": "^6.0.3",
53
56
  "typescript-eslint": "^8.71.2",
54
57
  "vitest": "^4.1.8"
@@ -59,5 +62,21 @@
59
62
  "bugs": {
60
63
  "url": "https://github.com/catfish-1234/sessionmem/issues"
61
64
  },
62
- "homepage": "https://github.com/catfish-1234/sessionmem#readme"
65
+ "homepage": "https://github.com/catfish-1234/sessionmem#readme",
66
+ "peerDependencies": {
67
+ "@huggingface/tokenizers": "0.2.0",
68
+ "onnxruntime-node": "1.30.0",
69
+ "@xcidos/genesis-memory-model": "0.1.0-alpha.1"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "@huggingface/tokenizers": {
73
+ "optional": true
74
+ },
75
+ "onnxruntime-node": {
76
+ "optional": true
77
+ },
78
+ "@xcidos/genesis-memory-model": {
79
+ "optional": true
80
+ }
81
+ }
63
82
  }