sessionmem 1.6.0 → 1.8.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 +62 -0
- package/README.md +122 -61
- package/dist/adapters/claudeMdInjector.js +45 -67
- package/dist/adapters/diagnostics.js +29 -4
- package/dist/adapters/generic.js +31 -5
- package/dist/adapters/global/claudeCode.js +6 -3
- package/dist/adapters/ide/cline.js +2 -1
- package/dist/adapters/ide/cursor.js +3 -3
- package/dist/adapters/ide/installer.js +1 -0
- package/dist/adapters/installedHosts.js +4 -1
- package/dist/cli/autostart/index.js +246 -0
- package/dist/cli/commands/autostart.js +24 -0
- package/dist/cli/commands/config.js +8 -0
- package/dist/cli/commands/doctor.js +45 -2
- package/dist/cli/commands/install.js +14 -3
- package/dist/cli/commands/promptSubmit.js +58 -0
- package/dist/cli/commands/sessionStart.js +14 -2
- package/dist/cli/commands/uninstall.js +4 -0
- package/dist/cli/commands/watch.js +14 -2
- package/dist/cli/index.js +12 -3
- package/dist/cli/projectId.js +17 -2
- package/dist/cli/watchCheckpoint.js +35 -0
- package/dist/cli/watchLog.js +23 -0
- package/dist/core/api/contracts.js +19 -0
- package/dist/core/api/memoryCoreService.js +84 -21
- package/dist/core/config/policyConfig.js +13 -0
- package/dist/core/embed/localSemanticEmbedder.js +62 -0
- package/dist/core/retrieve/memoryIndex.js +6 -0
- package/dist/core/retrieve/retrieveMemories.js +57 -9
- package/dist/core/retrieve/signalFusion.js +21 -0
- package/dist/core/retrieve/temporalIntent.js +59 -0
- package/dist/core/schema/migrations/016_semantic_embeddings.sql +12 -0
- package/dist/core/storage/entityLinkRepo.js +47 -0
- package/dist/core/storage/memorySearchRepo.js +16 -3
- package/dist/core/storage/semanticSearchRepo.js +56 -0
- package/dist/core/storage/temporalRepo.js +13 -0
- package/dist/core/storage/timelineRepo.js +27 -0
- package/docs/architecture.md +13 -1
- package/docs/automatic-memory.md +86 -0
- package/docs/benchmark.md +16 -0
- package/docs/local-semantic.md +58 -0
- package/docs/research/automatic-capture.md +62 -0
- package/docs/retrieval-tools.md +144 -0
- package/docs/troubleshooting.md +5 -0
- package/package.json +21 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const DAY = 86_400_000;
|
|
2
|
+
function range(start, end) {
|
|
3
|
+
return { kind: "past", start, end };
|
|
4
|
+
}
|
|
5
|
+
export function detectTemporalIntent(query, now) {
|
|
6
|
+
const text = query.normalize("NFKC").toLowerCase();
|
|
7
|
+
// A dated or historical clause takes precedence over a current-state cue.
|
|
8
|
+
const iso = /\b(\d{4})-(\d{2})(?:-(\d{2}))?\b/.exec(text);
|
|
9
|
+
if (iso) {
|
|
10
|
+
const year = Number(iso[1]), month = Number(iso[2]) - 1, day = Number(iso[3] ?? 1);
|
|
11
|
+
const start = Date.UTC(year, month, day);
|
|
12
|
+
const date = new Date(start);
|
|
13
|
+
if (date.getUTCFullYear() === year && date.getUTCMonth() === month && date.getUTCDate() === day) {
|
|
14
|
+
return range(start, iso[3] ? start + DAY : Date.UTC(year, month + 1, 1));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
18
|
+
if (/\byesterday\b/.test(text))
|
|
19
|
+
return range(today - DAY, today);
|
|
20
|
+
const relative = /\b(?:last|previous) (week|month|year)\b/.exec(text);
|
|
21
|
+
if (relative) {
|
|
22
|
+
const year = now.getUTCFullYear(), month = now.getUTCMonth();
|
|
23
|
+
if (relative[1] === "month")
|
|
24
|
+
return range(Date.UTC(year, month - 1, 1), Date.UTC(year, month, 1));
|
|
25
|
+
if (relative[1] === "year")
|
|
26
|
+
return range(Date.UTC(year - 1, 0, 1), Date.UTC(year, 0, 1));
|
|
27
|
+
// Calendar weeks start on Monday, in UTC.
|
|
28
|
+
const monday = today - ((now.getUTCDay() + 6) % 7) * DAY;
|
|
29
|
+
return range(monday - 7 * DAY, monday);
|
|
30
|
+
}
|
|
31
|
+
const ago = /\b(\d{1,3}) (day|week)s? ago\b/.exec(text);
|
|
32
|
+
if (ago) {
|
|
33
|
+
const start = today - Number(ago[1]) * (ago[2] === "week" ? 7 : 1) * DAY;
|
|
34
|
+
return range(start, start + (ago[2] === "week" ? 7 : 1) * DAY);
|
|
35
|
+
}
|
|
36
|
+
const year = /\b(?:in|during) ((?:19|20)\d{2})\b/.exec(text);
|
|
37
|
+
if (year)
|
|
38
|
+
return range(Date.UTC(Number(year[1]), 0, 1), Date.UTC(Number(year[1]) + 1, 0, 1));
|
|
39
|
+
if (/\b(before|previously|formerly|prior|used to|no longer)\b/.test(text))
|
|
40
|
+
return { kind: "past", previous: true };
|
|
41
|
+
if (/\b(when (?:did|was|were)|in the past|historical|history)\b/.test(text))
|
|
42
|
+
return { kind: "past" };
|
|
43
|
+
if (/\b(current|currently|now|latest|today|at present)\b/.test(text))
|
|
44
|
+
return { kind: "current" };
|
|
45
|
+
return { kind: "none" };
|
|
46
|
+
}
|
|
47
|
+
/** A bounded signal, separate from lexical relevance. Missing dates stay neutral. */
|
|
48
|
+
export function temporalRelevance(intent, timestamp, supersededFraction, now) {
|
|
49
|
+
const time = Date.parse(timestamp);
|
|
50
|
+
if (intent.kind === "current") {
|
|
51
|
+
const freshness = Number.isFinite(time) ? 1 / (1 + Math.max(0, now.getTime() - time) / (30 * DAY)) : 0.5;
|
|
52
|
+
return (1 - supersededFraction + freshness) / 2;
|
|
53
|
+
}
|
|
54
|
+
if (intent.start !== undefined && intent.end !== undefined && Number.isFinite(time)) {
|
|
55
|
+
const distance = time < intent.start ? intent.start - time : time >= intent.end ? time - intent.end + 1 : 0;
|
|
56
|
+
return 1 / (1 + distance / (30 * DAY));
|
|
57
|
+
}
|
|
58
|
+
return intent.previous ? supersededFraction : 0.5;
|
|
59
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { queryEntityNames } from "../extract/knowledge.js";
|
|
2
|
+
// Explicit spelling aliases only. No fuzzy matching or inferred coreference.
|
|
3
|
+
const ALIASES = [["postgres", "postgresql"], ["node", "node.js"], ["k8s", "kubernetes"]];
|
|
4
|
+
/** Link literal query names to existing entities, then weight rare names more. */
|
|
5
|
+
export function linkedEntityScores(db, projectId, query, now, includeHistory, hubDamping = false) {
|
|
6
|
+
const names = new Set(queryEntityNames(query));
|
|
7
|
+
for (const group of ALIASES)
|
|
8
|
+
if (group.some(name => names.has(name)))
|
|
9
|
+
for (const name of group)
|
|
10
|
+
names.add(name);
|
|
11
|
+
const rows = db.prepare(`SELECT DISTINCT e.memory_id, e.kind, e.name FROM memory_entities e
|
|
12
|
+
JOIN memories m ON m.id = e.memory_id
|
|
13
|
+
WHERE m.project_id = ? AND (m.expires_at IS NULL OR m.expires_at > ?)
|
|
14
|
+
AND (? = 1 OR m.id NOT IN (SELECT memory_id FROM superseded_memories))
|
|
15
|
+
AND e.name IN (SELECT value FROM json_each(?)) ORDER BY e.kind, e.name, e.memory_id`)
|
|
16
|
+
.all(projectId, now.toISOString(), Number(includeHistory), JSON.stringify([...names]));
|
|
17
|
+
const frequencies = new Map();
|
|
18
|
+
const key = (row) => `${row.kind}:${row.name}`;
|
|
19
|
+
for (const row of rows)
|
|
20
|
+
frequencies.set(key(row), (frequencies.get(key(row)) ?? 0) + 1);
|
|
21
|
+
if (hubDamping) {
|
|
22
|
+
// Match at most eight stored names, preferring specific spans. Exact and
|
|
23
|
+
// explicit alias matches have similarity 1; no entity model is implied.
|
|
24
|
+
const selected = new Set([...new Set(rows.map(row => row.name))]
|
|
25
|
+
.sort((a, b) => b.length - a.length || a.localeCompare(b)).slice(0, 8));
|
|
26
|
+
const boosts = new Map();
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
if (!selected.has(row.name))
|
|
29
|
+
continue;
|
|
30
|
+
const degree = frequencies.get(key(row));
|
|
31
|
+
const boost = 0.5 / (1 + 0.001 * (degree - 1) ** 2);
|
|
32
|
+
boosts.set(row.memory_id, Math.max(boosts.get(row.memory_id) ?? 0, boost));
|
|
33
|
+
}
|
|
34
|
+
return new Map([...boosts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
|
|
35
|
+
}
|
|
36
|
+
const total = db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE project_id = ?
|
|
37
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
38
|
+
AND (? = 1 OR id NOT IN (SELECT memory_id FROM superseded_memories))`)
|
|
39
|
+
.get(projectId, now.toISOString(), Number(includeHistory)).n;
|
|
40
|
+
const weights = new Map([...frequencies].map(([name, count]) => [name, 1 + Math.log((total + 1) / (count + 1))]));
|
|
41
|
+
const denominator = [...weights.values()].reduce((a, b) => a + b, 0);
|
|
42
|
+
const scores = new Map();
|
|
43
|
+
for (const row of rows)
|
|
44
|
+
scores.set(row.memory_id, (scores.get(row.memory_id) ?? 0) + weights.get(key(row)) / denominator);
|
|
45
|
+
// Keep scores for all existing lexical/dense candidates. Only admission is capped.
|
|
46
|
+
return new Map([...scores].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
|
|
47
|
+
}
|
|
@@ -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,
|
|
@@ -107,13 +118,15 @@ const QUERY_STOP_WORDS = new Set(("a an and are as at be been being but by can c
|
|
|
107
118
|
"where which who whom why will with would you your yours yourself yourselves " +
|
|
108
119
|
"s t don doesn didn isn wasn weren won wouldn couldn shouldn").split(" "));
|
|
109
120
|
/** Match any meaningful query term; question grammar must not exclude evidence. */
|
|
110
|
-
function
|
|
121
|
+
export function lexicalQueryTerms(queryText) {
|
|
111
122
|
const tokens = [...new Set(queryText.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])];
|
|
112
123
|
const terms = tokens.filter((token) => !QUERY_STOP_WORDS.has(token));
|
|
113
124
|
// Preserve literal searches consisting entirely of stop words. Tokens contain
|
|
114
125
|
// only letters/numbers, so FTS operators and punctuation cannot become syntax.
|
|
115
|
-
return (terms.length ? terms : tokens)
|
|
116
|
-
|
|
126
|
+
return (terms.length ? terms : tokens).slice(0, 64);
|
|
127
|
+
}
|
|
128
|
+
function sanitizeFtsQuery(queryText) {
|
|
129
|
+
return lexicalQueryTerms(queryText)
|
|
117
130
|
.map((token) => `"${token}"`)
|
|
118
131
|
.join(" OR ");
|
|
119
132
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Use claim observation times when available, otherwise the original capture time. */
|
|
2
|
+
export function memoryTemporalMetadata(db, projectId, ids) {
|
|
3
|
+
const rows = db.prepare(`SELECT m.id, m.created_at, MAX(c.observed_at) AS observed_at,
|
|
4
|
+
COUNT(c.claim_key) AS claims, COUNT(s.claim_key) AS superseded
|
|
5
|
+
FROM memories m LEFT JOIN memory_claims c ON c.memory_id = m.id
|
|
6
|
+
LEFT JOIN memory_supersessions s ON s.supersedes = c.memory_id AND s.claim_key = c.claim_key
|
|
7
|
+
WHERE m.project_id = ? AND m.id IN (SELECT value FROM json_each(?)) GROUP BY m.id`)
|
|
8
|
+
.all(projectId, JSON.stringify(ids));
|
|
9
|
+
return new Map(rows.map(row => [row.id, {
|
|
10
|
+
timestamp: row.observed_at ?? row.created_at,
|
|
11
|
+
supersededFraction: row.claims ? row.superseded / row.claims : 0,
|
|
12
|
+
}]));
|
|
13
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DomainError } from "../api/errors.js";
|
|
2
|
+
/** Small SQL projections, stable ordering, and no access-count writes. */
|
|
3
|
+
export function memoryTimeline(db, input, now) {
|
|
4
|
+
const visible = `project_id = @projectId AND (expires_at IS NULL OR expires_at > @now)
|
|
5
|
+
AND (@history = 1 OR id NOT IN (SELECT memory_id FROM superseded_memories))`;
|
|
6
|
+
const bindings = { projectId: input.projectId, now: now.toISOString(), history: Number(input.includeHistory) };
|
|
7
|
+
// Project only enough text for the one-line summary, never embeddings or bodies.
|
|
8
|
+
const select = `SELECT id, substr(content, 1, 1000) AS content, kind, created_at FROM memories WHERE ${visible}`;
|
|
9
|
+
if (!input.memoryId) {
|
|
10
|
+
const rows = db.prepare(`${select} AND created_at >= @fromDate AND created_at <= @toDate
|
|
11
|
+
ORDER BY created_at, id LIMIT @limit`).all({ ...bindings,
|
|
12
|
+
fromDate: new Date(input.fromDate).toISOString(), toDate: new Date(input.toDate).toISOString(), limit: input.limit + 1, });
|
|
13
|
+
return { rows: rows.slice(0, input.limit), hasMore: rows.length > input.limit };
|
|
14
|
+
}
|
|
15
|
+
const anchor = db.prepare(`${select} AND id = @memoryId`).get({ ...bindings, memoryId: input.memoryId });
|
|
16
|
+
if (!anchor)
|
|
17
|
+
throw new DomainError("NOT_FOUND", `Memory not found: ${input.memoryId}`);
|
|
18
|
+
const params = { ...bindings, date: anchor.created_at, id: anchor.id, limit: input.limit + 1 };
|
|
19
|
+
const before = db.prepare(`${select} AND (created_at < @date OR (created_at = @date AND id < @id))
|
|
20
|
+
ORDER BY created_at DESC, id DESC LIMIT @limit`).all(params);
|
|
21
|
+
const after = db.prepare(`${select} AND (created_at > @date OR (created_at = @date AND id > @id))
|
|
22
|
+
ORDER BY created_at, id LIMIT @limit`).all(params);
|
|
23
|
+
const left = Math.min(before.length, Math.max(Math.floor((input.limit - 1) / 2), input.limit - 1 - after.length));
|
|
24
|
+
const right = Math.min(after.length, input.limit - 1 - left);
|
|
25
|
+
return { rows: [...before.slice(0, left).reverse(), anchor, ...after.slice(0, right)],
|
|
26
|
+
hasMore: before.length + after.length + 1 > input.limit };
|
|
27
|
+
}
|
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
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Automatic capture and recall
|
|
2
|
+
|
|
3
|
+
Install once and restart the host so it loads its MCP configuration and guidance.
|
|
4
|
+
The installed flow does not ask the user to run scans, recall commands or save
|
|
5
|
+
commands during ordinary agent work. Host tool permissions must allow the memory
|
|
6
|
+
tools. `sessionmem doctor --adapter <name>` checks the configured flow and gives
|
|
7
|
+
repair hints for missing hooks, stale instructions, disabled MCP wiring and stale
|
|
8
|
+
watcher checkpoints.
|
|
9
|
+
|
|
10
|
+
## Host audit and behavior
|
|
11
|
+
|
|
12
|
+
| Host | Capture after install | Session-start recall | Recall on prompts |
|
|
13
|
+
| --- | --- | --- | --- |
|
|
14
|
+
| Claude Code | PostToolUse captures Bash/Edit/Write/MultiEdit; SessionEnd summarizes; login watcher also imports transcripts | SessionStart hook catches up transcripts and injects context | UserPromptSubmit hook retrieves context for the submitted prompt |
|
|
15
|
+
| Codex | Login-started watcher imports local rollout transcripts | Global AGENTS.md and MCP instructions tell the agent to search before work and fetch selected details | Instructions tell the agent to search for a new topic |
|
|
16
|
+
| Cursor | Login-started watcher imports supported local composer SQLite records | Project always-apply rule and global MCP instructions tell the agent to search before work | Same instruction-driven topic recall |
|
|
17
|
+
| Windsurf | Agent calls storeMemory/batchStoreMemory under global rules; no transcript importer | Global rules and MCP instructions | Instruction-driven topic recall |
|
|
18
|
+
| Cline | Agent calls storeMemory/batchStoreMemory under installed rules; no transcript importer | Installed rules and MCP instructions | Instruction-driven topic recall |
|
|
19
|
+
| Antigravity, QCoder adapters | Agent tool saves when the host loads the configured MCP server and guidance; no transcript importer | Instruction-driven; existing adapter file paths are unverified against the native products | Instruction-driven; host loading must be verified |
|
|
20
|
+
| Generic MCP | Agent tool saves when the host reads project AGENTS.md or MCP instructions; no transcript importer | Instruction-driven, dependent on host support | Instruction-driven topic recall |
|
|
21
|
+
|
|
22
|
+
Only Claude Code has deterministic host hooks in this integration. Instruction-
|
|
23
|
+
driven recall is configured by install, but doctor cannot prove that a model
|
|
24
|
+
executed the instructions or that an unverified native host loaded a guessed
|
|
25
|
+
configuration path. Doctor marks Antigravity/QCoder automation unestablished and
|
|
26
|
+
gives a native-settings verification hint. Its output identifies this distinction. The tests simulate
|
|
27
|
+
an agent following the installed instructions; they do not launch proprietary
|
|
28
|
+
host interfaces or claim that every model obeys instructions.
|
|
29
|
+
|
|
30
|
+
The watcher starts immediately at install and at login through launchd on macOS,
|
|
31
|
+
a user systemd service on Linux, or Task Scheduler on Windows. It polls supported
|
|
32
|
+
transcripts every five seconds by default. Capture requires readable transcript
|
|
33
|
+
files in a supported format. A missing directory is valid before the first
|
|
34
|
+
session; an unsupported format or unreadable source is a diagnostic failure.
|
|
35
|
+
No user action is needed to import later sessions while the service is healthy.
|
|
36
|
+
|
|
37
|
+
Claude Code SessionStart and a new MCP server connection also perform an
|
|
38
|
+
incremental catch-up. A session opened immediately after another session can
|
|
39
|
+
therefore recall newly written transcript events before the next watcher poll.
|
|
40
|
+
The MCP server advertises the startup, new-topic recall and decision-save flow
|
|
41
|
+
in its protocol instructions for all hosts. Existing user prose is preserved
|
|
42
|
+
when managed guidance blocks are updated.
|
|
43
|
+
|
|
44
|
+
Cursor rules are project scoped. The old home-level `.cursor/rules` location did
|
|
45
|
+
not establish a rule for arbitrary projects. Install now writes the rule in the
|
|
46
|
+
current project, repairs `alwaysApply:true`, and supplies the same flow through
|
|
47
|
+
the global MCP server's instructions in other projects. A host that ignores MCP
|
|
48
|
+
instructions needs an active native rule in that project.
|
|
49
|
+
|
|
50
|
+
Plain-terminal install detection recognizes Claude Code, Codex, Cursor and
|
|
51
|
+
Windsurf CLI executables when on PATH, plus the documented native Claude paths.
|
|
52
|
+
Other hosts still need selection at setup with `--adapter`. Configuration
|
|
53
|
+
directories alone are not treated as evidence of an installed host.
|
|
54
|
+
|
|
55
|
+
Cline's installed MCP entry preapproves the local search, detail and capture tools
|
|
56
|
+
so these calls do not require a separate user action each time. Deletion and
|
|
57
|
+
external operations are excluded. Other hosts retain their own permission rules.
|
|
58
|
+
|
|
59
|
+
## Diagnosis and repair
|
|
60
|
+
|
|
61
|
+
Doctor separately reports hook definitions, MCP startup and tool availability,
|
|
62
|
+
current agent instructions, capture mode and recall mode. For hookless hosts it
|
|
63
|
+
says that recall is instruction-driven and execution is unverified. For hosts
|
|
64
|
+
without transcript support it says that capture consists of agent-driven saves,
|
|
65
|
+
not background transcript import. Hook and guidance configuration are checked
|
|
66
|
+
against the current installed version. Failed guidance writes make install fail
|
|
67
|
+
instead of printing a successful setup message.
|
|
68
|
+
|
|
69
|
+
Doctor's MCP probe bypasses startup capture so diagnosis does not import
|
|
70
|
+
transcripts or alter the user's memories. Its isolated storage canary rolls back.
|
|
71
|
+
The watcher-health check requires a recent, successful checkpoint for the selected
|
|
72
|
+
database, rather than assuming that a registered service is running.
|
|
73
|
+
|
|
74
|
+
## CI evidence
|
|
75
|
+
|
|
76
|
+
`tests/integration/cli/automatic-memory-flow.spec.ts` runs in the CI OS matrix.
|
|
77
|
+
It uses a temporary home and project. The OS supervisor commands are simulated,
|
|
78
|
+
but the registered watcher command performs real transcript ingestion.
|
|
79
|
+
|
|
80
|
+
The Claude case installs the actual hook definitions, feeds capture events,
|
|
81
|
+
ends the session, starts a new session, and verifies the startup and prompt
|
|
82
|
+
context envelopes contain the stored database decision. The Codex and Cursor
|
|
83
|
+
cases create real-format transcript fixtures, install and run the automatic
|
|
84
|
+
watcher sweep, run doctor, then open a new MCP session. A simulated agent follows
|
|
85
|
+
the installed instructions and verifies selected full details reach its context.
|
|
86
|
+
No manual import or scan command is used.
|
package/docs/benchmark.md
CHANGED
|
@@ -5,6 +5,22 @@
|
|
|
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
|
+
## Compact tools, temporal retrieval and entity fusion
|
|
9
|
+
|
|
10
|
+
See the [retrieval evaluation](../benchmarks/retrieval-gaps.md) for fresh 1.7.0
|
|
11
|
+
baselines, per-category before/after tables, complete regression artifacts and
|
|
12
|
+
measured compact-tool token costs. Temporal and source-informed additive experiments remain off by default. Entity
|
|
13
|
+
coverage improves default BM25; optional hybrid results are preserved.
|
|
14
|
+
|
|
15
|
+
## Local semantic and competitor comparison
|
|
16
|
+
|
|
17
|
+
The [measured comparison](../COMPARISON.md) records a new unchanged-main run,
|
|
18
|
+
local raw-turn retrieval variants from claude-mem, Mem0 OSS, Graphiti and cognee,
|
|
19
|
+
and the optional MiniLM hybrid. It includes exact commands and commits, complete
|
|
20
|
+
per-question archives, latency costs and regressions. These component scores do
|
|
21
|
+
not evaluate full capture/graph/answer pipelines. The comparisons below are
|
|
22
|
+
earlier changes on earlier commits; their timings are not the new baseline.
|
|
23
|
+
|
|
8
24
|
## Entity overlap comparison
|
|
9
25
|
|
|
10
26
|
The baseline was run first on current main at `81f987c`. A fixed 10% entity
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Optional local semantic retrieval
|
|
2
|
+
|
|
3
|
+
The default is BM25 with entity-link coverage. Active MiniLM retains the existing
|
|
4
|
+
entity-overlap fusion because stronger coverage lowered its hit rate and recall.
|
|
5
|
+
To add a learned semantic candidate pool,
|
|
6
|
+
install the optional packages beside sessionmem and enable it:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
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
|
|
10
|
+
npx sessionmem config set semanticSearch true
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For a global sessionmem installation, use `npm install -g` for the same packages.
|
|
14
|
+
`ONNXRUNTIME_NODE_INSTALL=skip` skips optional CUDA downloads; the npm package
|
|
15
|
+
already contains the CPU runtime. On PowerShell set
|
|
16
|
+
`$env:ONNXRUNTIME_NODE_INSTALL = 'skip'` before the install command.
|
|
17
|
+
Developers should also use that variable with `npm ci` to avoid CUDA downloads.
|
|
18
|
+
Disable semantic retrieval with `sessionmem config set semanticSearch false`.
|
|
19
|
+
|
|
20
|
+
Installation fetches packages and weights. Capture and retrieval only load files
|
|
21
|
+
from these installed packages; they never download a model, contact a model
|
|
22
|
+
server, or send memory text over a network. Missing packages, checksum mismatches,
|
|
23
|
+
unsupported native runtimes, and inference errors fall back to BM25 and emit one
|
|
24
|
+
diagnostic per service instance. Restart after installing or repairing packages.
|
|
25
|
+
Before loading ONNX Runtime, sessionmem sets the process-wide
|
|
26
|
+
`ORT_DISABLE_TELEMETRY=1` to suppress its native POSIX telemetry uploader.
|
|
27
|
+
The benchmark's `--semantic` mode instead fails if the model cannot load, to
|
|
28
|
+
prevent accidentally publishing fallback scores as semantic results.
|
|
29
|
+
|
|
30
|
+
The encoder is sentence-transformers/all-MiniLM-L6-v2: 384 dimensions, mean pooling,
|
|
31
|
+
L2 normalization, and at most 256 WordPiece tokens. Its weights and tokenizer come
|
|
32
|
+
from the pinned npm distribution above, whose provenance names upstream revision
|
|
33
|
+
`c9745ed1d9f207416be6d2e6f8de32d1f16199bf`. Both files are SHA-256 checked before
|
|
34
|
+
loading. The model is by the sentence-transformers contributors, distributed under
|
|
35
|
+
[Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0); see
|
|
36
|
+
[model information](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2)
|
|
37
|
+
and the package's provenance README. No competitor code or prompt is included.
|
|
38
|
+
|
|
39
|
+
Learned vectors are a separate SQLite BLOB index, keyed by memory and complete
|
|
40
|
+
model/tokenizer/pooling version. Content edits invalidate vectors; deletes cascade.
|
|
41
|
+
Only stored text is embedded, after the configured capture redaction policy.
|
|
42
|
+
Capture through `storeMemory` indexes it immediately. Existing memories are
|
|
43
|
+
embedded as stored; other write paths are backfilled
|
|
44
|
+
on the next semantic query, so the first query on a large existing store can be
|
|
45
|
+
slow. Model initialization and indexing cost are distinct from warm retrieval.
|
|
46
|
+
|
|
47
|
+
Queries perform an exact local cosine scan, take up to 50 semantic candidates,
|
|
48
|
+
and union them with FTS candidates. Project, expiry, and supersession filters
|
|
49
|
+
apply to both channels; `includeHistory` explicitly includes superseded rows.
|
|
50
|
+
Normalized BM25 relevance and positive cosine are blended 2:1 before the existing
|
|
51
|
+
entity, recency, importance, and access signals. With no lexical match, cosine
|
|
52
|
+
supplies relevance. This weighting follows the measured weakness of dense-only
|
|
53
|
+
retrieval; it is not a held-out optimum. Read the measured regressions in
|
|
54
|
+
[the comparison](../COMPARISON.md).
|
|
55
|
+
|
|
56
|
+
Exact scans are intended for small project stores and scale linearly with the
|
|
57
|
+
number of vectors. Long memories are truncated to the encoder's token window;
|
|
58
|
+
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.
|