omo-memory 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,8 +21,14 @@ It gives lazycodex, omo-on-opencode, lfg, and future OMO adapters a shared local
21
21
  npm install
22
22
  npm run build
23
23
  node dist/cli.js init
24
+ node dist/cli.js global scan --root ..
25
+ node dist/cli.js global migrate --root .. --global-db ~/.omo/memory/global.sqlite
24
26
  node dist/cli.js session start --host grok --adapter lfg
25
27
  node dist/cli.js event record --type decision --summary "Chose SQLite + MCP + CLI for OMO shared memory"
28
+ node dist/cli.js ontology candidates
29
+ node dist/cli.js ontology score
30
+ node dist/cli.js ontology recall --query "sqlite retention"
31
+ node dist/cli.js graph tui
26
32
  node dist/cli.js recent
27
33
  node dist/cli.js mcp
28
34
  ```
@@ -33,8 +39,13 @@ After the package is published to npm, use the same package for CLI and MCP:
33
39
 
34
40
  ```sh
35
41
  npx -y omo-memory init
42
+ npx -y omo-memory global scan --root .
43
+ npx -y omo-memory global migrate --root . --global-db ~/.omo/memory/global.sqlite
36
44
  npx -y omo-memory session bootstrap --host codex --adapter lazycodex --limit 5
45
+ npx -y omo-memory ontology recall --query "why did we choose sqlite" --limit 5
46
+ npx -y omo-memory graph tui
37
47
  npx -y omo-memory recent --limit 5
48
+ npx -y omo-memory recall --query "why did we choose sqlite" --limit 5
38
49
  npx -y omo-memory mcp
39
50
  ```
40
51
 
@@ -67,7 +78,7 @@ Both hosts use the current project ledger at `<project-root>/.omo/memory/state.s
67
78
 
68
79
  ## Session bootstrap
69
80
 
70
- Adapters should call the bootstrap tool at the beginning of each host session:
81
+ Adapters may call the bootstrap tool when they need a session id for later writes:
71
82
 
72
83
  ```json
73
84
  {
@@ -80,7 +91,7 @@ Adapters should call the bootstrap tool at the beginning of each host session:
80
91
  }
81
92
  ```
82
93
 
83
- The response contains a new `sessionId` plus `recentEvents` for the current project. Reuse that `sessionId` when recording follow-up events:
94
+ The response contains a new `sessionId` and project metadata only. It deliberately does not return recent memory, because starting a session should not inject the last session into every user prompt. Reuse that `sessionId` when recording follow-up events:
84
95
 
85
96
  ```json
86
97
  {
@@ -93,7 +104,16 @@ The response contains a new `sessionId` plus `recentEvents` for the current proj
93
104
  }
94
105
  ```
95
106
 
96
- This is local routing, not transcript scraping. OMO Memory does not automatically read full Codex or Grok transcripts; the host or adapter must call these MCP tools.
107
+ This is local routing, not transcript scraping. OMO Memory does not automatically read full Codex or Grok transcripts. Hooks should record concise user actions, decisions, QA evidence, and handoffs; they should retrieve memory only when the user explicitly asks for OMO Memory or when the current user input can be matched to recorded intent.
108
+
109
+ Use explicit retrieval for memory reads:
110
+
111
+ ```sh
112
+ omo-memory recent --limit 5
113
+ omo-memory recall --query "schema migration decision" --limit 5
114
+ ```
115
+
116
+ For MCP, use `memory_recent_events` for explicit recent-history requests and `memory_recall_events` for query-gated recall.
97
117
 
98
118
  ## MCP tools
99
119
 
@@ -105,9 +125,69 @@ Initial stdio MCP tools:
105
125
  - `memory_bootstrap_session`
106
126
  - `memory_record_event`
107
127
  - `memory_recent_events`
128
+ - `memory_recall_events`
108
129
  - `memory_write_handoff`
109
130
  - `memory_export`
