omo-memory 0.1.11 → 0.1.13
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/README.md +100 -3
- package/dist/autoUpdate.js +84 -0
- package/dist/cli.js +130 -3
- package/dist/conceptExtraction.js +188 -0
- package/dist/globalMemory.js +162 -0
- package/dist/globalMemoryCanonical.js +32 -0
- package/dist/globalMemoryImport.js +194 -0
- package/dist/graphTui.js +239 -0
- package/dist/mcp.js +26 -3
- package/dist/mcpOntologyTools.js +117 -0
- package/dist/memory.js +66 -29
- package/dist/memoryDb.js +145 -1
- package/dist/memoryRecall.js +56 -0
- package/dist/memoryReport.js +33 -0
- package/dist/ontologyCore.js +142 -0
- package/dist/ontologyGraph.js +173 -0
- package/dist/ontologyQueries.js +30 -0
- package/dist/ontologySupersede.js +49 -0
- package/dist/retentionPolicy.js +76 -0
- package/dist/retentionRecompute.js +175 -0
- package/docs/adapter-integration.md +115 -3
- package/docs/epic-omo-memory.md +63 -7
- package/package.json +3 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { upsertProject } from "./memory.js";
|
|
3
|
+
import { migrate, openMemoryDb } from "./memoryDb.js";
|
|
4
|
+
import { redactSecrets } from "./privacy.js";
|
|
5
|
+
export { listOntologyRows } from "./ontologyQueries.js";
|
|
6
|
+
export { supersedeDurableMemory } from "./ontologySupersede.js";
|
|
7
|
+
function nowIso() {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
function normalizeLabel(label) {
|
|
11
|
+
return label.trim().toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
export function upsertConcept(dbPath, project, input) {
|
|
14
|
+
const db = openMemoryDb(dbPath);
|
|
15
|
+
try {
|
|
16
|
+
migrate(db);
|
|
17
|
+
upsertProject(db, project);
|
|
18
|
+
const label = normalizeLabel(input.label);
|
|
19
|
+
const now = nowIso();
|
|
20
|
+
const existing = db.prepare(`SELECT id FROM concepts WHERE project_id = ? AND label = ? LIMIT 1`).get(project.id, label);
|
|
21
|
+
if (existing?.id) {
|
|
22
|
+
db.prepare("UPDATE concepts SET last_seen = ?, updated_at = ? WHERE id = ? AND project_id = ?").run(now, now, existing.id, project.id);
|
|
23
|
+
const row = db
|
|
24
|
+
.prepare(`SELECT id, kind, label, description, aliases_json AS aliasesJson, payload_json AS payloadJson,
|
|
25
|
+
valid_from AS validFrom, valid_to AS validTo, created_at AS createdAt, updated_at AS updatedAt,
|
|
26
|
+
COALESCE(score, 0) AS score, COALESCE(retention_class, 'working') AS retentionClass,
|
|
27
|
+
COALESCE(manual_pin, 0) AS manualPin, COALESCE(ref_count, 0) AS refCount,
|
|
28
|
+
COALESCE(project_spread, 1) AS projectSpread, first_seen AS firstSeen, last_seen AS lastSeen
|
|
29
|
+
FROM concepts WHERE id = ?`)
|
|
30
|
+
.get(existing.id);
|
|
31
|
+
return row;
|
|
32
|
+
}
|
|
33
|
+
const id = randomUUID();
|
|
34
|
+
const score = input.score ?? 0;
|
|
35
|
+
const retentionClass = input.retentionClass ?? "working";
|
|
36
|
+
const manualPin = input.manualPin ?? 0;
|
|
37
|
+
const created = now;
|
|
38
|
+
db.prepare(`
|
|
39
|
+
INSERT INTO concepts (
|
|
40
|
+
id, project_id, kind, label, description, aliases_json, payload_json,
|
|
41
|
+
valid_from, valid_to, created_at, updated_at,
|
|
42
|
+
score, retention_class, manual_pin, ref_count, project_spread, first_seen, last_seen
|
|
43
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
44
|
+
`).run(id, project.id, input.kind, label, input.description ?? null, "[]", "{}", null, null, created, created, score, retentionClass, manualPin, 0, 1, created, created);
|
|
45
|
+
const row = db
|
|
46
|
+
.prepare(`SELECT id, kind, label, description, aliases_json AS aliasesJson, payload_json AS payloadJson,
|
|
47
|
+
valid_from AS validFrom, valid_to AS validTo, created_at AS createdAt, updated_at AS updatedAt,
|
|
48
|
+
COALESCE(score, 0) AS score, COALESCE(retention_class, 'working') AS retentionClass,
|
|
49
|
+
COALESCE(manual_pin, 0) AS manualPin, COALESCE(ref_count, 0) AS refCount,
|
|
50
|
+
COALESCE(project_spread, 1) AS projectSpread, first_seen AS firstSeen, last_seen AS lastSeen
|
|
51
|
+
FROM concepts WHERE id = ?`)
|
|
52
|
+
.get(id);
|
|
53
|
+
return row;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
db.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function createDurableMemory(dbPath, project, input) {
|
|
60
|
+
const db = openMemoryDb(dbPath);
|
|
61
|
+
try {
|
|
62
|
+
migrate(db);
|
|
63
|
+
upsertProject(db, project);
|
|
64
|
+
const id = randomUUID();
|
|
65
|
+
const created = nowIso();
|
|
66
|
+
const redactedSummary = redactSecrets(input.summary);
|
|
67
|
+
const redactedBody = input.body == null ? null : redactSecrets(input.body);
|
|
68
|
+
const status = input.status ?? "active";
|
|
69
|
+
const retentionClass = input.retentionClass ?? "durable";
|
|
70
|
+
const confidence = input.confidence ?? 0;
|
|
71
|
+
db.prepare(`
|
|
72
|
+
INSERT INTO durable_memories (
|
|
73
|
+
id, project_id, type, summary, body, source_event_id, source_handoff_id,
|
|
74
|
+
confidence, status, retention_class, valid_from, valid_to, created_at, updated_at
|
|
75
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
`).run(id, project.id, input.type, redactedSummary, redactedBody, input.sourceEventId ?? null, input.sourceHandoffId ?? null, confidence, status, retentionClass, null, null, created, created);
|
|
77
|
+
const row = db
|
|
78
|
+
.prepare(`SELECT id, type, summary, body, source_event_id AS sourceEventId, source_handoff_id AS sourceHandoffId,
|
|
79
|
+
confidence, status, COALESCE(retention_class, 'durable') AS retentionClass,
|
|
80
|
+
valid_from AS validFrom, valid_to AS validTo, created_at AS createdAt, updated_at AS updatedAt
|
|
81
|
+
FROM durable_memories WHERE id = ?`)
|
|
82
|
+
.get(id);
|
|
83
|
+
return row;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function recordMemoryReference(dbPath, project, input) {
|
|
90
|
+
const db = openMemoryDb(dbPath);
|
|
91
|
+
try {
|
|
92
|
+
migrate(db);
|
|
93
|
+
upsertProject(db, project);
|
|
94
|
+
const id = randomUUID();
|
|
95
|
+
const created = nowIso();
|
|
96
|
+
const refKind = input.refKind ?? "mentions";
|
|
97
|
+
const weight = input.weight ?? 1;
|
|
98
|
+
const inserted = db
|
|
99
|
+
.prepare(`
|
|
100
|
+
INSERT INTO memory_references (
|
|
101
|
+
id, project_id, source_type, source_id, target_type, target_id, ref_kind, weight, created_at
|
|
102
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
|
+
ON CONFLICT(project_id, source_type, source_id, target_type, target_id, ref_kind) DO NOTHING
|
|
104
|
+
`)
|
|
105
|
+
.run(id, project.id, input.sourceType, input.sourceId, input.targetType, input.targetId, refKind, weight, created);
|
|
106
|
+
if (inserted.changes > 0 && input.targetType === "concept") {
|
|
107
|
+
db.prepare("UPDATE concepts SET ref_count = COALESCE(ref_count, 0) + 1, last_seen = ?, updated_at = ? WHERE id = ? AND project_id = ?").run(created, created, input.targetId, project.id);
|
|
108
|
+
}
|
|
109
|
+
const row = db
|
|
110
|
+
.prepare(`SELECT id, source_type AS sourceType, source_id AS sourceId, target_type AS targetType, target_id AS targetId,
|
|
111
|
+
ref_kind AS refKind, weight, created_at AS createdAt
|
|
112
|
+
FROM memory_references
|
|
113
|
+
WHERE project_id = ? AND source_type = ? AND source_id = ? AND target_type = ? AND target_id = ? AND ref_kind = ?`)
|
|
114
|
+
.get(project.id, input.sourceType, input.sourceId, input.targetType, input.targetId, refKind);
|
|
115
|
+
return row;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
db.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function updateDurableRetention(dbPath, project, durableId, update) {
|
|
122
|
+
const db = openMemoryDb(dbPath);
|
|
123
|
+
try {
|
|
124
|
+
migrate(db);
|
|
125
|
+
const existing = db.prepare("SELECT retention_class FROM durable_memories WHERE id = ? AND project_id = ?").get(durableId, project.id);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
throw new Error("durable memory not found for project");
|
|
128
|
+
}
|
|
129
|
+
const now = nowIso();
|
|
130
|
+
db.prepare("UPDATE durable_memories SET retention_class = COALESCE(?, retention_class), updated_at = ? WHERE id = ? AND project_id = ?").run(update.retentionClass ?? null, now, durableId, project.id);
|
|
131
|
+
const row = db
|
|
132
|
+
.prepare(`SELECT id, type, summary, body, source_event_id AS sourceEventId, source_handoff_id AS sourceHandoffId,
|
|
133
|
+
confidence, status, COALESCE(retention_class, 'durable') AS retentionClass,
|
|
134
|
+
valid_from AS validFrom, valid_to AS validTo, created_at AS createdAt, updated_at AS updatedAt
|
|
135
|
+
FROM durable_memories WHERE id = ?`)
|
|
136
|
+
.get(durableId);
|
|
137
|
+
return row;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
db.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { migrate, openMemoryDb } from "./memoryDb.js";
|
|
2
|
+
import { redactSecrets, sanitizeGitRemote } from "./privacy.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return value !== null && typeof value === "object";
|
|
5
|
+
}
|
|
6
|
+
function text(value) {
|
|
7
|
+
return typeof value === "string" ? value : String(value ?? "");
|
|
8
|
+
}
|
|
9
|
+
function nullableText(value) {
|
|
10
|
+
return value === null || value === undefined ? null : text(value);
|
|
11
|
+
}
|
|
12
|
+
function parseAliases(value) {
|
|
13
|
+
if (typeof value !== "string")
|
|
14
|
+
return [];
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(value);
|
|
17
|
+
if (!Array.isArray(parsed))
|
|
18
|
+
return [];
|
|
19
|
+
return parsed.filter((item) => typeof item === "string").map(redactSecrets);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof SyntaxError)
|
|
23
|
+
return [];
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function numberValue(value) {
|
|
28
|
+
return typeof value === "number" && Number.isFinite(value) ? value : Number(value ?? 0);
|
|
29
|
+
}
|
|
30
|
+
function projectFrom(row) {
|
|
31
|
+
return {
|
|
32
|
+
id: row.projectId,
|
|
33
|
+
repoRoot: redactSecrets(row.repoRoot),
|
|
34
|
+
gitRemote: sanitizeGitRemote(row.gitRemote),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function parseConceptRow(value) {
|
|
38
|
+
if (!isRecord(value)) {
|
|
39
|
+
throw new Error("invalid concept row");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
id: text(value["id"]),
|
|
43
|
+
projectId: text(value["projectId"]),
|
|
44
|
+
kind: text(value["kind"]),
|
|
45
|
+
label: text(value["label"]),
|
|
46
|
+
description: nullableText(value["description"]),
|
|
47
|
+
aliases: parseAliases(value["aliasesJson"]),
|
|
48
|
+
score: numberValue(value["score"]),
|
|
49
|
+
retentionClass: text(value["retentionClass"]),
|
|
50
|
+
refCount: numberValue(value["refCount"]),
|
|
51
|
+
projectSpread: numberValue(value["projectSpread"]),
|
|
52
|
+
firstSeen: nullableText(value["firstSeen"]),
|
|
53
|
+
lastSeen: nullableText(value["lastSeen"]),
|
|
54
|
+
repoRoot: text(value["repoRoot"]),
|
|
55
|
+
gitRemote: nullableText(value["gitRemote"]),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function parseRelationRow(value) {
|
|
59
|
+
if (!isRecord(value)) {
|
|
60
|
+
throw new Error("invalid relation row");
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
id: text(value["id"]),
|
|
64
|
+
projectId: text(value["projectId"]),
|
|
65
|
+
sourceId: text(value["sourceId"]),
|
|
66
|
+
targetId: text(value["targetId"]),
|
|
67
|
+
relation: text(value["relation"]),
|
|
68
|
+
weight: numberValue(value["weight"]),
|
|
69
|
+
repoRoot: text(value["repoRoot"]),
|
|
70
|
+
gitRemote: nullableText(value["gitRemote"]),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function matchesQuery(row, query) {
|
|
74
|
+
const haystack = `${row.label}\n${row.description ?? ""}\n${row.aliases.join("\n")}`.toLowerCase();
|
|
75
|
+
return haystack.includes(query);
|
|
76
|
+
}
|
|
77
|
+
function toNode(row, selectedId) {
|
|
78
|
+
const score = Math.round(row.score);
|
|
79
|
+
const retentionClass = redactSecrets(row.retentionClass);
|
|
80
|
+
return {
|
|
81
|
+
id: row.id,
|
|
82
|
+
kind: redactSecrets(row.kind),
|
|
83
|
+
label: redactSecrets(row.label),
|
|
84
|
+
description: row.description === null ? null : redactSecrets(row.description),
|
|
85
|
+
aliases: row.aliases,
|
|
86
|
+
retentionClass,
|
|
87
|
+
score,
|
|
88
|
+
scoreLabel: `${score} ${retentionClass}`,
|
|
89
|
+
refCount: Math.round(row.refCount),
|
|
90
|
+
projectSpread: Math.round(row.projectSpread),
|
|
91
|
+
project: projectFrom(row),
|
|
92
|
+
selected: selectedId === row.id,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function toDetail(row) {
|
|
96
|
+
const score = Math.round(row.score);
|
|
97
|
+
const retentionClass = redactSecrets(row.retentionClass);
|
|
98
|
+
return {
|
|
99
|
+
id: row.id,
|
|
100
|
+
label: redactSecrets(row.label),
|
|
101
|
+
kind: redactSecrets(row.kind),
|
|
102
|
+
description: row.description === null ? null : redactSecrets(row.description),
|
|
103
|
+
aliases: row.aliases,
|
|
104
|
+
retentionClass,
|
|
105
|
+
score,
|
|
106
|
+
scoreLabel: `${score} ${retentionClass}`,
|
|
107
|
+
refCount: Math.round(row.refCount),
|
|
108
|
+
projectSpread: Math.round(row.projectSpread),
|
|
109
|
+
firstSeen: row.firstSeen,
|
|
110
|
+
lastSeen: row.lastSeen,
|
|
111
|
+
project: projectFrom(row),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function toEdge(row) {
|
|
115
|
+
const weight = Number(row.weight.toFixed(2));
|
|
116
|
+
const relation = redactSecrets(row.relation);
|
|
117
|
+
return {
|
|
118
|
+
id: row.id,
|
|
119
|
+
sourceId: row.sourceId,
|
|
120
|
+
targetId: row.targetId,
|
|
121
|
+
relation,
|
|
122
|
+
label: `${relation} ${weight.toFixed(2)}`,
|
|
123
|
+
weight,
|
|
124
|
+
project: projectFrom(row),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function projectOntologyGraph(options) {
|
|
128
|
+
const db = openMemoryDb(options.dbPath);
|
|
129
|
+
try {
|
|
130
|
+
migrate(db);
|
|
131
|
+
const query = options.query?.trim().toLowerCase() ?? "";
|
|
132
|
+
const conceptRows = db
|
|
133
|
+
.prepare(`
|
|
134
|
+
SELECT c.id, c.project_id AS projectId, c.kind, c.label, c.description, c.aliases_json AS aliasesJson,
|
|
135
|
+
COALESCE(c.score, 0) AS score, COALESCE(c.retention_class, 'working') AS retentionClass,
|
|
136
|
+
COALESCE(c.ref_count, 0) AS refCount, COALESCE(c.project_spread, 1) AS projectSpread,
|
|
137
|
+
c.first_seen AS firstSeen, c.last_seen AS lastSeen,
|
|
138
|
+
p.repo_root AS repoRoot, p.git_remote AS gitRemote
|
|
139
|
+
FROM concepts c
|
|
140
|
+
JOIN projects p ON p.id = c.project_id
|
|
141
|
+
WHERE c.valid_to IS NULL
|
|
142
|
+
ORDER BY lower(c.label) ASC, c.id ASC
|
|
143
|
+
`)
|
|
144
|
+
.all()
|
|
145
|
+
.map(parseConceptRow)
|
|
146
|
+
.filter((row) => query === "" || matchesQuery(row, query));
|
|
147
|
+
const conceptIds = new Set(conceptRows.map((row) => row.id));
|
|
148
|
+
const selectedId = options.selectedId && conceptIds.has(options.selectedId) ? options.selectedId : (conceptRows[0]?.id ?? null);
|
|
149
|
+
const nodes = conceptRows.map((row) => toNode(row, selectedId));
|
|
150
|
+
const selectedRow = selectedId === null ? undefined : conceptRows.find((row) => row.id === selectedId);
|
|
151
|
+
const relationRows = db
|
|
152
|
+
.prepare(`
|
|
153
|
+
SELECT r.id, r.project_id AS projectId, r.source_id AS sourceId, r.target_id AS targetId,
|
|
154
|
+
r.relation, COALESCE(r.weight, 1) AS weight, p.repo_root AS repoRoot, p.git_remote AS gitRemote
|
|
155
|
+
FROM relations r
|
|
156
|
+
JOIN projects p ON p.id = r.project_id
|
|
157
|
+
WHERE r.source_type = 'concept' AND r.target_type = 'concept' AND r.valid_to IS NULL
|
|
158
|
+
ORDER BY lower(r.relation) ASC, r.id ASC
|
|
159
|
+
`)
|
|
160
|
+
.all()
|
|
161
|
+
.map(parseRelationRow)
|
|
162
|
+
.filter((row) => conceptIds.has(row.sourceId) && conceptIds.has(row.targetId));
|
|
163
|
+
return {
|
|
164
|
+
nodes,
|
|
165
|
+
edges: relationRows.map(toEdge),
|
|
166
|
+
detail: selectedRow === undefined ? null : toDetail(selectedRow),
|
|
167
|
+
message: nodes.length === 0 ? "No ontology graph data is available yet." : null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
db.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { migrate, openMemoryDb } from "./memoryDb.js";
|
|
2
|
+
export function listOntologyRows(dbPath, project) {
|
|
3
|
+
const db = openMemoryDb(dbPath);
|
|
4
|
+
try {
|
|
5
|
+
migrate(db);
|
|
6
|
+
const concepts = db
|
|
7
|
+
.prepare(`SELECT id, kind, label, description, aliases_json AS aliasesJson, payload_json AS payloadJson,
|
|
8
|
+
valid_from AS validFrom, valid_to AS validTo, created_at AS createdAt, updated_at AS updatedAt,
|
|
9
|
+
COALESCE(score, 0) AS score, COALESCE(retention_class, 'working') AS retentionClass,
|
|
10
|
+
COALESCE(manual_pin, 0) AS manualPin, COALESCE(ref_count, 0) AS refCount,
|
|
11
|
+
COALESCE(project_spread, 1) AS projectSpread, first_seen AS firstSeen, last_seen AS lastSeen
|
|
12
|
+
FROM concepts WHERE project_id = ? ORDER BY created_at ASC, id ASC`)
|
|
13
|
+
.all(project.id);
|
|
14
|
+
const durableMemories = db
|
|
15
|
+
.prepare(`SELECT id, type, summary, body, source_event_id AS sourceEventId, source_handoff_id AS sourceHandoffId,
|
|
16
|
+
confidence, status, COALESCE(retention_class, 'durable') AS retentionClass,
|
|
17
|
+
valid_from AS validFrom, valid_to AS validTo, created_at AS createdAt, updated_at AS updatedAt
|
|
18
|
+
FROM durable_memories WHERE project_id = ? ORDER BY created_at ASC, id ASC`)
|
|
19
|
+
.all(project.id);
|
|
20
|
+
const memoryReferences = db
|
|
21
|
+
.prepare(`SELECT id, source_type AS sourceType, source_id AS sourceId, target_type AS targetType, target_id AS targetId,
|
|
22
|
+
ref_kind AS refKind, weight, created_at AS createdAt
|
|
23
|
+
FROM memory_references WHERE project_id = ? ORDER BY created_at ASC, id ASC`)
|
|
24
|
+
.all(project.id);
|
|
25
|
+
return { concepts, durableMemories, memoryReferences };
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { migrate, openMemoryDb } from "./memoryDb.js";
|
|
3
|
+
import { redactSecrets } from "./privacy.js";
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return value !== null && typeof value === "object";
|
|
6
|
+
}
|
|
7
|
+
function parseOriginal(value) {
|
|
8
|
+
if (!isRecord(value))
|
|
9
|
+
throw new Error("original durable not found");
|
|
10
|
+
const id = String(value["id"] ?? "");
|
|
11
|
+
if (id.length === 0)
|
|
12
|
+
throw new Error("original durable not found");
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
type: String(value["type"]),
|
|
16
|
+
summary: String(value["summary"]),
|
|
17
|
+
body: value["body"] == null ? null : String(value["body"]),
|
|
18
|
+
sourceEventId: value["source_event_id"] == null ? null : String(value["source_event_id"]),
|
|
19
|
+
sourceHandoffId: value["source_handoff_id"] == null ? null : String(value["source_handoff_id"]),
|
|
20
|
+
confidence: Number(value["confidence"] ?? 0),
|
|
21
|
+
retentionClass: value["retention_class"] == null ? null : String(value["retention_class"]),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function supersedeDurableMemory(dbPath, project, originalId, opts = {}) {
|
|
25
|
+
const db = openMemoryDb(dbPath);
|
|
26
|
+
try {
|
|
27
|
+
migrate(db);
|
|
28
|
+
const original = parseOriginal(db
|
|
29
|
+
.prepare("SELECT id, type, summary, body, source_event_id, source_handoff_id, confidence, retention_class FROM durable_memories WHERE id = ? AND project_id = ?")
|
|
30
|
+
.get(originalId, project.id));
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
db.prepare("UPDATE durable_memories SET status = 'superseded', valid_to = ?, updated_at = ? WHERE id = ? AND project_id = ?").run(now, now, originalId, project.id);
|
|
33
|
+
const newId = randomUUID();
|
|
34
|
+
db.prepare(`
|
|
35
|
+
INSERT INTO durable_memories (
|
|
36
|
+
id, project_id, type, summary, body, source_event_id, source_handoff_id,
|
|
37
|
+
confidence, status, retention_class, valid_from, valid_to, created_at, updated_at
|
|
38
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
39
|
+
`).run(newId, project.id, original.type, opts.newSummary ? redactSecrets(opts.newSummary) : redactSecrets(original.summary), original.body == null ? null : redactSecrets(original.body), original.sourceEventId, original.sourceHandoffId, original.confidence, "active", original.retentionClass ?? "durable", null, null, now, now);
|
|
40
|
+
db.prepare(`
|
|
41
|
+
INSERT INTO memory_references (id, project_id, source_type, source_id, target_type, target_id, ref_kind, weight, created_at)
|
|
42
|
+
VALUES (?, ?, 'durable_memory', ?, 'durable_memory', ?, 'supersedes', 1, ?)
|
|
43
|
+
`).run(randomUUID(), project.id, originalId, newId, now);
|
|
44
|
+
return { originalId, supersedingId: newId };
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic retention scoring policy and classification contract.
|
|
3
|
+
* Pure functions. No ML, no embeddings, no side effects, no DB.
|
|
4
|
+
* All inputs are explicit evidence signals (frequency, recency, spread, weights, pin, decay, contradictions).
|
|
5
|
+
*/
|
|
6
|
+
export const RETENTION_CLASSES = ["forget", "temporary", "working", "durable", "permanent"];
|
|
7
|
+
/**
|
|
8
|
+
* Minimum score (inclusive) required to enter each class when not manually pinned.
|
|
9
|
+
* Classification walks from highest to lowest.
|
|
10
|
+
*/
|
|
11
|
+
export const RETENTION_THRESHOLDS = {
|
|
12
|
+
forget: 0,
|
|
13
|
+
temporary: 30,
|
|
14
|
+
working: 50,
|
|
15
|
+
durable: 75,
|
|
16
|
+
permanent: 90,
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Weights and factors for the deterministic linear scoring formula.
|
|
20
|
+
* Exposed so docs and tests can reference exact terms.
|
|
21
|
+
*/
|
|
22
|
+
export const RETENTION_WEIGHTS = {
|
|
23
|
+
frequency: 4.5,
|
|
24
|
+
spread: 7,
|
|
25
|
+
decision: 12,
|
|
26
|
+
qa: 10,
|
|
27
|
+
relation: 4,
|
|
28
|
+
confidence: 10,
|
|
29
|
+
recencyBase: 18,
|
|
30
|
+
recencyPerDay: 0.55,
|
|
31
|
+
agePerDay: 0.12,
|
|
32
|
+
ageCap: 22,
|
|
33
|
+
contradiction: 9,
|
|
34
|
+
};
|
|
35
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic retention score in [0, 110] range (rounded).
|
|
38
|
+
* Terms: frequency, spread, decision/qa importance, relation degree,
|
|
39
|
+
* confidence, recency bonus, age-based linear decay, contradiction penalty.
|
|
40
|
+
* Manual pin does NOT change the raw score; it forces "permanent" at classify time.
|
|
41
|
+
*/
|
|
42
|
+
export function computeRetentionScore(input) {
|
|
43
|
+
const freqPart = input.frequency * RETENTION_WEIGHTS.frequency;
|
|
44
|
+
const spreadPart = input.spread * RETENTION_WEIGHTS.spread;
|
|
45
|
+
const decisionPart = input.decisionWeight * RETENTION_WEIGHTS.decision;
|
|
46
|
+
const qaPart = input.qaWeight * RETENTION_WEIGHTS.qa;
|
|
47
|
+
const relPart = input.relationDegree * RETENTION_WEIGHTS.relation;
|
|
48
|
+
const confPart = input.confidence * RETENTION_WEIGHTS.confidence;
|
|
49
|
+
let score = freqPart + spreadPart + decisionPart + qaPart + relPart + confPart;
|
|
50
|
+
const recencyBonus = Math.max(0, RETENTION_WEIGHTS.recencyBase - input.recencyDays * RETENTION_WEIGHTS.recencyPerDay);
|
|
51
|
+
score += recencyBonus;
|
|
52
|
+
const ageDecay = Math.min(RETENTION_WEIGHTS.ageCap, input.ageDays * RETENTION_WEIGHTS.agePerDay);
|
|
53
|
+
score -= ageDecay;
|
|
54
|
+
score -= input.contradictionCount * RETENTION_WEIGHTS.contradiction;
|
|
55
|
+
return Math.round(clamp(score, 0, 110));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Classify a (score, manualPin) pair into one of the five retention classes.
|
|
59
|
+
* Manual pin is a hard override: pinned items are permanent and MUST NOT be
|
|
60
|
+
* auto-expired by any decay job or age-based rule.
|
|
61
|
+
*/
|
|
62
|
+
export function classifyRetention(score, manualPin) {
|
|
63
|
+
if (manualPin) {
|
|
64
|
+
return "permanent";
|
|
65
|
+
}
|
|
66
|
+
const s = Math.max(0, Math.round(score));
|
|
67
|
+
if (s >= RETENTION_THRESHOLDS.permanent)
|
|
68
|
+
return "permanent";
|
|
69
|
+
if (s >= RETENTION_THRESHOLDS.durable)
|
|
70
|
+
return "durable";
|
|
71
|
+
if (s >= RETENTION_THRESHOLDS.working)
|
|
72
|
+
return "working";
|
|
73
|
+
if (s >= RETENTION_THRESHOLDS.temporary)
|
|
74
|
+
return "temporary";
|
|
75
|
+
return "forget";
|
|
76
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { migrate, openMemoryDb } from "./memoryDb.js";
|
|
2
|
+
import { classifyRetention, computeRetentionScore } from "./retentionPolicy.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function parsePayload(payloadJson) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(payloadJson);
|
|
9
|
+
if (!isRecord(parsed))
|
|
10
|
+
return {};
|
|
11
|
+
const confidence = parsed["confidence"];
|
|
12
|
+
if (typeof confidence === "number" && Number.isFinite(confidence)) {
|
|
13
|
+
return { confidence: Math.max(0, Math.min(1, confidence)) };
|
|
14
|
+
}
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof SyntaxError)
|
|
19
|
+
return {};
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function ageDays(nowIso, seenIso) {
|
|
24
|
+
if (seenIso == null)
|
|
25
|
+
return 0;
|
|
26
|
+
const nowMs = Date.parse(nowIso);
|
|
27
|
+
const seenMs = Date.parse(seenIso);
|
|
28
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(seenMs))
|
|
29
|
+
return 0;
|
|
30
|
+
return Math.max(0, Math.floor((nowMs - seenMs) / 86_400_000));
|
|
31
|
+
}
|
|
32
|
+
function loadSignals(db) {
|
|
33
|
+
const rows = db
|
|
34
|
+
.prepare(`
|
|
35
|
+
SELECT
|
|
36
|
+
c.label AS label,
|
|
37
|
+
COALESCE(SUM(mr.weight), 0) AS frequency,
|
|
38
|
+
COUNT(DISTINCT c.project_id) AS projectSpread,
|
|
39
|
+
MIN(COALESCE(mr.created_at, c.first_seen, c.created_at)) AS firstSeen,
|
|
40
|
+
MAX(COALESCE(mr.created_at, c.last_seen, c.updated_at)) AS lastSeen,
|
|
41
|
+
COALESCE(SUM(CASE WHEN e.type IN ('decision', 'decide', 'decision_record') THEN mr.weight ELSE 0 END), 0) AS decisionWeight,
|
|
42
|
+
COALESCE(SUM(CASE WHEN e.type IN ('qa', 'test', 'verification', 'evidence') THEN mr.weight ELSE 0 END), 0) AS qaWeight,
|
|
43
|
+
COALESCE(SUM(CASE WHEN e.type IN ('contradiction', 'conflict', 'reversal') THEN 1 ELSE 0 END), 0) AS contradictionCount
|
|
44
|
+
FROM concepts c
|
|
45
|
+
LEFT JOIN memory_references mr ON mr.target_type = 'concept' AND mr.target_id = c.id
|
|
46
|
+
LEFT JOIN events e ON mr.source_type = 'event' AND e.id = mr.source_id
|
|
47
|
+
GROUP BY c.label
|
|
48
|
+
`)
|
|
49
|
+
.all();
|
|
50
|
+
return new Map(rows.map((row) => [
|
|
51
|
+
row.label,
|
|
52
|
+
{
|
|
53
|
+
frequency: row.frequency,
|
|
54
|
+
projectSpread: row.projectSpread,
|
|
55
|
+
firstSeen: row.firstSeen,
|
|
56
|
+
lastSeen: row.lastSeen,
|
|
57
|
+
decisionWeight: row.decisionWeight,
|
|
58
|
+
qaWeight: row.qaWeight,
|
|
59
|
+
contradictionCount: row.contradictionCount,
|
|
60
|
+
},
|
|
61
|
+
]));
|
|
62
|
+
}
|
|
63
|
+
function loadReferenceCounts(db) {
|
|
64
|
+
const rows = db
|
|
65
|
+
.prepare(`
|
|
66
|
+
SELECT c.id AS conceptId, COUNT(mr.id) AS refCount
|
|
67
|
+
FROM concepts c
|
|
68
|
+
LEFT JOIN memory_references mr ON mr.target_type = 'concept' AND mr.target_id = c.id
|
|
69
|
+
GROUP BY c.id
|
|
70
|
+
`)
|
|
71
|
+
.all();
|
|
72
|
+
return new Map(rows.map((row) => [row.conceptId, row.refCount]));
|
|
73
|
+
}
|
|
74
|
+
function loadRelationDegrees(db) {
|
|
75
|
+
const rows = db
|
|
76
|
+
.prepare(`
|
|
77
|
+
SELECT concept_id AS conceptId, COUNT(DISTINCT relation_id) AS relationDegree
|
|
78
|
+
FROM (
|
|
79
|
+
SELECT source_id AS concept_id, id AS relation_id FROM relations WHERE source_type = 'concept'
|
|
80
|
+
UNION ALL
|
|
81
|
+
SELECT target_id AS concept_id, id AS relation_id FROM relations WHERE target_type = 'concept'
|
|
82
|
+
)
|
|
83
|
+
GROUP BY concept_id
|
|
84
|
+
`)
|
|
85
|
+
.all();
|
|
86
|
+
return new Map(rows.map((row) => [row.conceptId, row.relationDegree]));
|
|
87
|
+
}
|
|
88
|
+
function updateConcept(db, row, update) {
|
|
89
|
+
if (row.score === update.score &&
|
|
90
|
+
row.retentionClass === update.retentionClass &&
|
|
91
|
+
row.refCount === update.refCount &&
|
|
92
|
+
row.projectSpread === update.projectSpread &&
|
|
93
|
+
row.firstSeen === update.firstSeen &&
|
|
94
|
+
row.lastSeen === update.lastSeen) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
db.prepare(`
|
|
98
|
+
UPDATE concepts
|
|
99
|
+
SET score = ?, retention_class = ?, ref_count = ?, project_spread = ?,
|
|
100
|
+
first_seen = ?, last_seen = ?, updated_at = ?
|
|
101
|
+
WHERE id = ?
|
|
102
|
+
`).run(update.score, update.retentionClass, update.refCount, update.projectSpread, update.firstSeen, update.lastSeen, update.nowIso, row.id);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
export function recomputeRetentionScores(options) {
|
|
106
|
+
const db = openMemoryDb(options.dbPath);
|
|
107
|
+
try {
|
|
108
|
+
migrate(db);
|
|
109
|
+
const rows = db
|
|
110
|
+
.prepare(`
|
|
111
|
+
SELECT id, label, payload_json AS payloadJson, COALESCE(score, 0) AS score,
|
|
112
|
+
COALESCE(retention_class, 'working') AS retentionClass, COALESCE(manual_pin, 0) AS manualPin,
|
|
113
|
+
COALESCE(ref_count, 0) AS refCount, COALESCE(project_spread, 1) AS projectSpread,
|
|
114
|
+
first_seen AS firstSeen, last_seen AS lastSeen
|
|
115
|
+
FROM concepts
|
|
116
|
+
ORDER BY id ASC
|
|
117
|
+
`)
|
|
118
|
+
.all();
|
|
119
|
+
const signals = loadSignals(db);
|
|
120
|
+
const referenceCounts = loadReferenceCounts(db);
|
|
121
|
+
const relationDegrees = loadRelationDegrees(db);
|
|
122
|
+
let updatedConcepts = 0;
|
|
123
|
+
let skippedPermanentConcepts = 0;
|
|
124
|
+
const recompute = db.transaction(() => {
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
if (row.manualPin === 1 || row.retentionClass === "permanent") {
|
|
127
|
+
skippedPermanentConcepts += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const signal = signals.get(row.label) ?? {
|
|
131
|
+
frequency: row.refCount,
|
|
132
|
+
projectSpread: row.projectSpread,
|
|
133
|
+
firstSeen: row.firstSeen,
|
|
134
|
+
lastSeen: row.lastSeen,
|
|
135
|
+
decisionWeight: 0,
|
|
136
|
+
qaWeight: 0,
|
|
137
|
+
contradictionCount: 0,
|
|
138
|
+
};
|
|
139
|
+
const score = computeRetentionScore({
|
|
140
|
+
frequency: signal.frequency,
|
|
141
|
+
recencyDays: ageDays(options.nowIso, signal.lastSeen),
|
|
142
|
+
spread: signal.projectSpread,
|
|
143
|
+
decisionWeight: signal.decisionWeight,
|
|
144
|
+
qaWeight: signal.qaWeight,
|
|
145
|
+
relationDegree: relationDegrees.get(row.id) ?? 0,
|
|
146
|
+
confidence: parsePayload(row.payloadJson).confidence ?? 0,
|
|
147
|
+
manualPin: false,
|
|
148
|
+
ageDays: ageDays(options.nowIso, signal.firstSeen),
|
|
149
|
+
contradictionCount: signal.contradictionCount,
|
|
150
|
+
});
|
|
151
|
+
const changed = updateConcept(db, row, {
|
|
152
|
+
score,
|
|
153
|
+
retentionClass: classifyRetention(score, false),
|
|
154
|
+
refCount: referenceCounts.get(row.id) ?? row.refCount,
|
|
155
|
+
projectSpread: signal.projectSpread,
|
|
156
|
+
firstSeen: signal.firstSeen,
|
|
157
|
+
lastSeen: signal.lastSeen,
|
|
158
|
+
nowIso: options.nowIso,
|
|
159
|
+
});
|
|
160
|
+
if (changed)
|
|
161
|
+
updatedConcepts += 1;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
recompute();
|
|
165
|
+
return {
|
|
166
|
+
scannedConcepts: rows.length,
|
|
167
|
+
updatedConcepts,
|
|
168
|
+
skippedPermanentConcepts,
|
|
169
|
+
nowIso: options.nowIso,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
db.close();
|
|
174
|
+
}
|
|
175
|
+
}
|