omo-memory 0.1.10 → 0.1.12

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,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
+ }