110
131
  - `memory_purge`
132
+ - `memory_global_scan`
133
+ - `memory_global_migrate`
134
+ - `memory_global_list`
135
+ - `memory_ontology_candidates`
136
+ - `memory_ontology_extract`
137
+ - `memory_ontology_score`
138
+ - `memory_ontology_promote`
139
+ - `memory_ontology_demote`
140
+ - `memory_ontology_supersede`
141
+ - `memory_ontology_recall`
142
+
143
+ ## Second-brain layer
144
+
145
+ The base ledger remains project-local and chronological: sessions, events, handoffs, and explicit recall. The second-brain layer adds deterministic ontology tables and lifecycle commands:
146
+
147
+ - Global migration copies existing local `.omo/memory/state.sqlite` databases into one global SQLite store with source provenance and an aggregate OMO schema view. It does not delete or rewrite local project ledgers.
148
+ - Concept extraction turns concise event summaries into vocabulary candidates and reference counts.
149
+ - Retention scoring classifies memory as `forget`, `temporary`, `working`, `durable`, or `permanent`; manual pins force `permanent`.
150
+ - Durable memories can be promoted, demoted, superseded, and recalled through CLI or MCP.
151
+ - `omo-memory graph tui` opens an OpenTUI ontology graph viewer for concepts, relations, retention class, and detail panes. This command needs `bun` on `PATH` because OpenTUI's terminal renderer uses Bun native FFI; the rest of the CLI runs on Node.
152
+
153
+ Retention classes:
154
+
155
+ - `forget`: low-value or stale one-off context that can be dropped.
156
+ - `temporary`: short-term context useful during a narrow task.
157
+ - `working`: active project memory worth keeping across the current iteration.
158
+ - `durable`: cross-session knowledge that should survive normal decay.
159
+ - `permanent`: manually pinned or high-score knowledge; only explicit demote, supersede, or purge should change it.
160
+
161
+ Ontology lifecycle commands:
162
+
163
+ ```sh
164
+ omo-memory ontology candidates
165
+ omo-memory ontology score
166
+ omo-memory ontology promote --concept linaforge --summary "Linaforge is an active game-engine project"
167
+ omo-memory ontology recall --query "linaforge"
168
+ omo-memory ontology demote --id <durable-id>
169
+ omo-memory ontology supersede --id <durable-id> --summary "Updated durable memory"
170
+ ```
171
+
172
+ Global second-brain flow:
173
+
174
+ ```sh
175
+ omo-memory global scan --root /Users/ilseoblee/workspace
176
+ omo-memory global migrate --root /Users/ilseoblee/workspace --global-db ~/.omo/memory/global.sqlite
177
+ OMO_MEMORY_DB=~/.omo/memory/global.sqlite omo-memory ontology candidates
178
+ OMO_MEMORY_DB=~/.omo/memory/global.sqlite omo-memory ontology score
179
+ bun --version
180
+ omo-memory graph tui --db ~/.omo/memory/global.sqlite --query linaforge
181
+ ```
182
+
183
+ OpenTUI graph controls:
184
+
185
+ - `q`: quit.
186
+ - `Up` / `Down`: move selected concept.
187
+ - `Tab`: move to the next concept.
188
+ - `/` or `f`: focus filter input when supported by the terminal runtime.
189
+
190
+ The graph is terminal-native. It does not require a browser, web server, cloud service, or embeddings, but it does require Bun for the OpenTUI renderer.
111
191
 
112
192
  ## Non-goals for MVP
113
193
 
package/dist/cli.js CHANGED
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
+ import { applyConceptExtraction } from "./conceptExtraction.js";
4
+ import { migrateToGlobalMemory, scanForMemoryDbs } from "./globalMemory.js";
5
+ import { runGraphTui } from "./graphTui.js";
3
6
  import { runMcpServer } from "./mcp.js";
