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.
@@ -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
+ }