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
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
|
|
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
|
|
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`
|
|
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
|
|
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 {
|
|
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,
|
|
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 === "
|
|
35
|
-
return
|
|
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
|
|
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.
|
|
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
|
+
}
|