4
- import { bootstrapSession, doctorReport, exportMemory, purgeMemory, recentEvents, recordEvent, startSession, writeHandoff } from "./memory.js";
7
+ import { bootstrapSession, exportMemory, purgeMemory, recentEvents, recordEvent, startSession, writeHandoff } from "./memory.js";
5
8
  import { initMemory } from "./memoryDb.js";
9
+ import { recallEvents } from "./memoryRecall.js";
10
+ import { doctorReport } from "./memoryReport.js";
11
+ import { createDurableMemory, listOntologyRows, recordMemoryReference, supersedeDurableMemory, updateDurableRetention } from "./ontologyCore.js";
12
+ import { defaultDbPath } from "./projectContext.js";
13
+ import { recomputeRetentionScores } from "./retentionRecompute.js";
6
14
  async function main(argv) {
7
15
  const [command, subcommand, ...rest] = argv;
8
16
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
@@ -13,6 +21,11 @@ async function main(argv) {
13
21
  await runMcpServer();
14
22
  return;
15
23
  }
24
+ if (command === "graph" && subcommand === "tui") {
25
+ const query = readFlag(rest, "--query");
26
+ await runGraphTui({ dbPath: readFlag(rest, "--db") ?? defaultDbPath(), ...(query === undefined ? {} : { query }) });
27
+ return;
28
+ }
16
29
  const result = runCommand(command, subcommand, rest);
17
30
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
18
31
  }
@@ -30,6 +43,12 @@ function runCommand(command, subcommand, rest) {
30
43
  const args = [subcommand, ...rest].filter((value) => value !== undefined);
31
44
  return { ok: true, ...purgeMemory({ yes: args.includes("--yes") }) };
32
45
  }
