@empirical-gauzza/cli 1.0.37 → 1.0.39

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.
@@ -195,6 +195,26 @@ Use these when connected: `get_empirical_policy`, `query_memories`, `list_memori
195
195
  graph work, use `get_memory_neighbors` and `update_memory_relationships` when available instead of
196
196
  reconstructing relationships through many unrelated calls.
197
197
 
198
+ ## Shared workspaces: shape, and the setup interview
199
+
200
+ When the current team workspace has been shaped, `get_current_workspace` also returns `shape` (its
201
+ kinds of item, with their keys, statuses and fields) and `policy` (the rules its managers
202
+ confirmed). Read them before saving into that workspace and follow them. A workspace with no shape
203
+ returns neither.
204
+
205
+ Setting up or changing a shared workspace is an interview the person answers. It is available only
206
+ when `list_guided_setups` lists `setup_workspace`; when it is not listed, do not offer it. Call
207
+ `start_guided_setup` with that setupId to create a new workspace. To change an existing one, also
208
+ pass `reshapeWorkspaceId` with that workspace's id, and only when the person asked to change that
209
+ workspace: leaving it out always creates a new one, and the selected workspace is never the target.
210
+ A start can first return a cost: tell the person and call again with `acceptEmpiricalTokenCost`
211
+ true only after they agree. Ask each question, send the person's own words with
212
+ `continue_guided_setup`, show the whole draft, and call `confirm_guided_setup` with its draftHash
213
+ only after they clearly say yes. Confirming creates the workspace or saves its shape and emails the
214
+ invitations the draft lists. From the CLI, the same interview
215
+ runs with `empirical workspace create --interview` and `empirical workspace shape --workspace-id <id>`;
216
+ `empirical setup abandon <sessionId>` stops one and does not return what it cost.
217
+
198
218
  ## Reminders — set them, relay them, close them
199
219
 
200
220
  Any memory can carry a due date. Empirical then brings it back: an email with a calendar entry
@@ -179,6 +179,16 @@ token error, run `empirical oauth bootstrap headless --write-env`. When in doubt
179
179
  subcommand's flags, run `empirical doc memory`, `empirical doc kit`, or `empirical doc skill`
180
180
  rather than guessing.
181
181
 
182
+ ## Shared workspaces: setup interview
183
+
184
+ `empirical workspace create --interview` sets up a new shared workspace by interview, and
185
+ `empirical workspace shape --workspace-id <id>` re-shapes one you manage. Both ask the person
186
+ questions at the terminal, show a draft, and create or change the workspace only after they type
187
+ yes. A new interview is charged, and `empirical setup abandon <sessionId>` stops one without
188
+ returning what it cost, so say that before suggesting it. If the command says the interview is not
189
+ switched on for the account, stop: nothing was started or charged. Run `empirical doc workspace`
190
+ for the details.
191
+
182
192
  ## Reminders — set them, relay them, close them
183
193
 
184
194
  Any memory can carry a due date. Empirical then brings it back: an email with a calendar entry
@@ -167,6 +167,24 @@ An `empirical` CLI may also be installed on this machine for diagnostics (`empir
167
167
  manual account setup, but it is not the point of this variant — do not lead with CLI commands for
168
168
  query or record workflows here.
169
169
 
170
+ ## Shared workspaces: shape, and the setup interview
171
+
172
+ When the current team workspace has been shaped, `get_current_workspace` also returns `shape` (its
173
+ kinds of item, with their keys, statuses and fields) and `policy` (the rules its managers
174
+ confirmed). Read them before saving into that workspace and follow them. A workspace with no shape
175
+ returns neither.
176
+
177
+ Setting up or changing a shared workspace is an interview the person answers. It is available only
178
+ when `list_guided_setups` lists `setup_workspace`; when it is not listed, do not offer it. Call
179
+ `start_guided_setup` with that setupId to create a new workspace. To change an existing one, also
180
+ pass `reshapeWorkspaceId` with that workspace's id, and only when the person asked to change that
181
+ workspace: leaving it out always creates a new one, and the selected workspace is never the target.
182
+ A start can first return a cost: tell the person and call again with `acceptEmpiricalTokenCost`
183
+ true only after they agree. Ask each question, send the person's own words with
184
+ `continue_guided_setup`, show the whole draft, and call `confirm_guided_setup` with its draftHash
185
+ only after they clearly say yes. Confirming creates the workspace or saves its shape and emails the
186
+ invitations the draft lists.
187
+
170
188
  ## Reminders — set them, relay them, close them
171
189
 
172
190
  Any memory can carry a due date. Empirical then brings it back: an email with a calendar entry
