@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.
- package/lib/attachmentCommands.js +388 -0
- package/lib/constants.js +65 -0
- package/lib/empiricalTokensFormat.js +6 -4
- package/lib/help.js +15 -0
- package/lib/http.js +160 -0
- package/lib/installers/hooks.js +1312 -1312
- package/lib/installers/skill-variants/SKILL.both.md +20 -0
- package/lib/installers/skill-variants/SKILL.cli.md +10 -0
- package/lib/installers/skill-variants/SKILL.mcp.md +18 -0
- package/lib/memoryCommands.js +142 -97
- package/lib/memoryResolve.js +112 -0
- package/lib/setupCommands.js +191 -14
- package/lib/shapeCommands.js +313 -0
- package/lib/workspaceCommands.js +80 -5
- package/lib/workspaceContext.js +176 -176
- package/package.json +33 -33
|
@@ -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
|
package/lib/memoryCommands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|