sessionmem 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +186 -41
  3. package/dist/adapters/diagnostics.js +109 -0
  4. package/dist/adapters/generic.js +6 -2
  5. package/dist/adapters/global/antigravity.js +4 -1
  6. package/dist/adapters/global/claudeCode.js +19 -4
  7. package/dist/adapters/global/codex.js +7 -6
  8. package/dist/adapters/global/qcoder.js +4 -1
  9. package/dist/adapters/ide/cline.js +3 -0
  10. package/dist/adapters/ide/cursor.js +3 -0
  11. package/dist/adapters/ide/installer.js +17 -5
  12. package/dist/adapters/ide/windsurf.js +3 -0
  13. package/dist/adapters/installedHosts.js +51 -0
  14. package/dist/cli/commands/doctor.js +146 -0
  15. package/dist/cli/commands/entities.js +27 -0
  16. package/dist/cli/commands/history.js +23 -0
  17. package/dist/cli/commands/ingestHook.js +4 -78
  18. package/dist/cli/commands/install.js +34 -56
  19. package/dist/cli/commands/scan.js +16 -0
  20. package/dist/cli/commands/search.js +1 -0
  21. package/dist/cli/commands/team.js +7 -3
  22. package/dist/cli/commands/ui.js +67 -0
  23. package/dist/cli/commands/watch.js +48 -0
  24. package/dist/cli/index.js +42 -1
  25. package/dist/cli/projectId.js +1 -2
  26. package/dist/cli/transcriptSweep.js +182 -0
  27. package/dist/core/api/contracts.js +1 -0
  28. package/dist/core/api/memoryCoreService.js +8 -0
  29. package/dist/core/api/sessionLifecycleService.js +13 -1
  30. package/dist/core/capture/toolEvent.js +82 -0
  31. package/dist/core/extract/factClaims.js +97 -0
  32. package/dist/core/extract/knowledge.js +127 -0
  33. package/dist/core/retrieve/retrieveMemories.js +20 -4
  34. package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
  35. package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
  36. package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
  37. package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
  38. package/dist/core/schema/runMigrations.js +46 -0
  39. package/dist/core/storage/knowledgeRepo.js +67 -0
  40. package/dist/core/storage/memoryRepo.js +45 -12
  41. package/dist/core/storage/memorySearchRepo.js +30 -20
  42. package/dist/core/storage/supersessionRepo.js +101 -0
  43. package/dist/core/summarize/summaryShape.js +1 -1
  44. package/dist/ui/assets.js +181 -0
  45. package/dist/ui/server.js +147 -0
  46. package/docs/REGISTRY_LISTING.md +88 -0
  47. package/docs/architecture.md +27 -3
  48. package/docs/benchmark.md +105 -33
  49. package/docs/migration.md +2 -0
  50. package/docs/troubleshooting.md +52 -4
  51. package/package.json +1 -1