@@ -8,6 +8,8 @@ const { parseOption, parseFlag } = require('./env');
8
8
  const { requireAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
9
9
  const { MEMORY_CATEGORIES, NODE_TYPES, DOC_TOPICS } = require('./constants');
10
10
  const { withSelectedWorkspaceContext } = require('./workspaceContext');
11
+ const { resolveMemoryByQuery, parseMatchFilters, printMemoryResolveError } = require('./memoryResolve');
12
+ const { runAttachmentCommand, ATTACHMENT_SUBCOMMAND_USAGE } = require('./attachmentCommands');
11
13
 
12
14
  // ─── Table rendering (--format table) ─────────────────────────────────────────
13
15
 
@@ -64,11 +66,15 @@ const MEMORY_SUBCOMMAND_USAGE = {
64
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]',
65
67
  get: 'Usage: empirical memory get --memory-id <id> [--pretty]',
66
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]',
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]',
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.',
68
70
  note: 'Usage: empirical memory note --text <note> (--match <query> | --memory-id <id>) [--min-score 0.35]',
69
71
  update: 'Usage: empirical memory update (--match <query> | --memory-id <id>) (--patch-json <json> | --json-file <path>) [--min-score 0.35]',
70
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)',
71
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.',
72
78
  duplicates: 'Usage: empirical memory duplicates --memory-id <id>',
73
79
  merge: 'Usage: empirical memory merge --canonical-id <id> --duplicate-id <id> [--no-merge-tags] [--no-merge-relationships]',
74
80
  export: 'Usage: empirical memory export --scope <personal|workspace> [--out <path>] [--workspace-id <id>]',
@@ -97,100 +103,6 @@ async function withCliAnalyticsContext(context, fn) {
97
103
  }
98
104
  }
99
105
 
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
106
 
195
107
  // ─── Memory subcommands ───────────────────────────────────────────────────────
196
108
 
@@ -514,7 +426,10 @@ async function runMemoryRecord(args) {
514
426
  }
515
427
 
516
428
  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]');
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);
518
433
  warn('Valid categories: ' + MEMORY_CATEGORIES.join(', '));
519
434
  warn('Valid node types: ' + NODE_TYPES.join(', '));
520
435
  process.exit(1);
@@ -591,6 +506,52 @@ async function runMemoryRecord(args) {
591
506
  payload.authorType = authorType;
592
507
  }
593
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
+
594
555
  // --due turns the memory into a reminder. Parsed and rejected HERE as well as server-side, so
595
556
  // a typo fails immediately at the terminal instead of coming back as a 400 after the write was
596
557
  // composed. Accepts anything Date can parse, so '2026-10-14' and a full ISO timestamp both work.
@@ -843,6 +804,84 @@ async function runMemoryDelete(args) {
843
804
  log(JSON.stringify({ deleted: true, id: targetId, summary: targetSummary }));
844
805
  }
845
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
+
846
885
  async function runMemoryDuplicates(args) {
847
886
  const memoryId = parseOption(args, '--memory-id', '');
848
887
  if (!memoryId) {
@@ -1180,6 +1219,11 @@ async function runMemoryCommand(subArgs) {
1180
1219
  if (sub === 'update') return runMemoryUpdate(rest);
1181
1220
  if (sub === 'acknowledge' || sub === 'ack' || sub === 'done') return runMemoryAcknowledge(rest);
1182
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);
1183
1227
  if (sub === 'duplicates') return runMemoryDuplicates(rest);
1184
1228
  if (sub === 'merge') return runMemoryMerge(rest);
1185
1229
  if (sub === 'export') return runMemoryExport(rest);
@@ -1189,7 +1233,7 @@ async function runMemoryCommand(subArgs) {
1189
1233
  if (sub === 'compact') return runMemoryCompact(rest);
1190
1234
 
1191
1235
  warn('Unknown memory subcommand: ' + (sub || '(none)'));
1192
- warn('Usage: empirical memory <query|record|note|update|delete|duplicates|merge|export|import|stats|compact> [options]');
1236
+ warn('Usage: empirical memory <query|record|note|update|delete|attachment|tree|duplicates|merge|export|import|stats|compact> [options]');
1193
1237
  process.exit(1);
1194
1238
  })
1195
1239
  );
