@empirical-gauzza/cli 1.0.38 → 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/bin/empirical.js +1 -1
- package/lib/attachmentCommands.js +388 -388
- package/lib/constants.js +15 -0
- package/lib/help.js +4 -1
- package/lib/installers/skill-variants/SKILL.cli.md +231 -231
- package/lib/memoryCommands.js +1267 -1267
- package/lib/memoryResolve.js +112 -112
- package/lib/shapeCommands.js +313 -0
- package/lib/workspaceCommands.js +8 -0
- package/package.json +1 -1
package/lib/memoryResolve.js
CHANGED
|
@@ -1,112 +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
|
-
};
|
|
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
|
+
};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Editing a workspace's shape by hand, without the interview.
|
|
4
|
+
//
|
|
5
|
+
// empirical workspace shape show --workspace-id <id>
|
|
6
|
+
// empirical workspace shape set --workspace-id <id> --file <shape.json> [--apply]
|
|
7
|
+
// empirical workspace shape edit --workspace-id <id> --file <edit.json> [--apply]
|
|
8
|
+
// empirical workspace shape add-type --workspace-id <id> <label> [--statuses "A,B,C (done)"] ...
|
|
9
|
+
// empirical workspace shape add-status --workspace-id <id> <type-key> <label> [--done]
|
|
10
|
+
// empirical workspace shape add-field --workspace-id <id> <type-key> <label> --kind <kind> ...
|
|
11
|
+
//
|
|
12
|
+
// The interview is charged (100 Empirical Tokens, plus 25 per change to a draft) and takes nine
|
|
13
|
+
// questions, so adding a single status cost more than the status was worth. The server has had the
|
|
14
|
+
// door for this since POST/PATCH /api/v1/team-brains/:id/shape and /shape/preview; this file is the
|
|
15
|
+
// CLI side of it.
|
|
16
|
+
//
|
|
17
|
+
// Every change is a DRY RUN until --apply. A change goes to /shape/preview first, which validates it
|
|
18
|
+
// against the active shape and returns a confirmDigest; --apply sends that digest back, so an edit
|
|
19
|
+
// is only ever applied to the shape that was shown. The commands here only ADD (a type, a status, a
|
|
20
|
+
// field); removing needs a mapping for the stored values, which `edit --mapping-file` accepts.
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const { log, warn } = require('./ui');
|
|
24
|
+
const { httpJson } = require('./http');
|
|
25
|
+
const { parseOption, parseFlag } = require('./env');
|
|
26
|
+
|
|
27
|
+
const SHAPE_ACTIONS = ['show', 'set', 'edit', 'add-type', 'add-status', 'add-field'];
|
|
28
|
+
const FIELD_KINDS = ['text', 'longText', 'number', 'date', 'select', 'multiSelect', 'person', 'link', 'url'];
|
|
29
|
+
const VALUE_OPTIONS = new Set([
|
|
30
|
+
'--workspace-id', '--file', '--mapping-file', '--purpose', '--plural', '--category', '--node-type',
|
|
31
|
+
'--icon', '--color', '--statuses', '--fields', '--nest-under', '--kind', '--options'
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const SHAPE_USAGE = [
|
|
35
|
+
'Usage: empirical workspace shape <show|set|edit|add-type|add-status|add-field> --workspace-id <id> ...',
|
|
36
|
+
' show print the workspace\'s item types, statuses and fields',
|
|
37
|
+
' set --file <shape.json> [--apply] give a workspace with no shape its first shape',
|
|
38
|
+
' edit --file <edit.json> [--mapping-file <m.json>] [--apply]',
|
|
39
|
+
' add, update or remove entries (see `empirical doc workspace`)',
|
|
40
|
+
' add-type <label> [--statuses "A,B,C (done)"] [--fields "Email:text:required;Wave:number"]',
|
|
41
|
+
' [--plural <label>] [--category <cat>] [--node-type <type>] [--icon <i>] [--color <c>]',
|
|
42
|
+
' [--nest-under <type-key,...>] [--purpose <text>] (--purpose only when the workspace has no shape yet)',
|
|
43
|
+
' add-status <type-key> <label> [--done]',
|
|
44
|
+
' add-field <type-key> <label> [--kind text|longText|number|date|select|multiSelect|person|link|url]',
|
|
45
|
+
' [--required] [--options "Yes|No|Not asked"]',
|
|
46
|
+
'Changes are a dry run until you add --apply. Running the interview instead: `empirical workspace shape` with no action.'
|
|
47
|
+
].join('\n');
|
|
48
|
+
|
|
49
|
+
function isManualShapeAction(args) {
|
|
50
|
+
return SHAPE_ACTIONS.includes(args[2]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Words after the action that are not options or option values, in order.
|
|
54
|
+
function positionals(args, startIndex = 3) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (let i = startIndex; i < args.length; i += 1) {
|
|
57
|
+
if (VALUE_OPTIONS.has(args[i])) i += 1;
|
|
58
|
+
else if (!String(args[i]).startsWith('--')) out.push(args[i]);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// "Not Yet Invited, Invited, Call Completed (done)" -> statuses in order; "(done)" marks the finished one.
|
|
64
|
+
function parseStatusList(text) {
|
|
65
|
+
return String(text || '')
|
|
66
|
+
.split(',')
|
|
67
|
+
.map((part) => part.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((part) => {
|
|
70
|
+
const done = /\(\s*done\s*\)\s*$/i.test(part);
|
|
71
|
+
const label = part.replace(/\(\s*done\s*\)\s*$/i, '').trim();
|
|
72
|
+
return done ? { label, isDone: true } : { label };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// "Email:text:required; Open to a call:select:Yes|No|Not asked; Wave:number"
|
|
77
|
+
// label[:kind][:required][:option|option...] separated by semicolons (options may contain commas).
|
|
78
|
+
function parseFieldList(text) {
|
|
79
|
+
return String(text || '')
|
|
80
|
+
.split(';')
|
|
81
|
+
.map((part) => part.trim())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((part) => {
|
|
84
|
+
const [label, kindRaw, ...rest] = part.split(':').map((s) => s.trim());
|
|
85
|
+
const kind = kindRaw || 'text';
|
|
86
|
+
if (!FIELD_KINDS.includes(kind)) {
|
|
87
|
+
throw new Error(`Field "${label}" has kind "${kind}". Use one of: ${FIELD_KINDS.join(', ')}.`);
|
|
88
|
+
}
|
|
89
|
+
const field = { label, kind };
|
|
90
|
+
for (const piece of rest) {
|
|
91
|
+
if (/^required$/i.test(piece)) field.required = true;
|
|
92
|
+
else if (piece) field.options = piece.split('|').map((o) => o.trim()).filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
return field;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildTypeFromFlags(args, label) {
|
|
99
|
+
const type = {
|
|
100
|
+
label,
|
|
101
|
+
baseCategory: parseOption(args, '--category', 'project'),
|
|
102
|
+
baseNodeType: parseOption(args, '--node-type', 'entity')
|
|
103
|
+
};
|
|
104
|
+
const plural = parseOption(args, '--plural', '');
|
|
105
|
+
if (plural) type.labelPlural = plural;
|
|
106
|
+
for (const [flag, key] of [['--icon', 'icon'], ['--color', 'color']]) {
|
|
107
|
+
const value = parseOption(args, flag, '');
|
|
108
|
+
if (value) type[key] = value;
|
|
109
|
+
}
|
|
110
|
+
const nest = parseOption(args, '--nest-under', '');
|
|
111
|
+
if (nest) type.canNestUnder = nest.split(',').map((s) => s.trim()).filter(Boolean);
|
|
112
|
+
const statuses = parseStatusList(parseOption(args, '--statuses', ''));
|
|
113
|
+
if (statuses.length) type.statuses = statuses;
|
|
114
|
+
const fields = parseFieldList(parseOption(args, '--fields', ''));
|
|
115
|
+
if (fields.length) type.fields = fields;
|
|
116
|
+
return type;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildAddStatusEdit(typeKey, label, isDone) {
|
|
120
|
+
return { itemTypes: { update: [{ key: typeKey, statuses: { add: [isDone ? { label, isDone: true } : { label }] } }] } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildAddFieldEdit(typeKey, field) {
|
|
124
|
+
return { itemTypes: { update: [{ key: typeKey, fields: { add: [field] } }] } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readJsonFile(path, what) {
|
|
128
|
+
let raw;
|
|
129
|
+
try {
|
|
130
|
+
raw = fs.readFileSync(path, 'utf8');
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new Error(`Could not read ${what} ${path}: ${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(raw);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(`${what} ${path} is not valid JSON: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// One readable block per item type. Statuses in order, (done) on the finished ones.
|
|
142
|
+
function formatShape(shape) {
|
|
143
|
+
if (!shape) return ['This workspace has no shape yet.'];
|
|
144
|
+
const lines = [];
|
|
145
|
+
if (shape.purpose) lines.push(`Purpose: ${shape.purpose}`);
|
|
146
|
+
if (shape.version !== undefined) lines.push(`Version ${shape.version}${shape.source ? ` (${shape.source})` : ''}`);
|
|
147
|
+
for (const type of shape.itemTypes || []) {
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push(`${type.label} [${type.key}] saved as ${type.baseCategory}/${type.baseNodeType}`);
|
|
150
|
+
const statuses = (type.statuses || []).map((s) => `${s.label} [${s.key}]${s.isDone ? ' (done)' : ''}`);
|
|
151
|
+
lines.push(` statuses: ${statuses.length ? statuses.join(' > ') : 'none'}`);
|
|
152
|
+
for (const f of type.fields || []) {
|
|
153
|
+
const extra = [f.kind, f.required ? 'required' : null, f.options && f.options.length ? f.options.join('|') : null].filter(Boolean);
|
|
154
|
+
lines.push(` field ${f.label} [${f.key}]: ${extra.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
if (type.canNestUnder && type.canNestUnder.length) lines.push(` nests under: ${type.canNestUnder.join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
if ((shape.policy || []).length) {
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push('Rules:');
|
|
161
|
+
for (const p of shape.policy) lines.push(` ${p.mode || ''} ${p.key || ''}: ${p.text || ''}`.replace(/\s+/g, ' ').replace(/^ /, ' '));
|
|
162
|
+
}
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function shapeUrl(baseUrl, workspaceId, suffix = '') {
|
|
167
|
+
return new URL(`/api/v1/team-brains/${encodeURIComponent(workspaceId)}/shape${suffix}`, baseUrl).toString();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// An edit goes to /shape/preview, is shown, and only with --apply is sent to PATCH /shape with the
|
|
171
|
+
// digest the preview returned. Returns what happened so a script can act on it.
|
|
172
|
+
async function previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit, mapping, apply, asJson }) {
|
|
173
|
+
const preview = (await httpJson(shapeUrl(baseUrl, workspaceId, '/preview'), 'POST', { edit, ...(mapping ? { mapping } : {}) }, authHeaders)).preview;
|
|
174
|
+
if (asJson && !apply) {
|
|
175
|
+
log(JSON.stringify({ applied: false, preview }));
|
|
176
|
+
} else if (!asJson) {
|
|
177
|
+
for (const line of preview.summary || []) log(line);
|
|
178
|
+
for (const line of preview.mappingSummary || []) log(line);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if ((preview.mappingProblems || []).length) {
|
|
182
|
+
for (const problem of preview.mappingProblems) warn(`Needs a decision: ${problem}`);
|
|
183
|
+
throw new Error('This change removes or renames something that has stored values. Say where they go with --mapping-file (see `empirical doc workspace`).');
|
|
184
|
+
}
|
|
185
|
+
if (!apply) {
|
|
186
|
+
if (!asJson) log('Dry run: nothing was changed. Add --apply to save this.');
|
|
187
|
+
return { applied: false, preview };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const saved = await httpJson(shapeUrl(baseUrl, workspaceId), 'PATCH', {
|
|
191
|
+
edit,
|
|
192
|
+
confirmDigest: preview.confirmDigest,
|
|
193
|
+
...(mapping ? { mapping } : {})
|
|
194
|
+
}, authHeaders);
|
|
195
|
+
if (asJson) log(JSON.stringify({ applied: true, shape: saved.shape }));
|
|
196
|
+
else log(`Saved: the shape of ${workspaceId} is now version ${saved.shape && saved.shape.version}.`);
|
|
197
|
+
return { applied: true, preview, saved };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Gives a workspace with no shape its first one. The server refuses if it already has one.
|
|
201
|
+
async function createShape({ baseUrl, workspaceId, authHeaders, shape, apply, asJson }) {
|
|
202
|
+
if (!apply) {
|
|
203
|
+
if (asJson) log(JSON.stringify({ applied: false, shape }));
|
|
204
|
+
else {
|
|
205
|
+
log(`Purpose: ${shape.purpose}`);
|
|
206
|
+
for (const t of shape.itemTypes || []) {
|
|
207
|
+
log(`${t.label} saved as ${t.baseCategory}/${t.baseNodeType}`);
|
|
208
|
+
log(` statuses: ${(t.statuses || []).map((st) => st.label + (st.isDone ? ' (done)' : '')).join(' > ') || 'none'}`);
|
|
209
|
+
for (const f of t.fields || []) log(` field ${f.label}: ${[f.kind, f.required ? 'required' : null, (f.options || []).join('|') || null].filter(Boolean).join(', ')}`);
|
|
210
|
+
}
|
|
211
|
+
log('');
|
|
212
|
+
log('Dry run: nothing was created. Add --apply to give this workspace that shape.');
|
|
213
|
+
}
|
|
214
|
+
return { applied: false };
|
|
215
|
+
}
|
|
216
|
+
const created = await httpJson(shapeUrl(baseUrl, workspaceId), 'POST', { shape }, authHeaders);
|
|
217
|
+
if (asJson) log(JSON.stringify({ applied: true, shape: created.shape }));
|
|
218
|
+
else log(`Created: ${workspaceId} now has a shape (version ${created.shape && created.shape.version}).`);
|
|
219
|
+
return { applied: true, created };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function requireWorkspaceId(args, { needsExplicit }) {
|
|
223
|
+
const id = parseOption(args, '--workspace-id', '').trim();
|
|
224
|
+
if (!id && needsExplicit) {
|
|
225
|
+
throw new Error('Saving a shape change needs --workspace-id <id>, so a stale directory binding cannot aim it at the wrong workspace.');
|
|
226
|
+
}
|
|
227
|
+
return id;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The entry point. `resolveBound` returns the bound workspace id (or '') for read-only and dry-run use.
|
|
231
|
+
async function runManualShapeCommand({ args, baseUrl, authHeaders, resolveBound = () => '' }) {
|
|
232
|
+
const action = args[2];
|
|
233
|
+
const apply = parseFlag(args, '--apply');
|
|
234
|
+
const asJson = parseFlag(args, '--json');
|
|
235
|
+
const workspaceId = requireWorkspaceId(args, { needsExplicit: apply }) || resolveBound();
|
|
236
|
+
if (!workspaceId) throw new Error(`${SHAPE_USAGE}\n\nNo workspace: pass --workspace-id <id>.`);
|
|
237
|
+
if (!apply && !parseOption(args, '--workspace-id', '')) warn(`Using the workspace bound to this directory: ${workspaceId}`);
|
|
238
|
+
|
|
239
|
+
const current = (await httpJson(shapeUrl(baseUrl, workspaceId), 'GET', null, authHeaders)).shape || null;
|
|
240
|
+
|
|
241
|
+
if (action === 'show') {
|
|
242
|
+
if (asJson) log(JSON.stringify({ shape: current }));
|
|
243
|
+
else for (const line of formatShape(current)) log(line);
|
|
244
|
+
return { shape: current };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (action === 'set') {
|
|
248
|
+
const file = parseOption(args, '--file', '');
|
|
249
|
+
if (!file) throw new Error('Usage: empirical workspace shape set --workspace-id <id> --file <shape.json> [--apply]');
|
|
250
|
+
if (current) throw new Error('This workspace already has a shape. Change it with `shape edit`, `add-type`, `add-status` or `add-field`.');
|
|
251
|
+
const body = readJsonFile(file, 'the shape file');
|
|
252
|
+
return createShape({ baseUrl, workspaceId, authHeaders, shape: body.shape || body, apply, asJson });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const mappingFile = parseOption(args, '--mapping-file', '');
|
|
256
|
+
const mapping = mappingFile ? readJsonFile(mappingFile, 'the mapping file') : undefined;
|
|
257
|
+
|
|
258
|
+
if (action === 'edit') {
|
|
259
|
+
const file = parseOption(args, '--file', '');
|
|
260
|
+
if (!file) throw new Error('Usage: empirical workspace shape edit --workspace-id <id> --file <edit.json> [--mapping-file <m.json>] [--apply]');
|
|
261
|
+
if (!current) throw new Error('This workspace has no shape to edit. Give it one first with `shape set` or `shape add-type --purpose "..."`.');
|
|
262
|
+
return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: readJsonFile(file, 'the edit file'), mapping, apply, asJson });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (action === 'add-type') {
|
|
266
|
+
const [label] = positionals(args);
|
|
267
|
+
if (!label) throw new Error('Usage: empirical workspace shape add-type <label> [--statuses "A,B,C (done)"] [--fields "Email:text:required"] [--purpose <text>] --workspace-id <id>');
|
|
268
|
+
const type = buildTypeFromFlags(args, label);
|
|
269
|
+
if (!current) {
|
|
270
|
+
const purpose = parseOption(args, '--purpose', '');
|
|
271
|
+
if (!purpose) throw new Error('This workspace has no shape yet, so add-type also needs --purpose "what this workspace is for".');
|
|
272
|
+
return createShape({ baseUrl, workspaceId, authHeaders, shape: { purpose, itemTypes: [type], policy: [] }, apply, asJson });
|
|
273
|
+
}
|
|
274
|
+
return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: { itemTypes: { add: [type] } }, mapping, apply, asJson });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!current) throw new Error('This workspace has no shape yet. Start with `shape add-type <label> --purpose "..."`.');
|
|
278
|
+
|
|
279
|
+
if (action === 'add-status') {
|
|
280
|
+
const [typeKey, label] = positionals(args);
|
|
281
|
+
if (!typeKey || !label) throw new Error('Usage: empirical workspace shape add-status <type-key> <label> [--done] --workspace-id <id> [--apply]');
|
|
282
|
+
return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: buildAddStatusEdit(typeKey, label, parseFlag(args, '--done')), mapping, apply, asJson });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (action === 'add-field') {
|
|
286
|
+
const [typeKey, label] = positionals(args);
|
|
287
|
+
if (!typeKey || !label) throw new Error('Usage: empirical workspace shape add-field <type-key> <label> [--kind text] [--required] [--options "A|B"] --workspace-id <id> [--apply]');
|
|
288
|
+
const kind = parseOption(args, '--kind', 'text');
|
|
289
|
+
if (!FIELD_KINDS.includes(kind)) throw new Error(`--kind must be one of: ${FIELD_KINDS.join(', ')}.`);
|
|
290
|
+
const field = { label, kind };
|
|
291
|
+
if (parseFlag(args, '--required')) field.required = true;
|
|
292
|
+
const options = parseOption(args, '--options', '');
|
|
293
|
+
if (options) field.options = options.split('|').map((o) => o.trim()).filter(Boolean);
|
|
294
|
+
return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: buildAddFieldEdit(typeKey, field), mapping, apply, asJson });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
throw new Error(SHAPE_USAGE);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = {
|
|
301
|
+
SHAPE_ACTIONS,
|
|
302
|
+
SHAPE_USAGE,
|
|
303
|
+
FIELD_KINDS,
|
|
304
|
+
isManualShapeAction,
|
|
305
|
+
positionals,
|
|
306
|
+
parseStatusList,
|
|
307
|
+
parseFieldList,
|
|
308
|
+
buildTypeFromFlags,
|
|
309
|
+
buildAddStatusEdit,
|
|
310
|
+
buildAddFieldEdit,
|
|
311
|
+
formatShape,
|
|
312
|
+
runManualShapeCommand
|
|
313
|
+
};
|
package/lib/workspaceCommands.js
CHANGED
|
@@ -8,6 +8,7 @@ const { readStoredOAuthClient } = require('./oauthClient');
|
|
|
8
8
|
const { deriveBaseUrlFromMcpUrl } = require('./constants');
|
|
9
9
|
const { withCliAnalyticsContext } = require('./memoryCommands');
|
|
10
10
|
const { runWorkspaceInterview, sanitizeForTerminal } = require('./setupCommands');
|
|
11
|
+
const { isManualShapeAction, runManualShapeCommand } = require('./shapeCommands');
|
|
11
12
|
const { isInteractiveTerminal } = require('./empiricalTokensFormat');
|
|
12
13
|
const {
|
|
13
14
|
writeSelectedWorkspace,
|
|
@@ -307,6 +308,13 @@ async function runWorkspaceCommand(args) {
|
|
|
307
308
|
return;
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
if (action === 'shape' && isManualShapeAction(args)) {
|
|
312
|
+
// Editing the shape by hand (show, set, edit, add-type, add-status, add-field): no interview, no charge.
|
|
313
|
+
// A dry run unless --apply, and a change is only applied to the shape that was previewed.
|
|
314
|
+
const bound = resolveLocalWorkspaceSelection();
|
|
315
|
+
return runManualShapeCommand({ args, baseUrl, authHeaders, resolveBound: () => (bound && bound.workspaceId) || '' });
|
|
316
|
+
}
|
|
317
|
+
|
|
310
318
|
if (action === 'shape') {
|
|
311
319
|
// Re-shapes a team workspace you manage through the interview. resolveShapeTarget decides which: an
|
|
312
320
|
// explicit --workspace-id, or (at a terminal only, after a named yes) the bound workspace.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empirical-gauzza/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.39",
|
|
4
4
|
"description": "Persistent cross-tool memory for AI agents. Give Claude Code, Codex, Copilot, Cursor, and ChatGPT one shared memory: save project context once, recall it everywhere via CLI or MCP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"memory",
|