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.
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,14 @@ 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 update
43
+ npx -y omo-memory global scan --root .
44
+ npx -y omo-memory global migrate --root . --global-db ~/.omo/memory/global.sqlite
36
45
  npx -y omo-memory session bootstrap --host codex --adapter lazycodex --limit 5
46
+ npx -y omo-memory ontology recall --query "why did we choose sqlite" --limit 5
47
+ npx -y omo-memory graph tui
37
48
  npx -y omo-memory recent --limit 5
49
+ npx -y omo-memory recall --query "why did we choose sqlite" --limit 5
38
50
  npx -y omo-memory mcp
39
51
  ```
40
52
 
@@ -67,7 +79,7 @@ Both hosts use the current project ledger at `<project-root>/.omo/memory/state.s
67
79
 
68
80
  ## Session bootstrap
69
81
 
70
- Adapters should call the bootstrap tool at the beginning of each host session:
82
+ Adapters may call the bootstrap tool when they need a session id for later writes:
71
83
 
72
84
  ```json
73
85
  {
@@ -80,7 +92,7 @@ Adapters should call the bootstrap tool at the beginning of each host session:
80
92
  }
81
93
  ```
82
94
 
83
- The response contains a new `sessionId` plus `recentEvents` for the current project. Reuse that `sessionId` when recording follow-up events:
95
+ 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
96
 
85
97
  ```json
86
98
  {
@@ -93,7 +105,16 @@ The response contains a new `sessionId` plus `recentEvents` for the current proj
93
105
  }
94
106
  ```
95
107
 
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.
108
+ 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.
109
+
110
+ Use explicit retrieval for memory reads:
111
+
112
+ ```sh
113
+ omo-memory recent --limit 5
114
+ omo-memory recall --query "schema migration decision" --limit 5
115
+ ```
116
+
117
+ For MCP, use `memory_recent_events` for explicit recent-history requests and `memory_recall_events` for query-gated recall.
97
118
 
98
119
  ## MCP tools
99
120
 
@@ -105,9 +126,85 @@ Initial stdio MCP tools:
105
126
  - `memory_bootstrap_session`
106
127
  - `memory_record_event`
107
128
  - `memory_recent_events`
129
+ - `memory_recall_events`
108
130
  - `memory_write_handoff`
109
131
  - `memory_export`
110
132
  - `memory_purge`
