th-memory-mcp 2.2.9 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,507 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { VERSION } from "../lib/config.js";
3
+ const CATEGORIES = ["work_style", "coding_pref", "language", "domain", "other"];
4
+ const FORGET_TYPES = ["memory", "preference", "lesson", "interaction"];
5
+ const KINDS = ["prompt", "tool_call", "error"];
6
+ const COMMANDS = [
7
+ "remember",
8
+ "recall",
9
+ "forget",
10
+ "export",
11
+ "import",
12
+ "stats",
13
+ "profile",
14
+ "history",
15
+ "recent",
16
+ "highlight",
17
+ ];
18
+ const GLOBAL_HELP = `th-memory ${VERSION} - local memory CLI (zero-dep)
19
+
20
+ Usage: th-memory [--db <path>] [--json] [--plain] <command> [options]
21
+
22
+ Global flags (every command):
23
+ --db <path> use this SQLite file (sets MEMORY_DB_PATH)
24
+ --json print JSON {ok,data} instead of plain text
25
+ --plain disable colors (also: --no-color)
26
+ -h, --help show help (global or per-command: <command> --help)
27
+ -V, --version print version
28
+
29
+ Commands:
30
+ remember --category <c> --key <k> --value <v|-> save a preference (--value - = stdin)
31
+ recall <topic> [--limit <n>] [--highlight] search memory
32
+ forget <id> [--type <t>] delete by id (t: memory|preference|lesson|interaction)
33
+ export [--include-interactions] [--filename <n>] export to data/exports/*.json
34
+ import (--file <p>|--json <s>) [--apply] [--user-id <id>] import backup (dry-run by default)
35
+ stats memory statistics
36
+ profile distilled user profile
37
+ history [--query <q>] [--limit <n>] search past prompts (no query = recent prompts)
38
+ recent [--limit <n>] [--kind <k>] recent interactions
39
+ highlight [text...] -q <topic> [--limit <n>] highlight topic matches (empty text = stdin)
40
+
41
+ Examples:
42
+ th-memory remember --category coding_pref --key package_manager --value pnpm
43
+ th-memory recall pnpm --limit 5
44
+ th-memory forget 3 --type preference
45
+ th-memory export --include-interactions --filename backup.json
46
+ th-memory --db ./tmp.db --json stats`;
47
+ const COMMAND_HELP = {
48
+ remember: `Usage: th-memory remember --category <c> --key <k> --value <v|->
49
+
50
+ --category <c> one of: ${CATEGORIES.join("|")}
51
+ --key <k> short stable key (1-200 chars)
52
+ --value <v> preference value (1-2000 chars); "-" reads from stdin`,
53
+ recall: `Usage: th-memory recall <topic> [--limit <n>] [--highlight]
54
+
55
+ <topic> search topic (required)
56
+ --limit <n> max matches, 1-50 (default 8)
57
+ --highlight pipe the result through memory highlight for <topic>`,
58
+ forget: `Usage: th-memory forget <id> [--type <t>]
59
+
60
+ <id> numeric row id (required, positive integer)
61
+ --type <t> one of: ${FORGET_TYPES.join("|")} (recommended: ids collide across tables)`,
62
+ export: `Usage: th-memory export [--include-interactions] [--filename <n>]
63
+
64
+ --include-interactions embed raw interaction rows (bigger file)
65
+ --filename <n> name inside data/exports/ ([A-Za-z0-9._-], must end .json)`,
66
+ import: `Usage: th-memory import (--file <p>|--json <s>) [--apply] [--user-id <id>]
67
+
68
+ --file <p> .json export file inside data/exports/
69
+ --json <s> inline JSON array or {memories:[...]} ("-" reads from stdin)
70
+ --apply write to DB (default: dry-run, report only)
71
+ --user-id <id> scope imported memories to a user`,
72
+ stats: `Usage: th-memory stats`,
73
+ profile: `Usage: th-memory profile`,
74
+ history: `Usage: th-memory history [--query <q>] [--limit <n>]
75
+
76
+ --query <q> keyword for past prompts (a bare positional also works)
77
+ --limit <n> max rows (default 10 with --query, 20 without)
78
+ (no --query: lists recent prompts instead)`,
79
+ recent: `Usage: th-memory recent [--limit <n>] [--kind <k>]
80
+
81
+ --limit <n> max rows, 1-100 (default 20)
82
+ --kind <k> one of: ${KINDS.join("|")}`,
83
+ highlight: `Usage: th-memory highlight [text...] -q <topic> [--limit <n>]
84
+
85
+ [text...] text to highlight (empty = read from stdin pipe)
86
+ -q, --topic <t> topic to highlight (required)
87
+ --limit <n> max highlight matches (positive integer)`,
88
+ };
89
+ function splitEq(token) {
90
+ const i = token.indexOf("=");
91
+ if (i < 0)
92
+ return [token, undefined];
93
+ return [token.slice(0, i), token.slice(i + 1)];
94
+ }
95
+ function parseBoolValue(raw, name) {
96
+ if (raw === undefined)
97
+ return true;
98
+ const v = raw.toLowerCase();
99
+ if (["true", "1", "yes", "y"].includes(v))
100
+ return true;
101
+ if (["false", "0", "no", "n"].includes(v))
102
+ return false;
103
+ throw new Error(`invalid value for ${name}: "${raw}" (expected true/false)`);
104
+ }
105
+ function fail(message, asJson, code = 2) {
106
+ if (asJson) {
107
+ process.stdout.write(JSON.stringify({ ok: false, data: message }) + "\n");
108
+ }
109
+ else {
110
+ process.stderr.write(`error: ${message}\n`);
111
+ }
112
+ process.exit(code);
113
+ }
114
+ function parseArgv(argv) {
115
+ const globals = { json: false, plain: false };
116
+ let i = 0;
117
+ while (i < argv.length) {
118
+ const tok = argv[i];
119
+ if (tok === "--db") {
120
+ const v = argv[i + 1];
121
+ if (v === undefined || (v.startsWith("--") && v !== "-"))
122
+ fail("--db requires a <path> value", globals.json);
123
+ globals.db = v;
124
+ i += 2;
125
+ }
126
+ else if (tok.startsWith("--db=")) {
127
+ globals.db = tok.slice("--db=".length);
128
+ if (!globals.db)
129
+ fail("--db requires a <path> value", globals.json);
130
+ i += 1;
131
+ }
132
+ else if (tok === "--json") {
133
+ globals.json = true;
134
+ i += 1;
135
+ }
136
+ else if (tok === "--plain" || tok === "--no-color") {
137
+ globals.plain = true;
138
+ i += 1;
139
+ }
140
+ else if (tok === "-h" || tok === "--help") {
141
+ printHelp(undefined);
142
+ process.exit(0);
143
+ }
144
+ else if (tok === "-V" || tok === "--version") {
145
+ printVersion(globals.json);
146
+ process.exit(0);
147
+ }
148
+ else if (tok === "--") {
149
+ i += 1;
150
+ break;
151
+ }
152
+ else if (tok.startsWith("-")) {
153
+ fail(`unknown global option "${tok}" (see --help)`, globals.json);
154
+ }
155
+ else {
156
+ break;
157
+ }
158
+ }
159
+ const cmd = argv[i];
160
+ if (cmd === undefined) {
161
+ process.stderr.write(GLOBAL_HELP + "\n");
162
+ process.exit(2);
163
+ }
164
+ if (!COMMANDS.includes(cmd))
165
+ fail(`unknown command "${cmd}" (see --help)`, globals.json);
166
+ const rest = argv.slice(i + 1);
167
+ const opts = {};
168
+ const positionals = [];
169
+ const valueOpts = new Set([
170
+ "--category",
171
+ "--key",
172
+ "--value",
173
+ "--limit",
174
+ "--type",
175
+ "--filename",
176
+ "--file",
177
+ "--user-id",
178
+ "--query",
179
+ "--kind",
180
+ "--topic",
181
+ "-q",
182
+ "--db",
183
+ ]);
184
+ const boolOpts = new Set([
185
+ "--json",
186
+ "--plain",
187
+ "--no-color",
188
+ "--highlight",
189
+ "--include-interactions",
190
+ "--apply",
191
+ ]);
192
+ let j = 0;
193
+ const takeValue = (name, inline) => {
194
+ if (inline !== undefined && inline !== "")
195
+ return inline;
196
+ const next = rest[j + 1];
197
+ if (next === undefined || (next.startsWith("--") && next !== "-"))
198
+ fail(`${name} requires a value`, globals.json);
199
+ j += 1;
200
+ return next;
201
+ };
202
+ while (j < rest.length) {
203
+ const tok = rest[j];
204
+ if (tok === "--") {
205
+ positionals.push(...rest.slice(j + 1));
206
+ break;
207
+ }
208
+ if (tok === "-h" || tok === "--help") {
209
+ printHelp(cmd);
210
+ process.exit(0);
211
+ }
212
+ if (tok === "-V" || tok === "--version") {
213
+ printVersion(globals.json);
214
+ process.exit(0);
215
+ }
216
+ if (tok === "-q") {
217
+ opts["--topic"] = takeValue("-q", undefined);
218
+ j += 1;
219
+ continue;
220
+ }
221
+ if (tok.startsWith("--")) {
222
+ const [name, inline] = splitEq(tok);
223
+ if (name === "--db") {
224
+ globals.db = takeValue("--db", inline);
225
+ j += 1;
226
+ continue;
227
+ }
228
+ if (name === "--json") {
229
+ globals.json = parseBoolValue(inline, "--json");
230
+ j += 1;
231
+ continue;
232
+ }
233
+ if (name === "--plain" || name === "--no-color") {
234
+ globals.plain = true;
235
+ j += 1;
236
+ continue;
237
+ }
238
+ if (name === "--topic") {
239
+ opts["--topic"] = takeValue("--topic", inline);
240
+ j += 1;
241
+ continue;
242
+ }
243
+ if (valueOpts.has(name)) {
244
+ opts[name] = takeValue(name, inline);
245
+ j += 1;
246
+ continue;
247
+ }
248
+ if (boolOpts.has(name)) {
249
+ opts[name] = parseBoolValue(inline, name);
250
+ j += 1;
251
+ continue;
252
+ }
253
+ if (name.startsWith("--no-")) {
254
+ opts["--" + name.slice("--no-".length)] = false;
255
+ j += 1;
256
+ continue;
257
+ }
258
+ fail(`unknown option "${name}" for "${cmd}" (see ${cmd} --help)`, globals.json);
259
+ }
260
+ else if (tok === "-" || !tok.startsWith("-")) {
261
+ positionals.push(tok);
262
+ j += 1;
263
+ }
264
+ else {
265
+ fail(`unknown option "${tok}" for "${cmd}" (see ${cmd} --help)`, globals.json);
266
+ }
267
+ }
268
+ if (cmd === "import" && typeof opts["--json"] === "boolean") {
269
+ fail("import --json requires an inline JSON string (or use --file <path>)", globals.json);
270
+ }
271
+ return { cmd, globals, opts, positionals };
272
+ }
273
+ function printHelp(cmd) {
274
+ if (cmd === undefined || !(cmd in COMMAND_HELP)) {
275
+ process.stdout.write(GLOBAL_HELP + "\n");
276
+ return;
277
+ }
278
+ process.stdout.write(`th-memory ${VERSION}\n\n${COMMAND_HELP[cmd]}\n`);
279
+ }
280
+ function printVersion(asJson) {
281
+ if (asJson) {
282
+ process.stdout.write(JSON.stringify({ ok: true, data: VERSION }) + "\n");
283
+ return;
284
+ }
285
+ process.stdout.write(`th-memory ${VERSION}\n`);
286
+ }
287
+ function useColor(g) {
288
+ return !g.plain && !g.json && process.stdout.isTTY === true;
289
+ }
290
+ function readStdin() {
291
+ return readFileSync(0, "utf8");
292
+ }
293
+ function textOf(result) {
294
+ return result.content
295
+ .filter((c) => c.type === "text")
296
+ .map((c) => c.text)
297
+ .join("\n");
298
+ }
299
+ function output(result, g) {
300
+ const text = textOf(result);
301
+ const isError = result.isError === true;
302
+ if (g.json) {
303
+ process.stdout.write(JSON.stringify({ ok: !isError, data: text }) + "\n");
304
+ return isError ? 1 : 0;
305
+ }
306
+ if (isError) {
307
+ process.stderr.write((text.startsWith("error:") ? text : `error: ${text}`) + "\n");
308
+ return 1;
309
+ }
310
+ process.stdout.write(text + "\n");
311
+ return 0;
312
+ }
313
+ function parseLimit(raw, def, min, max, g) {
314
+ if (raw === undefined)
315
+ return def;
316
+ const n = Number(raw);
317
+ if (!Number.isInteger(n) || n < min || n > max)
318
+ fail(`--limit must be an integer ${min}-${max} (got "${String(raw)}")`, g.json);
319
+ return n;
320
+ }
321
+ function optStr(opts, name) {
322
+ const v = opts[name];
323
+ if (typeof v === "string")
324
+ return v;
325
+ return undefined;
326
+ }
327
+ function optBool(opts, name) {
328
+ return opts[name] === true;
329
+ }
330
+ async function loadHighlightFn() {
331
+ let mod;
332
+ try {
333
+ // @ts-ignore contract owned by sibling workstream: export async function highlightTextWithMemory(text, topic, opts?): Promise<string>
334
+ mod = await import("../lib/highlight.js");
335
+ }
336
+ catch (e) {
337
+ const msg = e instanceof Error ? e.message : String(e);
338
+ throw new Error(`highlight module unavailable (src/lib/highlight.js): ${msg}`);
339
+ }
340
+ const fn = mod.highlightTextWithMemory;
341
+ if (typeof fn !== "function")
342
+ throw new Error("highlight module has no highlightTextWithMemory export");
343
+ return fn;
344
+ }
345
+ export async function runCli(argv) {
346
+ const { cmd, globals: g, opts, positionals: pos } = parseArgv(argv);
347
+ if (g.db !== undefined)
348
+ process.env.MEMORY_DB_PATH = g.db;
349
+ switch (cmd) {
350
+ case "remember": {
351
+ const category = optStr(opts, "--category");
352
+ const key = optStr(opts, "--key");
353
+ let value = optStr(opts, "--value");
354
+ if (pos.length > 0)
355
+ fail(`remember takes no positional args (see remember --help)`, g.json);
356
+ if (category === undefined)
357
+ fail("remember requires --category (work_style|coding_pref|language|domain|other)", g.json);
358
+ if (!CATEGORIES.includes(category))
359
+ fail(`invalid --category "${category}" (expected ${CATEGORIES.join("|")})`, g.json);
360
+ if (key === undefined)
361
+ fail("remember requires --key <key>", g.json);
362
+ if (value === undefined)
363
+ fail("remember requires --value <value> (use - for stdin)", g.json);
364
+ if (value === "-")
365
+ value = readStdin().replace(/\r?\n$/, "");
366
+ if (value.length === 0)
367
+ fail("--value must not be empty", g.json);
368
+ const { rememberHandler } = await import("../tools/remember.js");
369
+ return output(await rememberHandler({
370
+ category: category,
371
+ key,
372
+ value,
373
+ }), g);
374
+ }
375
+ case "recall": {
376
+ if (pos.length === 0)
377
+ fail("recall requires <topic>", g.json);
378
+ const topic = pos.join(" ");
379
+ const limit = parseLimit(opts["--limit"], 8, 1, 50, g);
380
+ const { recallHandler } = await import("../tools/recall.js");
381
+ const result = await recallHandler({ topic, limit });
382
+ if (optBool(opts, "--highlight") && result.isError !== true) {
383
+ try {
384
+ const highlight = await loadHighlightFn();
385
+ const highlighted = await highlight(textOf(result), topic, { limit, color: useColor(g) });
386
+ return output({ content: [{ type: "text", text: highlighted }] }, g);
387
+ }
388
+ catch (e) {
389
+ return output({ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true }, g);
390
+ }
391
+ }
392
+ return output(result, g);
393
+ }
394
+ case "forget": {
395
+ if (pos.length === 0)
396
+ fail("forget requires <id>", g.json);
397
+ if (pos.length > 1)
398
+ fail("forget takes a single <id>", g.json);
399
+ const id = Number(pos[0]);
400
+ if (!Number.isInteger(id) || id <= 0)
401
+ fail(`invalid <id> "${pos[0]}" (expected positive integer)`, g.json);
402
+ const type = optStr(opts, "--type");
403
+ if (type !== undefined && !FORGET_TYPES.includes(type))
404
+ fail(`invalid --type "${type}" (expected ${FORGET_TYPES.join("|")})`, g.json);
405
+ const { forgetHandler } = await import("../tools/forget.js");
406
+ return output(await forgetHandler({
407
+ target_id: id,
408
+ ...(type === undefined
409
+ ? {}
410
+ : { type: type }),
411
+ }), g);
412
+ }
413
+ case "export": {
414
+ if (pos.length > 0)
415
+ fail("export takes no positional args (see export --help)", g.json);
416
+ const { exportMemoryHandler } = await import("../tools/export_memory.js");
417
+ return output(await exportMemoryHandler({
418
+ includeInteractions: optBool(opts, "--include-interactions"),
419
+ ...(optStr(opts, "--filename") === undefined ? {} : { filename: optStr(opts, "--filename") }),
420
+ }), g);
421
+ }
422
+ case "import": {
423
+ if (pos.length > 0)
424
+ fail("import takes no positional args (see import --help)", g.json);
425
+ let file = optStr(opts, "--file");
426
+ let json = optStr(opts, "--json");
427
+ if ((file === undefined) === (json === undefined))
428
+ fail("import requires exactly one of --file <path> or --json <string>", g.json);
429
+ if (json === "-")
430
+ json = readStdin();
431
+ const userId = optStr(opts, "--user-id");
432
+ const { importMemoryHandler } = await import("../tools/import_memory.js");
433
+ return output(importMemoryHandler({
434
+ ...(file === undefined ? {} : { file }),
435
+ ...(json === undefined ? {} : { json }),
436
+ apply: optBool(opts, "--apply"),
437
+ ...(userId === undefined ? {} : { userId }),
438
+ }), g);
439
+ }
440
+ case "stats": {
441
+ if (pos.length > 0)
442
+ fail("stats takes no positional args", g.json);
443
+ const { memoryStatsHandler } = await import("../tools/memory_stats.js");
444
+ return output(await memoryStatsHandler(), g);
445
+ }
446
+ case "profile": {
447
+ if (pos.length > 0)
448
+ fail("profile takes no positional args", g.json);
449
+ const { getProfileHandler } = await import("../tools/profile.js");
450
+ return output(await getProfileHandler(), g);
451
+ }
452
+ case "history": {
453
+ const query = optStr(opts, "--query") ?? (pos.length > 0 ? pos.join(" ") : undefined);
454
+ const limit = parseLimit(opts["--limit"], query === undefined ? 20 : 10, 1, query === undefined ? 100 : 50, g);
455
+ if (query === undefined) {
456
+ const { getRecentInteractionsHandler } = await import("../tools/recent_interactions.js");
457
+ return output(await getRecentInteractionsHandler({ limit, kind: "prompt" }), g);
458
+ }
459
+ const { searchHistoryHandler } = await import("../tools/history.js");
460
+ return output(await searchHistoryHandler({ query, limit }), g);
461
+ }
462
+ case "recent": {
463
+ if (pos.length > 0)
464
+ fail("recent takes no positional args (use --kind/--limit)", g.json);
465
+ const limit = parseLimit(opts["--limit"], 20, 1, 100, g);
466
+ const kind = optStr(opts, "--kind");
467
+ if (kind !== undefined && !KINDS.includes(kind))
468
+ fail(`invalid --kind "${kind}" (expected ${KINDS.join("|")})`, g.json);
469
+ const { getRecentInteractionsHandler } = await import("../tools/recent_interactions.js");
470
+ return output(await getRecentInteractionsHandler({
471
+ limit,
472
+ ...(kind === undefined ? {} : { kind: kind }),
473
+ }), g);
474
+ }
475
+ case "highlight": {
476
+ const topic = optStr(opts, "--topic");
477
+ if (topic === undefined || topic.length === 0)
478
+ fail("highlight requires -q <topic>", g.json);
479
+ const limitRaw = opts["--limit"];
480
+ let limit;
481
+ if (limitRaw !== undefined) {
482
+ const n = Number(limitRaw);
483
+ if (!Number.isInteger(n) || n <= 0)
484
+ fail(`--limit must be a positive integer (got "${String(limitRaw)}")`, g.json);
485
+ limit = n;
486
+ }
487
+ let text = pos.join(" ");
488
+ if (text.length === 0) {
489
+ if (process.stdin.isTTY)
490
+ fail("highlight needs [text...] or piped stdin", g.json);
491
+ text = readStdin();
492
+ if (text.length === 0)
493
+ fail("highlight received empty input", g.json);
494
+ }
495
+ try {
496
+ const highlight = await loadHighlightFn();
497
+ const out = await highlight(text, topic, { ...(limit === undefined ? {} : { limit }), color: useColor(g) });
498
+ return output({ content: [{ type: "text", text: out }] }, g);
499
+ }
500
+ catch (e) {
501
+ return output({ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true }, g);
502
+ }
503
+ }
504
+ default:
505
+ fail(`unknown command "${cmd}" (see --help)`, g.json);
506
+ }
507
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli/commands.js";
3
+ const code = await runCli(process.argv.slice(2));
4
+ process.exitCode = code;
@@ -2,6 +2,7 @@ import { db } from "../db/index.js";
2
2
  import { cosine, deserialize } from "../lib/embed.js";
3
3
  import { createMemory } from "../db/repositories/memories.js";
4
4
  import { linkMemories } from "./graph-engine.js";
5
+ import { resolveUserId } from "../db/repositories/users.js";
5
6
  // Group similar active memories into clusters via embedding cosine + union-find (spec §16)
6
7
  export function clusterMemories(opts = {}) {
7
8
  const threshold = opts.threshold ?? 0.7;
@@ -13,10 +14,28 @@ export function clusterMemories(opts = {}) {
13
14
  WHERE e.ref_table = 'memories'
14
15
  AND ( ? OR m.status NOT IN ('deleted','archived','superseded'))`)
15
16
  .all(opts.includeArchived ? 1 : 0);
17
+ // External userId -> internal id once (null when unscoped/unknown; then no
18
+ // USER row can match, so only GLOBAL clusters — never another user's data).
19
+ const resolvedUid = opts.userId ? resolveUserId(opts.userId) : null;
16
20
  const valid = rows.filter((r) => {
17
- if (r.scope === "USER" || r.scope === "SESSION" || r.scope === "PROJECT") {
18
- if (opts.projectId && r.project_id !== opts.projectId)
21
+ // Batch B-1 strict scope filter (mirrors retrieval visible()): a scoped
22
+ // memory clusters only when the caller explicitly scopes to it. GLOBAL
23
+ // always participates. This stops USER/SESSION/PROJECT memories from
24
+ // being absorbed into a cluster the caller reads as another scope.
25
+ if (r.scope === "USER") {
26
+ if (opts.userId == null)
19
27
  return false;
28
+ return r.user_id === resolvedUid;
29
+ }
30
+ if (r.scope === "SESSION") {
31
+ if (opts.sessionId == null)
32
+ return false;
33
+ return r.session_id === opts.sessionId;
34
+ }
35
+ if (r.scope === "PROJECT") {
36
+ if (opts.projectId == null)
37
+ return false;
38
+ return r.project_id === opts.projectId;
20
39
  }
21
40
  return true;
22
41
  });
@@ -64,6 +83,102 @@ export function clusterMemories(opts = {}) {
64
83
  }
65
84
  return [...groups.values()].filter((g) => g.length >= minSize);
66
85
  }
86
+ function scopeRank(scope) {
87
+ switch (scope) {
88
+ case "SESSION":
89
+ return 3;
90
+ case "USER":
91
+ return 2;
92
+ case "PROJECT":
93
+ return 1;
94
+ default:
95
+ return 0;
96
+ }
97
+ }
98
+ function externalIdFor(internalId) {
99
+ if (internalId == null)
100
+ return null;
101
+ try {
102
+ const row = db
103
+ .prepare("SELECT external_id FROM users WHERE id = ?")
104
+ .get(internalId);
105
+ return row?.external_id ?? null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ /**
112
+ * Resolve the scope for a derived memory without ever escalating.
113
+ * Unanimous cluster → inherit that exact scope; mixed cluster → inherit the
114
+ * narrowest member scope (SESSION > USER > PROJECT > GLOBAL). GLOBAL is
115
+ * returned only when every source is GLOBAL (plus any explicit caller scope,
116
+ * which only narrows). Returns null when a non-GLOBAL source has no safely
117
+ * resolvable identity — the caller must skip deriving instead of escalating.
118
+ */
119
+ export function resolveDerivedScope(members, caller = {}) {
120
+ const norm = (v) => typeof v === "string" ? v : null;
121
+ if (members.length === 0) {
122
+ return {
123
+ projectId: norm(caller.projectId),
124
+ sessionId: norm(caller.sessionId),
125
+ userId: norm(caller.userId),
126
+ };
127
+ }
128
+ const sawNonGlobal = members.some((m) => m.scope !== "GLOBAL");
129
+ const first = members[0];
130
+ const unanimous = members.every((m) => m.scope === first.scope) &&
131
+ members.every((m) => m.project_id === first.project_id) &&
132
+ members.every((m) => m.session_id === first.session_id) &&
133
+ members.every((m) => m.user_id === first.user_id);
134
+ const ordered = unanimous
135
+ ? [first]
136
+ : [...members].sort((a, b) => {
137
+ const byRank = scopeRank(b.scope) - scopeRank(a.scope);
138
+ if (byRank !== 0)
139
+ return byRank;
140
+ // Deterministic tie-break on owner identity so equal-rank members
141
+ // (e.g. USER alice vs USER bob) never resolve to an arbitrary pick.
142
+ const ownerKey = (m) => `${m.user_id ?? ""}|${m.project_id ?? ""}|${m.session_id ?? ""}`;
143
+ return ownerKey(a).localeCompare(ownerKey(b));
144
+ });
145
+ // If the narrowest-scope group mixes distinct owners, we cannot safely
146
+ // attribute the derived memory to any single owner — skip instead of
147
+ // leaking one owner's data into another's derived memory.
148
+ const topRank = scopeRank(ordered[0].scope);
149
+ const topGroup = ordered.filter((m) => scopeRank(m.scope) === topRank);
150
+ const ownerSet = new Set(topGroup.map((m) => `${m.user_id ?? ""}|${m.project_id ?? ""}|${m.session_id ?? ""}`));
151
+ if (ownerSet.size > 1)
152
+ return null;
153
+ for (const pick of ordered) {
154
+ if (pick.scope === "SESSION" && pick.session_id != null) {
155
+ return {
156
+ projectId: pick.project_id,
157
+ sessionId: pick.session_id,
158
+ userId: externalIdFor(pick.user_id),
159
+ };
160
+ }
161
+ if (pick.scope === "USER") {
162
+ // createMemory derives USER only from userId alone (projectId would
163
+ // make it PROJECT), so pass the owner with no project/session.
164
+ const ext = externalIdFor(pick.user_id) ?? norm(caller.userId);
165
+ if (ext == null)
166
+ continue;
167
+ return { projectId: null, sessionId: null, userId: ext };
168
+ }
169
+ if (pick.scope === "PROJECT" && pick.project_id != null) {
170
+ return { projectId: pick.project_id, sessionId: null, userId: null };
171
+ }
172
+ if (pick.scope === "GLOBAL" && !sawNonGlobal) {
173
+ return {
174
+ projectId: norm(caller.projectId),
175
+ sessionId: norm(caller.sessionId),
176
+ userId: norm(caller.userId),
177
+ };
178
+ }
179
+ }
180
+ return null;
181
+ }
67
182
  // Create a derived/consolidated memory and link its sources via `derived_from` (spec §16)
68
183
  export function createDerivedMemory(input) {
69
184
  const id = createMemory({
@@ -72,6 +187,8 @@ export function createDerivedMemory(input) {
72
187
  summary: input.summary ?? null,
73
188
  source: "consolidated",
74
189
  projectId: input.projectId ?? null,
190
+ sessionId: input.sessionId ?? null,
191
+ userId: input.userId ?? null,
75
192
  });
76
193
  for (const src of input.sourceIds) {
77
194
  if (src !== id)
@@ -13,6 +13,17 @@ export function createEntity(input) {
13
13
  return Number(info.lastInsertRowid);
14
14
  }
15
15
  export function addRelation(input) {
16
+ // Batch A-3: upsert against idx_relations_unique (source+relation+target+
17
+ // coalesced source_memory) so repeats return the existing row instead of
18
+ // accumulating duplicates. SELECT-first keeps lastInsertRowid semantics
19
+ // (INSERT OR IGNORE would report a stale rowid on conflict).
20
+ const existing = db
21
+ .prepare(`SELECT id FROM relations
22
+ WHERE source_entity_id = ? AND relation = ? AND target_entity_id = ?
23
+ AND COALESCE(source_memory_id, -1) = COALESCE(?, -1)`)
24
+ .get(input.subjectId, input.predicate, input.objectId, input.sourceMemoryId ?? null);
25
+ if (existing)
26
+ return existing.id;
16
27
  const info = db
17
28
  .prepare(`INSERT INTO relations
18
29
  (source_entity_id, relation, target_entity_id, confidence, source_memory_id, metadata)