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.
- package/README.md +83 -19
- package/dist/cli.js +112 -11
- 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 -20
- package/docs/epic-omo-memory.md +63 -7
- package/package.json +3 -1
- package/dist/hookTemplates.js +0 -167
- package/dist/hooks.js +0 -198
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function upsertAggregateProject(db, project) {
|
|
2
|
+
const now = new Date().toISOString();
|
|
3
|
+
db.prepare(`
|
|
4
|
+
INSERT INTO projects (id, repo_root, git_remote, created_at, last_seen_at)
|
|
5
|
+
VALUES (?, ?, ?, ?, ?)
|
|
6
|
+
ON CONFLICT(id) DO UPDATE SET repo_root = excluded.repo_root, git_remote = excluded.git_remote, last_seen_at = excluded.last_seen_at
|
|
7
|
+
`).run(project.id, project.repoRoot, project.gitRemote, now, now);
|
|
8
|
+
}
|
|
9
|
+
export function upsertCanonicalSession(db, sourceId, aggregateProjectId, row) {
|
|
10
|
+
db.prepare(`
|
|
11
|
+
INSERT INTO sessions (id, project_id, host, adapter, started_at, ended_at, git_branch, git_head)
|
|
12
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
13
|
+
ON CONFLICT(id) DO UPDATE SET host = excluded.host, adapter = excluded.adapter, ended_at = excluded.ended_at, git_branch = excluded.git_branch, git_head = excluded.git_head
|
|
14
|
+
`).run(globalRowId(sourceId, row.id), aggregateProjectId, row.host, row.adapter, row.startedAt, row.endedAt, row.gitBranch, row.gitHead);
|
|
15
|
+
}
|
|
16
|
+
export function upsertCanonicalEvent(db, sourceId, aggregateProjectId, row) {
|
|
17
|
+
db.prepare(`
|
|
18
|
+
INSERT INTO events (id, session_id, project_id, type, summary, payload_json, created_at)
|
|
19
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
20
|
+
ON CONFLICT(id) DO UPDATE SET session_id = excluded.session_id, type = excluded.type, summary = excluded.summary, payload_json = excluded.payload_json, created_at = excluded.created_at
|
|
21
|
+
`).run(globalRowId(sourceId, row.id), row.sessionId === null ? null : globalRowId(sourceId, row.sessionId), aggregateProjectId, row.type, row.summary, row.payloadJson, row.createdAt);
|
|
22
|
+
}
|
|
23
|
+
export function upsertCanonicalHandoff(db, sourceId, aggregateProjectId, row) {
|
|
24
|
+
db.prepare(`
|
|
25
|
+
INSERT INTO handoffs (id, project_id, session_id, summary_md, created_at)
|
|
26
|
+
VALUES (?, ?, ?, ?, ?)
|
|
27
|
+
ON CONFLICT(id) DO UPDATE SET session_id = excluded.session_id, summary_md = excluded.summary_md, created_at = excluded.created_at
|
|
28
|
+
`).run(globalRowId(sourceId, row.id), aggregateProjectId, row.sessionId === null ? null : globalRowId(sourceId, row.sessionId), row.summaryMd, row.createdAt);
|
|
29
|
+
}
|
|
30
|
+
function globalRowId(sourceId, sourceRowId) {
|
|
31
|
+
return `${sourceId}:${sourceRowId}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { upsertAggregateProject, upsertCanonicalEvent, upsertCanonicalHandoff, upsertCanonicalSession, } from "./globalMemoryCanonical.js";
|
|
4
|
+
import { redactSecrets, sanitizeGitRemote } from "./privacy.js";
|
|
5
|
+
const GLOBAL_TABLES = ["sources", "global_projects", "global_sessions", "global_events", "global_handoffs"];
|
|
6
|
+
export function importCandidates(globalDbPath, scan, aggregateProject) {
|
|
7
|
+
const global = new Database(globalDbPath);
|
|
8
|
+
const skipped = [...scan.skipped];
|
|
9
|
+
try {
|
|
10
|
+
const before = readGlobalCounts(global);
|
|
11
|
+
const imported = { sources: 0, projects: 0, sessions: 0, events: 0, handoffs: 0 };
|
|
12
|
+
for (const candidate of scan.candidates) {
|
|
13
|
+
const result = importCandidate(global, candidate, aggregateProject);
|
|
14
|
+
if (result.kind === "skipped") {
|
|
15
|
+
skipped.push({ dbPath: candidate.dbPath, reason: result.reason });
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
imported.sources += result.imported.sources;
|
|
19
|
+
imported.projects += result.imported.projects;
|
|
20
|
+
imported.sessions += result.imported.sessions;
|
|
21
|
+
imported.events += result.imported.events;
|
|
22
|
+
imported.handoffs += result.imported.handoffs;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { sources: scan.candidates.length - (skipped.length - scan.skipped.length), imported, before, after: readGlobalCounts(global), skipped };
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
global.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function listGlobalMemoryFromDb(globalDbPath) {
|
|
32
|
+
const db = new Database(globalDbPath, { readonly: true, fileMustExist: true });
|
|
33
|
+
try {
|
|
34
|
+
return {
|
|
35
|
+
sources: db
|
|
36
|
+
.prepare("SELECT id, db_path AS dbPath, schema_version AS schemaVersion, imported_at AS importedAt, last_seen_at AS lastSeenAt FROM sources ORDER BY db_path ASC")
|
|
37
|
+
.all(),
|
|
38
|
+
counts: readGlobalCounts(db),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
db.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function importCandidate(global, candidate, aggregateProject) {
|
|
46
|
+
let source;
|
|
47
|
+
try {
|
|
48
|
+
source = new Database(candidate.dbPath, { readonly: true, fileMustExist: true });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof Error)
|
|
52
|
+
return { kind: "skipped", reason: error.message };
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const sourceId = sourceIdForPath(candidate.dbPath);
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const rows = {
|
|
59
|
+
projects: readProjects(source).map(redactProjectRow),
|
|
60
|
+
sessions: readSessions(source),
|
|
61
|
+
events: readEvents(source).map(redactEventRow),
|
|
62
|
+
handoffs: readHandoffs(source).map(redactHandoffRow),
|
|
63
|
+
};
|
|
64
|
+
const write = global.transaction(() => {
|
|
65
|
+
const counts = { sources: 0, projects: 0, sessions: 0, events: 0, handoffs: 0 };
|
|
66
|
+
upsertAggregateProject(global, aggregateProject);
|
|
67
|
+
counts.sources += global
|
|
68
|
+
.prepare("INSERT INTO sources (id, db_path, schema_version, imported_at, last_seen_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(db_path) DO UPDATE SET schema_version = excluded.schema_version, last_seen_at = excluded.last_seen_at")
|
|
69
|
+
.run(sourceId, candidate.dbPath, candidate.schemaVersion, now, now).changes;
|
|
70
|
+
for (const row of rows.projects)
|
|
71
|
+
counts.projects += upsertProject(global, sourceId, row);
|
|
72
|
+
for (const row of rows.sessions)
|
|
73
|
+
counts.sessions += upsertSession(global, sourceId, row);
|
|
74
|
+
for (const row of rows.events)
|
|
75
|
+
counts.events += upsertEvent(global, sourceId, row);
|
|
76
|
+
for (const row of rows.handoffs)
|
|
77
|
+
counts.handoffs += upsertHandoff(global, sourceId, row);
|
|
78
|
+
for (const row of rows.sessions)
|
|
79
|
+
upsertCanonicalSession(global, sourceId, aggregateProject.id, row);
|
|
80
|
+
for (const row of rows.events)
|
|
81
|
+
upsertCanonicalEvent(global, sourceId, aggregateProject.id, row);
|
|
82
|
+
for (const row of rows.handoffs)
|
|
83
|
+
upsertCanonicalHandoff(global, sourceId, aggregateProject.id, row);
|
|
84
|
+
return counts;
|
|
85
|
+
});
|
|
86
|
+
return { kind: "imported", imported: write() };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof Error)
|
|
90
|
+
return { kind: "skipped", reason: error.message };
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
source.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function readProjects(db) {
|
|
98
|
+
return db
|
|
99
|
+
.prepare("SELECT id, repo_root AS repoRoot, git_remote AS gitRemote, created_at AS createdAt, last_seen_at AS lastSeenAt FROM projects ORDER BY id ASC")
|
|
100
|
+
.all();
|
|
101
|
+
}
|
|
102
|
+
function readSessions(db) {
|
|
103
|
+
if (!tableExists(db, "sessions"))
|
|
104
|
+
return [];
|
|
105
|
+
return db
|
|
106
|
+
.prepare("SELECT id, project_id AS projectId, host, adapter, started_at AS startedAt, ended_at AS endedAt, git_branch AS gitBranch, git_head AS gitHead FROM sessions ORDER BY id ASC")
|
|
107
|
+
.all();
|
|
108
|
+
}
|
|
109
|
+
function readEvents(db) {
|
|
110
|
+
return db
|
|
111
|
+
.prepare("SELECT id, session_id AS sessionId, project_id AS projectId, type, summary, payload_json AS payloadJson, created_at AS createdAt FROM events ORDER BY id ASC")
|
|
112
|
+
.all();
|
|
113
|
+
}
|
|
114
|
+
function readHandoffs(db) {
|
|
115
|
+
if (!tableExists(db, "handoffs"))
|
|
116
|
+
return [];
|
|
117
|
+
return db
|
|
118
|
+
.prepare("SELECT id, project_id AS projectId, session_id AS sessionId, summary_md AS summaryMd, created_at AS createdAt FROM handoffs ORDER BY id ASC")
|
|
119
|
+
.all();
|
|
120
|
+
}
|
|
121
|
+
function upsertProject(db, sourceId, row) {
|
|
122
|
+
return db
|
|
123
|
+
.prepare(`
|
|
124
|
+
INSERT INTO global_projects (id, source_id, source_project_id, repo_root, git_remote, created_at, last_seen_at)
|
|
125
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
126
|
+
ON CONFLICT(source_id, source_project_id) DO UPDATE SET repo_root = excluded.repo_root, git_remote = excluded.git_remote, last_seen_at = excluded.last_seen_at
|
|
127
|
+
`)
|
|
128
|
+
.run(globalRowId(sourceId, row.id), sourceId, row.id, row.repoRoot, row.gitRemote ?? "", row.createdAt, row.lastSeenAt).changes;
|
|
129
|
+
}
|
|
130
|
+
function redactProjectRow(row) {
|
|
131
|
+
return { ...row, gitRemote: sanitizeGitRemote(row.gitRemote) };
|
|
132
|
+
}
|
|
133
|
+
function redactEventRow(row) {
|
|
134
|
+
return {
|
|
135
|
+
...row,
|
|
136
|
+
summary: redactSecrets(row.summary),
|
|
137
|
+
payloadJson: row.payloadJson === null ? null : redactSecrets(row.payloadJson),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function redactHandoffRow(row) {
|
|
141
|
+
return { ...row, summaryMd: redactSecrets(row.summaryMd) };
|
|
142
|
+
}
|
|
143
|
+
function upsertSession(db, sourceId, row) {
|
|
144
|
+
return db
|
|
145
|
+
.prepare(`
|
|
146
|
+
INSERT INTO global_sessions (id, source_id, source_session_id, source_project_id, host, adapter, started_at, ended_at, git_branch, git_head)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
148
|
+
ON CONFLICT(source_id, source_session_id) DO UPDATE SET source_project_id = excluded.source_project_id, host = excluded.host, adapter = excluded.adapter, ended_at = excluded.ended_at, git_branch = excluded.git_branch, git_head = excluded.git_head
|
|
149
|
+
`)
|
|
150
|
+
.run(globalRowId(sourceId, row.id), sourceId, row.id, row.projectId, row.host, row.adapter, row.startedAt, row.endedAt, row.gitBranch, row.gitHead).changes;
|
|
151
|
+
}
|
|
152
|
+
function upsertEvent(db, sourceId, row) {
|
|
153
|
+
return db
|
|
154
|
+
.prepare(`
|
|
155
|
+
INSERT INTO global_events (id, source_id, source_event_id, source_session_id, source_project_id, type, summary, payload_json, created_at)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
157
|
+
ON CONFLICT(source_id, source_event_id) DO UPDATE SET source_session_id = excluded.source_session_id, source_project_id = excluded.source_project_id, type = excluded.type, summary = excluded.summary, payload_json = excluded.payload_json, created_at = excluded.created_at
|
|
158
|
+
`)
|
|
159
|
+
.run(globalRowId(sourceId, row.id), sourceId, row.id, row.sessionId ?? "", row.projectId, row.type, row.summary, row.payloadJson, row.createdAt).changes;
|
|
160
|
+
}
|
|
161
|
+
function upsertHandoff(db, sourceId, row) {
|
|
162
|
+
return db
|
|
163
|
+
.prepare(`
|
|
164
|
+
INSERT INTO global_handoffs (id, source_id, source_handoff_id, source_project_id, source_session_id, summary_md, created_at)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
166
|
+
ON CONFLICT(source_id, source_handoff_id) DO UPDATE SET source_project_id = excluded.source_project_id, source_session_id = excluded.source_session_id, summary_md = excluded.summary_md, created_at = excluded.created_at
|
|
167
|
+
`)
|
|
168
|
+
.run(globalRowId(sourceId, row.id), sourceId, row.id, row.projectId, row.sessionId, row.summaryMd, row.createdAt).changes;
|
|
169
|
+
}
|
|
170
|
+
function readGlobalCounts(db) {
|
|
171
|
+
if (!GLOBAL_TABLES.every((tableName) => tableExists(db, tableName)))
|
|
172
|
+
return { sources: 0, projects: 0, sessions: 0, events: 0, handoffs: 0 };
|
|
173
|
+
return {
|
|
174
|
+
sources: readGlobalCount(db, "sources"),
|
|
175
|
+
projects: readGlobalCount(db, "global_projects"),
|
|
176
|
+
sessions: readGlobalCount(db, "global_sessions"),
|
|
177
|
+
events: readGlobalCount(db, "global_events"),
|
|
178
|
+
handoffs: readGlobalCount(db, "global_handoffs"),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function readGlobalCount(db, tableName) {
|
|
182
|
+
const row = db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get();
|
|
183
|
+
return row?.count ?? 0;
|
|
184
|
+
}
|
|
185
|
+
function tableExists(db, tableName) {
|
|
186
|
+
const row = db.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName);
|
|
187
|
+
return row !== undefined && row.count === 1;
|
|
188
|
+
}
|
|
189
|
+
function sourceIdForPath(dbPath) {
|
|
190
|
+
return `src_${createHash("sha256").update(dbPath).digest("hex").slice(0, 24)}`;
|
|
191
|
+
}
|
|
192
|
+
function globalRowId(sourceId, sourceRowId) {
|
|
193
|
+
return `${sourceId}:${sourceRowId}`;
|
|
194
|
+
}
|
package/dist/graphTui.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { BoxRenderable, createCliRenderer, TextRenderable } from "@opentui/core";
|
|
3
|
+
const SNAPSHOT_ENV = "OMO_MEMORY_GRAPH_TUI_SNAPSHOT";
|
|
4
|
+
const COLORS = {
|
|
5
|
+
background: "#101419",
|
|
6
|
+
border: "#516071",
|
|
7
|
+
detail: "#17212b",
|
|
8
|
+
text: "#d8dee9",
|
|
9
|
+
muted: "#94a3b8",
|
|
10
|
+
accent: "#7dd3fc",
|
|
11
|
+
};
|
|
12
|
+
export async function runGraphTui(options) {
|
|
13
|
+
const existingSnapshot = parseSnapshot(process.env[SNAPSHOT_ENV]);
|
|
14
|
+
if (process.versions["bun"] === undefined) {
|
|
15
|
+
const graphSnapshot = existingSnapshot ?? (await createGraphSnapshot(options));
|
|
16
|
+
await runWithBun(options, graphSnapshot);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (existingSnapshot === null) {
|
|
20
|
+
throw new Error("graph tui OpenTUI renderer requires a graph snapshot");
|
|
21
|
+
}
|
|
22
|
+
const renderer = await createCliRenderer({
|
|
23
|
+
targetFps: 12,
|
|
24
|
+
maxFps: 12,
|
|
25
|
+
screenMode: "main-screen",
|
|
26
|
+
consoleMode: "disabled",
|
|
27
|
+
clearOnShutdown: false,
|
|
28
|
+
});
|
|
29
|
+
renderer.setTerminalTitle("OMO Ontology Graph");
|
|
30
|
+
let state = { options, selectedId: null, snapshot: existingSnapshot };
|
|
31
|
+
let graph = loadGraph(state);
|
|
32
|
+
state = { ...state, selectedId: graph.detail?.id ?? null };
|
|
33
|
+
const root = new BoxRenderable(renderer, {
|
|
34
|
+
id: "graph-root",
|
|
35
|
+
width: "100%",
|
|
36
|
+
height: "100%",
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
backgroundColor: COLORS.background,
|
|
39
|
+
padding: 1,
|
|
40
|
+
gap: 1,
|
|
41
|
+
});
|
|
42
|
+
const title = new TextRenderable(renderer, { id: "graph-title", content: "", fg: COLORS.accent, height: 1, truncate: true });
|
|
43
|
+
const body = new BoxRenderable(renderer, { id: "graph-body", flexGrow: 1, flexDirection: "row", gap: 1 });
|
|
44
|
+
const nodesPane = new BoxRenderable(renderer, {
|
|
45
|
+
id: "graph-nodes-pane",
|
|
46
|
+
width: "50%",
|
|
47
|
+
height: "100%",
|
|
48
|
+
border: true,
|
|
49
|
+
borderColor: COLORS.border,
|
|
50
|
+
title: "Nodes",
|
|
51
|
+
padding: 1,
|
|
52
|
+
backgroundColor: COLORS.background,
|
|
53
|
+
});
|
|
54
|
+
const detailPane = new BoxRenderable(renderer, {
|
|
55
|
+
id: "graph-detail-pane",
|
|
56
|
+
flexGrow: 1,
|
|
57
|
+
height: "100%",
|
|
58
|
+
border: true,
|
|
59
|
+
borderColor: COLORS.border,
|
|
60
|
+
title: "Detail Pane",
|
|
61
|
+
padding: 1,
|
|
62
|
+
backgroundColor: COLORS.detail,
|
|
63
|
+
});
|
|
64
|
+
const nodesText = new TextRenderable(renderer, { id: "graph-nodes", content: "", fg: COLORS.text, wrapMode: "word" });
|
|
65
|
+
const detailText = new TextRenderable(renderer, { id: "graph-detail", content: "", fg: COLORS.text, wrapMode: "word" });
|
|
66
|
+
const footer = new TextRenderable(renderer, { id: "graph-footer", content: "", fg: COLORS.muted, height: 1, truncate: true });
|
|
67
|
+
nodesPane.add(nodesText);
|
|
68
|
+
detailPane.add(detailText);
|
|
69
|
+
body.add(nodesPane);
|
|
70
|
+
body.add(detailPane);
|
|
71
|
+
root.add(title);
|
|
72
|
+
root.add(body);
|
|
73
|
+
root.add(footer);
|
|
74
|
+
renderer.root.add(root);
|
|
75
|
+
const render = () => {
|
|
76
|
+
graph = loadGraph(state);
|
|
77
|
+
state = { ...state, selectedId: graph.detail?.id ?? null };
|
|
78
|
+
title.content = titleText(graph, state.options.query);
|
|
79
|
+
nodesText.content = nodesContent(graph);
|
|
80
|
+
detailText.content = detailContent(graph);
|
|
81
|
+
footer.content = "q quit | ArrowUp/ArrowDown/Tab select | Legend: D durable, W working, T temporary, E ephemeral";
|
|
82
|
+
renderer.requestRender();
|
|
83
|
+
};
|
|
84
|
+
await new Promise((resolve) => {
|
|
85
|
+
const quit = () => {
|
|
86
|
+
renderer.keyInput.removeAllListeners("keypress");
|
|
87
|
+
renderer.destroy();
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
renderer.keyInput.on("keypress", (key) => {
|
|
91
|
+
if (key.name === "q" || (key.name === "c" && key.ctrl)) {
|
|
92
|
+
quit();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const nextId = nextSelectedId(graph.nodes, state.selectedId, key.name);
|
|
96
|
+
if (nextId !== state.selectedId) {
|
|
97
|
+
state = { ...state, selectedId: nextId };
|
|
98
|
+
render();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
writeCaptureFrame(graph, state.options.query);
|
|
102
|
+
renderer.start();
|
|
103
|
+
render();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async function runWithBun(options, snapshot) {
|
|
107
|
+
const scriptPath = process.argv[1];
|
|
108
|
+
if (scriptPath === undefined)
|
|
109
|
+
throw new Error("graph tui requires a CLI script path");
|
|
110
|
+
const args = [scriptPath, "graph", "tui", "--db", options.dbPath];
|
|
111
|
+
if (options.query !== undefined)
|
|
112
|
+
args.push("--query", options.query);
|
|
113
|
+
await new Promise((resolve, reject) => {
|
|
114
|
+
const child = spawn("bun", args, {
|
|
115
|
+
stdio: "inherit",
|
|
116
|
+
env: { ...process.env, [SNAPSHOT_ENV]: JSON.stringify(snapshot) },
|
|
117
|
+
});
|
|
118
|
+
child.once("error", (error) => {
|
|
119
|
+
reject(new Error(`graph tui requires Bun for OpenTUI native FFI: ${error.message}`));
|
|
120
|
+
});
|
|
121
|
+
child.once("exit", (code, signal) => {
|
|
122
|
+
if (code === 0) {
|
|
123
|
+
resolve();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
reject(new Error(`graph tui OpenTUI runtime exited with ${signal ?? code ?? "unknown status"}`));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function createGraphSnapshot(options) {
|
|
131
|
+
const { projectOntologyGraph } = await import("./ontologyGraph.js");
|
|
132
|
+
const graph = projectOntologyGraph({
|
|
133
|
+
dbPath: options.dbPath,
|
|
134
|
+
...(options.query === undefined ? {} : { query: options.query }),
|
|
135
|
+
});
|
|
136
|
+
const details = graph.nodes
|
|
137
|
+
.map((node) => projectOntologyGraph({
|
|
138
|
+
dbPath: options.dbPath,
|
|
139
|
+
...(options.query === undefined ? {} : { query: options.query }),
|
|
140
|
+
selectedId: node.id,
|
|
141
|
+
}).detail)
|
|
142
|
+
.filter((detail) => detail !== null);
|
|
143
|
+
return { graph, details };
|
|
144
|
+
}
|
|
145
|
+
function parseSnapshot(raw) {
|
|
146
|
+
if (raw === undefined)
|
|
147
|
+
return null;
|
|
148
|
+
const parsed = JSON.parse(raw);
|
|
149
|
+
if (!isGraphSnapshot(parsed))
|
|
150
|
+
throw new Error("invalid graph tui snapshot");
|
|
151
|
+
return parsed;
|
|
152
|
+
}
|
|
153
|
+
function isRecord(value) {
|
|
154
|
+
return value !== null && typeof value === "object";
|
|
155
|
+
}
|
|
156
|
+
function isGraphSnapshot(value) {
|
|
157
|
+
if (!isRecord(value))
|
|
158
|
+
return false;
|
|
159
|
+
return isRecord(value["graph"]) && Array.isArray(value["details"]);
|
|
160
|
+
}
|
|
161
|
+
function loadGraph(state) {
|
|
162
|
+
const selectedId = state.selectedId ?? state.snapshot.graph.detail?.id ?? state.snapshot.graph.nodes[0]?.id ?? null;
|
|
163
|
+
const detail = selectedId === null ? state.snapshot.graph.detail : (state.snapshot.details.find((item) => item.id === selectedId) ?? null);
|
|
164
|
+
return {
|
|
165
|
+
...state.snapshot.graph,
|
|
166
|
+
nodes: state.snapshot.graph.nodes.map((node) => ({ ...node, selected: node.id === selectedId })),
|
|
167
|
+
detail,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function titleText(graph, query) {
|
|
171
|
+
const queryLabel = query === undefined ? "all concepts" : `query "${query}"`;
|
|
172
|
+
return `OMO Ontology Graph - ${queryLabel} - ${graph.nodes.length} nodes / ${graph.edges.length} edges`;
|
|
173
|
+
}
|
|
174
|
+
function nodesContent(graph) {
|
|
175
|
+
if (graph.nodes.length === 0)
|
|
176
|
+
return graph.message ?? "No ontology graph data is available yet.";
|
|
177
|
+
const edges = graph.edges.map((edge) => ` ${edge.sourceId} -> ${edge.targetId} [${edge.label}]`);
|
|
178
|
+
return [...graph.nodes.map((node) => nodeLine(node)), "", "Relations", ...(edges.length === 0 ? [" none in current filter"] : edges)].join("\n");
|
|
179
|
+
}
|
|
180
|
+
function detailContent(graph) {
|
|
181
|
+
if (graph.detail === null)
|
|
182
|
+
return graph.message ?? "No ontology graph data is available yet.";
|
|
183
|
+
const detail = graph.detail;
|
|
184
|
+
return [
|
|
185
|
+
`Label: ${detail.label}`,
|
|
186
|
+
`Kind: ${detail.kind}`,
|
|
187
|
+
`Retention: ${classCode(detail.retentionClass)} ${detail.retentionClass}`,
|
|
188
|
+
`Score: ${detail.scoreLabel}`,
|
|
189
|
+
`Refs: ${detail.refCount}`,
|
|
190
|
+
`Projects: ${detail.projectSpread}`,
|
|
191
|
+
`First seen: ${detail.firstSeen ?? "unknown"}`,
|
|
192
|
+
`Last seen: ${detail.lastSeen ?? "unknown"}`,
|
|
193
|
+
`Project: ${detail.project.repoRoot}`,
|
|
194
|
+
`Remote: ${detail.project.gitRemote ?? "none"}`,
|
|
195
|
+
"",
|
|
196
|
+
"Description:",
|
|
197
|
+
detail.description ?? "none",
|
|
198
|
+
"",
|
|
199
|
+
`Aliases: ${detail.aliases.length === 0 ? "none" : detail.aliases.join(", ")}`,
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
function writeCaptureFrame(graph, query) {
|
|
203
|
+
process.stdout.write([
|
|
204
|
+
titleText(graph, query),
|
|
205
|
+
"",
|
|
206
|
+
"Nodes",
|
|
207
|
+
nodesContent(graph),
|
|
208
|
+
"",
|
|
209
|
+
"Detail",
|
|
210
|
+
detailContent(graph),
|
|
211
|
+
"",
|
|
212
|
+
"Legend: D durable, W working, T temporary, E ephemeral",
|
|
213
|
+
"",
|
|
214
|
+
].join("\n"));
|
|
215
|
+
}
|
|
216
|
+
function nodeLine(node) {
|
|
217
|
+
const marker = node.selected ? ">" : " ";
|
|
218
|
+
return `${marker} ${classCode(node.retentionClass)} ${node.label} (${node.kind}, ${node.scoreLabel}, refs ${node.refCount})`;
|
|
219
|
+
}
|
|
220
|
+
function classCode(retentionClass) {
|
|
221
|
+
const normalized = retentionClass.toLowerCase();
|
|
222
|
+
if (normalized === "durable")
|
|
223
|
+
return "D";
|
|
224
|
+
if (normalized === "temporary")
|
|
225
|
+
return "T";
|
|
226
|
+
if (normalized === "ephemeral")
|
|
227
|
+
return "E";
|
|
228
|
+
return "W";
|
|
229
|
+
}
|
|
230
|
+
function nextSelectedId(nodes, selectedId, keyName) {
|
|
231
|
+
if (nodes.length === 0)
|
|
232
|
+
return null;
|
|
233
|
+
const currentIndex = Math.max(0, nodes.findIndex((node) => node.id === selectedId));
|
|
234
|
+
const delta = keyName === "up" ? -1 : keyName === "down" || keyName === "tab" ? 1 : 0;
|
|
235
|
+
if (delta === 0)
|
|
236
|
+
return selectedId;
|
|
237
|
+
const nextIndex = (currentIndex + delta + nodes.length) % nodes.length;
|
|
238
|
+
return nodes[nextIndex]?.id ?? selectedId;
|
|
239
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
+
import { registerGlobalOntologyTools } from "./mcpOntologyTools.js";
|
|
6
|
+
import { bootstrapSession, exportMemory, PurgeConfirmationError, purgeMemory, recentEvents, recordEvent, startSession, writeHandoff } from "./memory.js";
|
|
5
7
|
import { initMemory } from "./memoryDb.js";
|
|
8
|
+
import { recallEvents } from "./memoryRecall.js";
|
|
9
|
+
import { memoryPaths } from "./memoryReport.js";
|
|
6
10
|
import { resolveProjectContext } from "./projectContext.js";
|
|
7
11
|
export async function runMcpServer() {
|
|
8
|
-
const server = new McpServer({ name: "omo-memory", version:
|
|
12
|
+
const server = new McpServer({ name: "omo-memory", version: readPackageVersion() });
|
|
9
13
|
server.registerTool("memory_init", {
|
|
10
14
|
title: "Initialize OMO Memory",
|
|
11
15
|
description: "Create or migrate the local OMO memory SQLite database.",
|
|
@@ -38,6 +42,7 @@ export async function runMcpServer() {
|
|
|
38
42
|
throw error;
|
|
39
43
|
}
|
|
40
44
|
});
|
|
45
|
+
registerGlobalOntologyTools(server);
|
|
41
46
|
server.registerTool("memory_start_session", {
|
|
42
47
|
title: "Start OMO Session",
|
|
43
48
|
description: "Record a new OMO adapter session for the current project.",
|
|
@@ -48,13 +53,21 @@ export async function runMcpServer() {
|
|
|
48
53
|
}, async ({ host, adapter }) => jsonResult(startSession({ host, adapter })));
|
|
49
54
|
server.registerTool("memory_bootstrap_session", {
|
|
50
55
|
title: "Bootstrap OMO Session",
|
|
51
|
-
description: "Start a host adapter session
|
|
56
|
+
description: "Start a host adapter session without reading or injecting recent memory.",
|
|
52
57
|
inputSchema: {
|
|
53
58
|
host: z.enum(["codex", "opencode", "grok", "unknown"]),
|
|
54
59
|
adapter: z.string().min(1),
|
|
55
60
|
limit: z.number().int().positive().max(100).default(5),
|
|
56
61
|
},
|
|
57
62
|
}, async ({ host, adapter, limit }) => jsonResult(bootstrapSession({ host, adapter, limit })));
|
|
63
|
+
server.registerTool("memory_recall_events", {
|
|
64
|
+
title: "Recall OMO Memory Events",
|
|
65
|
+
description: "Return recorded events only when the query text matches stored intent or content.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
query: z.string().min(1),
|
|
68
|
+
limit: z.number().int().positive().max(100).default(10),
|
|
69
|
+
},
|
|
70
|
+
}, async ({ query, limit }) => jsonResult({ events: recallEvents({ query, limit }) }));
|
|
58
71
|
server.registerTool("memory_record_event", {
|
|
59
72
|
title: "Record OMO Memory Event",
|
|
60
73
|
description: "Append a summarized event to the current project's OMO memory ledger.",
|
|
@@ -82,6 +95,16 @@ export async function runMcpServer() {
|
|
|
82
95
|
}, async ({ summaryMd, sessionId }) => jsonResult(writeHandoff(summaryMd, sessionId)));
|
|
83
96
|
await server.connect(new StdioServerTransport());
|
|
84
97
|
}
|
|
98
|
+
function readPackageVersion() {
|
|
99
|
+
const rawPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
100
|
+
if (!isObject(rawPackage))
|
|
101
|
+
return "0.0.0";
|
|
102
|
+
const version = rawPackage["version"];
|
|
103
|
+
return typeof version === "string" && version.length > 0 ? version : "0.0.0";
|
|
104
|
+
}
|
|
105
|
+
function isObject(value) {
|
|
106
|
+
return value !== null && typeof value === "object";
|
|
107
|
+
}
|
|
85
108
|
function jsonResult(value) {
|
|
86
109
|
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
87
110
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { applyConceptExtraction, extractConceptCandidates } from "./conceptExtraction.js";
|
|
3
|
+
import { listGlobalMemory, migrateToGlobalMemory, scanForMemoryDbs } from "./globalMemory.js";
|
|
4
|
+
import { memoryPaths } from "./memoryReport.js";
|
|
5
|
+
import { createDurableMemory, listOntologyRows, supersedeDurableMemory, updateDurableRetention } from "./ontologyCore.js";
|
|
6
|
+
import { resolveProjectContext } from "./projectContext.js";
|
|
7
|
+
import { recomputeRetentionScores } from "./retentionRecompute.js";
|
|
8
|
+
const retentionClassSchema = z.enum(["forget", "temporary", "working", "durable", "permanent"]);
|
|
9
|
+
const nonBlankStringSchema = z.string().trim().min(1);
|
|
10
|
+
export function registerGlobalOntologyTools(server) {
|
|
11
|
+
server.registerTool("memory_global_scan", {
|
|
12
|
+
title: "Scan Global OMO Memory Sources",
|
|
13
|
+
description: "Explicitly scan a filesystem root for local OMO memory SQLite databases without importing them.",
|
|
14
|
+
inputSchema: { rootPath: nonBlankStringSchema },
|
|
15
|
+
}, async ({ rootPath }) => jsonResult(scanForMemoryDbs(rootPath)));
|
|
16
|
+
server.registerTool("memory_global_migrate", {
|
|
17
|
+
title: "Migrate OMO Memory To Global SQLite",
|
|
18
|
+
description: "Explicitly create or update a global OMO memory SQLite database from discovered local memory databases.",
|
|
19
|
+
inputSchema: { rootPath: nonBlankStringSchema, globalDbPath: nonBlankStringSchema },
|
|
20
|
+
}, async ({ rootPath, globalDbPath }) => jsonResult(migrateToGlobalMemory({ rootPath, globalDbPath })));
|
|
21
|
+
server.registerTool("memory_global_list", {
|
|
22
|
+
title: "List Global OMO Memory",
|
|
23
|
+
description: "List sources and counts from an explicit global OMO memory SQLite database.",
|
|
24
|
+
inputSchema: { globalDbPath: nonBlankStringSchema },
|
|
25
|
+
}, async ({ globalDbPath }) => jsonResult(listGlobalMemory(globalDbPath)));
|
|
26
|
+
registerOntologyTools(server);
|
|
27
|
+
}
|
|
28
|
+
function registerOntologyTools(server) {
|
|
29
|
+
server.registerTool("memory_ontology_candidates", {
|
|
30
|
+
title: "Extract OMO Ontology Candidates",
|
|
31
|
+
description: "Return deterministic ontology candidate labels from a short event summary without writing rows.",
|
|
32
|
+
inputSchema: { summary: nonBlankStringSchema, eventType: nonBlankStringSchema.optional() },
|
|
33
|
+
}, async ({ summary, eventType }) => jsonResult({ candidates: extractConceptCandidates(summary, eventType) }));
|
|
34
|
+
server.registerTool("memory_ontology_extract", {
|
|
35
|
+
title: "Write OMO Ontology Extraction",
|
|
36
|
+
description: "Explicitly extract concepts from a short event summary and write event-to-concept references.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
sourceEventId: nonBlankStringSchema,
|
|
39
|
+
summary: nonBlankStringSchema,
|
|
40
|
+
eventType: nonBlankStringSchema.optional(),
|
|
41
|
+
},
|
|
42
|
+
}, async ({ sourceEventId, summary, eventType }) => jsonResult(applyConceptExtraction(memoryPaths().dbPath, resolveProjectContext(), sourceEventId, summary, eventType)));
|
|
43
|
+
server.registerTool("memory_ontology_score", {
|
|
44
|
+
title: "Recompute OMO Ontology Scores",
|
|
45
|
+
description: "Explicitly recompute ontology retention scores for the local OMO memory SQLite database.",
|
|
46
|
+
inputSchema: { nowIso: z.string().datetime().optional() },
|
|
47
|
+
}, async ({ nowIso }) => jsonResult(recomputeRetentionScores({ dbPath: memoryPaths().dbPath, nowIso: nowIso ?? new Date().toISOString() })));
|
|
48
|
+
server.registerTool("memory_ontology_promote", {
|
|
49
|
+
title: "Promote OMO Durable Memory",
|
|
50
|
+
description: "Explicitly promote a summarized memory into durable ontology storage.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: nonBlankStringSchema,
|
|
53
|
+
summary: nonBlankStringSchema,
|
|
54
|
+
body: nonBlankStringSchema.optional(),
|
|
55
|
+
sourceEventId: nonBlankStringSchema.optional(),
|
|
56
|
+
sourceHandoffId: nonBlankStringSchema.optional(),
|
|
57
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
58
|
+
status: nonBlankStringSchema.optional(),
|
|
59
|
+
retentionClass: retentionClassSchema.optional(),
|
|
60
|
+
},
|
|
61
|
+
}, async ({ type, summary, body, sourceEventId, sourceHandoffId, confidence, status, retentionClass }) => jsonResult(createDurableMemory(memoryPaths().dbPath, resolveProjectContext(), {
|
|
62
|
+
type,
|
|
63
|
+
summary,
|
|
64
|
+
...(body === undefined ? {} : { body }),
|
|
65
|
+
...(sourceEventId === undefined ? {} : { sourceEventId }),
|
|
66
|
+
...(sourceHandoffId === undefined ? {} : { sourceHandoffId }),
|
|
67
|
+
...(confidence === undefined ? {} : { confidence }),
|
|
68
|
+
...(status === undefined ? {} : { status }),
|
|
69
|
+
...(retentionClass === undefined ? {} : { retentionClass }),
|
|
70
|
+
})));
|
|
71
|
+
server.registerTool("memory_ontology_demote", {
|
|
72
|
+
title: "Demote OMO Durable Memory",
|
|
73
|
+
description: "Explicitly change the retention class for a durable ontology memory.",
|
|
74
|
+
inputSchema: { durableId: nonBlankStringSchema, retentionClass: retentionClassSchema.default("temporary") },
|
|
75
|
+
}, async ({ durableId, retentionClass }) => jsonResult(updateDurableRetention(memoryPaths().dbPath, resolveProjectContext(), durableId, { retentionClass })));
|
|
76
|
+
server.registerTool("memory_ontology_supersede", {
|
|
77
|
+
title: "Supersede OMO Durable Memory",
|
|
78
|
+
description: "Explicitly mark a durable memory superseded and create a successor memory.",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
durableId: nonBlankStringSchema,
|
|
81
|
+
reason: nonBlankStringSchema.optional(),
|
|
82
|
+
newSummary: nonBlankStringSchema.optional(),
|
|
83
|
+
},
|
|
84
|
+
}, async ({ durableId, reason, newSummary }) => jsonResult(supersedeDurableMemory(memoryPaths().dbPath, resolveProjectContext(), durableId, {
|
|
85
|
+
...(reason === undefined ? {} : { reason }),
|
|
86
|
+
...(newSummary === undefined ? {} : { newSummary }),
|
|
87
|
+
})));
|
|
88
|
+
server.registerTool("memory_ontology_recall", {
|
|
89
|
+
title: "Recall OMO Ontology Rows",
|
|
90
|
+
description: "Explicitly recall ontology concepts and durable memories matching a query.",
|
|
91
|
+
inputSchema: { query: nonBlankStringSchema, limit: z.number().int().positive().max(100).default(10) },
|
|
92
|
+
}, async ({ query, limit }) => jsonResult(recallOntology(query, limit)));
|
|
93
|
+
}
|
|
94
|
+
function jsonResult(value) {
|
|
95
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
96
|
+
}
|
|
97
|
+
function recallOntology(query, limit) {
|
|
98
|
+
const rows = listOntologyRows(memoryPaths().dbPath, resolveProjectContext());
|
|
99
|
+
const normalizedQuery = query.toLowerCase();
|
|
100
|
+
const queryTerms = normalizedQuery.split(/[^a-z0-9_-]+/).filter((term) => term.length >= 3);
|
|
101
|
+
return {
|
|
102
|
+
concepts: rows.concepts.filter((concept) => matchesConcept(concept, normalizedQuery, queryTerms)).slice(0, limit),
|
|
103
|
+
durableMemories: rows.durableMemories.filter((memory) => matchesDurableMemory(memory, normalizedQuery, queryTerms)).slice(0, limit),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function matchesConcept(concept, normalizedQuery, queryTerms) {
|
|
107
|
+
return matchesText([concept.label, concept.description ?? "", concept.aliasesJson], normalizedQuery, queryTerms);
|
|
108
|
+
}
|
|
109
|
+
function matchesDurableMemory(memory, normalizedQuery, queryTerms) {
|
|
110
|
+
return matchesText([memory.type, memory.summary, memory.body ?? ""], normalizedQuery, queryTerms);
|
|
111
|
+
}
|
|
112
|
+
function matchesText(values, normalizedQuery, queryTerms) {
|
|
113
|
+
const normalizedValues = values.map((value) => value.toLowerCase());
|
|
114
|
+
if (normalizedValues.some((value) => value.includes(normalizedQuery)))
|
|
115
|
+
return true;
|
|
116
|
+
return queryTerms.length > 0 && queryTerms.every((term) => normalizedValues.some((value) => value.includes(term)));
|
|
117
|
+
}
|