sessionmem 1.5.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/CHANGELOG.md +43 -0
- package/README.md +148 -79
- package/dist/cli/autostart/index.js +246 -0
- package/dist/cli/commands/autostart.js +24 -0
- package/dist/cli/commands/config.js +1 -0
- package/dist/cli/commands/doctor.js +28 -0
- package/dist/cli/commands/install.js +10 -0
- package/dist/cli/commands/uninstall.js +4 -0
- package/dist/cli/commands/watch.js +14 -2
- package/dist/cli/index.js +16 -7
- package/dist/cli/transcriptSweep.js +103 -143
- package/dist/cli/transcripts/claudeCode.js +62 -0
- package/dist/cli/transcripts/codex.js +111 -0
- package/dist/cli/transcripts/cursor.js +295 -0
- package/dist/cli/transcripts/index.js +118 -0
- package/dist/cli/transcripts/types.js +15 -0
- package/dist/cli/watchCheckpoint.js +35 -0
- package/dist/cli/watchLog.js +23 -0
- package/dist/core/api/contracts.js +3 -0
- package/dist/core/api/memoryCoreService.js +37 -1
- package/dist/core/capture/toolEvent.js +14 -2
- package/dist/core/config/policyConfig.js +3 -0
- package/dist/core/embed/localSemanticEmbedder.js +62 -0
- package/dist/core/retrieve/retrieveMemories.js +18 -2
- package/dist/core/schema/migrations/016_semantic_embeddings.sql +12 -0
- package/dist/core/storage/memorySearchRepo.js +11 -0
- package/dist/core/storage/semanticSearchRepo.js +56 -0
- package/dist/core/storage/sessionEventsRepo.js +12 -3
- package/docs/architecture.md +13 -1
- package/docs/benchmark.md +9 -0
- package/docs/local-semantic.md +56 -0
- package/docs/research/automatic-capture.md +62 -0
- package/docs/troubleshooting.md +5 -0
- package/package.json +21 -2
|
@@ -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,
|
|
@@ -208,7 +225,7 @@ export function createMemoryCoreService(deps) {
|
|
|
208
225
|
event_type: event.eventType,
|
|
209
226
|
payload_json: redactedPayload,
|
|
210
227
|
created_at: event.createdAt,
|
|
211
|
-
});
|
|
228
|
+
}, parsed.replaceExisting);
|
|
212
229
|
}
|
|
213
230
|
return written;
|
|
214
231
|
});
|
|
@@ -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
|
|
@@ -61,16 +61,17 @@ function describeToolUse(toolName, input) {
|
|
|
61
61
|
* structured input and keep only the human-readable summary rather than
|
|
62
62
|
* emitting truncated — and therefore unparseable — JSON.
|
|
63
63
|
*/
|
|
64
|
-
function buildPayloadJson(toolName, text, input, cwd) {
|
|
64
|
+
function buildPayloadJson(toolName, text, input, cwd, callId) {
|
|
65
65
|
const full = JSON.stringify({
|
|
66
66
|
tool: toolName,
|
|
67
67
|
text,
|
|
68
68
|
input: shrinkValue(input),
|
|
69
69
|
cwd,
|
|
70
|
+
...(callId ? { call_id: callId } : {}),
|
|
70
71
|
});
|
|
71
72
|
if (full.length <= MAX_HOOK_PAYLOAD_CHARS)
|
|
72
73
|
return full;
|
|
73
|
-
return JSON.stringify({ tool: toolName, text, cwd, truncated: true });
|
|
74
|
+
return JSON.stringify({ tool: toolName, text, cwd, ...(callId ? { call_id: callId } : {}), truncated: true });
|
|
74
75
|
}
|
|
75
76
|
export function buildToolEvent(toolName, input, cwd) {
|
|
76
77
|
if (!CAPTURED_TOOL_TYPES.has(toolName))
|
|
@@ -80,3 +81,14 @@ export function buildToolEvent(toolName, input, cwd) {
|
|
|
80
81
|
payloadJson: buildPayloadJson(toolName, describeToolUse(toolName, input), input, cwd),
|
|
81
82
|
};
|
|
82
83
|
}
|
|
84
|
+
/** Codex function calls retain their native name and call ID. */
|
|
85
|
+
export function buildFunctionCallEvent(name, input, callId, cwd) {
|
|
86
|
+
const command = input.command ?? input.cmd;
|
|
87
|
+
const text = ["exec_command", "shell_command", "shell"].includes(name)
|
|
88
|
+
? describeToolUse("Bash", { command: Array.isArray(command) ? command.join(" ") : command })
|
|
89
|
+
: CAPTURED_TOOL_TYPES.has(name) ? describeToolUse(name, input) : truncate(`Called ${name}`, MAX_TEXT_CHARS);
|
|
90
|
+
return {
|
|
91
|
+
eventType: `tool_use:${name.toLowerCase()}`,
|
|
92
|
+
payloadJson: buildPayloadJson(name, text, input, cwd, callId),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
+
}
|
|
@@ -4,6 +4,11 @@ function getSessionEventsStatements(db) {
|
|
|
4
4
|
if (stmts)
|
|
5
5
|
return stmts;
|
|
6
6
|
stmts = {
|
|
7
|
+
updateEvent: db.prepare(`
|
|
8
|
+
UPDATE session_events SET event_type = @event_type, payload_json = @payload_json
|
|
9
|
+
WHERE id = @id AND project_id = @project_id AND session_id = @session_id
|
|
10
|
+
AND (event_type != @event_type OR payload_json != @payload_json)
|
|
11
|
+
`),
|
|
7
12
|
// INSERT OR IGNORE so re-ingesting an event with the same logical key
|
|
8
13
|
// (project_id, session_id, event_index) — now a UNIQUE index, migration 009
|
|
9
14
|
// — is a no-op rather than a duplicate row or a PK error.
|
|
@@ -34,10 +39,14 @@ function getSessionEventsStatements(db) {
|
|
|
34
39
|
/**
|
|
35
40
|
* Insert a session event. Returns the number of rows written (1, or 0 when the
|
|
36
41
|
* (project_id, session_id, event_index) key already exists and the insert was
|
|
37
|
-
* ignored).
|
|
42
|
+
* ignored). Mutable transcript imports can opt in to refreshing the content of
|
|
43
|
+
* an existing ID in the same project/session, preserving its index and date.
|
|
44
|
+
* Returns 1 for either an insertion or a content update; unchanged replays are 0.
|
|
38
45
|
*/
|
|
39
|
-
export function insertSessionEvent(db, input) {
|
|
40
|
-
|
|
46
|
+
export function insertSessionEvent(db, input, replaceExisting = false) {
|
|
47
|
+
const statements = getSessionEventsStatements(db);
|
|
48
|
+
const updated = replaceExisting ? statements.updateEvent.run(input).changes : 0;
|
|
49
|
+
return updated + statements.insertEvent.run(input).changes;
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
52
|
* Next free `event_index` for a session (max + 1, or 0 when the session has no
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
|
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
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
|
}
|