46
+ if (command === "global") {
47
+ return runGlobalCommand(subcommand, rest);
48
+ }
49
+ if (command === "ontology") {
50
+ return runOntologyCommand(subcommand, rest);
51
+ }
33
52
  if (command === "session" && subcommand === "start") {
34
53
  const host = parseHost(readFlag(rest, "--host") ?? "unknown");
35
54
  const adapter = readFlag(rest, "--adapter") ?? "unknown";
@@ -54,6 +73,11 @@ function runCommand(command, subcommand, rest) {
54
73
  const limitRaw = readFlag([subcommand, ...rest].filter((value) => value !== undefined), "--limit");
55
74
  return { ok: true, events: recentEvents(parsePositiveInt(limitRaw, "recent --limit")) };
56
75
  }
76
+ if (command === "recall") {
77
+ const args = [subcommand, ...rest].filter((value) => value !== undefined);
78
+ const query = readFlag(args, "--query") ?? fail("recall requires --query");
79
+ return { ok: true, events: recallEvents({ query, limit: readPositiveIntFlag(args, "--limit", 10) }) };
80
+ }
57
81
  if (command === "handoff" && subcommand === "write") {
58
82
  const summary = readFlag(rest, "--summary");
59
83
  const summaryFile = readFlag(rest, "--summary-file");
@@ -63,6 +87,92 @@ function runCommand(command, subcommand, rest) {
63
87
  }
64
88
  fail(`unknown command: ${[command, subcommand].filter(Boolean).join(" ")}`);
65
89
  }
90
+ function runGlobalCommand(subcommand, rest) {
91
+ if (subcommand === "scan") {
92
+ const rootPath = readFlag(rest, "--root") ?? fail("global scan requires --root");
93
+ return { ok: true, ...scanForMemoryDbs(rootPath) };
94
+ }
95
+ if (subcommand === "migrate") {
96
+ const rootPath = readFlag(rest, "--root") ?? fail("global migrate requires --root");
97
+ const globalDbPath = readFlag(rest, "--global-db") ?? fail("global migrate requires --global-db");
98
+ return { ok: true, ...migrateToGlobalMemory({ rootPath, globalDbPath }) };
99
+ }
100
+ fail(`unknown command: global ${subcommand ?? ""}`.trim());
101
+ }
102
+ function runOntologyCommand(subcommand, rest) {
103
+ const dbPath = defaultDbPath();
104
+ if (subcommand === "candidates")
105
+ return ontologyCandidates(dbPath);
106
+ if (subcommand === "score" || subcommand === "recompute") {
107
+ return { ok: true, ...recomputeRetentionScores({ dbPath, nowIso: new Date().toISOString() }) };
108
+ }
109
+ if (subcommand === "promote")
110
+ return ontologyPromote(dbPath, rest);
111
+ if (subcommand === "demote") {
112
+ const id = readFlag(rest, "--id") ?? fail("ontology demote requires --id");
113
+ return { ok: true, durableMemory: updateDurableRetention(dbPath, exportMemory(dbPath).project, id, { retentionClass: "temporary" }) };
114
+ }
115
+ if (subcommand === "supersede") {
116
+ const id = readFlag(rest, "--id") ?? fail("ontology supersede requires --id");
117
+ const newSummary = readFlag(rest, "--summary");
118
+ return { ok: true, ...supersedeDurableMemory(dbPath, exportMemory(dbPath).project, id, newSummary === undefined ? {} : { newSummary }) };
119
+ }
120
+ if (subcommand === "recall") {
121
+ const query = readFlag(rest, "--query") ?? fail("ontology recall requires --query");
122
+ const limit = readPositiveIntFlag(rest, "--limit", 10);
123
+ return { ok: true, durableMemories: recallDurableMemories(dbPath, query, limit) };
124
+ }
125
+ fail(`unknown command: ontology ${subcommand ?? ""}`.trim());
126
+ }
127
+ function ontologyCandidates(dbPath) {
128
+ const memory = exportMemory(dbPath);
129
+ let references = 0;
130
+ for (const event of memory.events) {
131
+ references += applyConceptExtraction(dbPath, memory.project, event.id, event.summary, event.type).references.length;
132
+ }
133
+ return { ok: true, concepts: listOntologyRows(dbPath, memory.project).concepts, references };
134
+ }
135
+ function ontologyPromote(dbPath, rest) {
136
+ const memory = exportMemory(dbPath);
137
+ const conceptSelector = readFlag(rest, "--concept") ?? readFlag(rest, "--concept-id") ?? fail("ontology promote requires --concept");
138
+ const concept = findConcept(memory.concepts, conceptSelector) ?? fail(`ontology promote candidate not found: ${conceptSelector}`);
139
+ const summary = readFlag(rest, "--summary") ?? `Durable memory: ${concept.label}`;
140
+ const body = readFlag(rest, "--body") ?? concept.description ?? concept.label;
141
+ const durableMemory = createDurableMemory(dbPath, memory.project, {
142
+ type: "concept",
143
+ summary,
144
+ body,
145
+ confidence: Math.min(1, Math.max(0, concept.score / 100)),
146
+ status: "active",
147
+ retentionClass: "durable",
148
+ });
149
+ const reference = recordMemoryReference(dbPath, memory.project, {
150
+ sourceType: "concept",
151
+ sourceId: concept.id,
152
+ targetType: "durable_memory",
153
+ targetId: durableMemory.id,
154
+ refKind: "promotes",
155
+ weight: 1,
156
+ });
157
+ return { ok: true, concept, durableMemory, reference };
158
+ }
159
+ function findConcept(concepts, selector) {
160
+ const normalized = selector.trim().toLowerCase();
161
+ return concepts.find((concept) => concept.id === selector || concept.label.toLowerCase() === normalized);
162
+ }
163
+ function recallDurableMemories(dbPath, query, limit) {
164
+ const memory = exportMemory(dbPath);
165
+ const terms = query.toLowerCase().match(/[\p{L}\p{N}_-]{3,}/gu) ?? [];
166
+ if (terms.length === 0)
167
+ return [];
168
+ return listOntologyRows(dbPath, memory.project)
169
+ .durableMemories.filter((durable) => durable.status === "active")
170
+ .filter((durable) => {
171
+ const haystack = `${durable.summary} ${durable.body ?? ""}`.toLowerCase();
172
+ return terms.some((term) => haystack.includes(term));
173
+ })
174
+ .slice(0, limit);
175
+ }
66
176
  function readFlag(args, name) {
67
177
  const index = args.indexOf(name);
68
178
  if (index === -1)
@@ -90,10 +200,10 @@ function fail(message) {
90
200
  throw new Error(message);
91
201
  }
92
202
  function printHelp() {
93
- process.stdout.write(`OMO Memory\n\nCommands:\n omo-memory init\n omo-memory doctor\n omo-memory export\n omo-memory purge --yes\n omo-memory session start --host <codex|opencode|grok|unknown> --adapter <name>\n omo-memory session bootstrap --host <codex|opencode|grok|unknown> --adapter <name> [--limit <n>]\n omo-memory event record --type <type> --summary <text> [--session-id <id>]\n omo-memory recent [--limit <n>]\n omo-memory handoff write (--summary <text> | --summary-file <path>) [--session-id <id>]\n omo-memory mcp\n`);
203
+ process.stdout.write(`OMO Memory\n\nCommands:\n omo-memory init\n omo-memory doctor\n omo-memory export\n omo-memory purge --yes\n omo-memory global scan --root <path> [--json]\n omo-memory global migrate --root <path> --global-db <path> [--json]\n omo-memory ontology candidates\n omo-memory ontology score\n omo-memory ontology promote --concept <label|id> [--summary <text>] [--body <text>]\n omo-memory ontology demote --id <durable-id>\n omo-memory ontology supersede --id <durable-id> [--summary <text>]\n omo-memory ontology recall --query <text> [--limit <n>]\n omo-memory session start --host <codex|opencode|grok|unknown> --adapter <name>\n omo-memory session bootstrap --host <codex|opencode|grok|unknown> --adapter <name> [--limit <n>]\n omo-memory event record --type <type> --summary <text> [--session-id <id>]\n omo-memory recent [--limit <n>]\n omo-memory recall --query <text> [--limit <n>]\n omo-memory handoff write (--summary <text> | --summary-file <path>) [--session-id <id>]\n omo-memory graph tui [--db <path>] [--query <text>] (requires Bun on PATH)\n omo-memory mcp\n`);
94
204
  }
95
205
  main(process.argv.slice(2)).catch((error) => {
96
206
  const message = error instanceof Error ? error.message : String(error);
97
- process.stderr.write(`${message}\n`);
207
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
98
208
  process.exitCode = 1;
99
209
  });
@@ -0,0 +1,188 @@
1
+ import { recordMemoryReference, upsertConcept } from "./ontologyCore.js";
2
+ import { redactSecrets } from "./privacy.js";
3
+ // Comprehensive generic/hook stopwords. Keep domain terms out.
4
+ const STOPWORDS = new Set([
5
+ // from recall + hook words
6
+ "action",
7
+ "asked",
8
+ "current",
9
+ "help",
10
+ "memory",
11
+ "need",
12
+ "needs",
13
+ "omo",
14
+ "please",
15
+ "prompt",
16
+ "request",
17
+ "requested",
18
+ "session",
19
+ "user",
20
+ "want",
21
+ "wants",
22
+ "work",
23
+ "working",
24
+ // common English + dev noise
25
+ "the",
26
+ "and",
27
+ "for",
28
+ "with",
29
+ "from",
30
+ "into",
31
+ "about",
32
+ "this",
33
+ "that",
34
+ "these",
35
+ "those",
36
+ "start",
37
+ "now",
38
+ "test",
39
+ "run",
40
+ "check",
41
+ "fix",
42
+ "add",
43
+ "change",
44
+ "update",
45
+ "get",
46
+ "set",
47
+ "make",
48
+ "create",
49
+ "delete",
50
+ "remove",
51
+ "show",
52
+ "list",
53
+ "use",
54
+ "using",
55
+ "via",
56
+ "task",
57
+ "todo",
58
+ "step",
59
+ "item",
60
+ "thing",
61
+ "stuff",
62
+ "new",
63
+ "old",
64
+ "one",
65
+ "two",
66
+ "three",
67
+ "four",
68
+ "five",
69
+ "six",
70
+ "seven",
71
+ "eight",
72
+ "nine",
73
+ "ten",
74
+ "please",
75
+ "thanks",
76
+ "thank",
77
+ "also",
78
+ "just",
79
+ "only",
80
+ "like",
81
+ "such",
82
+ "very",
83
+ "really",
84
+ "should",
85
+ "could",
86
+ "would",
87
+ "will",
88
+ "can",
89
+ "may",
90
+ "must",
91
+ "have",
92
+ "has",
93
+ "had",
94
+ "been",
95
+ "being",
96
+ "are",
97
+ "was",
98
+ "were",
99
+ "is",
100
+ "be",
101
+ "to",
102
+ "of",
103
+ "in",
104
+ "on",
105
+ "at",
106
+ "by",
107
+ "as",
108
+ "it",
109
+ "its",
110
+ "if",
111
+ "or",
112
+ "and",
113
+ "but",
114
+ "not",
115
+ "no",
116
+ "yes",
117
+ "all",
118
+ "any",
119
+ "each",
120
+ "few",
121
+ "more",
122
+ "most",
123
+ "other",
124
+ "some",
125
+ "such",
126
+ "than",
127
+ "too",
128
+ "very",
129
+ ]);
130
+ function normalizeLabel(raw) {
131
+ return raw.trim().toLowerCase();
132
+ }
133
+ function tokenize(input) {
134
+ // Preserve hyphenated compounds (local-first), keep alnum _ -
135
+ const cleaned = input
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9_\-\s]/g, " ")
138
+ .replace(/\s+/g, " ")
139
+ .trim();
140
+ if (!cleaned)
141
+ return [];
142
+ return cleaned.split(" ").filter((t) => t.length >= 3);
143
+ }
144
+ export function extractConceptCandidates(summary, _eventType) {
145
+ if (!summary || typeof summary !== "string")
146
+ return [];
147
+ // Never operate on large blobs: caller must pass short summary only.
148
+ const redacted = redactSecrets(summary);
149
+ const tokens = tokenize(redacted);
150
+ const seen = new Set();
151
+ const out = [];
152
+ for (const t of tokens) {
153
+ if (STOPWORDS.has(t))
154
+ continue;
155
+ const norm = normalizeLabel(t);
156
+ if (norm.length < 3)
157
+ continue;
158
+ if (seen.has(norm))
159
+ continue;
160
+ seen.add(norm);
161
+ out.push(norm);
162
+ }
163
+ return out;
164
+ }
165
+ export function applyConceptExtraction(dbPath, project, sourceEventId, summary, eventType) {
166
+ const candidates = extractConceptCandidates(summary, eventType);
167
+ const concepts = [];
168
+ const references = [];
169
+ for (const label of candidates) {
170
+ // upsertConcept will be made idempotent + bump ref_count by label
171
+ const concept = upsertConcept(dbPath, project, {
172
+ kind: "term",
173
+ label,
174
+ // score/retention left to scorer (Todo 6); default working
175
+ });
176
+ concepts.push(concept);
177
+ const ref = recordMemoryReference(dbPath, project, {
178
+ sourceType: "event",
179
+ sourceId: sourceEventId,
180
+ targetType: "concept",
181
+ targetId: concept.id,
182
+ refKind: "mentions",
183
+ weight: 1,
184
+ });
185
+ references.push(ref);
186
+ }
187
+ return { concepts, references };
188
+ }
@@ -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
+ }