@wirecat/cli-core 0.466.0 → 0.468.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +4 -2
  3. package/dist/cli/bot/mcp.js +2 -1
  4. package/dist/cli/command-runs.d.ts +38 -10
  5. package/dist/cli/command-runs.js +51 -17
  6. package/dist/cli/config-command.d.ts +4 -0
  7. package/dist/cli/config-command.js +5 -5
  8. package/dist/cli/context.d.ts +3 -0
  9. package/dist/cli/context.js +15 -3
  10. package/dist/cli/effects.d.ts +9 -0
  11. package/dist/cli/effects.js +27 -0
  12. package/dist/cli/index.d.ts +3 -3
  13. package/dist/cli/index.js +2 -2
  14. package/dist/cli/messenger/context.js +1 -1
  15. package/dist/cli/messenger/observed.js +10 -2
  16. package/dist/cli/messenger/report.js +48 -3
  17. package/dist/cli/program.d.ts +1 -1
  18. package/dist/cli/program.js +12 -5
  19. package/dist/cli/settings.d.ts +24 -6
  20. package/dist/cli/settings.js +26 -4
  21. package/dist/cli/shared-command-exports.d.ts +12 -4
  22. package/dist/cli/skill-command.js +11 -3
  23. package/dist/cli/steps.d.ts +9 -0
  24. package/dist/cli/steps.js +16 -0
  25. package/dist/cli/store-settings.d.ts +6 -0
  26. package/dist/cli/store-settings.js +6 -0
  27. package/dist/entities/index.d.ts +39 -0
  28. package/dist/entities/recorded-run.d.ts +39 -0
  29. package/dist/entities/recorded-run.js +14 -1
  30. package/dist/mcp/agent.d.ts +5 -0
  31. package/dist/mcp/agent.js +3 -1
  32. package/dist/mcp/all-profiles.d.ts +2 -0
  33. package/dist/mcp/all-profiles.js +16 -3
  34. package/dist/mcp/bot/server.d.ts +2 -0
  35. package/dist/mcp/bot/server.js +9 -3
  36. package/dist/mcp/caller.d.ts +38 -0
  37. package/dist/mcp/caller.js +95 -0
  38. package/dist/mcp/http/oauth.js +5 -1
  39. package/dist/mcp/server.js +21 -8
  40. package/dist/mcp/surface.js +11 -2
  41. package/dist/mcp/tool.d.ts +4 -1
  42. package/dist/mcp/tool.js +21 -11
  43. package/dist/runs/events.d.ts +16 -4
  44. package/dist/runs/events.js +4 -0
  45. package/dist/runs/log-attributes.js +7 -1
  46. package/dist/runs/log-events.js +5 -0
  47. package/dist/runs/recording.d.ts +10 -3
  48. package/dist/runs/recording.js +59 -15
  49. package/dist/runs/run-context.d.ts +55 -4
  50. package/dist/runs/run-context.js +34 -3
  51. package/dist/runs/run.d.ts +37 -11
  52. package/dist/runs/run.js +201 -52
  53. package/dist/runs/scope.d.ts +12 -0
  54. package/dist/runs/scope.js +6 -0
  55. package/dist/services/embeddings-maintenance.js +4 -6
  56. package/dist/services/patience.js +5 -1
  57. package/package.json +1 -1
package/dist/runs/run.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { userInfo } from "node:os";
4
+ import { join, sep } from "node:path";
4
5
  import { createRecordLog, resolvePaths, writeSecurely } from "@wirecat/cli-base";
5
6
  import { LOG_ATTRIBUTES } from "./log-attributes.js";
6
7
  import { fromLogAttributes, logAttributes, snakeLogger } from "./log-keys.js";
7
8
  export const KEEP_RUNS_FOR_DAYS = 30;
9
+ export const KEEP_SUCCESSFUL_RUNS_FOR_DAYS = 7;
10
+ export const RUNS_SIZE_LIMIT_MB = 200;
8
11
  /** `<state dir>/runs` — never the cache directory: a cache is deleted to fix a cache, and a run cannot be rebuilt. */
