th-memory-mcp 2.2.8 → 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.
- package/ARCHITECTURE_v2.md +1 -1
- package/README.md +54 -3
- package/README.th.md +51 -1
- package/SECURITY.md +9 -0
- package/design.md +1 -1
- package/dist/cli/commands.js +507 -0
- package/dist/cli.js +4 -0
- package/dist/core/consolidation-engine.js +119 -2
- package/dist/core/graph-engine.js +11 -0
- package/dist/db/index.js +4 -0
- package/dist/db/migrations.js +47 -12
- package/dist/index.js +1 -1
- package/dist/lib/config.js +3 -1
- package/dist/lib/highlight.js +139 -0
- package/dist/lib/iso.js +11 -0
- package/dist/lib/memory-format.js +53 -0
- package/dist/memory/deduplicator.js +24 -9
- package/dist/retrieval/fts.js +17 -26
- package/dist/tools/consolidate.js +47 -21
- package/dist/tools/context.js +6 -2
- package/dist/tools/export_memory.js +43 -0
- package/dist/tools/forget.js +31 -4
- package/dist/tools/import_memory.js +404 -88
- package/dist/tools/merge_memory.js +14 -0
- package/dist/tools/profile.js +16 -3
- package/dist/tools/recall.js +3 -1
- package/dist/tools/update_memory.js +29 -3
- package/package.json +5 -3
- package/dist/db.js +0 -111
|
@@ -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
|
@@ -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
|
-
|
|
18
|
-
|
|
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)
|