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 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,9 +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
36
- npx -y omo-memory hooks install --host all
42
+ npx -y omo-memory global scan --root .
43
+ npx -y omo-memory global migrate --root . --global-db ~/.omo/memory/global.sqlite
37
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
38
47
  npx -y omo-memory recent --limit 5
48
+ npx -y omo-memory recall --query "why did we choose sqlite" --limit 5
39
49
  npx -y omo-memory mcp
40
50
  ```
41
51
 
@@ -66,24 +76,9 @@ grok mcp add omo-memory -- npx -y omo-memory mcp
66
76
 
67
77
  Both hosts use the current project ledger at `<project-root>/.omo/memory/state.sqlite` by default. The `host` value is recorded when an adapter calls `memory_start_session`, not by installing separate servers.
68
78
 
69
- ## Passive setup
70
-
71
- Install the host plugin-style bundles with:
72
-
73
- ```sh
74
- npx -y omo-memory hooks install --host all
75
- ```
76
-
77
- This installs:
78
-
79
- - Codex: `~/.codex/skills/omo-memory/SKILL.md`, a global `~/.codex/AGENTS.md` lifecycle rule, and a local Codex plugin with a `SessionStart` hook.
80
- - Grok: `~/.grok/plugins/omo-memory` with `plugin.json`, skill, `SessionStart` hook, and `.mcp.json`, plus compatibility copies in `~/.grok/skills` and `~/.grok/hooks`.
81
-
82
- The installer is idempotent. For Codex it also runs `codex plugin add omo-memory@islee23520 --json` when installing into the real home directory.
83
-
84
79
  ## Session bootstrap
85
80
 
86
- 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:
87
82
 
88
83
  ```json
89
84
  {
@@ -96,7 +91,7 @@ Adapters should call the bootstrap tool at the beginning of each host session:
96
91
  }
97
92
  ```
98
93
 
99
- 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:
100
95
 
101
96
  ```json
102
97
  {
@@ -109,7 +104,16 @@ The response contains a new `sessionId` plus `recentEvents` for the current proj
109
104
  }
110
105
  ```
111
106
 
112
- 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.
113
117
 
114
118
  ## MCP tools
115
119
 
@@ -121,9 +125,69 @@ Initial stdio MCP tools:
121
125
  - `memory_bootstrap_session`
122
126
  - `memory_record_event`
123
127
  - `memory_recent_events`
128
+ - `memory_recall_events`
124
129
  - `memory_write_handoff`
125
130
  - `memory_export`
126
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.
127
191
 
128
192
  ## Non-goals for MVP
129
193
 
package/dist/cli.js CHANGED
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
- import { installHooks } from "./hooks.js";
3
+ import { applyConceptExtraction } from "./conceptExtraction.js";
4
+ import { migrateToGlobalMemory, scanForMemoryDbs } from "./globalMemory.js";
5
+ import { runGraphTui } from "./graphTui.js";
4
6
  import { runMcpServer } from "./mcp.js";
5
- import { bootstrapSession, doctorReport, exportMemory, purgeMemory, recentEvents, recordEvent, startSession, writeHandoff } from "./memory.js";
7
+ import { bootstrapSession, exportMemory, purgeMemory, recentEvents, recordEvent, startSession, writeHandoff } from "./memory.js";
6
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";
7
14
  async function main(argv) {
8
15
  const [command, subcommand, ...rest] = argv;
9
16
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
@@ -14,6 +21,11 @@ async function main(argv) {
14
21
  await runMcpServer();
15
22
  return;
16
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
+ }
17
29
  const result = runCommand(command, subcommand, rest);
18
30
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
19
31
  }
@@ -31,8 +43,11 @@ function runCommand(command, subcommand, rest) {
31
43
  const args = [subcommand, ...rest].filter((value) => value !== undefined);
32
44
  return { ok: true, ...purgeMemory({ yes: args.includes("--yes") }) };
33
45
  }
34
- if (command === "hooks" && subcommand === "install") {
35
- return { ok: true, ...installHooks({ host: parseHookInstallHost(readFlag(rest, "--host") ?? "all") }) };
46
+ if (command === "global") {
47
+ return runGlobalCommand(subcommand, rest);
48
+ }
49
+ if (command === "ontology") {
50
+ return runOntologyCommand(subcommand, rest);
36
51
  }
37
52
  if (command === "session" && subcommand === "start") {
38
53
  const host = parseHost(readFlag(rest, "--host") ?? "unknown");
@@ -58,6 +73,11 @@ function runCommand(command, subcommand, rest) {
58
73
  const limitRaw = readFlag([subcommand, ...rest].filter((value) => value !== undefined), "--limit");
59
74
  return { ok: true, events: recentEvents(parsePositiveInt(limitRaw, "recent --limit")) };
60
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
+ }
61
81
  if (command === "handoff" && subcommand === "write") {
62
82
  const summary = readFlag(rest, "--summary");
63
83
  const summaryFile = readFlag(rest, "--summary-file");
@@ -67,6 +87,92 @@ function runCommand(command, subcommand, rest) {
67
87
  }
68
88
  fail(`unknown command: ${[command, subcommand].filter(Boolean).join(" ")}`);
69
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
+ }
70
176
  function readFlag(args, name) {
71
177
  const index = args.indexOf(name);
72
178
  if (index === -1)
@@ -81,11 +187,6 @@ function parseHost(value) {
81
187
  return value;
82
188
  fail("--host must be one of codex, opencode, grok, unknown");
83
189
  }
84
- function parseHookInstallHost(value) {
85
- if (value === "codex" || value === "grok" || value === "all")
86
- return value;
87
- fail("--host must be one of codex, grok, all");
88
- }
89
190
  function readPositiveIntFlag(args, name, defaultValue) {
90
191
  return parsePositiveInt(readFlag(args, name) ?? String(defaultValue), name);
91
192
  }
@@ -99,10 +200,10 @@ function fail(message) {
99
200
  throw new Error(message);
100
201
  }
101
202
  function printHelp() {
102
- 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 hooks install --host <codex|grok|all>\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`);
103
204
  }
104
205
  main(process.argv.slice(2)).catch((error) => {
105
206
  const message = error instanceof Error ? error.message : String(error);
106
- process.stderr.write(`${message}\n`);
207
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
107
208
  process.exitCode = 1;
108
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
+ }