omo-memory 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ import { mkdirSync, readdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { importCandidates, listGlobalMemoryFromDb } from "./globalMemoryImport.js";
5
+ import { migrate } from "./memoryDb.js";
6
+ import { resolveProjectContext } from "./projectContext.js";
7
+ const STATE_DB_SUFFIX = join(".omo", "memory", "state.sqlite");
8
+ const REQUIRED_TABLES = ["schema_meta", "projects", "events"];
9
+ export function initGlobalMemory(globalDbPath) {
10
+ mkdirSync(dirname(globalDbPath), { recursive: true });
11
+ const db = new Database(globalDbPath);
12
+ try {
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS sources (
15
+ id TEXT PRIMARY KEY,
16
+ db_path TEXT UNIQUE NOT NULL,
17
+ schema_version INTEGER NOT NULL,
18
+ imported_at TEXT NOT NULL,
19
+ last_seen_at TEXT NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS global_projects (
22
+ id TEXT PRIMARY KEY,
23
+ source_id TEXT NOT NULL,
24
+ source_project_id TEXT NOT NULL,
25
+ repo_root TEXT NOT NULL,
26
+ git_remote TEXT NOT NULL,
27
+ created_at TEXT NOT NULL,
28
+ last_seen_at TEXT NOT NULL,
29
+ UNIQUE(source_id, source_project_id)
30
+ );
31
+ CREATE TABLE IF NOT EXISTS global_sessions (
32
+ id TEXT PRIMARY KEY,
33
+ source_id TEXT NOT NULL,
34
+ source_session_id TEXT NOT NULL,
35
+ source_project_id TEXT NOT NULL,
36
+ host TEXT NOT NULL,
37
+ adapter TEXT NOT NULL,
38
+ started_at TEXT NOT NULL,
39
+ ended_at TEXT,
40
+ git_branch TEXT,
41
+ git_head TEXT,
42
+ UNIQUE(source_id, source_session_id)
43
+ );
44
+ CREATE TABLE IF NOT EXISTS global_events (
45
+ id TEXT PRIMARY KEY,
46
+ source_id TEXT NOT NULL,
47
+ source_event_id TEXT NOT NULL,
48
+ source_session_id TEXT NOT NULL,
49
+ source_project_id TEXT NOT NULL,
50
+ type TEXT NOT NULL,
51
+ summary TEXT NOT NULL,
52
+ payload_json TEXT,
53
+ created_at TEXT NOT NULL,
54
+ UNIQUE(source_id, source_event_id)
55
+ );
56
+ CREATE TABLE IF NOT EXISTS global_handoffs (
57
+ id TEXT PRIMARY KEY,
58
+ source_id TEXT NOT NULL,
59
+ source_handoff_id TEXT NOT NULL,
60
+ source_project_id TEXT NOT NULL,
61
+ source_session_id TEXT,
62
+ summary_md TEXT NOT NULL,
63
+ created_at TEXT NOT NULL,
64
+ UNIQUE(source_id, source_handoff_id)
65
+ );
66
+ `);
67
+ migrate(db);
68
+ return { dbPath: globalDbPath };
69
+ }
70
+ finally {
71
+ db.close();
72
+ }
73
+ }
74
+ export function scanForMemoryDbs(rootPath) {
75
+ const candidates = [];
76
+ const skipped = [];
77
+ for (const dbPath of findStateDbs(rootPath)) {
78
+ const scan = scanSourceDb(dbPath);
79
+ if (scan.kind === "candidate")
80
+ candidates.push(scan.candidate);
81
+ else
82
+ skipped.push({ dbPath, reason: scan.reason });
83
+ }
84
+ return { candidates, skipped };
85
+ }
86
+ export function migrateToGlobalMemory(input) {
87
+ const scan = scanForMemoryDbs(input.rootPath);
88
+ initGlobalMemory(input.globalDbPath);
89
+ return importCandidates(input.globalDbPath, scan, resolveProjectContext());
90
+ }
91
+ export function listGlobalMemory(globalDbPath) {
92
+ return listGlobalMemoryFromDb(globalDbPath);
93
+ }
94
+ function findStateDbs(rootPath) {
95
+ const dbPaths = [];
96
+ const visit = (path) => {
97
+ let entries;
98
+ try {
99
+ entries = readdirSync(path, { withFileTypes: true });
100
+ }
101
+ catch (error) {
102
+ if (error instanceof Error)
103
+ return;
104
+ throw error;
105
+ }
106
+ for (const entry of entries) {
107
+ const entryPath = join(path, entry.name);
108
+ if (entry.isDirectory())
109
+ visit(entryPath);
110
+ else if (entry.isFile() && entryPath.endsWith(STATE_DB_SUFFIX))
111
+ dbPaths.push(entryPath);
112
+ }
113
+ };
114
+ visit(rootPath);
115
+ return dbPaths.sort();
116
+ }
117
+ function scanSourceDb(dbPath) {
118
+ let db;
119
+ try {
120
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
121
+ }
122
+ catch (error) {
123
+ if (error instanceof Error)
124
+ return { kind: "skipped", reason: error.message };
125
+ throw error;
126
+ }
127
+ try {
128
+ for (const tableName of REQUIRED_TABLES) {
129
+ if (!tableExists(db, tableName))
130
+ return { kind: "skipped", reason: `missing table ${tableName}` };
131
+ }
132
+ return {
133
+ kind: "candidate",
134
+ candidate: { dbPath, schemaVersion: readSchemaVersion(db), projectCount: readCount(db, "projects"), eventCount: readCount(db, "events") },
135
+ };
136
+ }
137
+ catch (error) {
138
+ if (error instanceof Error)
139
+ return { kind: "skipped", reason: error.message };
140
+ throw error;
141
+ }
142
+ finally {
143
+ db.close();
144
+ }
145
+ }
146
+ function tableExists(db, tableName) {
147
+ const row = db.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName);
148
+ return row !== undefined && row.count === 1;
149
+ }
150
+ function readSchemaVersion(db) {
151
+ const row = db
152
+ .prepare("SELECT value FROM schema_meta WHERE key IN ('schema_version', 'version') ORDER BY CASE key WHEN 'schema_version' THEN 0 ELSE 1 END LIMIT 1")
153
+ .get();
154
+ if (row === undefined)
155
+ return 0;
156
+ const parsed = Number.parseInt(row.value, 10);
157
+ return Number.isFinite(parsed) ? parsed : 0;
158
+ }
159
+ function readCount(db, tableName) {
160
+ const row = db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get();
161
+ return row?.count ?? 0;
162
+ }
@@ -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
+ }
@@ -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
+ }