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.
@@ -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` instead of separately calling `memory_start_session` and `memory_recent_events`.
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.