omo-memory 0.1.11 → 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.
- package/README.md +83 -3
- package/dist/cli.js +113 -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 +113 -3
- package/docs/epic-omo-memory.md +63 -7
- package/package.json +3 -1
|
@@ -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
|
+
}
|
|
@@ -32,6 +32,7 @@ npx -y omo-memory session bootstrap --host codex --adapter lazycodex --limit 5
|
|
|
32
32
|
npx -y omo-memory session start --host codex --adapter lazycodex
|
|
33
33
|
npx -y omo-memory session start --host grok --adapter lfg
|
|
34
34
|
npx -y omo-memory recent --limit 10
|
|
35
|
+
npx -y omo-memory recall --query "current roadmap decision" --limit 10
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
For a source checkout:
|
|
@@ -45,6 +46,7 @@ node dist/cli.js event record --type decision --summary "Use OMO Memory as the s
|
|
|
45
46
|
node dist/cli.js event record --type qa_evidence --summary "CLI smoke passed for init/session/event/recent."
|
|
46
47
|
node dist/cli.js handoff write --summary "Continue from recent decision and qa_evidence events."
|
|
47
48
|
node dist/cli.js recent --limit 10
|
|
49
|
+
node dist/cli.js recall --query "qa evidence" --limit 10
|
|
48
50
|
node dist/cli.js export
|
|
49
51
|
node dist/cli.js purge --yes
|
|
50
52
|
```
|
|
@@ -85,7 +87,7 @@ Register the same MCP server in every host that needs memory access. Do not crea
|
|
|
85
87
|
|
|
86
88
|
## Session Bootstrap Flow
|
|
87
89
|
|
|
88
|
-
At the beginning of a Codex, OpenCode, or Grok adapter session, call `memory_bootstrap_session`
|
|
90
|
+
At the beginning of a Codex, OpenCode, or Grok adapter session, call `memory_bootstrap_session` only when the adapter needs a session id for subsequent writes. Do not use bootstrap as a prompt-time memory injection hook.
|
|
89
91
|
|
|
90
92
|
```json
|
|
91
93
|
{
|
|
@@ -102,9 +104,8 @@ The tool returns:
|
|
|
102
104
|
|
|
103
105
|
- `sessionId`: the new session row for subsequent event/handoff writes.
|
|
104
106
|
- `project`: the current git/project namespace.
|
|
105
|
-
- `recentEvents`: recent events from the same project namespace.
|
|
106
107
|
|
|
107
|
-
During the session, write concise task state and evidence with the returned `sessionId`:
|
|
108
|
+
During the session, hooks should write concise user-action summaries, task state, decisions, and evidence with the returned `sessionId`:
|
|
108
109
|
|
|
109
110
|
```json
|
|
110
111
|
{
|
|
@@ -119,6 +120,13 @@ During the session, write concise task state and evidence with the returned `ses
|
|
|
119
120
|
|
|
120
121
|
This package is the local MCP-to-SQLite router. It does not scrape host transcripts or centralize cloud state. Hosts and adapters must call the MCP tools at their own lifecycle points.
|
|
121
122
|
|
|
123
|
+
Retrieval is opt-in or intent-gated:
|
|
124
|
+
|
|
125
|
+
- Use `memory_recent_events` only when the user explicitly asks for recent OMO Memory context.
|
|
126
|
+
- Use `memory_recall_events` when the current user input has a concrete query that can be matched to recorded summaries, decisions, or evidence.
|
|
127
|
+
- Do not automatically attach the last session to every user prompt.
|
|
128
|
+
- Do not store raw user prompts by default; record concise, redacted action summaries.
|
|
129
|
+
|
|
122
130
|
Use these tools:
|
|
123
131
|
|
|
124
132
|
- `memory_init`
|
|
@@ -127,9 +135,56 @@ Use these tools:
|
|
|
127
135
|
- `memory_start_session`
|
|
128
136
|
- `memory_record_event`
|
|
129
137
|
- `memory_recent_events`
|
|
138
|
+
- `memory_recall_events`
|
|
130
139
|
- `memory_write_handoff`
|
|
131
140
|
- `memory_export`
|
|
132
141
|
- `memory_purge`
|
|
142
|
+
- `memory_global_scan`
|
|
143
|
+
- `memory_global_migrate`
|
|
144
|
+
- `memory_global_list`
|
|
145
|
+
- `memory_ontology_candidates`
|
|
146
|
+
- `memory_ontology_extract`
|
|
147
|
+
- `memory_ontology_score`
|
|
148
|
+
- `memory_ontology_promote`
|
|
149
|
+
- `memory_ontology_demote`
|
|
150
|
+
- `memory_ontology_supersede`
|
|
151
|
+
- `memory_ontology_recall`
|
|
152
|
+
|
|
153
|
+
Global migration is copy/import only. Adapters may scan for existing project-local `.omo/memory/state.sqlite` databases and import them into a user-selected global SQLite file, but they must preserve source DBs and retain source provenance in the global store.
|
|
154
|
+
|
|
155
|
+
Global migration also materializes an aggregate OMO schema view inside the global SQLite file. This lets existing ontology extraction, retention scoring, recall, and OpenTUI graph code operate on integrated cross-project events while `global_*` tables retain source database provenance.
|
|
156
|
+
|
|
157
|
+
Example global second-brain flow:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
omo-memory global scan --root /Users/ilseoblee/workspace
|
|
161
|
+
omo-memory global migrate --root /Users/ilseoblee/workspace --global-db ~/.omo/memory/global.sqlite
|
|
162
|
+
OMO_MEMORY_DB=~/.omo/memory/global.sqlite omo-memory ontology candidates
|
|
163
|
+
OMO_MEMORY_DB=~/.omo/memory/global.sqlite omo-memory ontology score
|
|
164
|
+
bun --version
|
|
165
|
+
omo-memory graph tui --db ~/.omo/memory/global.sqlite --query linaforge
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`graph tui` requires `bun` on `PATH` for the OpenTUI terminal renderer. Other CLI and MCP commands run on Node.
|
|
169
|
+
|
|
170
|
+
Lifecycle commands:
|
|
171
|
+
|
|
172
|
+
- `memory_ontology_candidates`: derive candidate terms from concise summaries.
|
|
173
|
+
- `memory_ontology_extract`: explicitly extract candidates from one summary/event.
|
|
174
|
+
- `memory_ontology_score`: recompute deterministic scores and retention classes.
|
|
175
|
+
- `memory_ontology_promote`: curate a concept into durable memory.
|
|
176
|
+
- `memory_ontology_demote`: lower a durable memory's retention class.
|
|
177
|
+
- `memory_ontology_supersede`: preserve the old memory and create a replacement.
|
|
178
|
+
- `memory_ontology_recall`: retrieve ontology-backed memories only for an explicit query.
|
|
179
|
+
|
|
180
|
+
OpenTUI graph controls:
|
|
181
|
+
|
|
182
|
+
- `q`: quit.
|
|
183
|
+
- `Up` / `Down`: move selected concept.
|
|
184
|
+
- `Tab`: move to the next concept.
|
|
185
|
+
- `/` or `f`: focus filter input when supported by the terminal runtime.
|
|
186
|
+
|
|
187
|
+
The graph UI is local terminal UI. It does not require a browser, web server, cloud account, or vector service.
|
|
133
188
|
|
|
134
189
|
Example session start:
|
|
135
190
|
|
|
@@ -162,3 +217,58 @@ Example QA evidence:
|
|
|
162
217
|
- Store sanitized summaries and evidence references instead of full logs.
|
|
163
218
|
- Host-specific values may appear only in small redacted metadata payloads and must not require schema branches.
|
|
164
219
|
- Export and purge are explicit lifecycle commands; purge requires explicit confirmation.
|
|
220
|
+
|
|
221
|
+
## Ontology Schema Boundary
|
|
222
|
+
|
|
223
|
+
OMO Memory's chronological ledger remains authoritative: sessions, events, and handoffs record what happened in a project. The ontology schema is an additive layer for durable memory derived from that ledger:
|
|
224
|
+
|
|
225
|
+
- `concepts` stores vocabulary entries such as project terms, practices, tools, and recurring ideas.
|
|
226
|
+
- `relations` stores typed links between concepts, decisions, events, sessions, and handoffs.
|
|
227
|
+
- `durable_memories` stores approved long-term facts, preferences, and working rules.
|
|
228
|
+
- `decision_records` stores important choices with rationale, evidence, status, reversibility, and provenance.
|
|
229
|
+
|
|
230
|
+
Adapters must treat ontology rows as curated local memory, not as raw capture. Do not write full transcripts, raw logs, `.env` contents, auth files, cookies, bearer headers, or secret-bearing payloads into ontology tables. User-authored text must pass through the same redaction boundary used by event and handoff writes before it is promoted into durable memory.
|
|
231
|
+
|
|
232
|
+
The ontology layer is intentionally not a new adapter surface by itself. CLI and MCP commands should continue to call shared core functions, and future concept/decision commands must not create host-specific schemas or side databases.
|
|
233
|
+
|
|
234
|
+
## Retention Scoring Policy (Deterministic Contract)
|
|
235
|
+
|
|
236
|
+
Retention classification is strictly deterministic. There is no ML, embeddings, or hidden model. A pure function maps explicit signals to a numeric score and then to one of five classes.
|
|
237
|
+
|
|
238
|
+
Classes (in ascending durability):
|
|
239
|
+
|
|
240
|
+
- `forget`: score < 30 (or after decay/contradiction). Safe to drop.
|
|
241
|
+
- `temporary`: 30 <= score < 50. Short-term context only.
|
|
242
|
+
- `working`: 50 <= score < 75. Active task/iteration memory.
|
|
243
|
+
- `durable`: 75 <= score < 90. Cross-session value; survives typical decay.
|
|
244
|
+
- `permanent`: score >= 90, or any manually pinned item. Manual pin is a hard override.
|
|
245
|
+
|
|
246
|
+
Manual pin rule (critical): an item with `manualPin: true` is always classified `permanent` regardless of raw score, age, or frequency. Decay jobs and age-based expiration MUST NOT remove or downgrade pinned permanent memory; only explicit supersede/demote/purge may change it.
|
|
247
|
+
|
|
248
|
+
### Score Formula (pure, exposed)
|
|
249
|
+
|
|
250
|
+
Exported constants (see `src/retentionPolicy.ts`):
|
|
251
|
+
|
|
252
|
+
- `RETENTION_CLASSES = ["forget", "temporary", "working", "durable", "permanent"] as const`
|
|
253
|
+
- `RETENTION_THRESHOLDS = { forget: 0, temporary: 30, working: 50, durable: 75, permanent: 90 } as const`
|
|
254
|
+
- `RETENTION_WEIGHTS = { frequency: 4.5, spread: 7, decision: 12, qa: 10, relation: 4, confidence: 10, recencyBase: 18, recencyPerDay: 0.55, agePerDay: 0.12, ageCap: 22, contradiction: 9 } as const`
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
score = clamp(
|
|
258
|
+
frequency * 4.5
|
|
259
|
+
+ spread * 7
|
|
260
|
+
+ decisionWeight * 12
|
|
261
|
+
+ qaWeight * 10
|
|
262
|
+
+ relationDegree * 4
|
|
263
|
+
+ confidence * 10
|
|
264
|
+
+ max(0, 18 - recencyDays * 0.55)
|
|
265
|
+
- min(22, ageDays * 0.12)
|
|
266
|
+
- contradictionCount * 9
|
|
267
|
+
, 0, 110)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`computeRetentionScore(input)` returns the rounded integer. `classifyRetention(score, manualPin)` returns the class, applying the pin override first.
|
|
271
|
+
|
|
272
|
+
Boundary cases (score, pin=false): 29→forget, 30→temporary, 49→temporary, 50→working, 74→working, 75→durable, 89→durable, 90→permanent. These are verified by the focused contract test.
|
|
273
|
+
|
|
274
|
+
All scoring inputs are derived from ledger signals (event counts, last-seen deltas, project spread from index, decision/qa event types, explicit relation degree, stored confidence, manual pin flag, contradiction markers). No raw transcripts or secrets are inputs.
|