@@ -1210,6 +1254,7 @@ module.exports = {
1210
1254
  runMemoryUpdate,
1211
1255
  runMemoryAcknowledge,
1212
1256
  runMemoryDelete,
1257
+ runMemoryTree,
1213
1258
  runMemoryDuplicates,
1214
1259
  runMemoryMerge,
1215
1260
  runMemoryExport,
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ // Resolving "which memory did the user mean" from a --match query, shared by every memory
4
+ // subcommand that accepts --match. It lives in its own module so attachmentCommands.js can use
5
+ // it without a require cycle back through memoryCommands.js; memoryCommands.js re-exports the
6
+ // three names so existing importers and tests are unaffected.
7
+
8
+ const { warn } = require('./ui');
9
+ const { httpJson } = require('./http');
10
+ const { parseOption } = require('./env');
11
+ const { buildApiAuthHeaders } = require('./auth');
12
+
13
+ async function resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters) {
14
+ const queryUrl = new URL('/api/v1/memories/query', baseUrl).toString();
15
+ const body = { query: match, topK: 5 };
16
+ if (filters && Object.keys(filters).length) body.filters = filters;
17
+ let response;
18
+ try {
19
+ response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
20
+ } catch (error) {
21
+ return { found: false, reason: 'error', message: error.message };
22
+ }
23
+
24
+ const results = (response && response.data && Array.isArray(response.data.results))
25
+ ? response.data.results
26
+ : (Array.isArray(response) ? response : []);
27
+
28
+ if (!results.length) {
29
+ return { found: false, reason: 'no_results' };
30
+ }
31
+
32
+ const scored = results.map((r) => ({
33
+ id: r.id,
34
+ summary: r.summary,
35
+ score: r.rankScore != null ? r.rankScore : (r.similarityScore != null ? r.similarityScore : 0)
36
+ }));
37
+
38
+ scored.sort((a, b) => b.score - a.score);
39
+
40
+ // A caller-supplied filter (e.g. --tags) that narrows the server-side result set to exactly
41
+ // one memory is already a stronger disambiguation signal than the semantic score - trust it
42
+ // outright rather than falling through to the score check below. This matters because Atlas
43
+ // vector search has a real async indexing lag: a memory created moments ago can come back
44
+ // with score 0 (unindexed, not "not this one") even when a tag filter already proves it is
45
+ // the only candidate. Fixes a real bug found 2026-08-25 where delete --match failed on a
46
+ // memory `query` found instantly, because delete had no way to trust a filtered, unambiguous
47
+ // result over a stale/zero semantic score.
48
+ if (filters && Object.keys(filters).length && results.length === 1) {
49
+ const only = scored[0];
50
+ return { found: true, memory: { id: only.id, summary: only.summary, score: only.score } };
51
+ }
52
+
53
+ const top = scored[0];
54
+ const second = scored[1];
55
+
56
+ if (top.score < minScore) {
57
+ return { found: false, reason: 'low_score', candidates: scored.slice(0, 3) };
58
+ }
59
+
60
+ if (second && (top.score - second.score) <= 0.05) {
61
+ return { found: false, reason: 'ambiguous', candidates: scored.slice(0, 3) };
62
+ }
63
+
64
+ return { found: true, memory: { id: top.id, summary: top.summary, score: top.score } };
65
+ }
66
+
67
+ // --match resolution depends on Atlas vector search, which has a real async indexing lag:
68
+ // a memory created moments ago can be invisible to $vectorSearch even though it is already
69
+ // findable by tag. Letting --match commands pass --tags narrows the candidate pool directly
70
+ // instead of hoping semantic ranking surfaces the right one - the fix for a real bug found
71
+ // 2026-08-25 where delete --match returned unrelated score-0 candidates for a memory that
72
+ // `query` found instantly. Deliberately --tags (plural) only, not --category: `delete` already
73
+ // uses singular --tag/--category to mean "bulk delete everything in this category", so adding
74
+ // --category here would silently reroute a --match delete into a bulk delete instead of
75
+ // narrowing it - --tags never collides with that bulk-mode gate.
76
+ function parseMatchFilters(args) {
77
+ const tagsRaw = parseOption(args, '--tags', '');
78
+ const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
79
+ const filters = {};
80
+ if (tags.length) filters.tags = tags;
81
+ return filters;
82
+ }
83
+
84
+ function printMemoryResolveError(result) {
85
+ if (result.reason === 'no_results') {
86
+ warn(JSON.stringify({ error: true, code: 'no_results', message: 'No memories matched the query.' }));
87
+ } else if (result.reason === 'low_score') {
88
+ warn(JSON.stringify({
89
+ error: true,
90
+ code: 'low_score',
91
+ message: 'Top result score was below --min-score threshold.',
92
+ candidates: result.candidates
93
+ }));
94
+ } else if (result.reason === 'ambiguous') {
95
+ warn(JSON.stringify({
96
+ error: true,
97
+ code: 'ambiguous',
98
+ message: 'Multiple memories matched with similar scores. Use --memory-id to target one directly.',
99
+ candidates: result.candidates
100
+ }));
101
+ } else if (result.reason === 'error') {
102
+ warn(JSON.stringify({ error: true, code: 'query_error', message: result.message }));
103
+ } else {
104
+ warn(JSON.stringify({ error: true, code: 'resolve_failed', message: 'Could not resolve memory.' }));
105
+ }
106
+ }
107
+
108
+ module.exports = {
109
+ resolveMemoryByQuery,
110
+ parseMatchFilters,
111
+ printMemoryResolveError
112
+ };