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.
- package/CHANGELOG.md +44 -0
- package/README.md +186 -41
- package/dist/adapters/diagnostics.js +109 -0
- package/dist/adapters/generic.js +6 -2
- package/dist/adapters/global/antigravity.js +4 -1
- package/dist/adapters/global/claudeCode.js +19 -4
- package/dist/adapters/global/codex.js +7 -6
- package/dist/adapters/global/qcoder.js +4 -1
- package/dist/adapters/ide/cline.js +3 -0
- package/dist/adapters/ide/cursor.js +3 -0
- package/dist/adapters/ide/installer.js +17 -5
- package/dist/adapters/ide/windsurf.js +3 -0
- package/dist/adapters/installedHosts.js +51 -0
- package/dist/cli/commands/doctor.js +146 -0
- package/dist/cli/commands/entities.js +27 -0
- package/dist/cli/commands/history.js +23 -0
- package/dist/cli/commands/ingestHook.js +4 -78
- package/dist/cli/commands/install.js +34 -56
- package/dist/cli/commands/scan.js +16 -0
- package/dist/cli/commands/search.js +1 -0
- package/dist/cli/commands/team.js +7 -3
- package/dist/cli/commands/ui.js +67 -0
- package/dist/cli/commands/watch.js +48 -0
- package/dist/cli/index.js +42 -1
- package/dist/cli/projectId.js +1 -2
- package/dist/cli/transcriptSweep.js +182 -0
- package/dist/core/api/contracts.js +1 -0
- package/dist/core/api/memoryCoreService.js +8 -0
- package/dist/core/api/sessionLifecycleService.js +13 -1
- package/dist/core/capture/toolEvent.js +82 -0
- package/dist/core/extract/factClaims.js +97 -0
- package/dist/core/extract/knowledge.js +127 -0
- package/dist/core/retrieve/retrieveMemories.js +20 -4
- package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
- package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
- package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
- package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
- package/dist/core/schema/runMigrations.js +46 -0
- package/dist/core/storage/knowledgeRepo.js +67 -0
- package/dist/core/storage/memoryRepo.js +45 -12
- package/dist/core/storage/memorySearchRepo.js +30 -20
- package/dist/core/storage/supersessionRepo.js +101 -0
- package/dist/core/summarize/summaryShape.js +1 -1
- package/dist/ui/assets.js +181 -0
- package/dist/ui/server.js +147 -0
- package/docs/REGISTRY_LISTING.md +88 -0
- package/docs/architecture.md +27 -3
- package/docs/benchmark.md +105 -33
- package/docs/migration.md +2 -0
- package/docs/troubleshooting.md +52 -4
- 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
|
-
//
|
|
45
|
-
//
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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)
|