9
12
  export const runsDirFor = (app, env = process.env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "runs");
10
13
  /**
@@ -16,12 +19,11 @@ export const runsDirFor = (app, env = process.env) => join(resolvePaths({ appNam
16
19
  * events.jsonl one log record per event (cli-base's `LogRecord`), no ANSI ever
17
20
  * ```
18
21
  *
19
- * `run.json` is written **now**, with `status: "running"`, and again by `finish`. A directory
22
+ * `run.json` is written as the directory opens, with `status: "running"`, and again by `finish`. A directory
20
23
  * holding events and no metadata is a special case `runs list` would carry forever.
21
24
  *
22
25
  * Each write also appends the metadata to the day's `index.jsonl`, which `runs list` and `search runs`
23
- * read instead of every `run.json`. It is only a cache: pruned with its day, and a day without one is read
24
- * the slow way.
26
+ * read instead of every `run.json`. It is the only record of a `lazy` run that succeeded without events.
25
27
  */
26
28
  export const startRun = (options) => {
27
29
  const now = options.now ?? (() => new Date());
@@ -30,39 +32,13 @@ export const startRun = (options) => {
30
32
  const dir = join(options.runsDir, day(startedAt), id);
31
33
  // Only while something is being recorded: a tool that is not recording has no business walking
32
34
  // that directory.
33
- pruneRuns(options.runsDir, { keepDays: options.keepDays ?? KEEP_RUNS_FOR_DAYS, now: startedAt });
34
- mkdirSync(dir, { recursive: true, mode: 0o700 });
35
- // Created here rather than by the logger, which opens with whatever the umask allows. The
36
- // directory is already 0700, so this is the second lock rather than the first — but a log file
37
- // that outlives its directory, or is copied out of it, should not be the one that is readable.
38
- const events = join(dir, "events.jsonl");
39
- writeFileSync(events, "", { flag: "a", mode: 0o600 });
40
- const file = snakeLogger(createRecordLog({
41
- to: events,
42
- runId: id,
43
- command: options.command,
44
- resource: { service: options.service, version: options.cliVersion },
45
- allow: LOG_ATTRIBUTES,
46
- ...(options.traceId === undefined ? {} : { traceId: options.traceId }),
47
- ...(options.onError === undefined ? {} : { onError: options.onError }),
48
- }));
49
- const operations = new Set();
50
- const codes = new Set();
51
- const noted = (write) => (event, message) => {
52
- const { operation, errorCode, error_code, code } = event;
53
- if (typeof operation === "string")
54
- operations.add(operation);
55
- for (const value of [errorCode, error_code, code])
56
- if (typeof value === "string")
57
- codes.add(value);
58
- write(event, message);
59
- };
60
- const logger = {
61
- debug: noted(file.debug),
62
- info: noted(file.info),
63
- warn: noted(file.warn),
64
- error: noted(file.error),
65
- };
35
+ pruneRuns(options.runsDir, {
36
+ keepDays: options.keepDays ?? KEEP_RUNS_FOR_DAYS,
37
+ keepSuccessDays: options.keepSuccessDays ?? KEEP_SUCCESSFUL_RUNS_FOR_DAYS,
38
+ maxBytes: options.maxBytes ?? RUNS_SIZE_LIMIT_MB * 1024 * 1024,
39
+ now: startedAt,
40
+ });
41
+ refuseRealStateInTests(dir);
66
42
  const metadata = {
67
43
  runId: id,
68
44
  command: options.command,
@@ -76,8 +52,46 @@ export const startRun = (options) => {
76
52
  ...(options.traceId === undefined ? {} : { traceId: options.traceId }),
77
53
  ...options.context,
78
54
  };
79
- writeRunFile(dir, metadata);
55
+ let file;
56
+ const opened = () => {
57
+ if (file)
58
+ return file;
59
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ // Created here rather than by the logger, which opens with whatever the umask allows. The
61
+ // directory is already 0700, so this is the second lock rather than the first — but a log file
62
+ // that outlives its directory, or is copied out of it, should not be the one that is readable.
63
+ const events = join(dir, "events.jsonl");
64
+ writeFileSync(events, "", { flag: "a", mode: 0o600 });
65
+ file = snakeLogger(createRecordLog({
66
+ to: events,
67
+ runId: id,
68
+ command: options.command,
69
+ resource: { service: options.service, version: options.cliVersion },
70
+ allow: LOG_ATTRIBUTES,
71
+ ...(options.traceId === undefined ? {} : { traceId: options.traceId }),
72
+ ...(options.onError === undefined ? {} : { onError: options.onError }),
73
+ }));
74
+ writeRunFile(dir, metadata);
75
+ return file;
76
+ };
77
+ if (!options.lazy)
78
+ opened();
80
79
  let finished = false;
80
+ const operations = new Set();
81
+ const codes = new Set();
82
+ const noted = (level) => (event, message) => {
83
+ const { operation, errorCode, error_code, code } = event;
84
+ if (typeof operation === "string")
85
+ operations.add(operation);
86
+ for (const value of [errorCode, error_code, code])
87
+ if (typeof value === "string")
88
+ codes.add(value);
89
+ // A connection a long-running server keeps can log after the run that opened it ended: that is not this run.
90
+ if (finished)
91
+ return;
92
+ opened()[level](event, message);
93
+ };
94
+ const logger = { debug: noted("debug"), info: noted("info"), warn: noted("warn"), error: noted("error") };
81
95
  return {
82
96
  id,
83
97
  dir,
@@ -87,7 +101,7 @@ export const startRun = (options) => {
87
101
  return;
88
102
  finished = true;
89
103
  const completedAt = now();
90
- writeRunFile(dir, {
104
+ const final = {
91
105
  ...metadata,
92
106
  ...extra,
93
107
  status,
@@ -95,10 +109,40 @@ export const startRun = (options) => {
95
109
  durationMs: completedAt.getTime() - startedAt.getTime(),
96
110
  operations: [...operations].sort(),
97
111
  codes: [...codes].sort(),
98
- });
112
+ };
113
+ if (file || status !== "success") {
114
+ opened();
115
+ writeRunFile(dir, final);
116
+ return;
117
+ }
118
+ try {
119
+ mkdirSync(join(dir, ".."), { recursive: true, mode: 0o700 });
120
+ appendIndex(join(dir, ".."), runFileOf(final));
121
+ }
122
+ catch (error) {
123
+ options.onError?.(error);
124
+ }
99
125
  },
100
126
  };
101
127
  };
128
+ /**
129
+ * The test sandbox moves HOME and the XDG folders, so a run that still resolves into the owner's own data folder is a
130
+ * fixture that bypassed it: thousands of test runs once landed beside the owner's real ones. The passwd entry's home
131
+ * is read because HOME is the very thing the sandbox changed.
132
+ */
133
+ export const refuseRealStateInTests = (dir, env = process.env) => {
134
+ if (env.VITEST === undefined)
135
+ return;
136
+ let home;
137
+ try {
138
+ home = userInfo().homedir;
139
+ }
140
+ catch {
141
+ return;
142
+ }
143
+ if (dir.startsWith(join(home, ".local", "share") + sep))
144
+ throw new Error(`a test opened a run in the real data folder: ${dir}`);
145
+ };
102
146
  export const runtime = () => {
103
147
  const bun = process.versions.bun;
104
148
  return bun ? `bun ${bun}` : `node ${process.versions.node}`;
@@ -113,16 +157,19 @@ export const newRunId = (startedAt, command) => {
113
157
  return `${stamp}-${slug}-${randomUUID().slice(0, 6)}`;
114
158
  };
115
159
  const INDEX = "index.jsonl";
160
+ const appendIndex = (dayDir, written) => {
161
+ appendFileSync(join(dayDir, INDEX), `${JSON.stringify(written)}\n`, { mode: 0o600 });
162
+ };
116
163
  const writeRunFile = (dir, metadata) => {
117
164
  // Atomic, so a reader never sees half of one — and 0600, because it names a profile and a
118
165
  // command even though it carries nothing of what was said.
119
166
  const written = runFileOf(metadata);
120
167
  writeSecurely(join(dir, "run.json"), `${JSON.stringify(written, null, 2)}\n`, 0o600);
121
168
  try {
122
- appendFileSync(join(dir, "..", INDEX), `${JSON.stringify(written)}\n`, { mode: 0o600 });
169
+ appendIndex(join(dir, ".."), written);
123
170
  }
124
171
  catch {
125
- // The index only makes reading faster; a day without one is read run by run.
172
+ // A day without an index is read run by run.
126
173
  }
127
174
  };
128
175
  const matchesListFilter = (run, filter) => (filter.runId === undefined || run.runId === filter.runId) &&
@@ -151,7 +198,7 @@ export const listRuns = (runsDir, { since, atLeast = Number.POSITIVE_INFINITY, f
151
198
  * The index says what it holds; a run it does not name — written before there was an index, by an older
152
199
  * process still running, or when the append failed — is read from its own `run.json`.
153
200
  */
154
- const runsOfDay = (dayDir) => {
201
+ const indexedRuns = (dayDir) => {
155
202
  const latest = new Map();
156
203
  let raw = "";
157
204
  try {
@@ -170,8 +217,12 @@ const runsOfDay = (dayDir) => {
170
217
  // A torn line from a killed process; that run's next line, or its run.json, still says it.
171
218
  }
172
219
  }
220
+ return latest;
221
+ };
222
+ const runsOfDay = (dayDir) => {
223
+ const latest = indexedRuns(dayDir);
173
224
  for (const entry of safeReaddir(dayDir)) {
174
- if (entry === INDEX || latest.has(entry))
225
+ if (entry === INDEX || entry.startsWith(".") || latest.has(entry))
175
226
  continue;
176
227
  const metadata = readRun(join(dayDir, entry));
177
228
  if (metadata)
@@ -182,12 +233,19 @@ const runsOfDay = (dayDir) => {
182
233
  export const findRun = (runsDir, id) => {
183
234
  const stamped = /^(\d{4})(\d{2})(\d{2})T/.exec(id);
184
235
  const likely = stamped ? [`${stamped[1]}-${stamped[2]}-${stamped[3]}`] : [];
185
- for (const date of [...likely, ...days(runsDir)]) {
236
+ const dates = [...new Set([...likely, ...days(runsDir)])];
237
+ for (const date of dates) {
186
238
  const dir = join(runsDir, date, id);
187
239
  const metadata = readRun(dir);
188
240
  if (metadata)
189
241
  return { dir, metadata };
190
242
  }
243
+ // A success that logged no events is a line in its day's index and nothing else.
244
+ for (const date of dates) {
245
+ const metadata = indexedRuns(join(runsDir, date)).get(id);
246
+ if (metadata)
247
+ return { dir: join(runsDir, date, id), metadata };
248
+ }
191
249
  return undefined;
192
250
  };
193
251
  /**
@@ -226,17 +284,23 @@ const eventOf = (line) => {
226
284
  return { time, severity, ...logAttributes(attributes) };
227
285
  };
228
286
  /**
229
- * Removes every day older than `keepDays`, and answers how many days went.
287
+ * Removes every day older than `keepDays`, then the successful runs of every day older than `keepSuccessDays`, and
288
+ * answers how many days went. Then, once a day, the folder is measured against `maxBytes`: successful runs go first,
289
+ * oldest day first, then whole days, until it fits.
230
290
  *
231
- * A whole day at a time, by the directory name, so nothing has to be opened to decide — and a
232
- * directory that is not a date is left alone rather than guessed at.
291
+ * A day at a time, by the directory name and the day's index, so no run has to be opened to decide — and a directory
292
+ * that is not a date is left alone rather than guessed at.
233
293
  */
234
- export const pruneRuns = (runsDir, { keepDays, now }) => {
235
- const cutoff = day(new Date(now.getTime() - keepDays * 24 * 60 * 60 * 1000));
294
+ export const pruneRuns = (runsDir, { keepDays, keepSuccessDays = keepDays, maxBytes, now, }) => {
295
+ const cutoff = day(new Date(now.getTime() - keepDays * DAY_MS));
296
+ const successCutoff = day(new Date(now.getTime() - Math.min(keepSuccessDays, keepDays) * DAY_MS));
236
297
  let removed = 0;
237
298
  for (const date of days(runsDir)) {
238
- if (date >= cutoff)
299
+ if (date >= cutoff) {
300
+ if (date < successCutoff)
301
+ dropSuccesses(join(runsDir, date));
239
302
  continue;
303
+ }
240
304
  try {
241
305
  rmSync(join(runsDir, date), { recursive: true, force: true });
242
306
  removed += 1;
@@ -245,6 +309,91 @@ export const pruneRuns = (runsDir, { keepDays, now }) => {
245
309
  // A day we cannot remove is not a reason to fail the command that was only starting.
246
310
  }
247
311
  }
312
+ if (maxBytes !== undefined)
313
+ removed += fitSize(runsDir, maxBytes, now);
314
+ return removed;
315
+ };
316
+ const DAY_MS = 24 * 60 * 60 * 1000;
317
+ const SUCCESSES_DROPPED = ".successes-dropped";
318
+ const SIZE_CHECKED = ".size-checked";
319
+ /** A day's successful runs, out of its index and off the disk; once per day, marked by a file beside them. */
320
+ const dropSuccesses = (dayDir) => {
321
+ const marker = join(dayDir, SUCCESSES_DROPPED);
322
+ try {
323
+ statSync(marker);
324
+ return;
325
+ }
326
+ catch { }
327
+ try {
328
+ const kept = [];
329
+ for (const [id, metadata] of indexedRuns(dayDir)) {
330
+ if (metadata.status === "success")
331
+ rmSync(join(dayDir, id), { recursive: true, force: true });
332
+ else
333
+ kept.push(JSON.stringify(runFileOf(metadata)));
334
+ }
335
+ for (const entry of safeReaddir(dayDir)) {
336
+ if (entry === INDEX || entry.startsWith("."))
337
+ continue;
338
+ if (readRun(join(dayDir, entry))?.status === "success")
339
+ rmSync(join(dayDir, entry), { recursive: true, force: true });
340
+ }
341
+ writeSecurely(join(dayDir, INDEX), kept.map((line) => `${line}\n`).join(""), 0o600);
342
+ writeFileSync(marker, "", { mode: 0o600 });
343
+ }
344
+ catch {
345
+ // Tried again at the next run's start.
346
+ }
347
+ };
348
+ const sizeOf = (path) => {
349
+ let total = 0;
350
+ for (const entry of safeReaddir(path)) {
351
+ const full = join(path, entry);
352
+ try {
353
+ const stat = statSync(full);
354
+ total += stat.isDirectory() ? sizeOf(full) : stat.size;
355
+ }
356
+ catch { }
357
+ }
358
+ return total;
359
+ };
360
+ /** Measuring walks every file, so it runs at most once a day; a burst can overshoot until the next day's check. */
361
+ const fitSize = (runsDir, maxBytes, now) => {
362
+ const stamp = join(runsDir, SIZE_CHECKED);
363
+ const today = day(now);
364
+ try {
365
+ if (readFileSync(stamp, "utf8").trim() === today)
366
+ return 0;
367
+ }
368
+ catch { }
369
+ const dates = days(runsDir).sort();
370
+ const sizes = new Map(dates.map((date) => [date, sizeOf(join(runsDir, date))]));
371
+ let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
372
+ let removed = 0;
373
+ // Today is left to the age rule: a day marked done would keep the successes it gains later.
374
+ for (const date of dates) {
375
+ if (total <= maxBytes || date === today)
376
+ break;
377
+ dropSuccesses(join(runsDir, date));
378
+ const after = sizeOf(join(runsDir, date));
379
+ total -= (sizes.get(date) ?? 0) - after;
380
+ sizes.set(date, after);
381
+ }
382
+ for (const date of dates) {
383
+ if (total <= maxBytes || date === today)
384
+ break;
385
+ try {
386
+ rmSync(join(runsDir, date), { recursive: true, force: true });
387
+ total -= sizes.get(date) ?? 0;
388
+ removed += 1;
389
+ }
390
+ catch { }
391
+ }
392
+ try {
393
+ mkdirSync(runsDir, { recursive: true, mode: 0o700 });
394
+ writeFileSync(stamp, today, { mode: 0o600 });
395
+ }
396
+ catch { }
248
397
  return removed;
249
398
  };
250
399
  const day = (date) => date.toISOString().slice(0, 10);
@@ -1,3 +1,4 @@
1
+ import type { EventSink } from "./events.js";
1
2
  export interface RunScope {
2
3
  runId: string;
3
4
  command: string;
@@ -7,6 +8,8 @@ export interface RunScope {
7
8
  traceId?: string;
8
9
  /** The profile the run is for, so a hint deep in a service can name the command with it. */
9
10
  profile?: string;
11
+ /** The run's event sink, for a transport far below the command (a wire middleware) that is not handed one. */
12
+ events?: EventSink;
10
13
  }
11
14
  /**
12
15
  * The run a log line written now belongs to, for a writer that is not handed one — the send journal,
@@ -14,6 +17,8 @@ export interface RunScope {
14
17
  */
15
18
  export declare const currentRun: () => RunScope;
16
19
  export declare const inRun: <T>(scope: RunScope, body: () => T) => T;
20
+ /** One event into the run under way, or nowhere: outside a run, or in a run that keeps nothing. */
21
+ export declare const runEvent: EventSink;
17
22
  /**
18
23
  * A command as a person types it: `max personal store migrate`. A hint without the profile fails when copied,
19
24
  * since no profile is assumed; outside a run with a profile it stays `max store migrate`.
@@ -41,3 +46,10 @@ export declare const childTrace: () => {
41
46
  traceId: string;
42
47
  traceparent: string;
43
48
  };
49
+ export interface Attempt {
50
+ attempt: number;
51
+ waitMs?: number;
52
+ }
53
+ /** Marks the calls made under it as one try of a retried call: a wire event without its own attempt takes this one. */
54
+ export declare const withAttempt: <T>(attempt: Attempt, work: () => T) => T;
55
+ export declare const currentAttempt: () => Attempt | undefined;
@@ -21,6 +21,8 @@ export const currentRun = () => {
21
21
  return processScope;
22
22
  };
23
23
  export const inRun = (scope, body) => scopes.run(scope, body);
24
+ /** One event into the run under way, or nowhere: outside a run, or in a run that keeps nothing. */
25
+ export const runEvent = (event) => scopes.getStore()?.events?.(event);
24
26
  /**
25
27
  * A command as a person types it: `max personal store migrate`. A hint without the profile fails when copied,
26
28
  * since no profile is assumed; outside a run with a profile it stays `max store migrate`.
@@ -52,3 +54,7 @@ export const childTrace = () => {
52
54
  const traceId = hex(16);
53
55
  return { traceId, traceparent: `00-${traceId}-${hex(8)}-01` };
54
56
  };
57
+ const attempts = new AsyncLocalStorage();
58
+ /** Marks the calls made under it as one try of a retried call: a wire event without its own attempt takes this one. */
59
+ export const withAttempt = (attempt, work) => attempts.run(attempt, work);
60
+ export const currentAttempt = () => attempts.getStore();
@@ -19,10 +19,8 @@ const PER_SESSION = 8;
19
19
  const PER_REQUEST = 256;
20
20
  /** e5's tokenizer reads ~3.7 characters a token in Russian; 3 keeps the bound above the real count. */
21
21
  const CHARS_PER_TOKEN_AT_LEAST = 3;
22
- const notDownloaded = (deps, id) => new CliError("not_found", `${id} is not downloaded — \`${typedCommand(deps.messenger.app.command, `models download ${id}`)}\``, {
23
- reason: "model.not-downloaded",
24
- model: id,
25
- });
22
+ /** The download is the error's `next` step, named only by a CLI that has `models` (`failureSteps`). */
23
+ const notDownloaded = (id) => new CliError("not_found", `${id} is not downloaded on this machine`, { reason: "model.not-downloaded", model: id });
26
24
  export const notBuiltError = (deps, chatId) => new CliError("not_found", `chat ${chatId} has no conversations yet — \`${typedCommand(deps.messenger.app.command, `conversations links rebuild --chat ${chatId}`)}\``);
27
25
  export const embeddingTarget = (deps, choice = DEFAULT_TEXT_MODEL) => {
28
26
  if (typeof choice !== "string") {
@@ -50,7 +48,7 @@ export const embeddingTarget = (deps, choice = DEFAULT_TEXT_MODEL) => {
50
48
  perBatch: PER_SESSION,
51
49
  open: async ({ threads, apart = false, } = {}) => {
52
50
  if (!isTextModelInstalled(model, directory))
53
- throw notDownloaded(deps, model.id);
51
+ throw notDownloaded(model.id);
54
52
  if (apart) {
55
53
  const { openProcess } = await import("../embeddings/process.js");
56
54
  return openProcess(model, directory, { threads: threads ?? defaultThreads() });
@@ -264,7 +262,7 @@ export const embeddingsMaintenance = (deps) => {
264
262
  const account = await deps.account();
265
263
  const target = embeddingTarget(deps, choice);
266
264
  if (embed && !build && !target.installed)
267
- throw notDownloaded(deps, target.id);
265
+ throw notDownloaded(target.id);
268
266
  const chatId = chat === undefined ? undefined : await storedChatId(deps.messenger, chat, store, account, { includeLeft: true });
269
267
  const scan = async () => ({
270
268
  chats: (await readinessOf(deps, chatId, target.key)).sort(byOldestBuild),
@@ -1,13 +1,16 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { isCliFailure } from "../errors/failures.js";
3
+ import { withAttempt } from "../runs/scope.js";
3
4
  /** Waits longer than this are not sat out: the run stops, and the next one resumes. */
4
5
  const LONGEST_WAIT_MS = 5 * 60 * 1000;
5
6
  /** Sits out a provider's "wait N seconds" when it is short, a few times; a long one ends the run. */
6
7
  export const patiently = async (request, note, stop) => {
8
+ let waited;
7
9
  for (let attempt = 1;; attempt += 1) {
8
10
  stop.throwIfAborted();
9
11
  try {
10
- return await request();
12
+ // A transport below logs this call as the retry it is, with the wait before it.
13
+ return await withAttempt({ attempt, ...(waited === undefined ? {} : { waitMs: waited }) }, request);
11
14
  }
12
15
  catch (error) {
13
16
  const wait = isCliFailure(error) && error.code === "rate_limited" ? Number(error.details?.retry_after_ms) : NaN;
@@ -15,6 +18,7 @@ export const patiently = async (request, note, stop) => {
15
18
  throw error;
16
19
  note(`asked to wait ${Math.ceil(wait / 1000)} s — waiting, then going on`);
17
20
  await sleep(wait, undefined, { signal: stop }).catch(() => { });
21
+ waited = wait;
18
22
  if (stop.aborted)
19
23
  throw error;
20
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.466.0",
3
+ "version": "0.468.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",