@@ -0,0 +1,97 @@
1
+ import { canonicalEntityName, extractKnowledge } from "./knowledge.js";
2
+ import { applyRedaction } from "../summarize/redaction.js";
3
+ const CATEGORIES = [
4
+ ["editor", ["vs code", "vscode", "visual studio code", "neovim", "vim", "emacs", "sublime text", "intellij"]],
5
+ ["language", ["python", "rust", "typescript", "javascript", "java", "go", "ruby"]],
6
+ ["database", ["sqlite", "postgresql", "postgres", "mysql", "mongodb"]],
7
+ ["package manager", ["npm", "pnpm", "yarn", "bun"]],
8
+ ["test runner", ["vitest", "jest", "mocha", "pytest"]],
9
+ ];
10
+ const aliases = { vscode: "vs code", "visual studio code": "vs code", postgres: "postgresql" };
11
+ const value = (claim) => aliases[claim.target.name] ?? claim.target.name;
12
+ export function claimKey(claim) {
13
+ return JSON.stringify([claim.source.kind, claim.source.name, claim.type,
14
+ claim.target.kind, claim.target.name, claim.context]);
15
+ }
16
+ export function claimSlot(claim) {
17
+ return JSON.stringify([claim.source, claim.type, claim.context, claim.attribute || claim.target]);
18
+ }
19
+ /** Small affirmative/negative grammar layered on migration 014's entity rules. */
20
+ export function extractFactClaims(content) {
21
+ const text = applyRedaction(content, { redactionEnabled: true }).text.normalize("NFKC");
22
+ const claims = new Map();
23
+ let speaker = "";
24
+ for (let line of text.split(/\n|(?<=[.!?])\s+/)) {
25
+ line = line.replace(/^\s*(?:-\s*)?(?:\[[^\]]{1,60}\]\s*)?/, "").trim();
26
+ const prefix = /^([\p{Lu}][\p{L}'’-]*(?:\s+[\p{Lu}][\p{L}'’-]*){0,2}):\s*/u.exec(line);
27
+ if (prefix && !/^(actions|decisions|outcomes|facts|summary|user|assistant)$/i.test(prefix[1])) {
28
+ speaker = prefix[1];
29
+ line = line.slice(prefix[0].length);
30
+ }
31
+ // No questions, hypothetical statements, reported quotations or code.
32
+ if (/\?|^[`"“]|```/.test(line) || /\b(?:if|might|may|could|should|would|can|said|says|asked|used to)\b/i.test(line))
33
+ continue;
34
+ const negative = /\b(?:not|never|no longer|stopped)\b|n['’]t\b/i.test(line);
35
+ const explicitChange = /\b(?:now|switched|replaced|instead|no longer|stopped)\b/i.test(line);
36
+ const replaced = /\bswitched\s+from\s+(.+?)\s+to\b|\breplaced\s+(.+?)\s+with\b|\binstead\s+of\s+(.+?)[.!]?$/i.exec(line);
37
+ const replaces = canonicalEntityName(replaced?.slice(1).find(Boolean) ?? "").replace(/^(?:tool|service|project)\s+/, "");
38
+ let affirmative = line
39
+ .replace(/\b(?:do|does|did)\s+not\s+|\b(?:don['’]t|doesn['’]t|didn['’]t)\s+|\b(?:never|no longer)\s+/gi, "")
40
+ .replace(/\bstopped\s+using\b/gi, "uses")
41
+ .replace(/\bnot\s+(?=(?:uses?|using|prefers?|works?\s+on|working\s+on|blocked\s+by)\b)/gi, "")
42
+ .replace(/\bswitched\s+(?:from\s+.+?\s+)?to\s+/gi, "uses ")
43
+ .replace(/\breplaced\s+.+?\s+with\s+/gi, "uses ")
44
+ .replace(/\b(?:now|currently)\s+/gi, "")
45
+ .replace(/\s+(?:now|currently|again)[.!]?$/i, ".");
46
+ // Other negation forms are ambiguous; never turn them into positive claims.
47
+ if (/\bnot\b|n['’]t\b/i.test(affirmative))
48
+ continue;
49
+ const context = canonicalEntityName(/\b(?:for|as)\s+(.+?)(?:[.!;]|\s+because\b|$)/i.exec(affirmative)?.[1] ?? "")
50
+ .replace(/\b(?:my|our|the|a|an)\s+/g, "");
51
+ affirmative = affirmative.replace(/\s+as\s+.+$/i, "");
52
+ const graph = extractKnowledge(speaker ? `${speaker}: ${affirmative}` : affirmative);
53
+ for (const edge of graph.relations) {
54
+ const category = edge.type === "uses" || edge.type === "prefers"
55
+ ? CATEGORIES.find(([, names]) => names.includes(edge.target.name))?.[0] ?? "" : "";
56
+ const claim = { ...edge, context, attribute: category, negative, explicitChange, replaces };
57
+ // Last assertion about the same slot in a session wins, including retraction.
58
+ const slot = claimSlot(claim);
59
+ claims.delete(slot);
60
+ claims.set(slot, claim);
61
+ }
62
+ }
63
+ return [...claims.values()];
64
+ }
65
+ function contextOverlap(left, right) {
66
+ if (left === right)
67
+ return true;
68
+ if (!left || !right)
69
+ return false;
70
+ const words = (text) => new Set(text.match(/[\p{L}\p{N}]+/gu) ?? []);
71
+ const a = words(left), b = words(right);
72
+ const shared = [...a].filter(word => b.has(word)).length;
73
+ return shared >= 2 && shared / new Set([...a, ...b]).size >= 0.6;
74
+ }
75
+ export function supersessionReason(older, newer) {
76
+ if (older.source.kind !== newer.source.kind || older.source.name !== newer.source.name || older.type !== newer.type)
77
+ return;
78
+ if (value(older) === value(newer) && older.target.kind === newer.target.kind) {
79
+ if (older.negative !== newer.negative && (!older.context || !newer.context || contextOverlap(older.context, newer.context)))
80
+ return "explicit_negation";
81
+ return;
82
+ }
83
+ if (older.negative || newer.negative || older.target.kind !== newer.target.kind)
84
+ return;
85
+ if (value(older) === (aliases[newer.replaces] ?? newer.replaces))
86
+ return "different_value";
87
+ // Shared lexical purpose is a stronger attribute cue than a tool category.
88
+ if (older.context || newer.context) {
89
+ if (contextOverlap(older.context, newer.context))
90
+ return "different_value";
91
+ return;
92
+ }
93
+ if (older.attribute && older.attribute === newer.attribute)
94
+ return "different_value";
95
+ if (newer.explicitChange && older.type === "works_on")
96
+ return "different_value";
97
+ }
@@ -0,0 +1,127 @@
1
+ import { applyRedaction } from "../summarize/redaction.js";
2
+ // Deliberately small vocabulary. Unknown names require an explicit contextual
3
+ // cue; capitalized sentence starters alone are not evidence of a person/tool.
4
+ const TOOLS = ["SQLite", "PostgreSQL", "Postgres", "MySQL", "Redis", "Docker", "Git",
5
+ "Python", "TypeScript", "JavaScript", "Rust", "Node.js", "npm", "pnpm", "yarn",
6
+ "Vitest", "Jest", "React", "Vite", "Terraform", "Kubernetes", "VS Code", "Claude Code"];
7
+ const SERVICES = ["GitHub", "GitLab", "Slack", "AWS", "S3", "Azure", "Google Cloud",
8
+ "OpenAI", "Anthropic", "Supabase", "Vercel", "Firebase"];
9
+ const LABELS = new Set(["actions", "decisions", "outcomes", "facts", "decision", "summary",
10
+ "image caption", "ran", "edited", "created", "read", "updated", "tool", "user", "assistant"]);
11
+ const FILE_PATTERN = /(?:\.{0,2}\/)?[\p{L}\p{N}_@-]+(?:\/[\p{L}\p{N}_.@-]+)*\.(?:[cm]?[jt]sx?|py|rs|go|java|jsonl?|ya?ml|toml|md|sql|sh|css|html|txt|env|lock)\b/giu;
12
+ const key = (entity) => `${entity.kind}:${entity.name}`;
13
+ export function canonicalEntityName(name) {
14
+ return name.normalize("NFKC").toLowerCase().replace(/[`"“”]/g, "")
15
+ .replace(/’/g, "'").replace(/'s\b/g, "").replace(/\s+/g, " ").trim()
16
+ .replace(/^[\s'.,:;!?]+|[\s'.,:;!?]+$/g, "");
17
+ }
18
+ /** Offline, order-stable rules. No inferred coreference beyond I/we and a speaker. */
19
+ export function extractKnowledge(content) {
20
+ // Derived tables must never resurrect secrets, including during legacy backfill.
21
+ const text = applyRedaction(content, { redactionEnabled: true }).text.normalize("NFKC");
22
+ const entities = new Map();
23
+ const relations = new Map();
24
+ function add(kind, raw) {
25
+ const name = canonicalEntityName(raw);
26
+ if (!name || name.length > 160 || /redacted|[\n{}<>]/i.test(name))
27
+ return;
28
+ const entity = { kind, name };
29
+ entities.set(key(entity), entity);
30
+ return entity;
31
+ }
32
+ function classify(raw, fallback) {
33
+ const name = canonicalEntityName(raw);
34
+ const known = [...entities.values()].find(entity => entity.name === name);
35
+ if (known)
36
+ return known;
37
+ if (new RegExp(`^(?:${FILE_PATTERN.source})$`, "iu").test(name))
38
+ return add("file", name);
39
+ const label = /^(person|project|tool|service|file)\s+(.+)$/i.exec(name);
40
+ return label ? add(label[1], label[2]) : add(fallback, name);
41
+ }
42
+ for (const [kind, names] of [["tool", TOOLS], ["service", SERVICES]]) {
43
+ for (const name of names) {
44
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ if (new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, "iu").test(text))
46
+ add(kind, name);
47
+ }
48
+ }
49
+ for (const match of text.matchAll(FILE_PATTERN))
50
+ add("file", match[0]);
51
+ // Explicit labels support names outside the vocabulary, including lowercase.
52
+ for (const match of text.matchAll(/\b(person|project|tool|service|file)\s+(`[^`\n]{1,120}`|"[^"\n]{1,120}"|[\p{L}\p{N}_.@/-]+)/giu)) {
53
+ add(match[1].toLowerCase(), match[2]);
54
+ }
55
+ let speaker;
56
+ for (const rawLine of text.split(/\n|(?<=[.!?])\s+/)) {
57
+ let line = rawLine.replace(/^\s*(?:-\s*)?(?:\[[^\]]{1,60}\]\s*)?/, "");
58
+ const command = /^Ran:\s*(.+)$/i.exec(line)?.[1];
59
+ if (command) {
60
+ const source = add("person", "user");
61
+ const executable = command.split(/\s+/)[0];
62
+ const tool = [...entities.values()].find(entity => entity.kind === "tool" && entity.name === canonicalEntityName(executable));
63
+ if (tool) {
64
+ const edge = { source, type: "uses", target: tool };
65
+ relations.set(`${key(source)}:uses:${key(tool)}`, edge);
66
+ }
67
+ // The existing local summarizer treats git commits as durable decisions.
68
+ if (/^git\s+commit\b/i.test(command)) {
69
+ const target = add("decision", command);
70
+ if (target)
71
+ relations.set(`${key(source)}:decided:${key(target)}`, { source, type: "decided", target });
72
+ }
73
+ continue;
74
+ }
75
+ const prefix = /^([\p{Lu}][\p{L}'’-]*(?:\s+[\p{Lu}][\p{L}'’-]*){0,2}):\s*/u.exec(line);
76
+ if (prefix && !LABELS.has(prefix[1].toLowerCase())) {
77
+ speaker = add("person", prefix[1]);
78
+ line = line.slice(prefix[0].length);
79
+ }
80
+ else if (prefix) {
81
+ line = line.slice(prefix[0].length);
82
+ }
83
+ const relation = /\b(works?\s+on|working\s+on|decided(?:\s+to|\s+on)?|prefers?|uses?|using|(?:is\s+)?blocked\s+by)\s+(.+)$/i.exec(line);
84
+ if (!relation)
85
+ continue;
86
+ const verb = relation[1].toLowerCase();
87
+ const type = verb.startsWith("work") ? "works_on" : verb.startsWith("decided") ? "decided"
88
+ : verb.startsWith("prefer") ? "prefers" : verb.includes("blocked") ? "blocked_by" : "uses";
89
+ let subject = line.slice(0, relation.index).trim().replace(/\s+(?:is|was|am|are)$/i, "");
90
+ // Questions, negation and hypothetical statements are not affirmative edges.
91
+ if (/\?|\b(?:not|never|no longer|might|may|could|should|would|can|does|did)\b|n['’]t\b/i.test(line.slice(0, relation.index)))
92
+ continue;
93
+ if (/^(?:i|we|he|she|they|user|assistant)?$/i.test(subject)) {
94
+ // Third-person pronouns cannot be resolved safely without a parser.
95
+ if (/^(he|she|they)$/i.test(subject))
96
+ continue;
97
+ subject = speaker?.name ?? (/^we$/i.test(subject) ? "team" : "user");
98
+ }
99
+ // Avoid turning arbitrary preceding prose into a named subject.
100
+ if (subject.split(/\s+/).length > 4 || /[,;!?]/.test(subject) || line.endsWith("?"))
101
+ continue;
102
+ const source = classify(subject, "person");
103
+ let targetText = relation[2].replace(/[.!?]+$/, "").trim();
104
+ if (type !== "decided")
105
+ targetText = targetText.split(/\s+(?:for|with|because|but|and|instead|over|using|to)\s+/i)[0];
106
+ targetText = targetText.replace(/^(?:the|a|an)\s+/i, "");
107
+ const target = type === "decided" ? add("decision", targetText)
108
+ : classify(targetText, type === "works_on" ? "project" : "tool");
109
+ if (source && target && key(source) !== key(target)) {
110
+ const edge = { source, type, target };
111
+ relations.set(`${key(source)}:${type}:${key(target)}`, edge);
112
+ }
113
+ }
114
+ return { entities: [...entities.values()].sort((a, b) => key(a).localeCompare(key(b))),
115
+ relations: [...relations.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) };
116
+ }
117
+ /** Literal n-grams also match lowercase questions against known stored names. */
118
+ export function queryEntityNames(query) {
119
+ const words = canonicalEntityName(query).match(/[\p{L}\p{N}_.@/'-]+/gu) ?? [];
120
+ const names = new Set();
121
+ for (let i = 0; i < Math.min(words.length, 64); i++) {
122
+ for (let length = 1; length <= 8 && i + length <= words.length; length++) {
123
+ names.add(canonicalEntityName(words.slice(i, i + length).join(" ")));
124
+ }
125
+ }
126
+ return [...names];
127
+ }
@@ -1,8 +1,12 @@
1
+ import { entityOverlap } from "../storage/knowledgeRepo.js";
1
2
  import { deterministicEmbed } from "../embed/deterministicEmbed.js";
2
3
  import { decayOldBoosts } from "./decay.js";
3
4
  import { scoreMemoryCandidate, } from "./score.js";
4
5
  import { searchMemoryCandidatesFTS, } from "../storage/memorySearchRepo.js";
5
6
  const DEFAULT_EMBEDDING_DIMENSION = 32;
7
+ // Small, fixed contribution inside the existing relevance component. Keep the
8
+ // public score shape/outer recency and importance weights unchanged.
9
+ const ENTITY_RELEVANCE_WEIGHT = 0.10;
6
10
  function resolveEmbeddingDimension(candidates) {
7
11
  for (const candidate of candidates) {
8
12
  if (Number.isInteger(candidate.embedding_dim) && (candidate.embedding_dim ?? 0) > 0) {
@@ -41,12 +45,16 @@ export function retrieveMemories(input) {
41
45
  }
42
46
  const topK = input.topK ?? input.limit ?? 20;
43
47
  const now = input.now ?? new Date();
44
- // Use FTS5 pre-filtering to limit cosine similarity computation to ~50 candidates.
45
- // queryText is guaranteed non-empty by the Zod schema (z.string().min(1)) upstream.
46
- const candidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText, now);
48
+ // FTS5 retrieves up to 50 BM25 matches; sparse matches retain the existing
49
+ // recency/importance fallback. The public request schema validates queryText.
50
+ const candidates = searchMemoryCandidatesFTS(input.db, input.projectId, queryText, now, input.includeHistory);
47
51
  const decayedCandidates = decayOldBoosts(candidates, now);
48
52
  const dimension = resolveEmbeddingDimension(candidates);
49
53
  const queryVector = deterministicEmbed(queryText, dimension).vector;
54
+ const overlap = entityOverlap(input.db, input.projectId, queryText, now);
55
+ // FTS5 returns negative BM25 ranks; storage converts them to positive scores.
56
+ // Normalize within this query so BM25 fits the existing relevance weight.
57
+ const maxLexicalScore = candidates.reduce((max, candidate) => Math.max(max, candidate.lexical_score), 0);
50
58
  const ranked = decayedCandidates
51
59
  .map((candidate) => {
52
60
  // When embedding is null (version mismatch or missing), use a neutral
@@ -58,8 +66,16 @@ export function retrieveMemories(input) {
58
66
  candidate.embedding.length !== queryVector.length
59
67
  ? 0.5
60
68
  : cosineSimilarity(queryVector, candidate.embedding);
69
+ // Hash cosine is useful for identical text, but adds noise to lexical
70
+ // matches. Keep it as a diagnostic and as the no-match fallback only.
71
+ const baseRelevance = maxLexicalScore > 0
72
+ ? candidate.lexical_score / maxLexicalScore
73
+ : semantic;
74
+ const relevance = overlap.size > 0
75
+ ? (1 - ENTITY_RELEVANCE_WEIGHT) * baseRelevance + ENTITY_RELEVANCE_WEIGHT * (overlap.get(candidate.id) ?? 0)
76
+ : baseRelevance;
61
77
  const score = scoreMemoryCandidate({
62
- semantic,
78
+ semantic: relevance,
63
79
  updated_at: candidate.updated_at,
64
80
  importance: candidate.importance,
65
81
  access_count: candidate.access_count,
@@ -0,0 +1,4 @@
1
+ CREATE TABLE IF NOT EXISTS transcript_checkpoints (
2
+ path TEXT PRIMARY KEY,
3
+ fingerprint TEXT NOT NULL
4
+ );
@@ -0,0 +1,32 @@
1
+ -- Rebuild the external-content index with SQLite's offline English stemmer.
2
+ -- Existing memory bodies and IDs are unchanged; all rows are backfilled.
3
+ DROP TRIGGER IF EXISTS memories_fts_insert;
4
+ DROP TRIGGER IF EXISTS memories_fts_delete;
5
+ DROP TRIGGER IF EXISTS memories_fts_update;
6
+ DROP TABLE memories_fts;
7
+
8
+ CREATE VIRTUAL TABLE memories_fts USING fts5(
9
+ content,
10
+ normalized_content,
11
+ content='memories',
12
+ content_rowid='rowid',
13
+ tokenize='porter unicode61'
14
+ );
15
+ INSERT INTO memories_fts(memories_fts) VALUES ('rebuild');
16
+
17
+ CREATE TRIGGER memories_fts_insert AFTER INSERT ON memories BEGIN
18
+ INSERT INTO memories_fts(rowid, content, normalized_content)
19
+ VALUES (new.rowid, new.content, new.normalized_content);
20
+ END;
21
+
22
+ CREATE TRIGGER memories_fts_delete AFTER DELETE ON memories BEGIN
23
+ INSERT INTO memories_fts(memories_fts, rowid, content, normalized_content)
24
+ VALUES ('delete', old.rowid, old.content, old.normalized_content);
25
+ END;
26
+
27
+ CREATE TRIGGER memories_fts_update AFTER UPDATE OF content, normalized_content ON memories BEGIN
28
+ INSERT INTO memories_fts(memories_fts, rowid, content, normalized_content)
29
+ VALUES ('delete', old.rowid, old.content, old.normalized_content);
30
+ INSERT INTO memories_fts(rowid, content, normalized_content)
31
+ VALUES (new.rowid, new.content, new.normalized_content);
32
+ END;
@@ -0,0 +1,19 @@
1
+ CREATE TABLE IF NOT EXISTS memory_entities (
2
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE ON UPDATE CASCADE,
3
+ kind TEXT NOT NULL CHECK (kind IN ('person', 'project', 'tool', 'service', 'file', 'decision')),
4
+ name TEXT NOT NULL,
5
+ PRIMARY KEY (memory_id, kind, name)
6
+ );
7
+ CREATE INDEX IF NOT EXISTS idx_memory_entities_name ON memory_entities(name, memory_id);
8
+
9
+ CREATE TABLE IF NOT EXISTS memory_relations (
10
+ memory_id TEXT NOT NULL,
11
+ source_kind TEXT NOT NULL,
12
+ source_name TEXT NOT NULL,
13
+ relation TEXT NOT NULL CHECK (relation IN ('works_on', 'decided', 'prefers', 'uses', 'blocked_by')),
14
+ target_kind TEXT NOT NULL,
15
+ target_name TEXT NOT NULL,
16
+ PRIMARY KEY (memory_id, source_kind, source_name, relation, target_kind, target_name),
17
+ FOREIGN KEY (memory_id, source_kind, source_name) REFERENCES memory_entities(memory_id, kind, name) ON DELETE CASCADE ON UPDATE CASCADE,
18
+ FOREIGN KEY (memory_id, target_kind, target_name) REFERENCES memory_entities(memory_id, kind, name) ON DELETE CASCADE ON UPDATE CASCADE
19
+ );
@@ -0,0 +1,41 @@
1
+ -- Derived claims retain polarity and attribute context absent from migration 014.
2
+ CREATE TABLE memory_claims (
3
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE ON UPDATE CASCADE,
4
+ claim_key TEXT NOT NULL,
5
+ source_kind TEXT NOT NULL,
6
+ source_name TEXT NOT NULL,
7
+ relation TEXT NOT NULL,
8
+ target_kind TEXT NOT NULL,
9
+ target_name TEXT NOT NULL,
10
+ context TEXT NOT NULL,
11
+ attribute TEXT NOT NULL,
12
+ negative INTEGER NOT NULL CHECK (negative IN (0, 1)),
13
+ explicit_change INTEGER NOT NULL CHECK (explicit_change IN (0, 1)),
14
+ replaces_value TEXT NOT NULL,
15
+ observed_at TEXT NOT NULL,
16
+ PRIMARY KEY (memory_id, claim_key)
17
+ );
18
+ CREATE INDEX idx_memory_claims_subject ON memory_claims(source_kind, source_name, relation, memory_id);
19
+
20
+ -- supersedes is the old memory; superseded_by is its replacement. One link per
21
+ -- invalidated claim permits summaries to keep their independent, current facts.
22
+ CREATE TABLE memory_supersessions (
23
+ supersedes TEXT NOT NULL,
24
+ claim_key TEXT NOT NULL,
25
+ superseded_by TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE ON UPDATE CASCADE,
26
+ new_claim_key TEXT NOT NULL,
27
+ superseded_at TEXT NOT NULL,
28
+ detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
29
+ reason TEXT NOT NULL CHECK (reason IN ('different_value', 'explicit_negation')),
30
+ PRIMARY KEY (supersedes, claim_key),
31
+ FOREIGN KEY (supersedes, claim_key) REFERENCES memory_claims(memory_id, claim_key) ON DELETE CASCADE ON UPDATE CASCADE,
32
+ FOREIGN KEY (superseded_by, new_claim_key) REFERENCES memory_claims(memory_id, claim_key) ON DELETE CASCADE ON UPDATE CASCADE,
33
+ CHECK (supersedes <> superseded_by)
34
+ );
35
+ CREATE INDEX idx_memory_supersessions_replacement ON memory_supersessions(superseded_by);
36
+
37
+ -- A memory is fully superseded only when all of its extracted claims changed.
38
+ CREATE VIEW superseded_memories AS
39
+ SELECT c.memory_id FROM memory_claims c
40
+ LEFT JOIN memory_supersessions s ON s.supersedes = c.memory_id AND s.claim_key = c.claim_key
41
+ GROUP BY c.memory_id HAVING COUNT(*) = COUNT(s.claim_key);
@@ -1,6 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { indexMemoryKnowledge } from "../storage/knowledgeRepo.js";
5
+ import { indexMemorySupersessions } from "../storage/supersessionRepo.js";
6
+ import { listSessionEventsBySession } from "../storage/sessionEventsRepo.js";
7
+ import { extractEventText } from "../summarize/summaryShape.js";
4
8
  const DEFAULT_MIGRATIONS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "migrations");
5
9
  function ensureMigrationsTable(db) {
6
10
  db.exec(`
@@ -19,6 +23,20 @@ function listMigrationFiles(migrationsDir) {
19
23
  .filter((fileName) => fileName.endsWith(".sql"))
20
24
  .sort((left, right) => left.localeCompare(right));
21
25
  }
26
+ /** Inspect migration history without creating tables or applying migrations. */
27
+ export function inspectMigrations(db, migrationsDir = DEFAULT_MIGRATIONS_DIR) {
28
+ const expected = listMigrationFiles(migrationsDir);
29
+ if (expected.length === 0)
30
+ throw new Error(`No schema migrations found in ${migrationsDir}`);
31
+ const hasHistory = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_migrations'").get();
32
+ const applied = hasHistory
33
+ ? db.prepare("SELECT name FROM _migrations").all().map((row) => row.name)
34
+ : [];
35
+ return {
36
+ pending: expected.filter((name) => !applied.includes(name)),
37
+ unknown: applied.filter((name) => !expected.includes(name)),
38
+ };
39
+ }
22
40
  /**
23
41
  * Verify that every column an ADD COLUMN migration declares already exists in
24
42
  * its target table. Parses `ALTER TABLE <table> ADD COLUMN <name>` statements
@@ -62,6 +80,34 @@ export function runMigrations(db, migrationsDir = DEFAULT_MIGRATIONS_DIR) {
62
80
  const filePath = path.join(migrationsDir, fileName);
63
81
  const sql = fs.readFileSync(filePath, "utf8");
64
82
  db.exec(sql);
83
+ if (fileName === "014_memory_entities.sql") {
84
+ // Backfill in the migration transaction: failures roll back both schema
85
+ // and derived data, and reopening never indexes legacy rows twice.
86
+ // Finish each read before nested write transactions; an active SQLite
87
+ // iterator prevents SAVEPOINT. Batches bound memory use on large stores.
88
+ const batch = db.prepare("SELECT id, content, kind, project_id, session_id FROM memories ORDER BY id LIMIT 256 OFFSET ?");
89
+ for (let offset = 0;; offset += 256) {
90
+ const rows = batch.all(offset);
91
+ for (const row of rows) {
92
+ const events = row.kind === "summary" ? listSessionEventsBySession(db, row.project_id, row.session_id) : [];
93
+ indexMemoryKnowledge(db, row.id, events.length ? events.map(extractEventText).join("\n") : row.content);
94
+ }
95
+ if (rows.length < 256)
96
+ break;
97
+ }
98
+ }
99
+ if (fileName === "015_memory_supersessions.sql") {
100
+ const batch = db.prepare("SELECT id, content, kind, project_id, session_id FROM memories ORDER BY created_at, rowid LIMIT 256 OFFSET ?");
101
+ for (let offset = 0;; offset += 256) {
102
+ const rows = batch.all(offset);
103
+ for (const row of rows) {
104
+ const events = row.kind === "summary" ? listSessionEventsBySession(db, row.project_id, row.session_id) : [];
105
+ indexMemorySupersessions(db, row.id, row.content, events.length ? events.map(event => ({ text: extractEventText(event), observedAt: event.created_at })) : undefined);
106
+ }
107
+ if (rows.length < 256)
108
+ break;
109
+ }
110
+ }
65
111
  insertMigrationStmt.run(fileName);
66
112
  });
67
113
  for (const fileName of files) {
@@ -0,0 +1,67 @@
1
+ import { extractKnowledge, queryEntityNames } from "../extract/knowledge.js";
2
+ const cache = new WeakMap();
3
+ function statements(db) {
4
+ let result = cache.get(db);
5
+ if (result)
6
+ return result;
7
+ result = {
8
+ clear: db.prepare("DELETE FROM memory_entities WHERE memory_id = ?"),
9
+ entity: db.prepare("INSERT INTO memory_entities (memory_id, kind, name) VALUES (?, ?, ?)"),
10
+ relation: db.prepare(`INSERT INTO memory_relations
11
+ (memory_id, source_kind, source_name, relation, target_kind, target_name) VALUES (?, ?, ?, ?, ?, ?)`),
12
+ overlap: db.prepare(`SELECT DISTINCT e.memory_id, e.name FROM memory_entities e
13
+ JOIN memories m ON m.id = e.memory_id
14
+ WHERE m.project_id = ? AND (m.expires_at IS NULL OR m.expires_at > ?)
15
+ AND e.name IN (SELECT value FROM json_each(?))`),
16
+ };
17
+ cache.set(db, result);
18
+ return result;
19
+ }
20
+ /** Replace, never append: replays, redaction, import and summary upserts stay safe. */
21
+ export function replaceMemoryKnowledge(db, memoryId, knowledge) {
22
+ const stmts = statements(db);
23
+ db.transaction(() => {
24
+ stmts.clear.run(memoryId);
25
+ for (const entity of knowledge.entities)
26
+ stmts.entity.run(memoryId, entity.kind, entity.name);
27
+ for (const edge of knowledge.relations)
28
+ stmts.relation.run(memoryId, edge.source.kind, edge.source.name, edge.type, edge.target.kind, edge.target.name);
29
+ })();
30
+ }
31
+ export function indexMemoryKnowledge(db, memoryId, content) {
32
+ replaceMemoryKnowledge(db, memoryId, extractKnowledge(content));
33
+ }
34
+ export function entityOverlap(db, projectId, query, now) {
35
+ const rows = statements(db).overlap.all(projectId, now.toISOString(), JSON.stringify(queryEntityNames(query)));
36
+ const queryNames = new Set(rows.map(row => row.name));
37
+ const counts = new Map();
38
+ for (const row of rows)
39
+ counts.set(row.memory_id, (counts.get(row.memory_id) ?? 0) + 1);
40
+ return new Map([...counts].map(([id, count]) => [id, count / queryNames.size]));
41
+ }
42
+ export function listProjectKnowledge(db, projectId, now = new Date()) {
43
+ const entities = db.prepare(`SELECT e.kind, e.name, e.memory_id FROM memory_entities e
44
+ JOIN memories m ON m.id = e.memory_id WHERE m.project_id = ?
45
+ AND (m.expires_at IS NULL OR m.expires_at > ?) ORDER BY e.kind, e.name, e.memory_id`)
46
+ .all(projectId, now.toISOString());
47
+ const relations = db.prepare(`SELECT r.* FROM memory_relations r JOIN memories m ON m.id = r.memory_id
48
+ WHERE m.project_id = ? AND (m.expires_at IS NULL OR m.expires_at > ?)
49
+ ORDER BY source_kind, source_name, relation, target_kind, target_name, r.memory_id`)
50
+ .all(projectId, now.toISOString());
51
+ const nodes = new Map();
52
+ const edges = new Map();
53
+ for (const row of entities) {
54
+ const key = JSON.stringify([row.kind, row.name]);
55
+ const node = nodes.get(key) ?? { kind: row.kind, name: row.name, memoryIds: [] };
56
+ node.memoryIds.push(row.memory_id);
57
+ nodes.set(key, node);
58
+ }
59
+ for (const row of relations) {
60
+ const key = JSON.stringify([row.source_kind, row.source_name, row.relation, row.target_kind, row.target_name]);
61
+ const edge = edges.get(key) ?? { sourceKind: row.source_kind, sourceName: row.source_name,
62
+ relation: row.relation, targetKind: row.target_kind, targetName: row.target_name, memoryIds: [] };
63
+ edge.memoryIds.push(row.memory_id);
64
+ edges.set(key, edge);
65
+ }
66
+ return { entities: [...nodes.values()], relations: [...edges.values()] };
67
+ }
@@ -1,3 +1,5 @@
1
+ import { indexMemoryKnowledge, replaceMemoryKnowledge } from "./knowledgeRepo.js";
2
+ import { indexMemorySupersessions, reconcileProjectSupersessions } from "./supersessionRepo.js";
1
3
  // Shared INSERT ... ON CONFLICT(id) upsert column lists. The import and team-pull
2
4
  // paths differ only in how they resolve `importance` on conflict (import takes the
3
5
  // incoming value; pull preserves MAX(local, incoming)), so the surrounding SQL is
@@ -217,11 +219,22 @@ function toParams(input) {
217
219
  }
218
220
  export function insertMemory(db, input) {
219
221
  assertImportance(input.importance);
220
- getStatements(db).insertMemory.run(toParams(input));
222
+ db.transaction(() => {
223
+ getStatements(db).insertMemory.run(toParams(input));
224
+ indexMemoryKnowledge(db, input.id, input.content);
225
+ indexMemorySupersessions(db, input.id, input.content);
226
+ })();
221
227
  }
222
- export function upsertSessionSummaryMemory(db, input) {
228
+ export function upsertSessionSummaryMemory(db, input, knowledge, factSource) {
223
229
  assertImportance(input.importance);
224
- getStatements(db).upsertSessionSummary.run(toParams({ ...input, kind: "summary" }));
230
+ db.transaction(() => {
231
+ getStatements(db).upsertSessionSummary.run(toParams({ ...input, kind: "summary" }));
232
+ if (knowledge)
233
+ replaceMemoryKnowledge(db, input.id, knowledge);
234
+ else
235
+ indexMemoryKnowledge(db, input.id, input.content);
236
+ indexMemorySupersessions(db, input.id, input.content, factSource);
237
+ })();
225
238
  }
226
239
  /**
227
240
  * Upsert a memory imported from an external export. On `id` conflict the incoming
@@ -231,7 +244,11 @@ export function upsertSessionSummaryMemory(db, input) {
231
244
  */
232
245
  export function upsertImportedMemory(db, input) {
233
246
  assertImportance(input.importance);
234
- getStatements(db).importUpsert.run(toParams(input));
247
+ db.transaction(() => {
248
+ getStatements(db).importUpsert.run(toParams(input));
249
+ indexMemoryKnowledge(db, input.id, input.content);
250
+ indexMemorySupersessions(db, input.id, input.content);
251
+ })();
235
252
  }
236
253
  /**
237
254
  * Upsert a memory pulled from a teammate. Identical to {@link upsertImportedMemory}
@@ -240,7 +257,11 @@ export function upsertImportedMemory(db, input) {
240
257
  */
241
258
  export function upsertPulledMemory(db, input) {
242
259
  assertImportance(input.importance);
243
- getStatements(db).pullUpsert.run(toParams(input));
260
+ db.transaction(() => {
261
+ getStatements(db).pullUpsert.run(toParams(input));
262
+ indexMemoryKnowledge(db, input.id, input.content);
263
+ indexMemorySupersessions(db, input.id, input.content);
264
+ })();
244
265
  }
245
266
  export function listMemoriesByProject(db, projectId) {
246
267
  return getStatements(db).listByProject.all(projectId);
@@ -277,7 +298,12 @@ export function getMemoryOwnerProjectId(db, memoryId) {
277
298
  * removed (0 when the id does not exist in this project).
278
299
  */
279
300
  export function deleteMemoryById(db, projectId, memoryId) {
280
- return getStatements(db).deleteById.run(projectId, memoryId).changes;
301
+ return db.transaction(() => {
302
+ const deleted = getStatements(db).deleteById.run(projectId, memoryId).changes;
303
+ if (deleted)
304
+ reconcileProjectSupersessions(db, projectId);
305
+ return deleted;
306
+ })();
281
307
  }
282
308
  /**
283
309
  * All memory ids across every project. `id` is a globally-unique
@@ -299,8 +325,12 @@ export function deleteMemoriesOlderThan(db, projectId, cutoffIso) {
299
325
  // Hard-delete scoped to the memories table only; never touches
300
326
  // session_events or memory_feedback. project_id and cutoff are bound, never
301
327
  // string-concatenated, to prevent SQL injection.
302
- const result = getStatements(db).deleteOlderThan.run(projectId, cutoffIso);
303
- return result.changes;
328
+ return db.transaction(() => {
329
+ const result = getStatements(db).deleteOlderThan.run(projectId, cutoffIso);
330
+ if (result.changes)
331
+ reconcileProjectSupersessions(db, projectId);
332
+ return result.changes;
333
+ })();
304
334
  }
305
335
  // NOTE: no MCP tool, CLI command, or service method currently calls this. It is
306
336
  // retained as intentional repository API surface (the importance-update
@@ -345,10 +375,13 @@ export function updateMemoryContent(db, projectId, memoryId, newContent, newNorm
345
375
  // recomputed embedding so the vector tracks the redacted text; omit to leave
346
376
  // the existing embedding untouched (COALESCE keeps the prior value on null).
347
377
  newEmbedding) {
348
- const result = getStatements(db).updateContent.run(newContent, newNormalizedContent ?? null, newEmbedding ? JSON.stringify(newEmbedding.vector) : null, newEmbedding?.dimension ?? null, newEmbedding?.embeddingVersion ?? null, projectId, memoryId);
349
- if (result.changes === 0) {
350
- throw new Error(`Memory not found: ${memoryId}`);
351
- }
378
+ db.transaction(() => {
379
+ const result = getStatements(db).updateContent.run(newContent, newNormalizedContent ?? null, newEmbedding ? JSON.stringify(newEmbedding.vector) : null, newEmbedding?.dimension ?? null, newEmbedding?.embeddingVersion ?? null, projectId, memoryId);
380
+ if (result.changes === 0)
381
+ throw new Error(`Memory not found: ${memoryId}`);
382
+ indexMemoryKnowledge(db, memoryId, newContent);
383
+ indexMemorySupersessions(db, memoryId, newContent);
384
+ })();
352
385
  }
353
386
  export function incrementAccessCounts(db, projectId, memoryIds, accessedAt) {
354
387
  if (memoryIds.length === 0)