@empirical-gauzza/cli 1.0.37 → 1.0.38

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.
@@ -1,1222 +1,1267 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { log, warn } = require('./ui');
6
- const { httpJson, printApiError } = require('./http');
7
- const { parseOption, parseFlag } = require('./env');
8
- const { requireAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
9
- const { MEMORY_CATEGORIES, NODE_TYPES, DOC_TOPICS } = require('./constants');
10
- const { withSelectedWorkspaceContext } = require('./workspaceContext');
11
-
12
- // ─── Table rendering (--format table) ─────────────────────────────────────────
13
-
14
- function truncateForTable(value, maxLen) {
15
- const str = value === undefined || value === null ? '' : String(value);
16
- return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
17
- }
18
-
19
- function renderTable(rows, columns) {
20
- if (!Array.isArray(rows) || rows.length === 0) return '(no rows)';
21
- const cols = columns && columns.length ? columns : Object.keys(rows[0]);
22
- const cellText = (row, col) => (row[col] === undefined || row[col] === null ? '' : String(row[col]));
23
- const widths = cols.map((col) => Math.max(col.length, ...rows.map((row) => cellText(row, col).length)));
24
- const renderRow = (cells) => cells.map((cell, i) => String(cell).padEnd(widths[i])).join(' ');
25
- const lines = [renderRow(cols), renderRow(widths.map((w) => '-'.repeat(w)))];
26
- for (const row of rows) {
27
- lines.push(renderRow(cols.map((col) => cellText(row, col))));
28
- }
29
- return lines.join('\n');
30
- }
31
-
32
- function renderMemoryResultsTable(items) {
33
- const rows = (Array.isArray(items) ? items : []).map((m) => ({
34
- id: m.id || m._id || '',
35
- category: m.category || '',
36
- // A row marked usage 'style_preference_only' (a writing voice memory) says so, so a person
37
- // reading the table sees what an agent reading the JSON sees.
38
- summary: truncateForTable(m.summary, 60) + (m.usage === 'style_preference_only' ? ' [style]' : ''),
39
- // The recall front door emits `score` (toQueryRow plus score in mcp/tools/memory.ts); the
40
- // older /query route emitted rankScore/similarityScore. Read all three so the column is not
41
- // blank against a real /recall row. On the list pathways score is deliberately absent, and
42
- // an empty cell is the honest rendering of "this pathway does not rank".
43
- score: m.score != null ? m.score : (m.rankScore != null ? m.rankScore : (m.similarityScore != null ? m.similarityScore : ''))
44
- }));
45
- return renderTable(rows, ['id', 'category', 'summary', 'score']);
46
- }
47
-
48
- // The retrieval pathways `memory query --path` may pin. Same four names the server's shared
49
- // normalizer accepts (packages/server/src/services/recallParams.service.ts). Omitting --path
50
- // leaves the classifier deciding, exactly as before.
51
- const RECALL_PATHS = ['semantic', 'list', 'graph', 'hebbian'];
52
- const RECALL_HOPS = [1, 2];
53
- // Hops only mean something on a pathway that walks relationships.
54
- const RECALL_HOP_PATHS = ['graph', 'hebbian'];
55
- // Reminder states `memory query --remind-state` may keep. Same enum the server's shared
56
- // normalizer and Memory.remindState declare.
57
- const REMIND_STATES = ['pending', 'notified', 'acknowledged', 'cancelled'];
58
-
59
- // ─── Scoped `--help` (C-005 item 5) ───────────────────────────────────────────
60
- // `empirical memory --help` / `empirical memory <sub> --help` print scoped usage instead of
61
- // the 90-line global help wall bin/empirical.js otherwise short-circuits every --help to.
62
-
63
- const MEMORY_SUBCOMMAND_USAGE = {
64
- query: 'Usage: empirical memory query --match <query> [--top-k 3] [--category <cat>] [--tags <t1,t2>] [--tags-all <t1,t2>] [--status <s1,s2>] [--due-before <ISO date>] [--due-after <ISO date>] [--remind-state pending,notified] [--path semantic|list|graph|hebbian] [--hops 1|2] [--workspaces <n|name|id,...>] [--all-workspaces] [--pretty] [--format table]',
65
- get: 'Usage: empirical memory get --memory-id <id> [--pretty]',
66
- list: 'Usage: empirical memory list [--category <cat>] [--tags <t1,t2>] [--start-date <ISO>] [--end-date <ISO>] [--limit 20] [--cursor <cursor>] [--oldest] [--pretty] [--format table]',
67
- record: 'Usage: empirical memory record --category <cat> (--summary <text> | --summary - | --summary-file <path>) [--tags t1,t2] [--node-type observation] [--json-file <path>] [--no-dedupe] [--no-auto-link] [--author-type user|agent]',
68
- note: 'Usage: empirical memory note --text <note> (--match <query> | --memory-id <id>) [--min-score 0.35]',
69
- update: 'Usage: empirical memory update (--match <query> | --memory-id <id>) (--patch-json <json> | --json-file <path>) [--min-score 0.35]',
70
- acknowledge: 'Usage: empirical memory acknowledge --memory-id <id> [--cancel] (marks a due reminder done, or dropped with --cancel; frees its slot and clears the alert)',
71
- delete: 'Usage: empirical memory delete (--match <query> | --memory-id <id>) [--confirm] [--min-score 0.35]\nUsage: empirical memory delete (--tag <t> | --category <c> | --all) --confirm',
72
- duplicates: 'Usage: empirical memory duplicates --memory-id <id>',
73
- merge: 'Usage: empirical memory merge --canonical-id <id> --duplicate-id <id> [--no-merge-tags] [--no-merge-relationships]',
74
- export: 'Usage: empirical memory export --scope <personal|workspace> [--out <path>] [--workspace-id <id>]',
75
- import: 'Usage: empirical memory import <file> [--dry-run]',
76
- 'bulk-import': 'Usage: empirical memory bulk-import <file> [--dry-run]\nFile format: { "memories": [{ "localKey": "...", "category": "...", "nodeType": "...", "summary": "...", "data": {...} }], "relationships": [{ "fromLocalKey": "...", "toLocalKey": "...", "type": "..." }] }',
77
- stats: 'Usage: empirical memory stats [--format table]',
78
- compact: 'Usage: empirical memory compact [--apply] [--category <cat>]'
79
- };
80
-
81
- function printMemoryHelp(sub) {
82
- if (sub && MEMORY_SUBCOMMAND_USAGE[sub]) {
83
- log(MEMORY_SUBCOMMAND_USAGE[sub]);
84
- return;
85
- }
86
- log(DOC_TOPICS.memory);
87
- log('');
88
- log('Subcommands: ' + Object.keys(MEMORY_SUBCOMMAND_USAGE).join(', '));
89
- }
90
-
91
- async function withCliAnalyticsContext(context, fn) {
92
- globalThis.__empiricalCliAnalyticsContext = context || {};
93
- try {
94
- return await fn();
95
- } finally {
96
- globalThis.__empiricalCliAnalyticsContext = null;
97
- }
98
- }
99
-
100
- async function resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters) {
101
- const queryUrl = new URL('/api/v1/memories/query', baseUrl).toString();
102
- const body = { query: match, topK: 5 };
103
- if (filters && Object.keys(filters).length) body.filters = filters;
104
- let response;
105
- try {
106
- response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
107
- } catch (error) {
108
- return { found: false, reason: 'error', message: error.message };
109
- }
110
-
111
- const results = (response && response.data && Array.isArray(response.data.results))
112
- ? response.data.results
113
- : (Array.isArray(response) ? response : []);
114
-
115
- if (!results.length) {
116
- return { found: false, reason: 'no_results' };
117
- }
118
-
119
- const scored = results.map((r) => ({
120
- id: r.id,
121
- summary: r.summary,
122
- score: r.rankScore != null ? r.rankScore : (r.similarityScore != null ? r.similarityScore : 0)
123
- }));
124
-
125
- scored.sort((a, b) => b.score - a.score);
126
-
127
- // A caller-supplied filter (e.g. --tags) that narrows the server-side result set to exactly
128
- // one memory is already a stronger disambiguation signal than the semantic score - trust it
129
- // outright rather than falling through to the score check below. This matters because Atlas
130
- // vector search has a real async indexing lag: a memory created moments ago can come back
131
- // with score 0 (unindexed, not "not this one") even when a tag filter already proves it is
132
- // the only candidate. Fixes a real bug found 2026-08-25 where delete --match failed on a
133
- // memory `query` found instantly, because delete had no way to trust a filtered, unambiguous
134
- // result over a stale/zero semantic score.
135
- if (filters && Object.keys(filters).length && results.length === 1) {
136
- const only = scored[0];
137
- return { found: true, memory: { id: only.id, summary: only.summary, score: only.score } };
138
- }
139
-
140
- const top = scored[0];
141
- const second = scored[1];
142
-
143
- if (top.score < minScore) {
144
- return { found: false, reason: 'low_score', candidates: scored.slice(0, 3) };
145
- }
146
-
147
- if (second && (top.score - second.score) <= 0.05) {
148
- return { found: false, reason: 'ambiguous', candidates: scored.slice(0, 3) };
149
- }
150
-
151
- return { found: true, memory: { id: top.id, summary: top.summary, score: top.score } };
152
- }
153
-
154
- // --match resolution depends on Atlas vector search, which has a real async indexing lag:
155
- // a memory created moments ago can be invisible to $vectorSearch even though it is already
156
- // findable by tag. Letting --match commands pass --tags narrows the candidate pool directly
157
- // instead of hoping semantic ranking surfaces the right one - the fix for a real bug found
158
- // 2026-08-25 where delete --match returned unrelated score-0 candidates for a memory that
159
- // `query` found instantly. Deliberately --tags (plural) only, not --category: `delete` already
160
- // uses singular --tag/--category to mean "bulk delete everything in this category", so adding
161
- // --category here would silently reroute a --match delete into a bulk delete instead of
162
- // narrowing it - --tags never collides with that bulk-mode gate.
163
- function parseMatchFilters(args) {
164
- const tagsRaw = parseOption(args, '--tags', '');
165
- const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
166
- const filters = {};
167
- if (tags.length) filters.tags = tags;
168
- return filters;
169
- }
170
-
171
- function printMemoryResolveError(result) {
172
- if (result.reason === 'no_results') {
173
- warn(JSON.stringify({ error: true, code: 'no_results', message: 'No memories matched the query.' }));
174
- } else if (result.reason === 'low_score') {
175
- warn(JSON.stringify({
176
- error: true,
177
- code: 'low_score',
178
- message: 'Top result score was below --min-score threshold.',
179
- candidates: result.candidates
180
- }));
181
- } else if (result.reason === 'ambiguous') {
182
- warn(JSON.stringify({
183
- error: true,
184
- code: 'ambiguous',
185
- message: 'Multiple memories matched with similar scores. Use --memory-id to target one directly.',
186
- candidates: result.candidates
187
- }));
188
- } else if (result.reason === 'error') {
189
- warn(JSON.stringify({ error: true, code: 'query_error', message: result.message }));
190
- } else {
191
- warn(JSON.stringify({ error: true, code: 'resolve_failed', message: 'Could not resolve memory.' }));
192
- }
193
- }
194
-
195
- // ─── Memory subcommands ───────────────────────────────────────────────────────
196
-
197
- /**
198
- * Turn `--workspaces` selectors (list numbers, names, or ids) into workspace ids using the
199
- * per-user listing (/api/v1/users/me/workspaces, the same list `workspace list` prints; number 1
200
- * is always the Default Workspace). An id (ws_...) passes through without a lookup. An unknown
201
- * name is fatal at the terminal rather than a 403 or an empty answer after the round trip.
202
- */
203
- async function resolveWorkspaceIdsForQuery({ baseUrl, accessToken, selectors = [], all = false }) {
204
- const needsListing = all || selectors.some((s) => !/^ws_/.test(s));
205
- let options = [];
206
- if (needsListing) {
207
- const listUrl = new URL('/api/v1/users/me/workspaces', baseUrl).toString();
208
- let payload;
209
- try {
210
- payload = await httpJson(listUrl, 'GET', undefined, buildApiAuthHeaders(accessToken));
211
- } catch (error) {
212
- printApiError(error);
213
- process.exit(1);
214
- }
215
- options = [
216
- ...(payload?.defaultOption ? [payload.defaultOption] : []),
217
- ...(Array.isArray(payload?.workspaces) ? payload.workspaces : [])
218
- ].filter((o) => o && o.workspaceId);
219
- }
220
- if (all) return [...new Set(options.map((o) => o.workspaceId))];
221
-
222
- const ids = [];
223
- for (const selector of selectors) {
224
- if (/^ws_/.test(selector)) { ids.push(selector); continue; }
225
- const asNumber = Number(selector);
226
- let match = null;
227
- if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= options.length) {
228
- match = options[asNumber - 1];
229
- } else {
230
- const lower = selector.toLowerCase();
231
- const byLabel = options.filter((o) => String(o.label || '').toLowerCase() === lower || String(o.key || '').toLowerCase() === lower);
232
- if (byLabel.length > 1) {
233
- warn(`Ambiguous workspace "${selector}". Use its list number or workspace id.`);
234
- process.exit(1);
235
- }
236
- match = byLabel[0] || null;
237
- }
238
- if (!match) {
239
- warn(`Workspace not found: "${selector}". Run \`empirical workspace list\` to see names and numbers.`);
240
- process.exit(1);
241
- }
242
- ids.push(match.workspaceId);
243
- }
244
- return [...new Set(ids)];
245
- }
246
-
247
- async function runMemoryQuery(args) {
248
- const match = parseOption(args, '--match', '');
249
- if (!match) {
250
- warn(MEMORY_SUBCOMMAND_USAGE.query);
251
- process.exit(1);
252
- }
253
-
254
- const topKRaw = Number(parseOption(args, '--top-k', '3'));
255
- const topK = Math.min(100, Math.max(1, Number.isFinite(topKRaw) ? topKRaw : 3));
256
- const category = parseOption(args, '--category', '');
257
- const tagsRaw = parseOption(args, '--tags', '');
258
- const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
259
- // R3 (2026-09-06): --tags-all is an AND match (all must be present), unlike --tags' OR match;
260
- // --status filters data.status. Both are optional and additive to --tags/--category.
261
- const tagsAllRaw = parseOption(args, '--tags-all', '');
262
- const tagsAll = tagsAllRaw ? tagsAllRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
263
- // Multi-workspace recall (2026-09-24). --workspaces takes list numbers, names, or ids; names
264
- // and numbers are resolved against the same listing `workspace list` prints, so the server
265
- // contract stays ids-only. --all-workspaces sends every selectable workspace. Without either
266
- // flag the query is scoped to the single active workspace exactly as before.
267
- const workspacesRaw = parseOption(args, '--workspaces', '');
268
- const workspaceSelectors = workspacesRaw ? workspacesRaw.split(',').map((w) => w.trim()).filter(Boolean) : [];
269
- const allWorkspaces = parseFlag(args, '--all-workspaces');
270
- if (workspaceSelectors.length && allWorkspaces) {
271
- warn('Pass either --workspaces or --all-workspaces, not both.');
272
- process.exit(1);
273
- }
274
- const statusRaw = parseOption(args, '--status', '');
275
- const status = statusRaw ? statusRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
276
- const pretty = parseFlag(args, '--pretty');
277
- const format = parseOption(args, '--format', 'json');
278
-
279
- // Reminder filters. Dates are parsed HERE as well as server-side, for the same reason --due
280
- // is on record: a typo fails at the terminal, not as a 400 after the round trip. Anything
281
- // Date can parse is accepted and sent as the caller typed it.
282
- const dueFilters = {};
283
- for (const [flag, key] of [['--due-before', 'dueBefore'], ['--due-after', 'dueAfter']]) {
284
- const raw = parseOption(args, flag, '');
285
- if (!raw) continue;
286
- if (Number.isNaN(new Date(raw).getTime())) {
287
- warn(`Invalid ${flag} "${raw}". Use an ISO 8601 date, for example 2026-10-14 or 2026-10-14T09:00:00Z.`);
288
- process.exit(1);
289
- }
290
- dueFilters[key] = String(raw).trim();
291
- }
292
- const remindStateRaw = parseOption(args, '--remind-state', '');
293
- const remindState = remindStateRaw
294
- ? remindStateRaw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
295
- : [];
296
- const unknownStates = remindState.filter((s) => !REMIND_STATES.includes(s));
297
- if (unknownStates.length) {
298
- warn(`--remind-state must be one of: ${REMIND_STATES.join(', ')} (received ${JSON.stringify(unknownStates)})`);
299
- process.exit(1);
300
- }
301
-
302
- // --path / --hops are validated HERE, where the value is decided, so a typo costs no round
303
- // trip and the message names the four pathways. The server validates the same way (the shared
304
- // normalizer in packages/server/src/services/recallParams.service.ts); this is a fast local
305
- // echo of that contract, never a second, looser one.
306
- const pathRaw = parseOption(args, '--path', '');
307
- let path = '';
308
- if (pathRaw) {
309
- path = String(pathRaw).trim().toLowerCase();
310
- if (!RECALL_PATHS.includes(path)) {
311
- warn(`--path must be one of: ${RECALL_PATHS.join(', ')} (received ${JSON.stringify(pathRaw)})`);
312
- process.exit(1);
313
- }
314
- }
315
-
316
- const hopsRaw = parseOption(args, '--hops', '');
317
- let hops = null;
318
- if (hopsRaw) {
319
- const parsedHops = Number(hopsRaw);
320
- if (!RECALL_HOPS.includes(parsedHops)) {
321
- warn(`--hops must be 1 or 2 (received ${JSON.stringify(hopsRaw)})`);
322
- process.exit(1);
323
- }
324
- // Silently ignoring --hops on a ranked or list pathway would look like it took effect.
325
- if (!RECALL_HOP_PATHS.includes(path)) {
326
- warn(`--hops only applies with --path graph or --path hebbian${path ? ` (received --path ${path})` : ' (no --path given)'}`);
327
- process.exit(1);
328
- }
329
- hops = parsedHops;
330
- }
331
-
332
- const accessToken = requireAccessToken();
333
- const baseUrl = resolveApiBaseUrl();
334
- // The recall front door: the same answer the MCP query_memories tool gives, which /query never
335
- // gave (classifier routing, the list block, the exact-tag retry, agent-note exclusion,
336
- // retrievalPath). It takes its parameters FLAT, matching the shared normalizer, not nested
337
- // under `filters` the way the older /query route did. Send one shape only.
338
- const queryUrl = new URL('/api/v1/memories/recall', baseUrl).toString();
339
-
340
- const body = { query: match, topK };
341
- if (category) body.category = category;
342
- if (tags.length) body.tags = tags;
343
- if (tagsAll.length) body.tagsAll = tagsAll;
344
- if (workspaceSelectors.length || allWorkspaces) {
345
- body.workspaceIds = await resolveWorkspaceIdsForQuery({
346
- baseUrl,
347
- accessToken,
348
- selectors: workspaceSelectors,
349
- all: allWorkspaces
350
- });
351
- }
352
- if (status.length) body.status = status;
353
- if (dueFilters.dueBefore) body.dueBefore = dueFilters.dueBefore;
354
- if (dueFilters.dueAfter) body.dueAfter = dueFilters.dueAfter;
355
- if (remindState.length) body.remindState = remindState;
356
- if (path) body.path = path;
357
- if (hops !== null) body.hops = hops;
358
-
359
- let response;
360
- try {
361
- response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
362
- } catch (error) {
363
- // A 404 here means the server predates the recall front door. Say that plainly instead of
364
- // rerouting to /query: that fallback would quietly answer through a different routing path
365
- // with different defaults (topK 10 against 3) and keep the old route alive forever.
366
- if (error && error.statusCode === 404) {
367
- warn(JSON.stringify({
368
- error: true,
369
- code: 'recall_route_missing',
370
- message: 'This server does not serve /api/v1/memories/recall yet. Upgrade the Empirical server, or use an older CLI against it.'
371
- }));
372
- process.exit(1);
373
- }
374
- printApiError(error);
375
- process.exit(1);
376
- }
377
-
378
- // The front door answers with data.memories; the older /query route answered with data.results.
379
- // Both are accepted so a CLI pointed at a server that predates /recall still prints rows.
380
- const data = response && response.data ? response.data : null;
381
- const results = (data && Array.isArray(data.memories))
382
- ? data.memories
383
- : ((data && Array.isArray(data.results))
384
- ? data.results
385
- : (Array.isArray(response) ? response : []));
386
-
387
- if (format === 'table') {
388
- log(renderMemoryResultsTable(results));
389
- return;
390
- }
391
-
392
- if (pretty) {
393
- log(JSON.stringify(results, null, 2));
394
- } else {
395
- log(JSON.stringify(results));
396
- }
397
- }
398
-
399
- async function runMemoryGet(args) {
400
- const memoryId = parseOption(args, '--memory-id', '');
401
- const pretty = parseFlag(args, '--pretty');
402
- if (!memoryId) {
403
- warn('Usage: empirical memory get --memory-id <id> [--pretty]');
404
- process.exit(1);
405
- }
406
-
407
- const accessToken = requireAccessToken();
408
- const baseUrl = resolveApiBaseUrl();
409
- const url = new URL(`/api/v1/memories/${encodeURIComponent(memoryId)}`, baseUrl).toString();
410
- let response;
411
- try {
412
- response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
413
- } catch (error) {
414
- printApiError(error);
415
- process.exit(1);
416
- }
417
- const data = response && response.data ? response.data : response;
418
- log(pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
419
- }
420
-
421
- async function runMemoryList(args) {
422
- const category = parseOption(args, '--category', '');
423
- const tags = parseOption(args, '--tags', '');
424
- const startDate = parseOption(args, '--start-date', '');
425
- const endDate = parseOption(args, '--end-date', '');
426
- const limitRaw = Number(parseOption(args, '--limit', '20'));
427
- const limit = Math.min(100, Math.max(1, Number.isFinite(limitRaw) ? limitRaw : 20));
428
- const cursor = parseOption(args, '--cursor', '');
429
- const sortOrder = parseFlag(args, '--oldest') ? 'asc' : 'desc';
430
- const pretty = parseFlag(args, '--pretty');
431
- const format = parseOption(args, '--format', 'json');
432
-
433
- const params = new URLSearchParams();
434
- if (category) params.set('category', category);
435
- if (tags) params.set('tags', tags);
436
- if (startDate) params.set('startDate', startDate);
437
- if (endDate) params.set('endDate', endDate);
438
- params.set('limit', String(limit));
439
- if (cursor) params.set('cursor', cursor);
440
- params.set('sortBy', 'createdAt');
441
- params.set('sortOrder', sortOrder);
442
-
443
- const accessToken = requireAccessToken();
444
- const baseUrl = resolveApiBaseUrl();
445
- const url = new URL(`/api/v1/memories?${params.toString()}`, baseUrl).toString();
446
- let response;
447
- try {
448
- response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
449
- } catch (error) {
450
- printApiError(error);
451
- process.exit(1);
452
- }
453
- const data = response && response.data ? response.data : response;
454
-
455
- if (format === 'table') {
456
- const items = Array.isArray(data && data.memories) ? data.memories : (Array.isArray(data) ? data : []);
457
- log(renderMemoryResultsTable(items));
458
- return;
459
- }
460
-
461
- log(pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
462
- }
463
-
464
- // Shell substitution silently eats code out of a double-quoted --summary: backticks become
465
- // command substitution and ${...} becomes variable expansion BEFORE the CLI ever sees the
466
- // text, and the write still succeeds, so the memory looks fine with the code gone. Two
467
- // defenses: (1) inputs that never pass through shell quoting -- `--summary-file <path>` and
468
- // `--summary -` (stdin, pair it with a single-quoted heredoc); (2) a warning when an inline
469
- // summary carries the residue of an eaten substitution.
470
- function resolveSummaryInput(args, { readFile = (p) => fs.readFileSync(path.resolve(p), 'utf8'), readStdin = () => fs.readFileSync(0, 'utf8') } = {}) {
471
- const summaryFile = parseOption(args, '--summary-file', '');
472
- if (summaryFile) {
473
- return { summary: String(readFile(summaryFile)).replace(/\r?\n$/, ''), source: 'file' };
474
- }
475
- const inline = parseOption(args, '--summary', '');
476
- if (inline === '-') {
477
- return { summary: String(readStdin()).replace(/\r?\n$/, ''), source: 'stdin' };
478
- }
479
- return { summary: inline, source: inline ? 'inline' : 'none' };
480
- }
481
-
482
- function detectShellMangling(summary) {
483
- const text = String(summary || '');
484
- const reasons = [];
485
- if (/``/.test(text)) reasons.push('an empty backtick pair (a `command` was probably substituted away)');
486
- if (/\$(\s|$|[^A-Za-z0-9_{(])/.test(text) && !/\$\d/.test(text)) reasons.push('a lone "$" (a ${VAR} or $(cmd) was probably expanded to nothing)');
487
- if (/\$\{\s*\}/.test(text)) reasons.push('an empty ${} placeholder');
488
- return reasons;
489
- }
490
-
491
- async function runMemoryRecord(args) {
492
- const jsonFile = parseOption(args, '--json-file', '');
493
- let payload;
494
-
495
- if (jsonFile) {
496
- try {
497
- payload = JSON.parse(fs.readFileSync(path.resolve(jsonFile), 'utf8'));
498
- } catch (error) {
499
- warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
500
- process.exit(1);
501
- }
502
- } else {
503
- const category = parseOption(args, '--category', '');
504
- const { summary, source: summarySource } = resolveSummaryInput(args);
505
-
506
- if (summarySource === 'inline') {
507
- const mangled = detectShellMangling(summary);
508
- if (mangled.length) {
509
- warn(JSON.stringify({
510
- warning: 'possible_shell_substitution',
511
- message: 'The summary looks like the shell altered it before the CLI saw it: ' + mangled.join('; ') + '. Recording anyway. For text containing backticks or ${...}, use --summary-file <path>, or --summary - with a single-quoted heredoc, or the MCP record_graph_memory tool.'
512
- }));
513
- }
514
- }
515
-
516
- if (!category || !summary) {
517
- warn('Usage: empirical memory record --category <cat> (--summary <text> | --summary - | --summary-file <path>) [--tags t1,t2] [--node-type observation] [--json-file <path>] [--no-dedupe] [--no-auto-link] [--author-type user|agent]');
518
- warn('Valid categories: ' + MEMORY_CATEGORIES.join(', '));
519
- warn('Valid node types: ' + NODE_TYPES.join(', '));
520
- process.exit(1);
521
- }
522
-
523
- if (!MEMORY_CATEGORIES.includes(category)) {
524
- warn(JSON.stringify({
525
- error: true,
526
- code: 'invalid_category',
527
- message: `Invalid category "${category}". Valid: ${MEMORY_CATEGORIES.join(', ')}`
528
- }));
529
- process.exit(1);
530
- }
531
-
532
- const tagsRaw = parseOption(args, '--tags', '');
533
- const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
534
- const nodeType = parseOption(args, '--node-type', 'observation');
535
-
536
- if (!NODE_TYPES.includes(nodeType)) {
537
- warn(JSON.stringify({
538
- error: true,
539
- code: 'invalid_node_type',
540
- message: `Invalid nodeType "${nodeType}". Valid: ${NODE_TYPES.join(', ')}`
541
- }));
542
- process.exit(1);
543
- }
544
-
545
- payload = { category, summary, nodeType, data: { text: String(summary) } };
546
- if (tags.length) payload.tags = tags;
547
- }
548
-
549
- if (
550
- payload
551
- && payload.summary !== undefined
552
- && payload.data === undefined
553
- && payload.originalPayload === undefined
554
- ) {
555
- payload.data = { text: String(payload.summary) };
556
- }
557
-
558
- // C-002 (docs/prd-cli-and-plugin-fixes.md): the server now dedupes/auto-links every
559
- // POST /api/v1/memories write by default, matching the MCP record_graph_memory tool's
560
- // guarantee. Only send dedupeEnabled/autoLink at all when the caller explicitly opted
561
- // out here -- omitting them entirely lets the server's own default-on behavior apply,
562
- // rather than this CLI baking in a redundant "true" that could drift from the server's
563
- // actual default over time.
564
- if (parseFlag(args, '--no-dedupe')) {
565
- payload.dedupeEnabled = false;
566
- }
567
- if (parseFlag(args, '--no-auto-link')) {
568
- payload.autoLink = false;
569
- }
570
-
571
- // Authorship (2026-09-09). `source` is observable truth: this is the CLI, so say so.
572
- // Until now the CLI sent nothing, the server fell back to the transport it saw ('api'),
573
- // and api maps to authorType 'user' because that mapping was written for the web portal.
574
- // Every agent write through this command was therefore stored as authored by the user.
575
- // 'cli' resolves to authorType 'unknown', which is the honest answer when nobody says.
576
- if (payload.source === undefined) payload.source = 'cli';
577
-
578
- // --author-type lets the caller state what the transport cannot know. An agent recording
579
- // its own session notes should pass 'agent'; an agent recording something the user said
580
- // or decided should pass 'user'. Omitted, it stays 'unknown' rather than being guessed.
581
- const authorType = parseOption(args, '--author-type', '');
582
- if (authorType) {
583
- if (!['user', 'agent', 'system'].includes(authorType)) {
584
- warn(JSON.stringify({
585
- error: true,
586
- code: 'invalid_author_type',
587
- message: `Invalid --author-type "${authorType}". Valid: user, agent, system`
588
- }));
589
- process.exit(1);
590
- }
591
- payload.authorType = authorType;
592
- }
593
-
594
- // --due turns the memory into a reminder. Parsed and rejected HERE as well as server-side, so
595
- // a typo fails immediately at the terminal instead of coming back as a 400 after the write was
596
- // composed. Accepts anything Date can parse, so '2026-10-14' and a full ISO timestamp both work.
597
- const dueRaw = parseOption(args, '--due', '');
598
- if (dueRaw) {
599
- const parsedDue = new Date(dueRaw);
600
- if (Number.isNaN(parsedDue.getTime())) {
601
- warn(JSON.stringify({
602
- error: true,
603
- code: 'invalid_due',
604
- message: `Invalid --due "${dueRaw}". Use an ISO 8601 date, for example 2026-10-14 or 2026-10-14T09:00:00Z.`
605
- }));
606
- process.exit(1);
607
- }
608
- payload.dueAt = parsedDue.toISOString();
609
- }
610
-
611
- const accessToken = requireAccessToken();
612
- const baseUrl = resolveApiBaseUrl();
613
- const recordUrl = new URL('/api/v1/memories', baseUrl).toString();
614
-
615
- let response;
616
- try {
617
- response = await httpJson(recordUrl, 'POST', payload, buildApiAuthHeaders(accessToken));
618
- } catch (error) {
619
- printApiError(error);
620
- process.exit(1);
621
- }
622
-
623
- const created = (response && response.data) ? response.data : response;
624
- log(JSON.stringify(created));
625
- }
626
-
627
- async function runMemoryNote(args) {
628
- const match = parseOption(args, '--match', '');
629
- const text = parseOption(args, '--text', '');
630
- const memoryId = parseOption(args, '--memory-id', '');
631
- const minScore = Number(parseOption(args, '--min-score', '0.35'));
632
-
633
- if (!text) {
634
- warn('Usage: empirical memory note --text <note> (--match <query> | --memory-id <id>) [--tags <t1,t2>] [--min-score 0.35]');
635
- process.exit(1);
636
- }
637
-
638
- if (!match && !memoryId) {
639
- warn('Usage: empirical memory note --text <note> (--match <query> | --memory-id <id>) [--tags <t1,t2>]');
640
- process.exit(1);
641
- }
642
-
643
- const accessToken = requireAccessToken();
644
- const baseUrl = resolveApiBaseUrl();
645
-
646
- let targetId = memoryId;
647
- if (!targetId) {
648
- const filters = parseMatchFilters(args);
649
- const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters);
650
- if (!result.found) {
651
- printMemoryResolveError(result);
652
- process.exit(1);
653
- }
654
- targetId = result.memory.id;
655
- }
656
-
657
- const noteUrl = new URL(`/api/v1/memories/${encodeURIComponent(targetId)}/notes`, baseUrl).toString();
658
- let response;
659
- try {
660
- response = await httpJson(noteUrl, 'POST', { text }, buildApiAuthHeaders(accessToken));
661
- } catch (error) {
662
- printApiError(error);
663
- process.exit(1);
664
- }
665
-
666
- const created = (response && response.data) ? response.data : response;
667
- log(JSON.stringify(created));
668
- }
669
-
670
- // Closing a reminder from the terminal or from an agent reading a CLI banner. One PUT, the same
671
- // transition the portal's Mark done / Cancel make; the server retires the alert on every surface.
672
- async function runMemoryAcknowledge(args) {
673
- const memoryId = parseOption(args, '--memory-id', '');
674
- const cancel = parseFlag(args, '--cancel');
675
- if (!memoryId) {
676
- warn(MEMORY_SUBCOMMAND_USAGE.acknowledge);
677
- process.exit(1);
678
- }
679
- const accessToken = requireAccessToken();
680
- const baseUrl = resolveApiBaseUrl();
681
- const url = new URL(`/api/v1/memories/${encodeURIComponent(memoryId)}`, baseUrl).toString();
682
- let response;
683
- try {
684
- response = await httpJson(url, 'PUT', { remindState: cancel ? 'cancelled' : 'acknowledged' }, buildApiAuthHeaders(accessToken));
685
- } catch (error) {
686
- printApiError(error);
687
- process.exit(1);
688
- }
689
- const data = response && response.data ? response.data : response;
690
- log(JSON.stringify({ id: data.id, remindState: data.remindState, dueAt: data.dueAt || null, message: cancel ? 'Reminder cancelled. Its slot is free.' : 'Reminder marked done. Its slot is free.' }, null, 2));
691
- }
692
-
693
- async function runMemoryUpdate(args) {
694
- const match = parseOption(args, '--match', '');
695
- const patchJson = parseOption(args, '--patch-json', '');
696
- const jsonFile = parseOption(args, '--json-file', '');
697
- const memoryId = parseOption(args, '--memory-id', '');
698
- const minScore = Number(parseOption(args, '--min-score', '0.35'));
699
-
700
- if (!patchJson && !jsonFile) {
701
- warn('Usage: empirical memory update (--match <query> | --memory-id <id>) (--patch-json <json> | --json-file <path>) [--tags <t1,t2>]');
702
- process.exit(1);
703
- }
704
-
705
- if (!match && !memoryId) {
706
- warn('Usage: empirical memory update (--match <query> | --memory-id <id>) (--patch-json <json> | --json-file <path>) [--tags <t1,t2>]');
707
- process.exit(1);
708
- }
709
-
710
- let patch;
711
- if (jsonFile) {
712
- try {
713
- patch = JSON.parse(fs.readFileSync(path.resolve(jsonFile), 'utf8'));
714
- } catch (error) {
715
- warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
716
- process.exit(1);
717
- }
718
- } else {
719
- try {
720
- patch = JSON.parse(patchJson);
721
- } catch (error) {
722
- warn(JSON.stringify({ error: true, code: 'invalid_json', message: `--patch-json parse error: ${error.message}` }));
723
- process.exit(1);
724
- }
725
- }
726
-
727
- const accessToken = requireAccessToken();
728
- const baseUrl = resolveApiBaseUrl();
729
-
730
- let targetId = memoryId;
731
- if (!targetId) {
732
- const filters = parseMatchFilters(args);
733
- const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters);
734
- if (!result.found) {
735
- printMemoryResolveError(result);
736
- process.exit(1);
737
- }
738
- targetId = result.memory.id;
739
- }
740
-
741
- const updateUrl = new URL(`/api/v1/memories/${encodeURIComponent(targetId)}`, baseUrl).toString();
742
- let response;
743
- try {
744
- response = await httpJson(updateUrl, 'PUT', patch, buildApiAuthHeaders(accessToken));
745
- } catch (error) {
746
- printApiError(error);
747
- process.exit(1);
748
- }
749
-
750
- const updated = (response && response.data) ? response.data : response;
751
- log(JSON.stringify(updated));
752
- }
753
-
754
- async function runMemoryDeleteBulk(args) {
755
- const tag = parseOption(args, '--tag', '');
756
- const category = parseOption(args, '--category', '');
757
- const all = parseFlag(args, '--all');
758
- const confirm = parseFlag(args, '--confirm');
759
-
760
- const selectorCount = [Boolean(tag), Boolean(category), all].filter(Boolean).length;
761
- if (selectorCount !== 1) {
762
- warn('Usage: empirical memory delete (--tag <t> | --category <c> | --all) --confirm');
763
- process.exit(1);
764
- }
765
-
766
- const accessToken = requireAccessToken();
767
- const baseUrl = resolveApiBaseUrl();
768
- const url = new URL('/api/v1/memory-bulk-delete', baseUrl).toString();
769
- const body = { confirm };
770
- if (tag) body.tag = tag;
771
- if (category) body.category = category;
772
- if (all) body.all = true;
773
-
774
- let response;
775
- try {
776
- response = await httpJson(url, 'POST', body, buildApiAuthHeaders(accessToken));
777
- } catch (error) {
778
- printApiError(error);
779
- process.exit(1);
780
- }
781
-
782
- // Server always reports the count first: without --confirm it's a dry-run preview and no
783
- // writes happened; with --confirm it reports what it just deleted. Either way the count is
784
- // printed, matching the single-memory delete's dry-run-by-default convention above.
785
- const data = (response && response.data) ? response.data : response;
786
- log(JSON.stringify(data));
787
- }
788
-
789
- async function runMemoryDelete(args) {
790
- const tag = parseOption(args, '--tag', '');
791
- const category = parseOption(args, '--category', '');
792
- const all = parseFlag(args, '--all');
793
- if (tag || category || all) {
794
- return runMemoryDeleteBulk(args);
795
- }
796
-
797
- const match = parseOption(args, '--match', '');
798
- const memoryId = parseOption(args, '--memory-id', '');
799
- const minScore = Number(parseOption(args, '--min-score', '0.35'));
800
- const confirm = parseFlag(args, '--confirm');
801
-
802
- if (!match && !memoryId) {
803
- warn('Usage: empirical memory delete (--match <query> | --memory-id <id>) [--tags <t1,t2>] [--confirm] [--min-score 0.35]');
804
- warn(' or: empirical memory delete (--tag <t> | --category <c> | --all) --confirm');
805
- process.exit(1);
806
- }
807
-
808
- const accessToken = requireAccessToken();
809
- const baseUrl = resolveApiBaseUrl();
810
-
811
- let targetId = memoryId;
812
- let targetSummary = '';
813
-
814
- if (!targetId) {
815
- const filters = parseMatchFilters(args);
816
- const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters);
817
- if (!result.found) {
818
- if (!confirm && (result.reason === 'ambiguous' || result.reason === 'low_score')) {
819
- // Dry-run: return candidates info
820
- log(JSON.stringify({ dryRun: true, wouldDelete: false, reason: result.reason, candidates: result.candidates }));
821
- process.exit(0);
822
- }
823
- printMemoryResolveError(result);
824
- process.exit(1);
825
- }
826
- targetId = result.memory.id;
827
- targetSummary = result.memory.summary;
828
- }
829
-
830
- if (!confirm) {
831
- log(JSON.stringify({ dryRun: true, wouldDelete: true, id: targetId, summary: targetSummary }));
832
- process.exit(0);
833
- }
834
-
835
- const deleteUrl = new URL(`/api/v1/memories/${encodeURIComponent(targetId)}`, baseUrl).toString();
836
- try {
837
- await httpJson(deleteUrl, 'DELETE', null, buildApiAuthHeaders(accessToken));
838
- } catch (error) {
839
- printApiError(error);
840
- process.exit(1);
841
- }
842
-
843
- log(JSON.stringify({ deleted: true, id: targetId, summary: targetSummary }));
844
- }
845
-
846
- async function runMemoryDuplicates(args) {
847
- const memoryId = parseOption(args, '--memory-id', '');
848
- if (!memoryId) {
849
- warn('Usage: empirical memory duplicates --memory-id <id>');
850
- process.exit(1);
851
- }
852
-
853
- const accessToken = requireAccessToken();
854
- const baseUrl = resolveApiBaseUrl();
855
- const url = new URL(`/api/v1/memories/${encodeURIComponent(memoryId)}/duplicate-candidates`, baseUrl).toString();
856
-
857
- let response;
858
- try {
859
- response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
860
- } catch (error) {
861
- printApiError(error);
862
- process.exit(1);
863
- }
864
-
865
- const data = (response && response.data) ? response.data : response;
866
- log(JSON.stringify(data));
867
- }
868
-
869
- async function runMemoryMerge(args) {
870
- const canonicalId = parseOption(args, '--canonical-id', '');
871
- const duplicateId = parseOption(args, '--duplicate-id', '');
872
- const mergeTags = !parseFlag(args, '--no-merge-tags');
873
- const mergeRelationships = !parseFlag(args, '--no-merge-relationships');
874
-
875
- if (!canonicalId || !duplicateId) {
876
- warn('Usage: empirical memory merge --canonical-id <id> --duplicate-id <id> [--no-merge-tags] [--no-merge-relationships]');
877
- process.exit(1);
878
- }
879
-
880
- const accessToken = requireAccessToken();
881
- const baseUrl = resolveApiBaseUrl();
882
- const url = new URL('/api/v1/memories/merge', baseUrl).toString();
883
-
884
- let response;
885
- try {
886
- response = await httpJson(url, 'POST', { canonicalId, duplicateId, mergeTags, mergeRelationships }, buildApiAuthHeaders(accessToken));
887
- } catch (error) {
888
- printApiError(error);
889
- process.exit(1);
890
- }
891
-
892
- const data = (response && response.data) ? response.data : response;
893
- log(JSON.stringify(data));
894
- }
895
-
896
- async function runMemoryExport(args) {
897
- const scope = parseOption(args, '--scope', '');
898
- if (scope !== 'personal' && scope !== 'workspace') {
899
- warn('Usage: empirical memory export --scope <personal|workspace> [--out <path>] [--workspace-id <id>]');
900
- process.exit(1);
901
- }
902
-
903
- const workspaceId = parseOption(args, '--workspace-id', '');
904
- if (scope === 'workspace' && !workspaceId) {
905
- warn('Usage: empirical memory export --scope workspace --workspace-id <id> [--out <path>]');
906
- process.exit(1);
907
- }
908
-
909
- const dateStamp = new Date().toISOString().slice(0, 10);
910
- const outPath = parseOption(args, '--out', `./empirical-memory-export-${scope}-${dateStamp}.json`);
911
-
912
- const accessToken = requireAccessToken();
913
- const baseUrl = resolveApiBaseUrl();
914
- const url = new URL(`/api/v1/memories/export?scope=${encodeURIComponent(scope)}`, baseUrl).toString();
915
-
916
- const headers = buildApiAuthHeaders(accessToken);
917
- if (workspaceId) {
918
- headers['x-workspace-id'] = workspaceId;
919
- }
920
-
921
- let response;
922
- try {
923
- response = await httpJson(url, 'GET', undefined, headers);
924
- } catch (error) {
925
- printApiError(error);
926
- process.exit(1);
927
- }
928
-
929
- // The raw server response is the export envelope itself (streamed as one JSON body, not the
930
- // standard {status,data} wrapper): {schemaVersion, generatedAt, scope, workspaceId,
931
- // memoryCount, truncated, memories: [...]}. Re-shape it into the versioned, round-trippable
932
- // format `memory import` consumes: {schemaVersion: 1, exportedAt, memories: [{category,
933
- // summary, tags, nodeType, data, mass, gravity, timeContext, notes, relationships}]}.
934
- const rawMemories = Array.isArray(response && response.memories) ? response.memories : [];
935
- const envelope = {
936
- schemaVersion: 1,
937
- exportedAt: new Date().toISOString(),
938
- memories: rawMemories.map((m) => ({
939
- category: m.category,
940
- summary: m.summary,
941
- tags: Array.isArray(m.tags) ? m.tags : [],
942
- nodeType: m.nodeType,
943
- data: m.data,
944
- mass: m.mass,
945
- gravity: m.gravity,
946
- timeContext: m.timeContext || null,
947
- notes: Array.isArray(m.notes) ? m.notes.map((n) => ({ text: n.text })) : [],
948
- relationships: Array.isArray(m.relationships)
949
- ? m.relationships.map((r) => ({ type: r.type, targetId: r.targetId, weight: r.weight }))
950
- : []
951
- }))
952
- };
953
-
954
- const resolvedPath = path.resolve(outPath);
955
- fs.writeFileSync(resolvedPath, JSON.stringify(envelope, null, 2) + '\n', 'utf8');
956
-
957
- log(JSON.stringify({ exported: true, path: resolvedPath, schemaVersion: envelope.schemaVersion, memoryCount: envelope.memories.length }));
958
- }
959
-
960
- async function runMemoryImport(args) {
961
- const filePath = args.find((a) => a && !a.startsWith('--')) || '';
962
- const dryRun = parseFlag(args, '--dry-run');
963
-
964
- if (!filePath) {
965
- warn('Usage: empirical memory import <file> [--dry-run]');
966
- process.exit(1);
967
- }
968
-
969
- let doc;
970
- try {
971
- doc = JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'));
972
- } catch (error) {
973
- warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
974
- process.exit(1);
975
- }
976
-
977
- const memories = Array.isArray(doc && doc.memories) ? doc.memories : null;
978
- if (!memories) {
979
- warn(JSON.stringify({
980
- error: true,
981
- code: 'invalid_format',
982
- message: 'File is not a valid Empirical memory export: missing a top-level "memories" array. Re-export with `empirical memory export`.'
983
- }));
984
- process.exit(1);
985
- }
986
-
987
- if (dryRun) {
988
- log(JSON.stringify({ dryRun: true, wouldImport: memories.length, schemaVersion: doc.schemaVersion || null }));
989
- return;
990
- }
991
-
992
- const accessToken = requireAccessToken();
993
- const baseUrl = resolveApiBaseUrl();
994
- // Every row goes through the normal create endpoint, so imports get the same dedupe and
995
- // auto-link treatment as any other write, no special-casing needed here.
996
- const recordUrl = new URL('/api/v1/memories', baseUrl).toString();
997
-
998
- let imported = 0;
999
- let failed = 0;
1000
- const errors = [];
1001
-
1002
- for (const row of memories) {
1003
- const payload = {
1004
- category: row.category,
1005
- summary: row.summary,
1006
- nodeType: row.nodeType || 'observation',
1007
- tags: Array.isArray(row.tags) ? row.tags : [],
1008
- data: row.data !== undefined
1009
- ? row.data
1010
- : (row.summary !== undefined ? { text: String(row.summary) } : undefined)
1011
- };
1012
- if (Array.isArray(row.relationships) && row.relationships.length) payload.relationships = row.relationships;
1013
- if (row.mass !== undefined && row.mass !== null) payload.mass = row.mass;
1014
- if (row.gravity !== undefined && row.gravity !== null) payload.gravity = row.gravity;
1015
- if (row.timeContext) payload.timeContext = row.timeContext;
1016
-
1017
- let created;
1018
- try {
1019
- const response = await httpJson(recordUrl, 'POST', payload, buildApiAuthHeaders(accessToken));
1020
- created = (response && response.data) ? response.data : response;
1021
- imported++;
1022
- } catch (error) {
1023
- failed++;
1024
- errors.push({ summary: row.summary, message: error.message });
1025
- continue;
1026
- }
1027
-
1028
- const notes = Array.isArray(row.notes) ? row.notes : [];
1029
- const createdId = created && (created.id || created._id);
1030
- if (notes.length && createdId) {
1031
- const noteUrl = new URL(`/api/v1/memories/${encodeURIComponent(createdId)}/notes`, baseUrl).toString();
1032
- for (const note of notes) {
1033
- const text = typeof note === 'string' ? note : (note && note.text);
1034
- if (!text) continue;
1035
- try {
1036
- await httpJson(noteUrl, 'POST', { text }, buildApiAuthHeaders(accessToken));
1037
- } catch (_error) {
1038
- // Best-effort: a note failing to re-attach shouldn't fail the whole import.
1039
- }
1040
- }
1041
- }
1042
- }
1043
-
1044
- log(JSON.stringify({ imported, failed, total: memories.length, errors: errors.slice(0, 20) }));
1045
- }
1046
-
1047
- async function runMemoryBulkImport(args) {
1048
- const filePath = args.find((a) => a && !a.startsWith('--')) || '';
1049
- const dryRun = parseFlag(args, '--dry-run');
1050
-
1051
- if (!filePath) {
1052
- warn('Usage: empirical memory bulk-import <file> [--dry-run]');
1053
- process.exit(1);
1054
- }
1055
-
1056
- let plan;
1057
- try {
1058
- plan = JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'));
1059
- } catch (error) {
1060
- warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
1061
- process.exit(1);
1062
- }
1063
-
1064
- const memories = Array.isArray(plan && plan.memories) ? plan.memories : null;
1065
- if (!memories) {
1066
- warn(JSON.stringify({
1067
- error: true,
1068
- code: 'invalid_format',
1069
- message: 'File must have a top-level "memories" array. See `empirical memory bulk-import --help`.'
1070
- }));
1071
- process.exit(1);
1072
- }
1073
- const relationships = Array.isArray(plan.relationships) ? plan.relationships : [];
1074
-
1075
- if (dryRun) {
1076
- log(JSON.stringify({ dryRun: true, wouldImportMemories: memories.length, wouldImportRelationships: relationships.length }));
1077
- return;
1078
- }
1079
-
1080
- const accessToken = requireAccessToken();
1081
- const baseUrl = resolveApiBaseUrl();
1082
- const bulkUrl = new URL('/api/v1/memories/bulk', baseUrl).toString();
1083
-
1084
- try {
1085
- const enqueueResponse = await httpJson(bulkUrl, 'POST', { memories, relationships }, buildApiAuthHeaders(accessToken));
1086
- const { jobId } = (enqueueResponse && enqueueResponse.data) ? enqueueResponse.data : enqueueResponse;
1087
- const statusUrl = new URL(`/api/v1/memories/bulk/${jobId}`, baseUrl).toString();
1088
-
1089
- const pollIntervalMs = 3000;
1090
- const maxPolls = 200;
1091
- for (let i = 0; i < maxPolls; i += 1) {
1092
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1093
- const statusResponse = await httpJson(statusUrl, 'GET', undefined, buildApiAuthHeaders(accessToken));
1094
- const job = (statusResponse && statusResponse.data) ? statusResponse.data : statusResponse;
1095
- if (job.status === 'succeeded') { log(JSON.stringify(job)); return; }
1096
- if (job.status === 'failed') { warn(JSON.stringify(job)); process.exit(1); }
1097
- }
1098
- warn(JSON.stringify({ error: true, code: 'timeout', message: `Job ${jobId} did not complete within ${(maxPolls * pollIntervalMs) / 1000}s` }));
1099
- process.exit(1);
1100
- } catch (error) {
1101
- printApiError(error);
1102
- process.exit(1);
1103
- }
1104
- }
1105
-
1106
- async function runMemoryStats(args) {
1107
- const format = parseOption(args, '--format', 'json');
1108
-
1109
- const accessToken = requireAccessToken();
1110
- const baseUrl = resolveApiBaseUrl();
1111
- const url = new URL('/api/v1/memory-stats', baseUrl).toString();
1112
-
1113
- let response;
1114
- try {
1115
- response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
1116
- } catch (error) {
1117
- printApiError(error);
1118
- process.exit(1);
1119
- }
1120
-
1121
- const data = (response && response.data) ? response.data : response;
1122
-
1123
- if (format === 'table') {
1124
- log(`Total memories: ${data.total}`);
1125
- log(`Oldest: ${data.oldest || '(none)'}`);
1126
- log(`Newest: ${data.newest || '(none)'}`);
1127
- log('');
1128
- log('By category:');
1129
- log(renderTable(data.byCategory || [], ['category', 'count']));
1130
- log('');
1131
- log('By tag:');
1132
- log(renderTable(data.byTag || [], ['tag', 'count']));
1133
- return;
1134
- }
1135
-
1136
- log(JSON.stringify(data));
1137
- }
1138
-
1139
- async function runMemoryCompact(args) {
1140
- const apply = parseFlag(args, '--apply');
1141
- const category = parseOption(args, '--category', '');
1142
-
1143
- const accessToken = requireAccessToken();
1144
- const baseUrl = resolveApiBaseUrl();
1145
- const url = new URL('/api/v1/memory-compaction', baseUrl).toString();
1146
- const body = { apply };
1147
- if (category) body.category = category;
1148
-
1149
- let response;
1150
- try {
1151
- response = await httpJson(url, 'POST', body, buildApiAuthHeaders(accessToken));
1152
- } catch (error) {
1153
- printApiError(error);
1154
- process.exit(1);
1155
- }
1156
-
1157
- const data = (response && response.data) ? response.data : response;
1158
- log(JSON.stringify(data));
1159
- }
1160
-
1161
- async function runMemoryCommand(subArgs) {
1162
- const helpRequested = subArgs.includes('--help') || subArgs.includes('-h');
1163
- const sub = subArgs[0] && !subArgs[0].startsWith('-') ? subArgs[0] : undefined;
1164
-
1165
- if (helpRequested || !sub) {
1166
- printMemoryHelp(sub);
1167
- return;
1168
- }
1169
-
1170
- const rest = subArgs.slice(1);
1171
-
1172
- return withCliAnalyticsContext(
1173
- { command: `memory.${sub || 'unknown'}`, subcommand: sub || 'unknown' },
1174
- async () => withSelectedWorkspaceContext(async () => {
1175
- if (sub === 'query') return runMemoryQuery(rest);
1176
- if (sub === 'get') return runMemoryGet(rest);
1177
- if (sub === 'list') return runMemoryList(rest);
1178
- if (sub === 'record') return runMemoryRecord(rest);
1179
- if (sub === 'note') return runMemoryNote(rest);
1180
- if (sub === 'update') return runMemoryUpdate(rest);
1181
- if (sub === 'acknowledge' || sub === 'ack' || sub === 'done') return runMemoryAcknowledge(rest);
1182
- if (sub === 'delete') return runMemoryDelete(rest);
1183
- if (sub === 'duplicates') return runMemoryDuplicates(rest);
1184
- if (sub === 'merge') return runMemoryMerge(rest);
1185
- if (sub === 'export') return runMemoryExport(rest);
1186
- if (sub === 'import') return runMemoryImport(rest);
1187
- if (sub === 'bulk-import') return runMemoryBulkImport(rest);
1188
- if (sub === 'stats') return runMemoryStats(rest);
1189
- if (sub === 'compact') return runMemoryCompact(rest);
1190
-
1191
- warn('Unknown memory subcommand: ' + (sub || '(none)'));
1192
- warn('Usage: empirical memory <query|record|note|update|delete|duplicates|merge|export|import|stats|compact> [options]');
1193
- process.exit(1);
1194
- })
1195
- );
1196
- }
1197
-
1198
- module.exports = {
1199
- resolveSummaryInput,
1200
- detectShellMangling,
1201
- withCliAnalyticsContext,
1202
- resolveMemoryByQuery,
1203
- printMemoryResolveError,
1204
- resolveWorkspaceIdsForQuery,
1205
- runMemoryQuery,
1206
- runMemoryGet,
1207
- runMemoryList,
1208
- runMemoryRecord,
1209
- runMemoryNote,
1210
- runMemoryUpdate,
1211
- runMemoryAcknowledge,
1212
- runMemoryDelete,
1213
- runMemoryDuplicates,
1214
- runMemoryMerge,
1215
- runMemoryExport,
1216
- runMemoryImport,
1217
- runMemoryBulkImport,
1218
- runMemoryStats,
1219
- runMemoryCompact,
1220
- runMemoryDeleteBulk,
1221
- runMemoryCommand
1222
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { log, warn } = require('./ui');
6
+ const { httpJson, printApiError } = require('./http');
7
+ const { parseOption, parseFlag } = require('./env');
8
+ const { requireAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
9
+ const { MEMORY_CATEGORIES, NODE_TYPES, DOC_TOPICS } = require('./constants');
10
+ const { withSelectedWorkspaceContext } = require('./workspaceContext');
11
+ const { resolveMemoryByQuery, parseMatchFilters, printMemoryResolveError } = require('./memoryResolve');
12
+ const { runAttachmentCommand, ATTACHMENT_SUBCOMMAND_USAGE } = require('./attachmentCommands');
13
+
14
+ // ─── Table rendering (--format table) ─────────────────────────────────────────
15
+
16
+ function truncateForTable(value, maxLen) {
17
+ const str = value === undefined || value === null ? '' : String(value);
18
+ return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
19
+ }
20
+
21
+ function renderTable(rows, columns) {
22
+ if (!Array.isArray(rows) || rows.length === 0) return '(no rows)';
23
+ const cols = columns && columns.length ? columns : Object.keys(rows[0]);
24
+ const cellText = (row, col) => (row[col] === undefined || row[col] === null ? '' : String(row[col]));
25
+ const widths = cols.map((col) => Math.max(col.length, ...rows.map((row) => cellText(row, col).length)));
26
+ const renderRow = (cells) => cells.map((cell, i) => String(cell).padEnd(widths[i])).join(' ');
27
+ const lines = [renderRow(cols), renderRow(widths.map((w) => '-'.repeat(w)))];
28
+ for (const row of rows) {
29
+ lines.push(renderRow(cols.map((col) => cellText(row, col))));
30
+ }
31
+ return lines.join('\n');
32
+ }
33
+
34
+ function renderMemoryResultsTable(items) {
35
+ const rows = (Array.isArray(items) ? items : []).map((m) => ({
36
+ id: m.id || m._id || '',
37
+ category: m.category || '',
38
+ // A row marked usage 'style_preference_only' (a writing voice memory) says so, so a person
39
+ // reading the table sees what an agent reading the JSON sees.
40
+ summary: truncateForTable(m.summary, 60) + (m.usage === 'style_preference_only' ? ' [style]' : ''),
41
+ // The recall front door emits `score` (toQueryRow plus score in mcp/tools/memory.ts); the
42
+ // older /query route emitted rankScore/similarityScore. Read all three so the column is not
43
+ // blank against a real /recall row. On the list pathways score is deliberately absent, and
44
+ // an empty cell is the honest rendering of "this pathway does not rank".
45
+ score: m.score != null ? m.score : (m.rankScore != null ? m.rankScore : (m.similarityScore != null ? m.similarityScore : ''))
46
+ }));
47
+ return renderTable(rows, ['id', 'category', 'summary', 'score']);
48
+ }
49
+
50
+ // The retrieval pathways `memory query --path` may pin. Same four names the server's shared
51
+ // normalizer accepts (packages/server/src/services/recallParams.service.ts). Omitting --path
52
+ // leaves the classifier deciding, exactly as before.
53
+ const RECALL_PATHS = ['semantic', 'list', 'graph', 'hebbian'];
54
+ const RECALL_HOPS = [1, 2];
55
+ // Hops only mean something on a pathway that walks relationships.
56
+ const RECALL_HOP_PATHS = ['graph', 'hebbian'];
57
+ // Reminder states `memory query --remind-state` may keep. Same enum the server's shared
58
+ // normalizer and Memory.remindState declare.
59
+ const REMIND_STATES = ['pending', 'notified', 'acknowledged', 'cancelled'];
60
+
61
+ // ─── Scoped `--help` (C-005 item 5) ───────────────────────────────────────────
62
+ // `empirical memory --help` / `empirical memory <sub> --help` print scoped usage instead of
63
+ // the 90-line global help wall bin/empirical.js otherwise short-circuits every --help to.
64
+
65
+ const MEMORY_SUBCOMMAND_USAGE = {
66
+ query: 'Usage: empirical memory query --match <query> [--top-k 3] [--category <cat>] [--tags <t1,t2>] [--tags-all <t1,t2>] [--status <s1,s2>] [--due-before <ISO date>] [--due-after <ISO date>] [--remind-state pending,notified] [--path semantic|list|graph|hebbian] [--hops 1|2] [--workspaces <n|name|id,...>] [--all-workspaces] [--pretty] [--format table]',
67
+ get: 'Usage: empirical memory get --memory-id <id> [--pretty]',
68
+ list: 'Usage: empirical memory list [--category <cat>] [--tags <t1,t2>] [--start-date <ISO>] [--end-date <ISO>] [--limit 20] [--cursor <cursor>] [--oldest] [--pretty] [--format table]',
69
+ record: 'Usage: empirical memory record --category <cat> (--summary <text> | --summary - | --summary-file <path>) [--tags t1,t2] [--node-type observation] [--json-file <path>] [--no-dedupe] [--no-auto-link] [--author-type user|agent] [--item-type <key>] [--item-status <key>] [--parent <memoryId>] [--assignee <userId>] [--field name=value ...]\n The five item arguments only mean something in a TEAM workspace that has a shape: elsewhere they are accepted and stored as nothing.',
70
+ note: 'Usage: empirical memory note --text <note> (--match <query> | --memory-id <id>) [--min-score 0.35]',
71
+ update: 'Usage: empirical memory update (--match <query> | --memory-id <id>) (--patch-json <json> | --json-file <path>) [--min-score 0.35]',
72
+ acknowledge: 'Usage: empirical memory acknowledge --memory-id <id> [--cancel] (marks a due reminder done, or dropped with --cancel; frees its slot and clears the alert)',
73
+ delete: 'Usage: empirical memory delete (--match <query> | --memory-id <id>) [--confirm] [--min-score 0.35]\nUsage: empirical memory delete (--tag <t> | --category <c> | --all) --confirm',
74
+ attachment: `Files attached to a memory. Up to 20 per memory, 25MB each; a delete is reversible for 30 days.\n${Object.values(ATTACHMENT_SUBCOMMAND_USAGE).join('\n')}`,
75
+ attach: ATTACHMENT_SUBCOMMAND_USAGE.add + '\n (shorthand for `empirical memory attachment add`)',
76
+ attachments: ATTACHMENT_SUBCOMMAND_USAGE.list + '\n (shorthand for `empirical memory attachment list`)',
77
+ tree: 'Usage: empirical memory tree (--memory-id <id> | --match <query>) [--depth 10] [--max-nodes 500] [--no-notes] [--format markdown|json] [--out <path>] [--pretty]\n One item and everything nested under it, as a readable document. Markdown by default.',
78
+ duplicates: 'Usage: empirical memory duplicates --memory-id <id>',
79
+ merge: 'Usage: empirical memory merge --canonical-id <id> --duplicate-id <id> [--no-merge-tags] [--no-merge-relationships]',
80
+ export: 'Usage: empirical memory export --scope <personal|workspace> [--out <path>] [--workspace-id <id>]',
81
+ import: 'Usage: empirical memory import <file> [--dry-run]',
82
+ 'bulk-import': 'Usage: empirical memory bulk-import <file> [--dry-run]\nFile format: { "memories": [{ "localKey": "...", "category": "...", "nodeType": "...", "summary": "...", "data": {...} }], "relationships": [{ "fromLocalKey": "...", "toLocalKey": "...", "type": "..." }] }',
83
+ stats: 'Usage: empirical memory stats [--format table]',
84
+ compact: 'Usage: empirical memory compact [--apply] [--category <cat>]'
85
+ };
86
+
87
+ function printMemoryHelp(sub) {
88
+ if (sub && MEMORY_SUBCOMMAND_USAGE[sub]) {
89
+ log(MEMORY_SUBCOMMAND_USAGE[sub]);
90
+ return;
91
+ }
92
+ log(DOC_TOPICS.memory);
93
+ log('');
94
+ log('Subcommands: ' + Object.keys(MEMORY_SUBCOMMAND_USAGE).join(', '));
95
+ }
96
+
97
+ async function withCliAnalyticsContext(context, fn) {
98
+ globalThis.__empiricalCliAnalyticsContext = context || {};
99
+ try {
100
+ return await fn();
101
+ } finally {
102
+ globalThis.__empiricalCliAnalyticsContext = null;
103
+ }
104
+ }
105
+
106
+
107
+ // ─── Memory subcommands ───────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Turn `--workspaces` selectors (list numbers, names, or ids) into workspace ids using the
111
+ * per-user listing (/api/v1/users/me/workspaces, the same list `workspace list` prints; number 1
112
+ * is always the Default Workspace). An id (ws_...) passes through without a lookup. An unknown
113
+ * name is fatal at the terminal rather than a 403 or an empty answer after the round trip.
114
+ */
115
+ async function resolveWorkspaceIdsForQuery({ baseUrl, accessToken, selectors = [], all = false }) {
116
+ const needsListing = all || selectors.some((s) => !/^ws_/.test(s));
117
+ let options = [];
118
+ if (needsListing) {
119
+ const listUrl = new URL('/api/v1/users/me/workspaces', baseUrl).toString();
120
+ let payload;
121
+ try {
122
+ payload = await httpJson(listUrl, 'GET', undefined, buildApiAuthHeaders(accessToken));
123
+ } catch (error) {
124
+ printApiError(error);
125
+ process.exit(1);
126
+ }
127
+ options = [
128
+ ...(payload?.defaultOption ? [payload.defaultOption] : []),
129
+ ...(Array.isArray(payload?.workspaces) ? payload.workspaces : [])
130
+ ].filter((o) => o && o.workspaceId);
131
+ }
132
+ if (all) return [...new Set(options.map((o) => o.workspaceId))];
133
+
134
+ const ids = [];
135
+ for (const selector of selectors) {
136
+ if (/^ws_/.test(selector)) { ids.push(selector); continue; }
137
+ const asNumber = Number(selector);
138
+ let match = null;
139
+ if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= options.length) {
140
+ match = options[asNumber - 1];
141
+ } else {
142
+ const lower = selector.toLowerCase();
143
+ const byLabel = options.filter((o) => String(o.label || '').toLowerCase() === lower || String(o.key || '').toLowerCase() === lower);
144
+ if (byLabel.length > 1) {
145
+ warn(`Ambiguous workspace "${selector}". Use its list number or workspace id.`);
146
+ process.exit(1);
147
+ }
148
+ match = byLabel[0] || null;
149
+ }
150
+ if (!match) {
151
+ warn(`Workspace not found: "${selector}". Run \`empirical workspace list\` to see names and numbers.`);
152
+ process.exit(1);
153
+ }
154
+ ids.push(match.workspaceId);
155
+ }
156
+ return [...new Set(ids)];
157
+ }
158
+
159
+ async function runMemoryQuery(args) {
160
+ const match = parseOption(args, '--match', '');
161
+ if (!match) {
162
+ warn(MEMORY_SUBCOMMAND_USAGE.query);
163
+ process.exit(1);
164
+ }
165
+
166
+ const topKRaw = Number(parseOption(args, '--top-k', '3'));
167
+ const topK = Math.min(100, Math.max(1, Number.isFinite(topKRaw) ? topKRaw : 3));
168
+ const category = parseOption(args, '--category', '');
169
+ const tagsRaw = parseOption(args, '--tags', '');
170
+ const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
171
+ // R3 (2026-09-06): --tags-all is an AND match (all must be present), unlike --tags' OR match;
172
+ // --status filters data.status. Both are optional and additive to --tags/--category.
173
+ const tagsAllRaw = parseOption(args, '--tags-all', '');
174
+ const tagsAll = tagsAllRaw ? tagsAllRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
175
+ // Multi-workspace recall (2026-09-24). --workspaces takes list numbers, names, or ids; names
176
+ // and numbers are resolved against the same listing `workspace list` prints, so the server
177
+ // contract stays ids-only. --all-workspaces sends every selectable workspace. Without either
178
+ // flag the query is scoped to the single active workspace exactly as before.
179
+ const workspacesRaw = parseOption(args, '--workspaces', '');
180
+ const workspaceSelectors = workspacesRaw ? workspacesRaw.split(',').map((w) => w.trim()).filter(Boolean) : [];
181
+ const allWorkspaces = parseFlag(args, '--all-workspaces');
182
+ if (workspaceSelectors.length && allWorkspaces) {
183
+ warn('Pass either --workspaces or --all-workspaces, not both.');
184
+ process.exit(1);
185
+ }
186
+ const statusRaw = parseOption(args, '--status', '');
187
+ const status = statusRaw ? statusRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
188
+ const pretty = parseFlag(args, '--pretty');
189
+ const format = parseOption(args, '--format', 'json');
190
+
191
+ // Reminder filters. Dates are parsed HERE as well as server-side, for the same reason --due
192
+ // is on record: a typo fails at the terminal, not as a 400 after the round trip. Anything
193
+ // Date can parse is accepted and sent as the caller typed it.
194
+ const dueFilters = {};
195
+ for (const [flag, key] of [['--due-before', 'dueBefore'], ['--due-after', 'dueAfter']]) {
196
+ const raw = parseOption(args, flag, '');
197
+ if (!raw) continue;
198
+ if (Number.isNaN(new Date(raw).getTime())) {
199
+ warn(`Invalid ${flag} "${raw}". Use an ISO 8601 date, for example 2026-10-14 or 2026-10-14T09:00:00Z.`);
200
+ process.exit(1);
201
+ }
202
+ dueFilters[key] = String(raw).trim();
203
+ }
204
+ const remindStateRaw = parseOption(args, '--remind-state', '');
205
+ const remindState = remindStateRaw
206
+ ? remindStateRaw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
207
+ : [];
208
+ const unknownStates = remindState.filter((s) => !REMIND_STATES.includes(s));
209
+ if (unknownStates.length) {
210
+ warn(`--remind-state must be one of: ${REMIND_STATES.join(', ')} (received ${JSON.stringify(unknownStates)})`);
211
+ process.exit(1);
212
+ }
213
+
214
+ // --path / --hops are validated HERE, where the value is decided, so a typo costs no round
215
+ // trip and the message names the four pathways. The server validates the same way (the shared
216
+ // normalizer in packages/server/src/services/recallParams.service.ts); this is a fast local
217
+ // echo of that contract, never a second, looser one.
218
+ const pathRaw = parseOption(args, '--path', '');
219
+ let path = '';
220
+ if (pathRaw) {
221
+ path = String(pathRaw).trim().toLowerCase();
222
+ if (!RECALL_PATHS.includes(path)) {
223
+ warn(`--path must be one of: ${RECALL_PATHS.join(', ')} (received ${JSON.stringify(pathRaw)})`);
224
+ process.exit(1);
225
+ }
226
+ }
227
+
228
+ const hopsRaw = parseOption(args, '--hops', '');
229
+ let hops = null;
230
+ if (hopsRaw) {
231
+ const parsedHops = Number(hopsRaw);
232
+ if (!RECALL_HOPS.includes(parsedHops)) {
233
+ warn(`--hops must be 1 or 2 (received ${JSON.stringify(hopsRaw)})`);
234
+ process.exit(1);
235
+ }
236
+ // Silently ignoring --hops on a ranked or list pathway would look like it took effect.
237
+ if (!RECALL_HOP_PATHS.includes(path)) {
238
+ warn(`--hops only applies with --path graph or --path hebbian${path ? ` (received --path ${path})` : ' (no --path given)'}`);
239
+ process.exit(1);
240
+ }
241
+ hops = parsedHops;
242
+ }
243
+
244
+ const accessToken = requireAccessToken();
245
+ const baseUrl = resolveApiBaseUrl();
246
+ // The recall front door: the same answer the MCP query_memories tool gives, which /query never
247
+ // gave (classifier routing, the list block, the exact-tag retry, agent-note exclusion,
248
+ // retrievalPath). It takes its parameters FLAT, matching the shared normalizer, not nested
249
+ // under `filters` the way the older /query route did. Send one shape only.
250
+ const queryUrl = new URL('/api/v1/memories/recall', baseUrl).toString();
251
+
252
+ const body = { query: match, topK };
253
+ if (category) body.category = category;
254
+ if (tags.length) body.tags = tags;
255
+ if (tagsAll.length) body.tagsAll = tagsAll;
256
+ if (workspaceSelectors.length || allWorkspaces) {
257
+ body.workspaceIds = await resolveWorkspaceIdsForQuery({
258
+ baseUrl,
259
+ accessToken,
260
+ selectors: workspaceSelectors,
261
+ all: allWorkspaces
262
+ });
263
+ }
264
+ if (status.length) body.status = status;
265
+ if (dueFilters.dueBefore) body.dueBefore = dueFilters.dueBefore;
266
+ if (dueFilters.dueAfter) body.dueAfter = dueFilters.dueAfter;
267
+ if (remindState.length) body.remindState = remindState;
268
+ if (path) body.path = path;
269
+ if (hops !== null) body.hops = hops;
270
+
271
+ let response;
272
+ try {
273
+ response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
274
+ } catch (error) {
275
+ // A 404 here means the server predates the recall front door. Say that plainly instead of
276
+ // rerouting to /query: that fallback would quietly answer through a different routing path
277
+ // with different defaults (topK 10 against 3) and keep the old route alive forever.
278
+ if (error && error.statusCode === 404) {
279
+ warn(JSON.stringify({
280
+ error: true,
281
+ code: 'recall_route_missing',
282
+ message: 'This server does not serve /api/v1/memories/recall yet. Upgrade the Empirical server, or use an older CLI against it.'
283
+ }));
284
+ process.exit(1);
285
+ }
286
+ printApiError(error);
287
+ process.exit(1);
288
+ }
289
+
290
+ // The front door answers with data.memories; the older /query route answered with data.results.
291
+ // Both are accepted so a CLI pointed at a server that predates /recall still prints rows.
292
+ const data = response && response.data ? response.data : null;
293
+ const results = (data && Array.isArray(data.memories))
294
+ ? data.memories
295
+ : ((data && Array.isArray(data.results))
296
+ ? data.results
297
+ : (Array.isArray(response) ? response : []));
298
+
299
+ if (format === 'table') {
300
+ log(renderMemoryResultsTable(results));
301
+ return;
302
+ }
303
+
304
+ if (pretty) {
305
+ log(JSON.stringify(results, null, 2));
306
+ } else {
307
+ log(JSON.stringify(results));
308
+ }
309
+ }
310
+
311
+ async function runMemoryGet(args) {
312
+ const memoryId = parseOption(args, '--memory-id', '');
313
+ const pretty = parseFlag(args, '--pretty');
314
+ if (!memoryId) {
315
+ warn('Usage: empirical memory get --memory-id <id> [--pretty]');
316
+ process.exit(1);
317
+ }
318
+
319
+ const accessToken = requireAccessToken();
320
+ const baseUrl = resolveApiBaseUrl();
321
+ const url = new URL(`/api/v1/memories/${encodeURIComponent(memoryId)}`, baseUrl).toString();
322
+ let response;
323
+ try {
324
+ response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
325
+ } catch (error) {
326
+ printApiError(error);
327
+ process.exit(1);
328
+ }
329
+ const data = response && response.data ? response.data : response;
330
+ log(pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
331
+ }
332
+
333
+ async function runMemoryList(args) {
334
+ const category = parseOption(args, '--category', '');
335
+ const tags = parseOption(args, '--tags', '');
336
+ const startDate = parseOption(args, '--start-date', '');
337
+ const endDate = parseOption(args, '--end-date', '');
338
+ const limitRaw = Number(parseOption(args, '--limit', '20'));
339
+ const limit = Math.min(100, Math.max(1, Number.isFinite(limitRaw) ? limitRaw : 20));
340
+ const cursor = parseOption(args, '--cursor', '');
341
+ const sortOrder = parseFlag(args, '--oldest') ? 'asc' : 'desc';
342
+ const pretty = parseFlag(args, '--pretty');
343
+ const format = parseOption(args, '--format', 'json');
344
+
345
+ const params = new URLSearchParams();
346
+ if (category) params.set('category', category);
347
+ if (tags) params.set('tags', tags);
348
+ if (startDate) params.set('startDate', startDate);
349
+ if (endDate) params.set('endDate', endDate);
350
+ params.set('limit', String(limit));
351
+ if (cursor) params.set('cursor', cursor);
352
+ params.set('sortBy', 'createdAt');
353
+ params.set('sortOrder', sortOrder);
354
+
355
+ const accessToken = requireAccessToken();
356
+ const baseUrl = resolveApiBaseUrl();
357
+ const url = new URL(`/api/v1/memories?${params.toString()}`, baseUrl).toString();
358
+ let response;
359
+ try {
360
+ response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
361
+ } catch (error) {
362
+ printApiError(error);
363
+ process.exit(1);
364
+ }
365
+ const data = response && response.data ? response.data : response;
366
+
367
+ if (format === 'table') {
368
+ const items = Array.isArray(data && data.memories) ? data.memories : (Array.isArray(data) ? data : []);
369
+ log(renderMemoryResultsTable(items));
370
+ return;
371
+ }
372
+
373
+ log(pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
374
+ }
375
+
376
+ // Shell substitution silently eats code out of a double-quoted --summary: backticks become
377
+ // command substitution and ${...} becomes variable expansion BEFORE the CLI ever sees the
378
+ // text, and the write still succeeds, so the memory looks fine with the code gone. Two
379
+ // defenses: (1) inputs that never pass through shell quoting -- `--summary-file <path>` and
380
+ // `--summary -` (stdin, pair it with a single-quoted heredoc); (2) a warning when an inline
381
+ // summary carries the residue of an eaten substitution.
382
+ function resolveSummaryInput(args, { readFile = (p) => fs.readFileSync(path.resolve(p), 'utf8'), readStdin = () => fs.readFileSync(0, 'utf8') } = {}) {
383
+ const summaryFile = parseOption(args, '--summary-file', '');
384
+ if (summaryFile) {
385
+ return { summary: String(readFile(summaryFile)).replace(/\r?\n$/, ''), source: 'file' };
386
+ }
387
+ const inline = parseOption(args, '--summary', '');
388
+ if (inline === '-') {
389
+ return { summary: String(readStdin()).replace(/\r?\n$/, ''), source: 'stdin' };
390
+ }
391
+ return { summary: inline, source: inline ? 'inline' : 'none' };
392
+ }
393
+
394
+ function detectShellMangling(summary) {
395
+ const text = String(summary || '');
396
+ const reasons = [];
397
+ if (/``/.test(text)) reasons.push('an empty backtick pair (a `command` was probably substituted away)');
398
+ if (/\$(\s|$|[^A-Za-z0-9_{(])/.test(text) && !/\$\d/.test(text)) reasons.push('a lone "$" (a ${VAR} or $(cmd) was probably expanded to nothing)');
399
+ if (/\$\{\s*\}/.test(text)) reasons.push('an empty ${} placeholder');
400
+ return reasons;
401
+ }
402
+
403
+ async function runMemoryRecord(args) {
404
+ const jsonFile = parseOption(args, '--json-file', '');
405
+ let payload;
406
+
407
+ if (jsonFile) {
408
+ try {
409
+ payload = JSON.parse(fs.readFileSync(path.resolve(jsonFile), 'utf8'));
410
+ } catch (error) {
411
+ warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
412
+ process.exit(1);
413
+ }
414
+ } else {
415
+ const category = parseOption(args, '--category', '');
416
+ const { summary, source: summarySource } = resolveSummaryInput(args);
417
+
418
+ if (summarySource === 'inline') {
419
+ const mangled = detectShellMangling(summary);
420
+ if (mangled.length) {
421
+ warn(JSON.stringify({
422
+ warning: 'possible_shell_substitution',
423
+ message: 'The summary looks like the shell altered it before the CLI saw it: ' + mangled.join('; ') + '. Recording anyway. For text containing backticks or ${...}, use --summary-file <path>, or --summary - with a single-quoted heredoc, or the MCP record_graph_memory tool.'
424
+ }));
425
+ }
426
+ }
427
+
428
+ if (!category || !summary) {
429
+ // The canonical string, not a second copy: this line used to hold its own and had already
430
+ // drifted from MEMORY_SUBCOMMAND_USAGE.record, so a flag added to one was missing from the
431
+ // other, which is the message a person actually sees when they get the arguments wrong.
432
+ warn(MEMORY_SUBCOMMAND_USAGE.record);
433
+ warn('Valid categories: ' + MEMORY_CATEGORIES.join(', '));
434
+ warn('Valid node types: ' + NODE_TYPES.join(', '));
435
+ process.exit(1);
436
+ }
437
+
438
+ if (!MEMORY_CATEGORIES.includes(category)) {
439
+ warn(JSON.stringify({
440
+ error: true,
441
+ code: 'invalid_category',
442
+ message: `Invalid category "${category}". Valid: ${MEMORY_CATEGORIES.join(', ')}`
443
+ }));
444
+ process.exit(1);
445
+ }
446
+
447
+ const tagsRaw = parseOption(args, '--tags', '');
448
+ const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
449
+ const nodeType = parseOption(args, '--node-type', 'observation');
450
+
451
+ if (!NODE_TYPES.includes(nodeType)) {
452
+ warn(JSON.stringify({
453
+ error: true,
454
+ code: 'invalid_node_type',
455
+ message: `Invalid nodeType "${nodeType}". Valid: ${NODE_TYPES.join(', ')}`
456
+ }));
457
+ process.exit(1);
458
+ }
459
+
460
+ payload = { category, summary, nodeType, data: { text: String(summary) } };
461
+ if (tags.length) payload.tags = tags;
462
+ }
463
+
464
+ if (
465
+ payload
466
+ && payload.summary !== undefined
467
+ && payload.data === undefined
468
+ && payload.originalPayload === undefined
469
+ ) {
470
+ payload.data = { text: String(payload.summary) };
471
+ }
472
+
473
+ // C-002 (docs/prd-cli-and-plugin-fixes.md): the server now dedupes/auto-links every
474
+ // POST /api/v1/memories write by default, matching the MCP record_graph_memory tool's
475
+ // guarantee. Only send dedupeEnabled/autoLink at all when the caller explicitly opted
476
+ // out here -- omitting them entirely lets the server's own default-on behavior apply,
477
+ // rather than this CLI baking in a redundant "true" that could drift from the server's
478
+ // actual default over time.
479
+ if (parseFlag(args, '--no-dedupe')) {
480
+ payload.dedupeEnabled = false;
481
+ }
482
+ if (parseFlag(args, '--no-auto-link')) {
483
+ payload.autoLink = false;
484
+ }
485
+
486
+ // Authorship (2026-09-09). `source` is observable truth: this is the CLI, so say so.
487
+ // Until now the CLI sent nothing, the server fell back to the transport it saw ('api'),
488
+ // and api maps to authorType 'user' because that mapping was written for the web portal.
489
+ // Every agent write through this command was therefore stored as authored by the user.
490
+ // 'cli' resolves to authorType 'unknown', which is the honest answer when nobody says.
491
+ if (payload.source === undefined) payload.source = 'cli';
492
+
493
+ // --author-type lets the caller state what the transport cannot know. An agent recording
494
+ // its own session notes should pass 'agent'; an agent recording something the user said
495
+ // or decided should pass 'user'. Omitted, it stays 'unknown' rather than being guessed.
496
+ const authorType = parseOption(args, '--author-type', '');
497
+ if (authorType) {
498
+ if (!['user', 'agent', 'system'].includes(authorType)) {
499
+ warn(JSON.stringify({
500
+ error: true,
501
+ code: 'invalid_author_type',
502
+ message: `Invalid --author-type "${authorType}". Valid: user, agent, system`
503
+ }));
504
+ process.exit(1);
505
+ }
506
+ payload.authorType = authorType;
507
+ }
508
+
509
+ // PRD 8.1 item arguments. Sent under the names the REST door takes (ITEM_WRITE_ARGUMENTS in
510
+ // @empirical/shared): itemType, itemStatus, parentId, assignee and fields. The CLI deliberately
511
+ // does NOT check them against a shape; it has no shape and the server refuses with the
512
+ // workspace's own vocabulary, which is a better message than anything guessed here.
513
+ //
514
+ // Guarded on payload being an object: --json-file accepts whatever parses, so a file holding a
515
+ // bare array or null would otherwise crash here on assignment rather than in the request.
516
+ const canCarryItemArguments = payload && typeof payload === 'object' && !Array.isArray(payload);
517
+ for (const [flag, key] of [
518
+ ['--item-type', 'itemType'],
519
+ ['--item-status', 'itemStatus'],
520
+ ['--parent', 'parentId'],
521
+ ['--assignee', 'assignee']
522
+ ]) {
523
+ const value = parseOption(args, flag, '');
524
+ if (value && canCarryItemArguments) payload[key] = value;
525
+ }
526
+
527
+ // --field is repeatable and collected into one object, so `--field notes=hi --field source=web`
528
+ // sends { notes: 'hi', source: 'web' }. Split on the FIRST '=' only, or a value that contains one
529
+ // ('query=a=b') would be truncated. A malformed pair fails here rather than as a 400, because the
530
+ // person is at a terminal and the fix is in what they just typed.
531
+ const fieldPairs = [];
532
+ for (let i = 0; i < args.length; i += 1) {
533
+ if (args[i] !== '--field') continue;
534
+ fieldPairs.push(args[i + 1]);
535
+ i += 1;
536
+ }
537
+ if (fieldPairs.length) {
538
+ const fields = {};
539
+ for (const pair of fieldPairs) {
540
+ const at = typeof pair === 'string' ? pair.indexOf('=') : -1;
541
+ const name = at > 0 ? pair.slice(0, at).trim() : '';
542
+ if (!name) {
543
+ warn(JSON.stringify({
544
+ error: true,
545
+ code: 'invalid_field',
546
+ message: `Invalid --field ${JSON.stringify(pair === undefined ? '' : pair)}. Use the form --field name=value, for example --field notes=hello. Repeat --field for more than one.`
547
+ }));
548
+ process.exit(1);
549
+ }
550
+ fields[name] = pair.slice(at + 1);
551
+ }
552
+ if (canCarryItemArguments) payload.fields = fields;
553
+ }
554
+
555
+ // --due turns the memory into a reminder. Parsed and rejected HERE as well as server-side, so
556
+ // a typo fails immediately at the terminal instead of coming back as a 400 after the write was
557
+ // composed. Accepts anything Date can parse, so '2026-10-14' and a full ISO timestamp both work.
558
+ const dueRaw = parseOption(args, '--due', '');
559
+ if (dueRaw) {
560
+ const parsedDue = new Date(dueRaw);
561
+ if (Number.isNaN(parsedDue.getTime())) {
562
+ warn(JSON.stringify({
563
+ error: true,
564
+ code: 'invalid_due',
565
+ message: `Invalid --due "${dueRaw}". Use an ISO 8601 date, for example 2026-10-14 or 2026-10-14T09:00:00Z.`
566
+ }));
567
+ process.exit(1);
568
+ }
569
+ payload.dueAt = parsedDue.toISOString();
570
+ }
571
+
572
+ const accessToken = requireAccessToken();
573
+ const baseUrl = resolveApiBaseUrl();
574
+ const recordUrl = new URL('/api/v1/memories', baseUrl).toString();
575
+
576
+ let response;
577
+ try {
578
+ response = await httpJson(recordUrl, 'POST', payload, buildApiAuthHeaders(accessToken));
579
+ } catch (error) {
580
+ printApiError(error);
581
+ process.exit(1);
582
+ }
583
+
584
+ const created = (response && response.data) ? response.data : response;
585
+ log(JSON.stringify(created));
586
+ }
587
+
588
+ async function runMemoryNote(args) {
589
+ const match = parseOption(args, '--match', '');
590
+ const text = parseOption(args, '--text', '');
591
+ const memoryId = parseOption(args, '--memory-id', '');
592
+ const minScore = Number(parseOption(args, '--min-score', '0.35'));
593
+
594
+ if (!text) {
595
+ warn('Usage: empirical memory note --text <note> (--match <query> | --memory-id <id>) [--tags <t1,t2>] [--min-score 0.35]');
596
+ process.exit(1);
597
+ }
598
+
599
+ if (!match && !memoryId) {
600
+ warn('Usage: empirical memory note --text <note> (--match <query> | --memory-id <id>) [--tags <t1,t2>]');
601
+ process.exit(1);
602
+ }
603
+
604
+ const accessToken = requireAccessToken();
605
+ const baseUrl = resolveApiBaseUrl();
606
+
607
+ let targetId = memoryId;
608
+ if (!targetId) {
609
+ const filters = parseMatchFilters(args);
610
+ const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters);
611
+ if (!result.found) {
612
+ printMemoryResolveError(result);
613
+ process.exit(1);
614
+ }
615
+ targetId = result.memory.id;
616
+ }
617
+
618
+ const noteUrl = new URL(`/api/v1/memories/${encodeURIComponent(targetId)}/notes`, baseUrl).toString();
619
+ let response;
620
+ try {
621
+ response = await httpJson(noteUrl, 'POST', { text }, buildApiAuthHeaders(accessToken));
622
+ } catch (error) {
623
+ printApiError(error);
624
+ process.exit(1);
625
+ }
626
+
627
+ const created = (response && response.data) ? response.data : response;
628
+ log(JSON.stringify(created));
629
+ }
630
+
631
+ // Closing a reminder from the terminal or from an agent reading a CLI banner. One PUT, the same
632
+ // transition the portal's Mark done / Cancel make; the server retires the alert on every surface.
633
+ async function runMemoryAcknowledge(args) {
634
+ const memoryId = parseOption(args, '--memory-id', '');
635
+ const cancel = parseFlag(args, '--cancel');
636
+ if (!memoryId) {
637
+ warn(MEMORY_SUBCOMMAND_USAGE.acknowledge);
638
+ process.exit(1);
639
+ }
640
+ const accessToken = requireAccessToken();
641
+ const baseUrl = resolveApiBaseUrl();
642
+ const url = new URL(`/api/v1/memories/${encodeURIComponent(memoryId)}`, baseUrl).toString();
643
+ let response;
644
+ try {
645
+ response = await httpJson(url, 'PUT', { remindState: cancel ? 'cancelled' : 'acknowledged' }, buildApiAuthHeaders(accessToken));
646
+ } catch (error) {
647
+ printApiError(error);
648
+ process.exit(1);
649
+ }
650
+ const data = response && response.data ? response.data : response;
651
+ log(JSON.stringify({ id: data.id, remindState: data.remindState, dueAt: data.dueAt || null, message: cancel ? 'Reminder cancelled. Its slot is free.' : 'Reminder marked done. Its slot is free.' }, null, 2));
652
+ }
653
+
654
+ async function runMemoryUpdate(args) {
655
+ const match = parseOption(args, '--match', '');
656
+ const patchJson = parseOption(args, '--patch-json', '');
657
+ const jsonFile = parseOption(args, '--json-file', '');
658
+ const memoryId = parseOption(args, '--memory-id', '');
659
+ const minScore = Number(parseOption(args, '--min-score', '0.35'));
660
+
661
+ if (!patchJson && !jsonFile) {
662
+ warn('Usage: empirical memory update (--match <query> | --memory-id <id>) (--patch-json <json> | --json-file <path>) [--tags <t1,t2>]');
663
+ process.exit(1);
664
+ }
665
+
666
+ if (!match && !memoryId) {
667
+ warn('Usage: empirical memory update (--match <query> | --memory-id <id>) (--patch-json <json> | --json-file <path>) [--tags <t1,t2>]');
668
+ process.exit(1);
669
+ }
670
+
671
+ let patch;
672
+ if (jsonFile) {
673
+ try {
674
+ patch = JSON.parse(fs.readFileSync(path.resolve(jsonFile), 'utf8'));
675
+ } catch (error) {
676
+ warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
677
+ process.exit(1);
678
+ }
679
+ } else {
680
+ try {
681
+ patch = JSON.parse(patchJson);
682
+ } catch (error) {
683
+ warn(JSON.stringify({ error: true, code: 'invalid_json', message: `--patch-json parse error: ${error.message}` }));
684
+ process.exit(1);
685
+ }
686
+ }
687
+
688
+ const accessToken = requireAccessToken();
689
+ const baseUrl = resolveApiBaseUrl();
690
+
691
+ let targetId = memoryId;
692
+ if (!targetId) {
693
+ const filters = parseMatchFilters(args);
694
+ const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters);
695
+ if (!result.found) {
696
+ printMemoryResolveError(result);
697
+ process.exit(1);
698
+ }
699
+ targetId = result.memory.id;
700
+ }
701
+
702
+ const updateUrl = new URL(`/api/v1/memories/${encodeURIComponent(targetId)}`, baseUrl).toString();
703
+ let response;
704
+ try {
705
+ response = await httpJson(updateUrl, 'PUT', patch, buildApiAuthHeaders(accessToken));
706
+ } catch (error) {
707
+ printApiError(error);
708
+ process.exit(1);
709
+ }
710
+
711
+ const updated = (response && response.data) ? response.data : response;
712
+ log(JSON.stringify(updated));
713
+ }
714
+
715
+ async function runMemoryDeleteBulk(args) {
716
+ const tag = parseOption(args, '--tag', '');
717
+ const category = parseOption(args, '--category', '');
718
+ const all = parseFlag(args, '--all');
719
+ const confirm = parseFlag(args, '--confirm');
720
+
721
+ const selectorCount = [Boolean(tag), Boolean(category), all].filter(Boolean).length;
722
+ if (selectorCount !== 1) {
723
+ warn('Usage: empirical memory delete (--tag <t> | --category <c> | --all) --confirm');
724
+ process.exit(1);
725
+ }
726
+
727
+ const accessToken = requireAccessToken();
728
+ const baseUrl = resolveApiBaseUrl();
729
+ const url = new URL('/api/v1/memory-bulk-delete', baseUrl).toString();
730
+ const body = { confirm };
731
+ if (tag) body.tag = tag;
732
+ if (category) body.category = category;
733
+ if (all) body.all = true;
734
+
735
+ let response;
736
+ try {
737
+ response = await httpJson(url, 'POST', body, buildApiAuthHeaders(accessToken));
738
+ } catch (error) {
739
+ printApiError(error);
740
+ process.exit(1);
741
+ }
742
+
743
+ // Server always reports the count first: without --confirm it's a dry-run preview and no
744
+ // writes happened; with --confirm it reports what it just deleted. Either way the count is
745
+ // printed, matching the single-memory delete's dry-run-by-default convention above.
746
+ const data = (response && response.data) ? response.data : response;
747
+ log(JSON.stringify(data));
748
+ }
749
+
750
+ async function runMemoryDelete(args) {
751
+ const tag = parseOption(args, '--tag', '');
752
+ const category = parseOption(args, '--category', '');
753
+ const all = parseFlag(args, '--all');
754
+ if (tag || category || all) {
755
+ return runMemoryDeleteBulk(args);
756
+ }
757
+
758
+ const match = parseOption(args, '--match', '');
759
+ const memoryId = parseOption(args, '--memory-id', '');
760
+ const minScore = Number(parseOption(args, '--min-score', '0.35'));
761
+ const confirm = parseFlag(args, '--confirm');
762
+
763
+ if (!match && !memoryId) {
764
+ warn('Usage: empirical memory delete (--match <query> | --memory-id <id>) [--tags <t1,t2>] [--confirm] [--min-score 0.35]');
765
+ warn(' or: empirical memory delete (--tag <t> | --category <c> | --all) --confirm');
766
+ process.exit(1);
767
+ }
768
+
769
+ const accessToken = requireAccessToken();
770
+ const baseUrl = resolveApiBaseUrl();
771
+
772
+ let targetId = memoryId;
773
+ let targetSummary = '';
774
+
775
+ if (!targetId) {
776
+ const filters = parseMatchFilters(args);
777
+ const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters);
778
+ if (!result.found) {
779
+ if (!confirm && (result.reason === 'ambiguous' || result.reason === 'low_score')) {
780
+ // Dry-run: return candidates info
781
+ log(JSON.stringify({ dryRun: true, wouldDelete: false, reason: result.reason, candidates: result.candidates }));
782
+ process.exit(0);
783
+ }
784
+ printMemoryResolveError(result);
785
+ process.exit(1);
786
+ }
787
+ targetId = result.memory.id;
788
+ targetSummary = result.memory.summary;
789
+ }
790
+
791
+ if (!confirm) {
792
+ log(JSON.stringify({ dryRun: true, wouldDelete: true, id: targetId, summary: targetSummary }));
793
+ process.exit(0);
794
+ }
795
+
796
+ const deleteUrl = new URL(`/api/v1/memories/${encodeURIComponent(targetId)}`, baseUrl).toString();
797
+ try {
798
+ await httpJson(deleteUrl, 'DELETE', null, buildApiAuthHeaders(accessToken));
799
+ } catch (error) {
800
+ printApiError(error);
801
+ process.exit(1);
802
+ }
803
+
804
+ log(JSON.stringify({ deleted: true, id: targetId, summary: targetSummary }));
805
+ }
806
+
807
+ // `memory tree` is the terminal's answer to "give me this idea with everything under it". The
808
+ // server assembles the document (one indexed query over the item hierarchy), so this is a fetch
809
+ // and a write: no walking, no per-level calls.
810
+ async function runMemoryTree(args) {
811
+ const accessToken = requireAccessToken();
812
+ const baseUrl = resolveApiBaseUrl();
813
+
814
+ const memoryId = parseOption(args, '--memory-id', '');
815
+ const match = parseOption(args, '--match', '');
816
+ if (!memoryId && !match) {
817
+ warn(MEMORY_SUBCOMMAND_USAGE.tree);
818
+ process.exit(1);
819
+ }
820
+
821
+ let targetId = memoryId;
822
+ if (!targetId) {
823
+ const minScore = Number(parseOption(args, '--min-score', '0.35'));
824
+ const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, parseMatchFilters(args));
825
+ if (!result.found) {
826
+ printMemoryResolveError(result);
827
+ process.exit(1);
828
+ }
829
+ targetId = result.memory.id;
830
+ }
831
+
832
+ const query = new URLSearchParams();
833
+ const depth = parseOption(args, '--depth', '');
834
+ if (depth) query.set('depth', depth);
835
+ const maxNodes = parseOption(args, '--max-nodes', '');
836
+ if (maxNodes) query.set('maxNodes', maxNodes);
837
+ if (parseFlag(args, '--no-notes')) query.set('includeNotes', 'false');
838
+
839
+ const url = new URL(
840
+ `/api/v1/memories/${encodeURIComponent(targetId)}/tree${query.toString() ? `?${query}` : ''}`,
841
+ baseUrl
842
+ ).toString();
843
+
844
+ let response;
845
+ try {
846
+ response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
847
+ } catch (error) {
848
+ printApiError(error);
849
+ process.exit(1);
850
+ }
851
+
852
+ const data = (response && response.data) ? response.data : response;
853
+
854
+ // Markdown is the default here, unlike every other memory subcommand, because the whole point
855
+ // of this one is a document a person reads. --format json gives the nodes for scripting.
856
+ if (parseOption(args, '--format', 'markdown') === 'json') {
857
+ log(parseFlag(args, '--pretty') ? JSON.stringify(data, null, 2) : JSON.stringify(data));
858
+ return;
859
+ }
860
+
861
+ const out = parseOption(args, '--out', '');
862
+ if (out) {
863
+ const target = path.resolve(out);
864
+ try {
865
+ fs.writeFileSync(target, data.markdown);
866
+ } catch (error) {
867
+ warn(JSON.stringify({ error: true, code: 'write_failed', message: `Could not write ${target}: ${error.message}` }));
868
+ process.exit(1);
869
+ }
870
+ log(JSON.stringify({ written: true, path: target, nodeCount: data.nodeCount, depth: data.depth, truncated: data.truncated }));
871
+ return;
872
+ }
873
+
874
+ log(data.markdown);
875
+ // Truncation goes to stderr so it cannot end up inside a redirected document.
876
+ if (data.truncated) {
877
+ warn(JSON.stringify({
878
+ warning: true,
879
+ code: 'truncated',
880
+ message: `Document cut off at ${data.nodes.length} items. Raise --max-nodes or start lower in the tree.`
881
+ }));
882
+ }
883
+ }
884
+
885
+ async function runMemoryDuplicates(args) {
886
+ const memoryId = parseOption(args, '--memory-id', '');
887
+ if (!memoryId) {
888
+ warn('Usage: empirical memory duplicates --memory-id <id>');
889
+ process.exit(1);
890
+ }
891
+
892
+ const accessToken = requireAccessToken();
893
+ const baseUrl = resolveApiBaseUrl();
894
+ const url = new URL(`/api/v1/memories/${encodeURIComponent(memoryId)}/duplicate-candidates`, baseUrl).toString();
895
+
896
+ let response;
897
+ try {
898
+ response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
899
+ } catch (error) {
900
+ printApiError(error);
901
+ process.exit(1);
902
+ }
903
+
904
+ const data = (response && response.data) ? response.data : response;
905
+ log(JSON.stringify(data));
906
+ }
907
+
908
+ async function runMemoryMerge(args) {
909
+ const canonicalId = parseOption(args, '--canonical-id', '');
910
+ const duplicateId = parseOption(args, '--duplicate-id', '');
911
+ const mergeTags = !parseFlag(args, '--no-merge-tags');
912
+ const mergeRelationships = !parseFlag(args, '--no-merge-relationships');
913
+
914
+ if (!canonicalId || !duplicateId) {
915
+ warn('Usage: empirical memory merge --canonical-id <id> --duplicate-id <id> [--no-merge-tags] [--no-merge-relationships]');
916
+ process.exit(1);
917
+ }
918
+
919
+ const accessToken = requireAccessToken();
920
+ const baseUrl = resolveApiBaseUrl();
921
+ const url = new URL('/api/v1/memories/merge', baseUrl).toString();
922
+
923
+ let response;
924
+ try {
925
+ response = await httpJson(url, 'POST', { canonicalId, duplicateId, mergeTags, mergeRelationships }, buildApiAuthHeaders(accessToken));
926
+ } catch (error) {
927
+ printApiError(error);
928
+ process.exit(1);
929
+ }
930
+
931
+ const data = (response && response.data) ? response.data : response;
932
+ log(JSON.stringify(data));
933
+ }
934
+
935
+ async function runMemoryExport(args) {
936
+ const scope = parseOption(args, '--scope', '');
937
+ if (scope !== 'personal' && scope !== 'workspace') {
938
+ warn('Usage: empirical memory export --scope <personal|workspace> [--out <path>] [--workspace-id <id>]');
939
+ process.exit(1);
940
+ }
941
+
942
+ const workspaceId = parseOption(args, '--workspace-id', '');
943
+ if (scope === 'workspace' && !workspaceId) {
944
+ warn('Usage: empirical memory export --scope workspace --workspace-id <id> [--out <path>]');
945
+ process.exit(1);
946
+ }
947
+
948
+ const dateStamp = new Date().toISOString().slice(0, 10);
949
+ const outPath = parseOption(args, '--out', `./empirical-memory-export-${scope}-${dateStamp}.json`);
950
+
951
+ const accessToken = requireAccessToken();
952
+ const baseUrl = resolveApiBaseUrl();
953
+ const url = new URL(`/api/v1/memories/export?scope=${encodeURIComponent(scope)}`, baseUrl).toString();
954
+
955
+ const headers = buildApiAuthHeaders(accessToken);
956
+ if (workspaceId) {
957
+ headers['x-workspace-id'] = workspaceId;
958
+ }
959
+
960
+ let response;
961
+ try {
962
+ response = await httpJson(url, 'GET', undefined, headers);
963
+ } catch (error) {
964
+ printApiError(error);
965
+ process.exit(1);
966
+ }
967
+
968
+ // The raw server response is the export envelope itself (streamed as one JSON body, not the
969
+ // standard {status,data} wrapper): {schemaVersion, generatedAt, scope, workspaceId,
970
+ // memoryCount, truncated, memories: [...]}. Re-shape it into the versioned, round-trippable
971
+ // format `memory import` consumes: {schemaVersion: 1, exportedAt, memories: [{category,
972
+ // summary, tags, nodeType, data, mass, gravity, timeContext, notes, relationships}]}.
973
+ const rawMemories = Array.isArray(response && response.memories) ? response.memories : [];
974
+ const envelope = {
975
+ schemaVersion: 1,
976
+ exportedAt: new Date().toISOString(),
977
+ memories: rawMemories.map((m) => ({
978
+ category: m.category,
979
+ summary: m.summary,
980
+ tags: Array.isArray(m.tags) ? m.tags : [],
981
+ nodeType: m.nodeType,
982
+ data: m.data,
983
+ mass: m.mass,
984
+ gravity: m.gravity,
985
+ timeContext: m.timeContext || null,
986
+ notes: Array.isArray(m.notes) ? m.notes.map((n) => ({ text: n.text })) : [],
987
+ relationships: Array.isArray(m.relationships)
988
+ ? m.relationships.map((r) => ({ type: r.type, targetId: r.targetId, weight: r.weight }))
989
+ : []
990
+ }))
991
+ };
992
+
993
+ const resolvedPath = path.resolve(outPath);
994
+ fs.writeFileSync(resolvedPath, JSON.stringify(envelope, null, 2) + '\n', 'utf8');
995
+
996
+ log(JSON.stringify({ exported: true, path: resolvedPath, schemaVersion: envelope.schemaVersion, memoryCount: envelope.memories.length }));
997
+ }
998
+
999
+ async function runMemoryImport(args) {
1000
+ const filePath = args.find((a) => a && !a.startsWith('--')) || '';
1001
+ const dryRun = parseFlag(args, '--dry-run');
1002
+
1003
+ if (!filePath) {
1004
+ warn('Usage: empirical memory import <file> [--dry-run]');
1005
+ process.exit(1);
1006
+ }
1007
+
1008
+ let doc;
1009
+ try {
1010
+ doc = JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'));
1011
+ } catch (error) {
1012
+ warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
1013
+ process.exit(1);
1014
+ }
1015
+
1016
+ const memories = Array.isArray(doc && doc.memories) ? doc.memories : null;
1017
+ if (!memories) {
1018
+ warn(JSON.stringify({
1019
+ error: true,
1020
+ code: 'invalid_format',
1021
+ message: 'File is not a valid Empirical memory export: missing a top-level "memories" array. Re-export with `empirical memory export`.'
1022
+ }));
1023
+ process.exit(1);
1024
+ }
1025
+
1026
+ if (dryRun) {
1027
+ log(JSON.stringify({ dryRun: true, wouldImport: memories.length, schemaVersion: doc.schemaVersion || null }));
1028
+ return;
1029
+ }
1030
+
1031
+ const accessToken = requireAccessToken();
1032
+ const baseUrl = resolveApiBaseUrl();
1033
+ // Every row goes through the normal create endpoint, so imports get the same dedupe and
1034
+ // auto-link treatment as any other write, no special-casing needed here.
1035
+ const recordUrl = new URL('/api/v1/memories', baseUrl).toString();
1036
+
1037
+ let imported = 0;
1038
+ let failed = 0;
1039
+ const errors = [];
1040
+
1041
+ for (const row of memories) {
1042
+ const payload = {
1043
+ category: row.category,
1044
+ summary: row.summary,
1045
+ nodeType: row.nodeType || 'observation',
1046
+ tags: Array.isArray(row.tags) ? row.tags : [],
1047
+ data: row.data !== undefined
1048
+ ? row.data
1049
+ : (row.summary !== undefined ? { text: String(row.summary) } : undefined)
1050
+ };
1051
+ if (Array.isArray(row.relationships) && row.relationships.length) payload.relationships = row.relationships;
1052
+ if (row.mass !== undefined && row.mass !== null) payload.mass = row.mass;
1053
+ if (row.gravity !== undefined && row.gravity !== null) payload.gravity = row.gravity;
1054
+ if (row.timeContext) payload.timeContext = row.timeContext;
1055
+
1056
+ let created;
1057
+ try {
1058
+ const response = await httpJson(recordUrl, 'POST', payload, buildApiAuthHeaders(accessToken));
1059
+ created = (response && response.data) ? response.data : response;
1060
+ imported++;
1061
+ } catch (error) {
1062
+ failed++;
1063
+ errors.push({ summary: row.summary, message: error.message });
1064
+ continue;
1065
+ }
1066
+
1067
+ const notes = Array.isArray(row.notes) ? row.notes : [];
1068
+ const createdId = created && (created.id || created._id);
1069
+ if (notes.length && createdId) {
1070
+ const noteUrl = new URL(`/api/v1/memories/${encodeURIComponent(createdId)}/notes`, baseUrl).toString();
1071
+ for (const note of notes) {
1072
+ const text = typeof note === 'string' ? note : (note && note.text);
1073
+ if (!text) continue;
1074
+ try {
1075
+ await httpJson(noteUrl, 'POST', { text }, buildApiAuthHeaders(accessToken));
1076
+ } catch (_error) {
1077
+ // Best-effort: a note failing to re-attach shouldn't fail the whole import.
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ log(JSON.stringify({ imported, failed, total: memories.length, errors: errors.slice(0, 20) }));
1084
+ }
1085
+
1086
+ async function runMemoryBulkImport(args) {
1087
+ const filePath = args.find((a) => a && !a.startsWith('--')) || '';
1088
+ const dryRun = parseFlag(args, '--dry-run');
1089
+
1090
+ if (!filePath) {
1091
+ warn('Usage: empirical memory bulk-import <file> [--dry-run]');
1092
+ process.exit(1);
1093
+ }
1094
+
1095
+ let plan;
1096
+ try {
1097
+ plan = JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'));
1098
+ } catch (error) {
1099
+ warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
1100
+ process.exit(1);
1101
+ }
1102
+
1103
+ const memories = Array.isArray(plan && plan.memories) ? plan.memories : null;
1104
+ if (!memories) {
1105
+ warn(JSON.stringify({
1106
+ error: true,
1107
+ code: 'invalid_format',
1108
+ message: 'File must have a top-level "memories" array. See `empirical memory bulk-import --help`.'
1109
+ }));
1110
+ process.exit(1);
1111
+ }
1112
+ const relationships = Array.isArray(plan.relationships) ? plan.relationships : [];
1113
+
1114
+ if (dryRun) {
1115
+ log(JSON.stringify({ dryRun: true, wouldImportMemories: memories.length, wouldImportRelationships: relationships.length }));
1116
+ return;
1117
+ }
1118
+
1119
+ const accessToken = requireAccessToken();
1120
+ const baseUrl = resolveApiBaseUrl();
1121
+ const bulkUrl = new URL('/api/v1/memories/bulk', baseUrl).toString();
1122
+
1123
+ try {
1124
+ const enqueueResponse = await httpJson(bulkUrl, 'POST', { memories, relationships }, buildApiAuthHeaders(accessToken));
1125
+ const { jobId } = (enqueueResponse && enqueueResponse.data) ? enqueueResponse.data : enqueueResponse;
1126
+ const statusUrl = new URL(`/api/v1/memories/bulk/${jobId}`, baseUrl).toString();
1127
+
1128
+ const pollIntervalMs = 3000;
1129
+ const maxPolls = 200;
1130
+ for (let i = 0; i < maxPolls; i += 1) {
1131
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1132
+ const statusResponse = await httpJson(statusUrl, 'GET', undefined, buildApiAuthHeaders(accessToken));
1133
+ const job = (statusResponse && statusResponse.data) ? statusResponse.data : statusResponse;
1134
+ if (job.status === 'succeeded') { log(JSON.stringify(job)); return; }
1135
+ if (job.status === 'failed') { warn(JSON.stringify(job)); process.exit(1); }
1136
+ }
1137
+ warn(JSON.stringify({ error: true, code: 'timeout', message: `Job ${jobId} did not complete within ${(maxPolls * pollIntervalMs) / 1000}s` }));
1138
+ process.exit(1);
1139
+ } catch (error) {
1140
+ printApiError(error);
1141
+ process.exit(1);
1142
+ }
1143
+ }
1144
+
1145
+ async function runMemoryStats(args) {
1146
+ const format = parseOption(args, '--format', 'json');
1147
+
1148
+ const accessToken = requireAccessToken();
1149
+ const baseUrl = resolveApiBaseUrl();
1150
+ const url = new URL('/api/v1/memory-stats', baseUrl).toString();
1151
+
1152
+ let response;
1153
+ try {
1154
+ response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
1155
+ } catch (error) {
1156
+ printApiError(error);
1157
+ process.exit(1);
1158
+ }
1159
+
1160
+ const data = (response && response.data) ? response.data : response;
1161
+
1162
+ if (format === 'table') {
1163
+ log(`Total memories: ${data.total}`);
1164
+ log(`Oldest: ${data.oldest || '(none)'}`);
1165
+ log(`Newest: ${data.newest || '(none)'}`);
1166
+ log('');
1167
+ log('By category:');
1168
+ log(renderTable(data.byCategory || [], ['category', 'count']));
1169
+ log('');
1170
+ log('By tag:');
1171
+ log(renderTable(data.byTag || [], ['tag', 'count']));
1172
+ return;
1173
+ }
1174
+
1175
+ log(JSON.stringify(data));
1176
+ }
1177
+
1178
+ async function runMemoryCompact(args) {
1179
+ const apply = parseFlag(args, '--apply');
1180
+ const category = parseOption(args, '--category', '');
1181
+
1182
+ const accessToken = requireAccessToken();
1183
+ const baseUrl = resolveApiBaseUrl();
1184
+ const url = new URL('/api/v1/memory-compaction', baseUrl).toString();
1185
+ const body = { apply };
1186
+ if (category) body.category = category;
1187
+
1188
+ let response;
1189
+ try {
1190
+ response = await httpJson(url, 'POST', body, buildApiAuthHeaders(accessToken));
1191
+ } catch (error) {
1192
+ printApiError(error);
1193
+ process.exit(1);
1194
+ }
1195
+
1196
+ const data = (response && response.data) ? response.data : response;
1197
+ log(JSON.stringify(data));
1198
+ }
1199
+
1200
+ async function runMemoryCommand(subArgs) {
1201
+ const helpRequested = subArgs.includes('--help') || subArgs.includes('-h');
1202
+ const sub = subArgs[0] && !subArgs[0].startsWith('-') ? subArgs[0] : undefined;
1203
+
1204
+ if (helpRequested || !sub) {
1205
+ printMemoryHelp(sub);
1206
+ return;
1207
+ }
1208
+
1209
+ const rest = subArgs.slice(1);
1210
+
1211
+ return withCliAnalyticsContext(
1212
+ { command: `memory.${sub || 'unknown'}`, subcommand: sub || 'unknown' },
1213
+ async () => withSelectedWorkspaceContext(async () => {
1214
+ if (sub === 'query') return runMemoryQuery(rest);
1215
+ if (sub === 'get') return runMemoryGet(rest);
1216
+ if (sub === 'list') return runMemoryList(rest);
1217
+ if (sub === 'record') return runMemoryRecord(rest);
1218
+ if (sub === 'note') return runMemoryNote(rest);
1219
+ if (sub === 'update') return runMemoryUpdate(rest);
1220
+ if (sub === 'acknowledge' || sub === 'ack' || sub === 'done') return runMemoryAcknowledge(rest);
1221
+ if (sub === 'delete') return runMemoryDelete(rest);
1222
+ // `attachments` and `attach` are shorthands for the two subcommands that get used most.
1223
+ if (sub === 'attachment') return runAttachmentCommand(rest);
1224
+ if (sub === 'attachments') return runAttachmentCommand(['list', ...rest]);
1225
+ if (sub === 'attach') return runAttachmentCommand(['add', ...rest]);
1226
+ if (sub === 'tree') return runMemoryTree(rest);
1227
+ if (sub === 'duplicates') return runMemoryDuplicates(rest);
1228
+ if (sub === 'merge') return runMemoryMerge(rest);
1229
+ if (sub === 'export') return runMemoryExport(rest);
1230
+ if (sub === 'import') return runMemoryImport(rest);
1231
+ if (sub === 'bulk-import') return runMemoryBulkImport(rest);
1232
+ if (sub === 'stats') return runMemoryStats(rest);
1233
+ if (sub === 'compact') return runMemoryCompact(rest);
1234
+
1235
+ warn('Unknown memory subcommand: ' + (sub || '(none)'));
1236
+ warn('Usage: empirical memory <query|record|note|update|delete|attachment|tree|duplicates|merge|export|import|stats|compact> [options]');
1237
+ process.exit(1);
1238
+ })
1239
+ );
1240
+ }
1241
+
1242
+ module.exports = {
1243
+ resolveSummaryInput,
1244
+ detectShellMangling,
1245
+ withCliAnalyticsContext,
1246
+ resolveMemoryByQuery,
1247
+ printMemoryResolveError,
1248
+ resolveWorkspaceIdsForQuery,
1249
+ runMemoryQuery,
1250
+ runMemoryGet,
1251
+ runMemoryList,
1252
+ runMemoryRecord,
1253
+ runMemoryNote,
1254
+ runMemoryUpdate,
1255
+ runMemoryAcknowledge,
1256
+ runMemoryDelete,
1257
+ runMemoryTree,
1258
+ runMemoryDuplicates,
1259
+ runMemoryMerge,
1260
+ runMemoryExport,
1261
+ runMemoryImport,
1262
+ runMemoryBulkImport,
1263
+ runMemoryStats,
1264
+ runMemoryCompact,
1265
+ runMemoryDeleteBulk,
1266
+ runMemoryCommand
1267
+ };