133
+ - `memory_global_scan`
134
+ - `memory_global_migrate`
135
+ - `memory_global_list`
136
+ - `memory_ontology_candidates`
137
+ - `memory_ontology_extract`
138
+ - `memory_ontology_score`
139
+ - `memory_ontology_promote`
140
+ - `memory_ontology_demote`
141
+ - `memory_ontology_supersede`
142
+ - `memory_ontology_recall`
143
+
144
+ ## Updates
145
+
146
+ Installed CLI commands automatically launch a quiet background `npm install -g omo-memory@latest` at most once per day. MCP startup does not run the updater, so stdio handshakes stay clean.
147
+
148
+ Manual update:
149
+
150
+ ```sh
151
+ omo-memory update
152
+ ```
153
+
154
+ Disable automatic update for pinned environments:
155
+
156
+ ```sh
157
+ OMO_MEMORY_AUTO_UPDATE=0 omo-memory doctor
158
+ ```
159
+
160
+ ## Second-brain layer
161
+
162
+ 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:
163
+
164
+ - 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.
165
+ - Concept extraction turns concise event summaries into vocabulary candidates and reference counts.
166
+ - Retention scoring classifies memory as `forget`, `temporary`, `working`, `durable`, or `permanent`; manual pins force `permanent`.
167
+ - Durable memories can be promoted, demoted, superseded, and recalled through CLI or MCP.
168
+ - `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.
169
+
170
+ Retention classes:
171
+
172
+ - `forget`: low-value or stale one-off context that can be dropped.
173
+ - `temporary`: short-term context useful during a narrow task.
174
+ - `working`: active project memory worth keeping across the current iteration.
175
+ - `durable`: cross-session knowledge that should survive normal decay.
176
+ - `permanent`: manually pinned or high-score knowledge; only explicit demote, supersede, or purge should change it.
177
+
178
+ Ontology lifecycle commands:
179
+
180
+ ```sh
181
+ omo-memory ontology candidates
182
+ omo-memory ontology score
183
+ omo-memory ontology promote --concept linaforge --summary "Linaforge is an active game-engine project"
184
+ omo-memory ontology recall --query "linaforge"
185
+ omo-memory ontology demote --id <durable-id>
186
+ omo-memory ontology supersede --id <durable-id> --summary "Updated durable memory"
187
+ ```
188
+
189
+ Global second-brain flow:
190
+
191
+ ```sh
192
+ omo-memory global scan --root /Users/ilseoblee/workspace
193
+ omo-memory global migrate --root /Users/ilseoblee/workspace --global-db ~/.omo/memory/global.sqlite
194
+ OMO_MEMORY_DB=~/.omo/memory/global.sqlite omo-memory ontology candidates
195
+ OMO_MEMORY_DB=~/.omo/memory/global.sqlite omo-memory ontology score
196
+ bun --version
197
+ omo-memory graph tui --db ~/.omo/memory/global.sqlite --query linaforge
198
+ ```
199
+
200
+ OpenTUI graph controls:
201
+
202
+ - `q`: quit.
203
+ - `Up` / `Down`: move selected concept.
204
+ - `Tab`: move to the next concept.
205
+ - `/` or `f`: focus filter input when supported by the terminal runtime.
206
+
207
+ 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
208
 
112
209
  ## Non-goals for MVP
113
210
 
@@ -0,0 +1,84 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ const PACKAGE_NAME = "omo-memory";
6
+ const DEFAULT_INTERVAL_MS = 86_400_000;
7
+ const STATE_PATH = join(homedir(), ".omo", "memory", "auto-update.json");
8
+ export function maybeRunAutoUpdate(currentVersion, nowMs = Date.now()) {
9
+ const statePath = process.env["OMO_MEMORY_UPDATE_STATE"] ?? STATE_PATH;
10
+ if (!shouldAttemptAutoUpdate({ nowMs, statePath }))
11
+ return;
12
+ writeAttemptStamp(statePath, nowMs);
13
+ const child = spawn(npmCommand(), installArgs(), {
14
+ detached: true,
15
+ stdio: "ignore",
16
+ env: updateEnv(currentVersion),
17
+ });
18
+ child.unref();
19
+ }
20
+ export function runAutoUpdate(currentVersion) {
21
+ const command = npmCommand();
22
+ const args = installArgs();
23
+ const result = spawnSync(command, args, { encoding: "utf8", env: updateEnv(currentVersion) });
24
+ return {
25
+ ok: result.status === 0,
26
+ packageName: PACKAGE_NAME,
27
+ currentVersion,
28
+ command: [command, ...args],
29
+ status: result.status,
30
+ stdout: result.stdout,
31
+ stderr: result.stderr,
32
+ };
33
+ }
34
+ function shouldAttemptAutoUpdate(input) {
35
+ if (process.env["OMO_MEMORY_AUTO_UPDATE"] === "0")
36
+ return false;
37
+ if (process.env["OMO_MEMORY_AUTO_UPDATE"] === "false")
38
+ return false;
39
+ if (process.env["OMO_MEMORY_AUTO_UPDATE_CHILD"] === "1")
40
+ return false;
41
+ const intervalMs = updateIntervalMs();
42
+ if (!existsSync(input.statePath))
43
+ return true;
44
+ const lastAttemptMs = readLastAttemptMs(input.statePath);
45
+ return lastAttemptMs === null || input.nowMs - lastAttemptMs >= intervalMs;
46
+ }
47
+ function updateIntervalMs() {
48
+ const raw = process.env["OMO_MEMORY_AUTO_UPDATE_INTERVAL_MS"];
49
+ if (raw === undefined)
50
+ return DEFAULT_INTERVAL_MS;
51
+ const parsed = Number(raw);
52
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_INTERVAL_MS;
53
+ }
54
+ function readLastAttemptMs(statePath) {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(statePath, "utf8"));
57
+ if (!isRecord(parsed))
58
+ return null;
59
+ const value = parsed["lastAttemptMs"];
60
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
61
+ }
62
+ catch (error) {
63
+ if (error instanceof Error)
64
+ return null;
65
+ throw error;
66
+ }
67
+ }
68
+ function writeAttemptStamp(statePath, nowMs) {
69
+ mkdirSync(dirname(statePath), { recursive: true });
70
+ writeFileSync(statePath, `${JSON.stringify({ lastAttemptMs: nowMs })}\n`);
71
+ }
72
+ function npmCommand() {
73
+ return process.env["OMO_MEMORY_NPM_COMMAND"] ?? "npm";
74
+ }
75
+ function installArgs() {
76
+ const target = process.env["OMO_MEMORY_UPDATE_TARGET"] ?? `${PACKAGE_NAME}@latest`;
77
+ return ["install", "-g", target];
78
+ }
79
+ function updateEnv(currentVersion) {
80
+ return { ...process.env, OMO_MEMORY_AUTO_UPDATE_CHILD: "1", OMO_MEMORY_CURRENT_VERSION: currentVersion };
81
+ }
82
+ function isRecord(value) {
83
+ return value !== null && typeof value === "object";
84
+ }
package/dist/cli.js CHANGED
@@ -1,8 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
+ import { maybeRunAutoUpdate, runAutoUpdate } from "./autoUpdate.js";
4
+ import { applyConceptExtraction } from "./conceptExtraction.js";
5
+ import { migrateToGlobalMemory, scanForMemoryDbs } from "./globalMemory.js";
6
+ import { runGraphTui } from "./graphTui.js";
3
7
  import { runMcpServer } from "./mcp.js";
4
- import { bootstrapSession, doctorReport, exportMemory, purgeMemory, recentEvents, recordEvent, startSession, writeHandoff } from "./memory.js";
8
+ import { bootstrapSession, exportMemory, purgeMemory, recentEvents, recordEvent, startSession, writeHandoff } from "./memory.js";
5
9
  import { initMemory } from "./memoryDb.js";
10
+ import { recallEvents } from "./memoryRecall.js";
11
+ import { doctorReport } from "./memoryReport.js";
12
+ import { createDurableMemory, listOntologyRows, recordMemoryReference, supersedeDurableMemory, updateDurableRetention } from "./ontologyCore.js";
13
+ import { defaultDbPath } from "./projectContext.js";
14
+ import { recomputeRetentionScores } from "./retentionRecompute.js";
6
15
  async function main(argv) {
7
16
  const [command, subcommand, ...rest] = argv;
8
17
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
@@ -13,6 +22,17 @@ async function main(argv) {
13
22
  await runMcpServer();
14
23
  return;
15
24
  }
25
+ const currentVersion = readPackageVersion();
26
+ if (command === "update") {
27
+ process.stdout.write(`${JSON.stringify(runAutoUpdate(currentVersion), null, 2)}\n`);
28
+ return;
29
+ }
30
+ maybeRunAutoUpdate(currentVersion);
31
+ if (command === "graph" && subcommand === "tui") {
32
+ const query = readFlag(rest, "--query");
33
+ await runGraphTui({ dbPath: readFlag(rest, "--db") ?? defaultDbPath(), ...(query === undefined ? {} : { query }) });
34
+ return;
35
+ }
16
36
  const result = runCommand(command, subcommand, rest);
17
37
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
18
38
  }
@@ -30,6 +50,12 @@ function runCommand(command, subcommand, rest) {
30
50
  const args = [subcommand, ...rest].filter((value) => value !== undefined);
31
51
  return { ok: true, ...purgeMemory({ yes: args.includes("--yes") }) };
32
52
  }
53
+ if (command === "global") {
54
+ return runGlobalCommand(subcommand, rest);
55
+ }
56
+ if (command === "ontology") {
57
+ return runOntologyCommand(subcommand, rest);
58
+ }
33
59
  if (command === "session" && subcommand === "start") {
34
60
  const host = parseHost(readFlag(rest, "--host") ?? "unknown");
35
61
  const adapter = readFlag(rest, "--adapter") ?? "unknown";
@@ -54,6 +80,11 @@ function runCommand(command, subcommand, rest) {
54
80
  const limitRaw = readFlag([subcommand, ...rest].filter((value) => value !== undefined), "--limit");
55
81
  return { ok: true, events: recentEvents(parsePositiveInt(limitRaw, "recent --limit")) };
56
82
  }
83
+ if (command === "recall") {
84
+ const args = [subcommand, ...rest].filter((value) => value !== undefined);
85
+ const query = readFlag(args, "--query") ?? fail("recall requires --query");
86
+ return { ok: true, events: recallEvents({ query, limit: readPositiveIntFlag(args, "--limit", 10) }) };
87
+ }
57
88
  if (command === "handoff" && subcommand === "write") {
58
89
  const summary = readFlag(rest, "--summary");
59
90
  const summaryFile = readFlag(rest, "--summary-file");
@@ -63,6 +94,92 @@ function runCommand(command, subcommand, rest) {
63
94
  }
64
95
  fail(`unknown command: ${[command, subcommand].filter(Boolean).join(" ")}`);
65
96
  }
97
+ function runGlobalCommand(subcommand, rest) {
98
+ if (subcommand === "scan") {
99
+ const rootPath = readFlag(rest, "--root") ?? fail("global scan requires --root");
100
+ return { ok: true, ...scanForMemoryDbs(rootPath) };
101
+ }
102
+ if (subcommand === "migrate") {
103
+ const rootPath = readFlag(rest, "--root") ?? fail("global migrate requires --root");
104
+ const globalDbPath = readFlag(rest, "--global-db") ?? fail("global migrate requires --global-db");
105
+ return { ok: true, ...migrateToGlobalMemory({ rootPath, globalDbPath }) };
106
+ }
107
+ fail(`unknown command: global ${subcommand ?? ""}`.trim());
108
+ }
109
+ function runOntologyCommand(subcommand, rest) {
110
+ const dbPath = defaultDbPath();
111
+ if (subcommand === "candidates")
112
+ return ontologyCandidates(dbPath);
113
+ if (subcommand === "score" || subcommand === "recompute") {
114
+ return { ok: true, ...recomputeRetentionScores({ dbPath, nowIso: new Date().toISOString() }) };
115
+ }
116
+ if (subcommand === "promote")
117
+ return ontologyPromote(dbPath, rest);
118
+ if (subcommand === "demote") {
119
+ const id = readFlag(rest, "--id") ?? fail("ontology demote requires --id");
120
+ return { ok: true, durableMemory: updateDurableRetention(dbPath, exportMemory(dbPath).project, id, { retentionClass: "temporary" }) };
121
+ }
122
+ if (subcommand === "supersede") {
123
+ const id = readFlag(rest, "--id") ?? fail("ontology supersede requires --id");
124
+ const newSummary = readFlag(rest, "--summary");
125
+ return { ok: true, ...supersedeDurableMemory(dbPath, exportMemory(dbPath).project, id, newSummary === undefined ? {} : { newSummary }) };
126
+ }
127
+ if (subcommand === "recall") {
128
+ const query = readFlag(rest, "--query") ?? fail("ontology recall requires --query");
129
+ const limit = readPositiveIntFlag(rest, "--limit", 10);
130
+ return { ok: true, durableMemories: recallDurableMemories(dbPath, query, limit) };
131
+ }
132
+ fail(`unknown command: ontology ${subcommand ?? ""}`.trim());
133
+ }
134
+ function ontologyCandidates(dbPath) {
135
+ const memory = exportMemory(dbPath);
136
+ let references = 0;
137
+ for (const event of memory.events) {
138
+ references += applyConceptExtraction(dbPath, memory.project, event.id, event.summary, event.type).references.length;
139
+ }
140
+ return { ok: true, concepts: listOntologyRows(dbPath, memory.project).concepts, references };
141
+ }
142
+ function ontologyPromote(dbPath, rest) {
143
+ const memory = exportMemory(dbPath);
144
+ const conceptSelector = readFlag(rest, "--concept") ?? readFlag(rest, "--concept-id") ?? fail("ontology promote requires --concept");
145
+ const concept = findConcept(memory.concepts, conceptSelector) ?? fail(`ontology promote candidate not found: ${conceptSelector}`);
146
+ const summary = readFlag(rest, "--summary") ?? `Durable memory: ${concept.label}`;
147
+ const body = readFlag(rest, "--body") ?? concept.description ?? concept.label;
148
+ const durableMemory = createDurableMemory(dbPath, memory.project, {
149
+ type: "concept",
150
+ summary,
151
+ body,
152
+ confidence: Math.min(1, Math.max(0, concept.score / 100)),
153
+ status: "active",
154
+ retentionClass: "durable",
155
+ });
156
+ const reference = recordMemoryReference(dbPath, memory.project, {
157
+ sourceType: "concept",
158
+ sourceId: concept.id,
159
+ targetType: "durable_memory",
160
+ targetId: durableMemory.id,
161
+ refKind: "promotes",
162
+ weight: 1,
163
+ });
164
+ return { ok: true, concept, durableMemory, reference };
165
+ }
166
+ function findConcept(concepts, selector) {
167
+ const normalized = selector.trim().toLowerCase();
168
+ return concepts.find((concept) => concept.id === selector || concept.label.toLowerCase() === normalized);
169
+ }
170
+ function recallDurableMemories(dbPath, query, limit) {
171
+ const memory = exportMemory(dbPath);
172
+ const terms = query.toLowerCase().match(/[\p{L}\p{N}_-]{3,}/gu) ?? [];
173
+ if (terms.length === 0)
174
+ return [];
175
+ return listOntologyRows(dbPath, memory.project)
176
+ .durableMemories.filter((durable) => durable.status === "active")
177
+ .filter((durable) => {
178
+ const haystack = `${durable.summary} ${durable.body ?? ""}`.toLowerCase();
179
+ return terms.some((term) => haystack.includes(term));
180
+ })
181
+ .slice(0, limit);
182
+ }
66
183
  function readFlag(args, name) {
67
184
  const index = args.indexOf(name);
68
185
  if (index === -1)
@@ -90,10 +207,20 @@ function fail(message) {
90
207
  throw new Error(message);
91
208
  }
92
209
  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`);
210
+ process.stdout.write(`OMO Memory\n\nCommands:\n omo-memory init\n omo-memory doctor\n omo-memory update\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`);
211
+ }
212
+ function readPackageVersion() {
213
+ const rawPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
214
+ if (!isObject(rawPackage))
215
+ return "0.0.0";
216
+ const version = rawPackage["version"];
217
+ return typeof version === "string" && version.length > 0 ? version : "0.0.0";
218
+ }
219
+ function isObject(value) {
220
+ return value !== null && typeof value === "object";
94
221
  }
95
222
  main(process.argv.slice(2)).catch((error) => {
96
223
  const message = error instanceof Error ? error.message : String(error);
97
- process.stderr.write(`${message}\n`);
224
+ process.stdout.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
98
225
  process.exitCode = 1;
99
226
  });
@@ -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
+ }