@empirical-gauzza/cli 1.0.35 → 1.0.37

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.
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ // Empirical Tokens wording and the cost gate shared by every CLI command that can start a priced
4
+ // action (`setup run`, `blueprint install`) or print a reset date (`usage`, `whoami`, error hints).
5
+
6
+ // '<Month> <D> at <h:mm AM/PM> <TZ>' in this machine's time zone, e.g. 'October 31 at 8:00 PM EDT'.
7
+ // The reset instant is 00:00 UTC on the 1st, so the local date can be the day before: date AND time
8
+ // are always shown. This is a standalone copy of formatResetInstant in packages/shared (the CLI is
9
+ // published on its own and does not import shared); both are pinned to the same fixtures in tests.
10
+ // timeZone defaults to the machine's own; an invalid or empty one reads as UTC and says so. Returns
11
+ // null for a missing or unreadable date so callers can fall back to neutral wording.
12
+ function resetLabel(resetsAt, timeZone) {
13
+ if (resetsAt === undefined || resetsAt === null || resetsAt === '') return null;
14
+ const date = new Date(resetsAt);
15
+ if (Number.isNaN(date.getTime())) return null;
16
+ const machineZone = (() => {
17
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_error) { return null; }
18
+ })();
19
+ const requested = timeZone === undefined ? machineZone : timeZone;
20
+ const zones = [typeof requested === 'string' && requested.trim() ? requested.trim() : null, 'UTC'].filter(Boolean);
21
+ for (const zone of zones) {
22
+ try {
23
+ const parts = new Intl.DateTimeFormat('en-US', {
24
+ timeZone: zone, month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
25
+ }).formatToParts(date);
26
+ const get = (type) => (parts.find((p) => p.type === type) || {}).value || '';
27
+ return `${get('month')} ${get('day')} at ${get('hour')}:${get('minute')} ${get('dayPeriod')} ${get('timeZoneName')}`;
28
+ } catch (_error) {
29
+ // Invalid time zone: try UTC.
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ // A charge is offered only to a real terminal; piped input never accepts one. EMPIRICAL_TEST_ASSUME_TTY=1
36
+ // exists only so tests can drive the prompt through a pipe. It is not documented in --help on purpose,
37
+ // it must be exactly '1', and nothing in the product sets it; setting it only lets a person who typed
38
+ // 'y' at the prompt through a pipe, it never accepts a cost by itself.
39
+ function isInteractiveTerminal() {
40
+ return Boolean(process.stdin.isTTY) || process.env.EMPIRICAL_TEST_ASSUME_TTY === '1';
41
+ }
42
+
43
+ // A priced start answered 200 with status 'cost_confirmation_required' and started nothing. This
44
+ // resolves only when a person at a terminal typed y or yes; every other path (no prompter, no terminal,
45
+ // any other reply) prints why nothing started and exits 1. The cost is never accepted on the person's
46
+ // behalf: acceptance reaches the server only from --accept-cost or from this prompt.
47
+ // preview the server's preview payload (message, empiricalTokenCost)
48
+ // prompter createPrompter(...) result, or null for a non-interactive run
49
+ // warn the CLI's stderr printer
50
+ // sanitize terminal sanitiser for server text
51
+ async function confirmCostOrExit(preview, { prompter, warn, sanitize = String }) {
52
+ warn(sanitize(preview.message || `This uses ${preview.empiricalTokenCost} Empirical Tokens.`));
53
+ if (!prompter || !isInteractiveTerminal()) {
54
+ warn('Nothing was started. Re-run with --accept-cost to start.');
55
+ process.exit(1);
56
+ }
57
+ const reply = (await prompter.askText('Start? (y/N)')).trim();
58
+ if (!/^(y|yes)$/i.test(reply)) {
59
+ warn('Nothing was started.');
60
+ process.exit(1);
61
+ }
62
+ }
63
+
64
+ const isCostPreview = (value) => Boolean(value) && value.status === 'cost_confirmation_required';
65
+
66
+ module.exports = { resetLabel, isInteractiveTerminal, confirmCostOrExit, isCostPreview };
package/lib/help.js CHANGED
@@ -13,7 +13,7 @@ function showHelp() {
13
13
  log('');
14
14
  log('Usage:');
15
15
  log(' empirical install');
16
- log(' empirical plugin install <codex|copilot|claude|all> [--all-optional]');
16
+ log(' empirical plugin install <codex|copilot|claude|all> [--all-optional|--voice]');
17
17
  log(' empirical plugin check [--force]');
18
18
  log(' empirical policy show [--refresh]');
19
19
  log(' empirical policy set --json <policy>');
@@ -25,6 +25,7 @@ function showHelp() {
25
25
  log(' Mint a new API key (--replace-all wipes the existing ones)');
26
26
  log(' empirical auth revoke-key (--key-id <id> | --all)');
27
27
  log(' Revoke one API key, or every key on the account');
28
+ log(' empirical usage [--json] Memory, installs and Empirical Tokens this month');
28
29
  log(' empirical oauth bootstrap headless [--print-access-token]');
29
30
  log(' First-time token setup for CI/headless/API-key use');
30
31
  log(' empirical oauth bootstrap headless --write-env [--env-output <path>]');
@@ -38,9 +39,11 @@ function showHelp() {
38
39
  log(' empirical oauth client show');
39
40
  log(' empirical oauth client clear');
40
41
  log(' empirical workspace list List workspaces you can select, and which one is active');
41
- log(' empirical workspace current Show the currently active workspace');
42
+ log(' empirical workspace current Show the active workspace and where it comes from (env, project, global)');
42
43
  log(' empirical workspace select [<number|name>] Set the active workspace (prompts if omitted)');
43
- log(' empirical workspace use <number|name|id> Alias for `workspace select`');
44
+ log(' empirical workspace use <number|name|id> Alias for `workspace select`. Inside a git repo binds THIS project;');
45
+ log(' --global sets the machine-wide default, --project forces a binding');
46
+ log(' empirical workspace unbind Remove this project\'s workspace binding');
44
47
  log(' empirical workspace create <name> [--description <text>]');
45
48
  log(' Create a new shared team workspace');
46
49
  log(' empirical workspace invite create [--role reader|writer|manager] [--expires-in-days <n>] [--max-uses <n>]');
@@ -50,6 +53,15 @@ function showHelp() {
50
53
  log(' empirical workspace delete <number|name|id> [--confirm]');
51
54
  log(' Soft-delete a team workspace (dry-run without --confirm)');
52
55
  log('');
56
+ log('Guided setups:');
57
+ log(' empirical setup list List guided setups');
58
+ log(' empirical setup run <setupId> [--accept-cost] Run one interactively');
59
+ log(' empirical setup run <setupId> --answers <f> Run from a JSON file of answers');
60
+ log(' empirical setup resume <sessionId> Show the current question');
61
+ log(' empirical setup answer <sessionId> <key> <v> Answer a question');
62
+ log(' empirical setup confirm <sessionId> <hash> Confirm and install a reviewed draft');
63
+ log(' empirical setup finish <sessionId> Finish a scripted setup early');
64
+ log('');
53
65
  log('Options:');
54
66
  log(' install Interactive installer for Codex/Copilot/Claude CLI integration');
55
67
  log(' plugin install <target> Install core plugin; add --all-optional or capability flags');
package/lib/http.js CHANGED
@@ -52,8 +52,10 @@ function httpErrorFromResponse(method, urlString, statusCode, data) {
52
52
 
53
53
  const error = new Error(parts.join(' '));
54
54
  error.statusCode = statusCode;
55
+ error.serverMessage = baseMsg;
55
56
  error.code = data.code || null;
56
57
  error.upgradeUrl = upgradeUrl;
58
+ error.details = data.details && typeof data.details === 'object' ? data.details : null;
57
59
  error.retryAfterSeconds = retryAfterSeconds;
58
60
  return error;
59
61
  }
@@ -61,14 +63,19 @@ function httpErrorFromResponse(method, urlString, statusCode, data) {
61
63
  // Prints a structured JSON error line to stderr so scripts/agents parsing CLI
62
64
  // output can branch on `code` (e.g. RATE_LIMITED, PLAN_LIMIT_MEMORY_REACHED)
63
65
  // and act on `upgradeUrl`/`retryAfterSeconds` without string-parsing `message`.
64
- function printApiError(error) {
65
- warn(JSON.stringify({
66
+ // JSON.stringify escapes C0 controls but not C1 (0x80 to 0x9f), which some terminals act on.
67
+ function formatApiError(error) {
68
+ return JSON.stringify({
66
69
  error: true,
67
70
  code: error.code || 'api_error',
68
71
  message: error.message,
69
72
  ...(error.retryAfterSeconds != null && { retryAfterSeconds: error.retryAfterSeconds }),
70
73
  ...(error.upgradeUrl && { upgradeUrl: error.upgradeUrl })
71
- }));
74
+ }).replace(/[\x80-\x9f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
75
+ }
76
+
77
+ function printApiError(error) {
78
+ warn(formatApiError(error));
72
79
  }
73
80
 
74
81
  function httpJson(urlString, method, payload, headers = {}, timeoutMs = 0) {
@@ -186,6 +193,7 @@ function httpForm(urlString, method, params, headers = {}) {
186
193
  module.exports = {
187
194
  httpGet,
188
195
  httpErrorFromResponse,
196
+ formatApiError,
189
197
  printApiError,
190
198
  httpJson,
191
199
  httpForm
@@ -32,6 +32,7 @@ function pluginNamesForInstallerOptions(options = {}) {
32
32
  if (options.recordWorkHistory !== false) names.push('empirical-work-history');
33
33
  if (options.recordScarTissue !== false) names.push('empirical-scar-tissue');
34
34
  if (options.recordTodos === true) names.push('empirical-todo');
35
+ if (options.installVoicePlugin === true) names.push('empirical-voice');
35
36
  return names.filter((name) => name === 'empirical-memory' || OPTIONAL_PLUGIN_NAMES.includes(name));
36
37
  }
37
38
 
@@ -405,5 +406,6 @@ async function runInstallFlow() {
405
406
  }
406
407
 
407
408
  module.exports = {
408
- runInstallFlow
409
+ runInstallFlow,
410
+ pluginNamesForInstallerOptions
409
411
  };
@@ -23,7 +23,8 @@ const PLUGIN_DESCRIPTIONS = {
23
23
  [CORE_PLUGIN_NAME]: 'query and save durable project/user context - the core memory skill',
24
24
  'empirical-work-history': 'record concise summaries of completed work for future agents',
25
25
  'empirical-scar-tissue': 'record recurring failure patterns and prevention guidance',
26
- 'empirical-todo': 'track actionable follow-ups by project'
26
+ 'empirical-todo': 'track actionable follow-ups by project',
27
+ 'empirical-voice': "write in the person's saved voice whenever you write as them"
27
28
  };
28
29
 
29
30
  function describePlugin(name) {
@@ -17,7 +17,7 @@ const { skillFilePath } = require('../skillPaths');
17
17
 
18
18
  const EMPIRICAL_PLUGIN_SOURCE = process.env.EMPIRICAL_PLUGIN_SOURCE || 'mattgauzza/empirical-memory-plugin';
19
19
  const CORE_PLUGIN_NAME = 'empirical-memory';
20
- const OPTIONAL_PLUGIN_NAMES = ['empirical-work-history', 'empirical-scar-tissue', 'empirical-todo'];
20
+ const OPTIONAL_PLUGIN_NAMES = ['empirical-work-history', 'empirical-scar-tissue', 'empirical-todo', 'empirical-voice'];
21
21
 
22
22
  function runPluginCommand(command, args, options = {}) {
23
23
  return runResolvedCommand(command, args, { stdio: 'inherit', ...options });
@@ -23,6 +23,7 @@ const INSTALL_PROMPT_OPTIONS_DEFAULTS = Object.freeze({
23
23
  recordWorkHistory: true,
24
24
  recordScarTissue: true,
25
25
  recordTodos: false,
26
+ installVoicePlugin: false,
26
27
  storeInstructionArtifacts: false,
27
28
  checkMemoryBeforeAskingContext: true,
28
29
  durableWritesOnly: true
@@ -75,6 +76,8 @@ function normalizeInstallPromptOptions(options = {}) {
75
76
  options.recordScarTissue !== false,
76
77
  recordTodos:
77
78
  options.recordTodos === true,
79
+ installVoicePlugin:
80
+ options.installVoicePlugin === true,
78
81
  storeInstructionArtifacts:
79
82
  options.storeInstructionArtifacts === true,
80
83
  checkMemoryBeforeAskingContext:
@@ -228,6 +231,7 @@ function formatPromptOptionSummary(promptOptions) {
228
231
  ['Record timestamped work history', promptOptions.recordWorkHistory],
229
232
  ['Record scar-tissue guidance', promptOptions.recordScarTissue],
230
233
  ['Enable project-based Todo skill', promptOptions.recordTodos],
234
+ ['Install the writing voice skill', promptOptions.installVoicePlugin],
231
235
  ['Store/recall instruction artifacts', promptOptions.storeInstructionArtifacts],
232
236
  ['Check memory before asking for context', promptOptions.checkMemoryBeforeAskingContext],
233
237
  ['Store durable context only', promptOptions.durableWritesOnly]
@@ -313,6 +317,10 @@ async function collectInstallerPromptOptions() {
313
317
  'Install the project-based Todo skill for actionable follow-ups?',
314
318
  false
315
319
  );
320
+ const installVoicePlugin = await askYesNo(
321
+ 'Install the writing voice skill so agents write as you once you have saved a voice?',
322
+ false
323
+ );
316
324
  const storeInstructionArtifacts = await askYesNo(
317
325
  'Store and recall instruction artifacts (AGENTS.md/CLAUDE.md/skills-style rules) in Empirical?',
318
326
  false
@@ -337,6 +345,7 @@ async function collectInstallerPromptOptions() {
337
345
  recordWorkHistory,
338
346
  recordScarTissue,
339
347
  recordTodos,
348
+ installVoicePlugin,
340
349
  storeInstructionArtifacts,
341
350
  checkMemoryBeforeAskingContext,
342
351
  durableWritesOnly
@@ -33,7 +33,9 @@ function renderMemoryResultsTable(items) {
33
33
  const rows = (Array.isArray(items) ? items : []).map((m) => ({
34
34
  id: m.id || m._id || '',
35
35
  category: m.category || '',
36
- summary: truncateForTable(m.summary, 60),
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]' : ''),
37
39
  // The recall front door emits `score` (toQueryRow plus score in mcp/tools/memory.ts); the
38
40
  // older /query route emitted rankScore/similarityScore. Read all three so the column is not
39
41
  // blank against a real /recall row. On the list pathways score is deliberately absent, and
@@ -59,7 +61,7 @@ const REMIND_STATES = ['pending', 'notified', 'acknowledged', 'cancelled'];
59
61
  // the 90-line global help wall bin/empirical.js otherwise short-circuits every --help to.
60
62
 
61
63
  const MEMORY_SUBCOMMAND_USAGE = {
62
- 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] [--pretty] [--format table]',
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]',
63
65
  get: 'Usage: empirical memory get --memory-id <id> [--pretty]',
64
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]',
65
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]',
@@ -192,6 +194,56 @@ function printMemoryResolveError(result) {
192
194
 
193
195
  // ─── Memory subcommands ───────────────────────────────────────────────────────
194
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
+
195
247
  async function runMemoryQuery(args) {
196
248
  const match = parseOption(args, '--match', '');
197
249
  if (!match) {
@@ -208,6 +260,17 @@ async function runMemoryQuery(args) {
208
260
  // --status filters data.status. Both are optional and additive to --tags/--category.
209
261
  const tagsAllRaw = parseOption(args, '--tags-all', '');
210
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
+ }
211
274
  const statusRaw = parseOption(args, '--status', '');
212
275
  const status = statusRaw ? statusRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
213
276
  const pretty = parseFlag(args, '--pretty');
@@ -278,6 +341,14 @@ async function runMemoryQuery(args) {
278
341
  if (category) body.category = category;
279
342
  if (tags.length) body.tags = tags;
280
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
+ }
281
352
  if (status.length) body.status = status;
282
353
  if (dueFilters.dueBefore) body.dueBefore = dueFilters.dueBefore;
283
354
  if (dueFilters.dueAfter) body.dueAfter = dueFilters.dueAfter;
@@ -1130,6 +1201,7 @@ module.exports = {
1130
1201
  withCliAnalyticsContext,
1131
1202
  resolveMemoryByQuery,
1132
1203
  printMemoryResolveError,
1204
+ resolveWorkspaceIdsForQuery,
1133
1205
  runMemoryQuery,
1134
1206
  runMemoryGet,
1135
1207
  runMemoryList,
@@ -4,7 +4,7 @@ const { log, warn, uiHeader, uiSuccess } = require('./ui');
4
4
  const { detectInstallTargets } = require('./installers/detect');
5
5
  const {
6
6
  installNativePlugin,
7
- OPTIONAL_PLUGIN_NAMES = ['empirical-work-history', 'empirical-scar-tissue', 'empirical-todo']
7
+ OPTIONAL_PLUGIN_NAMES = ['empirical-work-history', 'empirical-scar-tissue', 'empirical-todo', 'empirical-voice']
8
8
  } = require('./installers/nativePlugin');
9
9
  const { checkNativePluginUpdates } = require('./pluginUpdateCheck');
10
10
  const { addInstalledHosts } = require('./deviceConfig');
@@ -35,6 +35,7 @@ function resolvePluginNames(args = []) {
35
35
  if (args.includes('--all-optional') || args.includes('--work-history')) names.push('empirical-work-history');
36
36
  if (args.includes('--all-optional') || args.includes('--scar-tissue')) names.push('empirical-scar-tissue');
37
37
  if (args.includes('--all-optional') || args.includes('--todo')) names.push('empirical-todo');
38
+ if (args.includes('--all-optional') || args.includes('--voice')) names.push('empirical-voice');
38
39
  return names.filter((name) => name === 'empirical-memory' || OPTIONAL_PLUGIN_NAMES.includes(name));
39
40
  }
40
41
 
@@ -82,7 +83,7 @@ async function runPluginCommand(args = []) {
82
83
  }
83
84
  log('');
84
85
  log('Native plugin installation does not configure MCP or credentials.');
85
- log('Optional packages: --work-history, --scar-tissue, --todo, or --all-optional.');
86
+ log('Optional packages: --work-history, --scar-tissue, --todo, --voice, or --all-optional.');
86
87
  log('Use `empirical auth login` for normal CLI OAuth, or the full `empirical install` flow for MCP setup.');
87
88
  }
88
89