@wirecat/cli-core 0.562.0 → 0.563.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 (69) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli/config-command.js +18 -1
  3. package/dist/cli/envelope.js +13 -1
  4. package/dist/cli/execution.js +1 -0
  5. package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
  6. package/dist/cli/messenger/reply-stats-command.js +57 -0
  7. package/dist/cli/messenger/search-command.js +8 -0
  8. package/dist/cli/messenger/stats-answers.d.ts +3 -0
  9. package/dist/cli/messenger/stats-answers.js +18 -0
  10. package/dist/cli/messenger/stats-command.js +5 -0
  11. package/dist/cli/output-budget.d.ts +26 -0
  12. package/dist/cli/output-budget.js +73 -0
  13. package/dist/cli/program.js +14 -1
  14. package/dist/cli/settings.d.ts +5 -0
  15. package/dist/cli/settings.js +88 -5
  16. package/dist/cli/shared-command-exports.d.ts +8 -0
  17. package/dist/domain/admin-statistics.d.ts +5 -0
  18. package/dist/domain/admin-statistics.js +4 -3
  19. package/dist/entities/index.d.ts +31 -1
  20. package/dist/entities/index.js +4 -1
  21. package/dist/entities/reply-stats.d.ts +33 -0
  22. package/dist/entities/reply-stats.js +19 -0
  23. package/dist/errors/reasons.d.ts +1 -1
  24. package/dist/errors/reasons.js +2 -3
  25. package/dist/mail/attachments.d.ts +15 -2
  26. package/dist/mail/attachments.js +19 -3
  27. package/dist/mail/cli/index.d.ts +17 -1
  28. package/dist/mail/cli/index.js +1 -1
  29. package/dist/mail/cli/review.d.ts +5 -2
  30. package/dist/mail/cli/review.js +47 -39
  31. package/dist/mail/index.d.ts +2 -0
  32. package/dist/mail/index.js +2 -0
  33. package/dist/mail/partial.d.ts +3 -1
  34. package/dist/mail/partial.js +2 -2
  35. package/dist/mail/retry.d.ts +3 -1
  36. package/dist/mail/retry.js +3 -3
  37. package/dist/mail/sync-types.d.ts +3 -0
  38. package/dist/mail/sync.js +5 -3
  39. package/dist/mail/whole.d.ts +28 -0
  40. package/dist/mail/whole.js +69 -0
  41. package/dist/services/index.d.ts +2 -0
  42. package/dist/services/index.js +2 -0
  43. package/dist/services/proposal-tasks.js +3 -12
  44. package/dist/services/proposals/kinds/action.js +3 -2
  45. package/dist/services/proposals/registry.js +8 -4
  46. package/dist/services/proposals/types.d.ts +2 -0
  47. package/dist/services/reply-stats.d.ts +48 -0
  48. package/dist/services/reply-stats.js +137 -0
  49. package/dist/store/decisions.d.ts +14 -11
  50. package/dist/store/decisions.js +9 -2
  51. package/dist/store/index.d.ts +3 -3
  52. package/dist/store/index.js +2 -2
  53. package/dist/store/queries.d.ts +4 -0
  54. package/dist/store/queries.js +3 -0
  55. package/dist/store/sqlite/email-locations.d.ts +4 -0
  56. package/dist/store/sqlite/email-locations.js +26 -11
  57. package/dist/store/sqlite/lucene-fields.d.ts +2 -0
  58. package/dist/store/sqlite/lucene-fields.js +3 -1
  59. package/dist/store/sqlite/mail-findings.js +1 -1
  60. package/dist/store/sqlite/meeting-findings.js +1 -1
  61. package/dist/store/sqlite/proposed-actions.d.ts +2 -0
  62. package/dist/store/sqlite/proposed-actions.js +24 -17
  63. package/dist/store/sqlite/reply-times.d.ts +57 -0
  64. package/dist/store/sqlite/reply-times.js +113 -0
  65. package/dist/store/sqlite/work-tasks.d.ts +12 -2
  66. package/dist/store/sqlite/work-tasks.js +35 -26
  67. package/dist/store/store.d.ts +1 -0
  68. package/package.json +1 -1
  69. package/parity.json +24 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.563.0 — 03.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **Mail sync reads attachments up to 10 MB a file by default, and can skip them.** Until now every attachment was
15
+ read, whatever its size; at the next bump a file over 10 MB is no longer read into the index unless the caller
16
+ passes a policy. `MailSyncOptions.attachments` takes a `MailAttachmentPolicy` (`read`, `maxBytes`); left out it is
17
+ `DEFAULT_ATTACHMENT_POLICY`. A file the policy leaves is stored with its name, type and size but no text, with
18
+ `extractionError` `not-read` or `too-large` (`POLICY_SKIPS`), and the run's `attachments` log says the same.
19
+ Partial completion and `retryAttachments` follow it too.
20
+ - **Moving a task to another project moves its subtasks with it.** `tasks.edit(id, { project })` on a task with
21
+ subtasks no longer refuses with `task.has-subtasks`; that reason is gone. Every subtask, all the way down, takes the
22
+ next key in the new project and keeps its parent, and its old key still finds it. The answer names them as
23
+ `movedSubtasks: [{ from, to }]`, old key and new; the field is left out when nothing moved with the task. A subtask
24
+ moved on its own still needs `parent: null` in the same edit (`task.is-subtask`).
25
+
26
+ - **Owner-only proposals refuse an agent on every decide path.** `mayDecide` and `assertMayDecide` now read
27
+ the row's stored `kind`: a `mail_findings` or `meeting_findings` row is the owner's alone, whatever
28
+ `agent_decisions` says. The registry's `get` and `list` rows carry `ownerOnly` from their kind, and the generic
29
+ action kind marks a stored findings row too, so a host checking `assertMayDecide(await registry.get(id), …)` is
30
+ refused. `approveAsTask` and `proposedActions.approve` / `reject` refuse a mail findings batch as they refuse
31
+ meeting and evidence findings (`mail-findings.dedicated-review`), and the generic action kind no longer lists any
32
+ findings batch. Meeting findings were open to another agent with a reason before; now only the owner reviews them.
33
+
34
+ ### Added
35
+
36
+ - **An answer over the output cap says what to run instead (WC-OUT-22).** The `output.too-large` failure now names up
37
+ to two reads, both safe to run unasked: the same command line with `--compact`, when the command declares a row and
38
+ was not compact already, and the same line with a `--limit` scaled down to fit. The lines are rebuilt from the
39
+ words the caller typed, never from the answer. The failure also carries `needed_bytes`.
40
+ - **A JSON answer over 256 KiB adds one step to the compact form.** The answer is still printed whole. Nothing is
41
+ added when the answer already names two steps, over MCP, or for a command with no declared row.
42
+ - **`search messages --chat` and `search all --chat` say how to search one chat:** `chat:<chat>` in the query.
43
+
44
+ - **`readEmailWhole` reads one stored email again from its mail server, every attachment of it with no limit.** It
45
+ saves the new text on the email as a sync does, keeping its mailboxes, flags and rule, and answers each attachment's
46
+ bytes for the caller to save. Gmail's email is found by id, another server's at its stored place.
47
+ `store.mail.emailPlace(accountId, externalId)` gives an email's thread and stored places.
48
+ - **`stats messages responses`**: how fast replies come in one-to-one chats, the owner's (`--who me`) or the other
49
+ side's (`--who others`): count, median, p90, mean and four time buckets, in total or `--by day` in `--timezone`.
50
+ Several messages in a row followed by one answer are one reply, timed from the first. Reads by index, so all time
51
+ works on a large store; the query after `--` takes `date:`, `chat:` and `kind:` only.
52
+ - **`stats messages waiting`**: how many chats wait on the owner — a one-to-one chat whose newest message is a
53
+ question, or a group whose newest message mentions or replies to the owner — with the messages since the owner
54
+ last wrote, the oldest of them, and the split by kind. Muted or archived one-to-one chats are left out, as in
55
+ `inbox`.
56
+ - **tg, max, wa, mail, gcal and drive's config file holds every shared setting with its default.** A first run now
57
+ writes all of them into `defaults`, not five; `config set` and `config unset` add the missing ones (but not the one
58
+ just unset); and the new `<cli> config migrate` fills an existing file. Your values, their order and unknown keys
59
+ stay; a file without `defaults` gets that section, and no other section is created. `config show` gives each of
60
+ these settings its `default` and `description`, and no longer creates the file when there is none. `settingsFor`
61
+ returns `declaredSettings` and `migrate(path)`; `extension.settings` declares a CLI's own (cli-base WC-CMD-30).
62
+ Settings with no single default (`timeout_ms`, `color`, `requests_per_minute`, the embedding model) and objects
63
+ (`permissions`, `models`) are left out.
64
+ - **`MailReads.mount`: the steps a mail review hands on name where the host mounted it.** A host that mounts `review`
65
+ and `messages acknowledge` elsewhere passes their paths and the option naming the mailbox — memo passes
66
+ `review: "mail review"`, `conversation: null` (it mounts no thread read) and `mailbox: { option: "source", value }`
67
+ — and `next` names those paths and `--source email:<account>`. `mailReviewSteps(mount)` gives the definitions for
68
+ a host's step checks; a mail CLI leaves it out, and its step lines carry the profile as before.
69
+
8
70
  ## 0.562.0 — 03.11.2026
9
71
 
10
72
  ### Added
@@ -30,6 +30,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
30
30
  const bot = this.opts().bot === true;
31
31
  const context = baseContext(this, (flags, options) => config.resolveSettings(flags, {
32
32
  ...options,
33
+ readOnly: true,
33
34
  needsProfile: false,
34
35
  withIssues: true,
35
36
  ...(bot ? { kind: "bot" } : {}),
@@ -48,7 +49,13 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
48
49
  paths_overridden: overridden,
49
50
  settings: [...config.allSettings, "command_timeout_ms"]
50
51
  .filter((setting) => bot || setting !== "read_other_bots")
51
- .map((setting) => sourced(settings, setting)),
52
+ .map((setting) => {
53
+ const declared = config.declaredSettings.find(({ key }) => key === `defaults.${setting}`);
54
+ return {
55
+ ...sourced(settings, setting),
56
+ ...(declared ? { default: declared.default, description: declared.description } : {}),
57
+ };
58
+ }),
52
59
  store_settings: await storeSettings(env),
53
60
  }));
54
61
  for (const issue of [
@@ -144,9 +151,19 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
144
151
  }));
145
152
  });
146
153
  }
154
+ command.addCommand(migrateCommand(app, config));
147
155
  command.addCommand(repairCommand(app, config));
148
156
  return command;
149
157
  };
158
+ const migrateCommand = (app, config) => profileFree(grantsAuthority(annotate(new Command("migrate"), { effects: LOCAL_WRITE, examples: [`${app.command} config migrate`] })))
159
+ .description("write each setting the file lacks at its default, keeping yours")
160
+ .action(function () {
161
+ const context = baseContext(this, (flags, options) => config.resolveSettings(flags, { ...options, needsProfile: false, withIssues: true }));
162
+ const file = context.settings.configPath;
163
+ const added = config.migrate(file);
164
+ renderAnswer(this, context, itemOf({ config_file: file, added }));
165
+ context.renderer.note(added.length > 0 ? `added ${added.join(", ")} to ${file}` : `${file} already has every setting`);
166
+ });
150
167
  /**
151
168
  * Mends JSON syntax with one reading and keeps every value (NEED-2178): no network, no AI, since the file may hold
152
169
  * keys. Anything else is refused with nothing changed.
@@ -5,6 +5,7 @@ import * as v from "valibot";
5
5
  import { currentRun } from "../runs/scope.js";
6
6
  import { compactText, entityRows, rowShapeOf, rowsOfEntity, TEXT_CHARS } from "./compact.js";
7
7
  import { environmentOf, setExitCode } from "./context.js";
8
+ import { largeAnswerSteps, SOFT_OUTPUT_BYTES } from "./output-budget.js";
8
9
  import { withoutProviderRefs } from "./provider-refs.js";
9
10
  import { nextStep, rootOf, runnable, stepLine, withLines, withSafety } from "./steps.js";
10
11
  /** Every answer: a list or one thing, with items of `item` — any value where the command declares no entity. */
@@ -131,6 +132,16 @@ export const sealed = (value) => {
131
132
  };
132
133
  /** The steps a compact answer prints, each rerunning in the compact form the caller chose. */
133
134
  const compactSteps = (root, steps) => steps.map((step) => step.kind === "command" ? { ...step, line: `${step.line ?? stepLine(root, step)} ${COMPACT_FLAG}` } : step);
135
+ /** A JSON answer past the soft budget, printed whole, with one more step: the same read in the compact form. */
136
+ const withLargeAnswerStep = (command, body) => {
137
+ if ((body.next?.length ?? 0) >= MAX_NEXT_STEPS)
138
+ return body;
139
+ const added = largeAnswerSteps(command);
140
+ if (added.length === 0 || Buffer.byteLength(JSON.stringify(body)) <= SOFT_OUTPUT_BYTES)
141
+ return body;
142
+ const root = rootOf(command);
143
+ return { ...body, next: [...(body.next ?? []), ...withLines(root, runnable(root, added))] };
144
+ };
134
145
  /** An answer as JSON lines: one line per item, then the end line. One thing is a list of one that has no more. */
135
146
  const linesOf = (body) => jsonlOf("items" in body ? body : { items: [body.item], page: { mode: "page", limit: 1, has_more: false }, meta: body.meta });
136
147
  /** An answer as JSON-lines records, for a writer that streams objects: one per item, then the end record. */
@@ -151,9 +162,10 @@ export const renderAnswer = (command, { renderer, format, streams, compact, full
151
162
  const kept = withoutProviderRefs({ ...body, ...(next.length > 0 ? { next } : {}) });
152
163
  if (next.length === 0)
153
164
  delete kept.next;
154
- const whole = sealed(kept);
165
+ const sealedBody = sealed(kept);
155
166
  const environment = environmentOf(command);
156
167
  const drawn = compact && !environment.agent;
168
+ const whole = format === "json" && !drawn && !environment.agent ? withLargeAnswerStep(command, sealedBody) : sealedBody;
157
169
  if (drawn) {
158
170
  const lined = whole.next ? { ...whole, next: compactSteps(root, whole.next) } : whole;
159
171
  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -116,6 +116,7 @@ export const execution = (streams, { timeoutMs, fields, maxOutputBytes = DEFAULT
116
116
  reason: "output.too-large",
117
117
  max_bytes: maxOutputBytes,
118
118
  emitted_bytes: bytes,
119
+ needed_bytes: bytes + size,
119
120
  partial_output: bytes > 0,
120
121
  });
121
122
  bytes += size;
@@ -0,0 +1,4 @@
1
+ import { Command } from "commander";
2
+ import { type Messenger } from "./context.js";
3
+ export declare const replyResponsesCommand: (messenger: Messenger) => Command;
4
+ export declare const replyWaitingCommand: (messenger: Messenger) => Command;
@@ -0,0 +1,57 @@
1
+ import { annotate } from "@wirecat/cli-base/commands";
2
+ import { Command, Option } from "commander";
3
+ import { LOCAL_READ } from "../effects.js";
4
+ import { renderAnswer } from "../envelope.js";
5
+ import { messengerContext } from "./context.js";
6
+ import { refuseJsonl, responsesAnswer, waitingAnswer } from "./stats-answers.js";
7
+ const QUERY = "date:, chat: and kind: terms, AND-ed — date>=30d, date:[60d TO 30d]; none reads all time";
8
+ const GUIDE = "Guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/rankings.md#reply-times-and-chats-waiting-on-you";
9
+ const minutes = (ms) => (ms === null ? "—" : `${Math.round(ms / 60_000)} min`);
10
+ export const replyResponsesCommand = (messenger) => annotate(new Command("responses"), { effects: LOCAL_READ, output: { entity: "response_times" } })
11
+ .summary("how fast replies come in one-to-one chats: median, p90, mean and buckets")
12
+ .description("how long the owner took to answer (--who me), or the other side took to answer the owner (--who others), in " +
13
+ "one-to-one chats — the local store only. Several messages in a row followed by one answer count as one " +
14
+ "reply, timed from the first unanswered message; the day of a row is the day of the reply")
15
+ .argument("[query...]", QUERY)
16
+ .addOption(new Option("--who <side>", "whose replies: the owner's or the other side's").choices(["me", "others"]))
17
+ .addOption(new Option("--by <group>", "one row per calendar day of the reply, or none").choices(["day", "total"]))
18
+ .option("--timezone <zone>", "the IANA timezone for calendar days and date: bounds")
19
+ .option("--source <accounts>", "every account of this messenger held in the store; personal, bots or all")
20
+ .addHelpText("after", GUIDE)
21
+ .action(async function (words) {
22
+ const context = messengerContext(this, messenger);
23
+ refuseJsonl(context.format);
24
+ const { who, by, timezone, source } = this.opts();
25
+ const found = await context.withServices((services) => services.replyStats.responses({
26
+ who: who ?? "me",
27
+ by: by ?? "total",
28
+ ...(words.length ? { text: words.join(" ") } : {}),
29
+ ...(timezone === undefined ? {} : { timezone }),
30
+ ...(source === undefined ? {} : { source }),
31
+ }));
32
+ renderAnswer(this, context, responsesAnswer(found), () => [
33
+ `${found.replies} replies · median ${minutes(found.medianMs)} · p90 ${minutes(found.p90Ms)} · mean ${minutes(found.meanMs)}`,
34
+ ...found.rows.map((row) => `${row.key} ${row.replies} replies · median ${minutes(row.medianMs)}`),
35
+ "",
36
+ ].join("\n"));
37
+ });
38
+ export const replyWaitingCommand = (messenger) => annotate(new Command("waiting"), { effects: LOCAL_READ, output: { entity: "waiting_chats" } })
39
+ .summary("chats whose newest message asks the owner something")
40
+ .description("how many chats wait on the owner — the local store only: a one-to-one chat whose newest message is a " +
41
+ "question, or a group whose newest message mentions the owner or replies to them. Muted or archived " +
42
+ "one-to-one chats are left out, as inbox leaves them out; date: bounds the newest message's time")
43
+ .argument("[query...]", QUERY)
44
+ .option("--timezone <zone>", "the IANA timezone for date: bounds")
45
+ .option("--source <accounts>", "every account of this messenger held in the store; personal, bots or all")
46
+ .addHelpText("after", GUIDE)
47
+ .action(async function (words) {
48
+ const context = messengerContext(this, messenger);
49
+ refuseJsonl(context.format);
50
+ const { timezone, source } = this.opts();
51
+ const found = await context.withServices((services) => services.replyStats.waiting({
52
+ ...(words.length ? { text: words.join(" ") } : {}),
53
+ ...(timezone === undefined ? {} : { timezone }),
54
+ ...(source === undefined ? {} : { source }),
55
+ }));
56
+ renderAnswer(this, context, waitingAnswer(found), () => `${found.chats} chats wait on you (${found.byKind.private} one-to-one, ${found.byKind.group} groups) · ${found.messages} messages${found.oldestAt ? ` · oldest ${found.oldestAt}` : ""}\n`);
57
+ });
@@ -5,6 +5,7 @@ import { SAVE_HELP } from "../../meetings/index.js";
5
5
  import { searchNotesOf } from "../../services/notes-search.js";
6
6
  import { LOCAL_READ, REMOTE_READ } from "../effects.js";
7
7
  import { renderAnswer } from "../envelope.js";
8
+ import { withOptionHints } from "../option-hints.js";
8
9
  import { positiveCount } from "../paging.js";
9
10
  import { runsSearchCommand } from "../runs/search-command.js";
10
11
  import { commandAt } from "../steps.js";
@@ -193,5 +194,12 @@ export const searchCommand = (messenger, { topics = false } = {}) => {
193
194
  if (command)
194
195
  annotate(command, { examples: [`${messenger.app.command} personal search ${path} ${args}`] });
195
196
  }
197
+ for (const path of ["all", "messages"]) {
198
+ const command = commandAt(search, path);
199
+ if (command)
200
+ withOptionHints(command, {
201
+ "--chat": `search in one chat with chat:<chat> in the query: search ${path} 'chat:<chat> words'`,
202
+ });
203
+ }
196
204
  return search;
197
205
  };
@@ -8,6 +8,7 @@ import type { AdminEvidenceFound, AdminFound } from "../../services/admin-statis
8
8
  import type { ChatStats } from "../../services/chat-stats.js";
9
9
  import type { MessageStats } from "../../services/messages-search.js";
10
10
  import type { RankingFound } from "../../services/rankings.js";
11
+ import type { ReplyTimes, Waiting } from "../../services/reply-stats.js";
11
12
  import type { RetentionService } from "../../services/retention.js";
12
13
  import type { AccountKey, RankedEvidence } from "../../store/store.js";
13
14
  import type { GroupStats } from "../../tasks/index.js";
@@ -72,4 +73,6 @@ export declare const chatStatsAnswer: (stats: ChatStats, account: AccountKey, me
72
73
  export declare const MEMBERS_UNRECORDED = "joins and leaves are not recorded for this chat yet, so they are not counted";
73
74
  /** `stats tasks show`, CLI and MCP alike. */
74
75
  export declare const taskStatsAnswer: (rows: GroupStats[], account: AccountKey) => ItemBody<Of<"task_stats">>;
76
+ export declare const responsesAnswer: (found: ReplyTimes) => ItemBody<Of<"response_times">>;
77
+ export declare const waitingAnswer: (found: Waiting) => ItemBody<Of<"waiting_chats">>;
75
78
  export {};
@@ -420,3 +420,21 @@ export const taskStatsAnswer = (rows, account) => itemOf({
420
420
  median_close_ms: row.medianCloseMs ?? null,
421
421
  })),
422
422
  });
423
+ const replyFigures = (one) => ({
424
+ replies: one.replies,
425
+ median_ms: one.medianMs,
426
+ p90_ms: one.p90Ms,
427
+ mean_ms: one.meanMs,
428
+ });
429
+ export const responsesAnswer = (found) => itemOf({
430
+ who: found.who,
431
+ ...replyFigures(found),
432
+ buckets: {
433
+ under_5m: found.buckets.under5m,
434
+ under_1h: found.buckets.under1h,
435
+ under_1d: found.buckets.under1d,
436
+ over_1d: found.buckets.over1d,
437
+ },
438
+ rows: found.rows.map((row) => ({ key: row.key, ...replyFigures(row) })),
439
+ });
440
+ export const waitingAnswer = (found) => itemOf({ chats: found.chats, messages: found.messages, oldest_at: found.oldestAt, by_kind: found.byKind });
@@ -20,6 +20,7 @@ import { countersCommand } from "./counters-command.js";
20
20
  import { messagesStatsCommand } from "./messages-stats-command.js";
21
21
  import { officialStatsCommand } from "./official-stats-command.js";
22
22
  import { rankingEvidenceCommand, rankingsTopCommand } from "./rankings-command.js";
23
+ import { replyResponsesCommand, replyWaitingCommand } from "./reply-stats-command.js";
23
24
  import { retentionCommand } from "./retention-command.js";
24
25
  import { chartAnswer, refuseJsonl } from "./stats-answers.js";
25
26
  import { tasksStatsCommand } from "./tasks-command.js";
@@ -31,6 +32,8 @@ export const statsCommand = (messenger, loadRenderer = chartRenderer) => {
31
32
  .addCommand(countersCommand(messenger))
32
33
  .addCommand(adminStatisticsCommand(messenger, "unanswered"))
33
34
  .addCommand(adminStatisticsCommand(messenger, "discussion"))
35
+ .addCommand(replyResponsesCommand(messenger))
36
+ .addCommand(replyWaitingCommand(messenger))
34
37
  .addCommand(rankingsTopCommand(messenger, "messages"))
35
38
  .addCommand(rankingEvidenceCommand(messenger, "messages")));
36
39
  stats.addCommand(new Command("contacts")
@@ -96,10 +99,12 @@ export const statsCommand = (messenger, loadRenderer = chartRenderer) => {
96
99
  "messages counters sync": '--chat "Book club" --dry-run',
97
100
  "messages counters show": '--chat "Book club" --limit 10',
98
101
  "messages discussion": 'chat:"Book club"',
102
+ "messages responses": "--who me --by day --timezone Europe/Madrid -- date>=30d",
99
103
  "messages evidence": '"$message" --selection "$selection" --component reactions',
100
104
  "messages show": '--chat "Book club" --by day',
101
105
  "messages top": 'chat:"Book club" --limit 10',
102
106
  "messages unanswered": 'chat:"Project room"',
107
+ "messages waiting": "kind:private",
103
108
  "tasks show": '--chat "Project room"',
104
109
  };
105
110
  for (const [path, args] of Object.entries(examples)) {
@@ -0,0 +1,26 @@
1
+ import type { NextStep } from "@wirecat/cli-base/standards";
2
+ import type { Command } from "commander";
3
+ /** About a third of a 200k-token context at ~3 bytes per token (inferred, not measured). */
4
+ export declare const SOFT_OUTPUT_BYTES: number;
5
+ /** The same read in a form that fits the cap: one line per item, then a `--limit` scaled down to fit (WC-OUT-22). */
6
+ export declare const tooLargeSteps: (command: Command, { maxBytes, neededBytes }: {
7
+ maxBytes: number;
8
+ neededBytes: number;
9
+ }) => ({
10
+ kind: "command";
11
+ step: string;
12
+ command: string;
13
+ args: {
14
+ [x: string]: unknown;
15
+ };
16
+ line?: string | undefined;
17
+ why: string;
18
+ safe_to_auto_execute: boolean;
19
+ } | {
20
+ kind: "user_action";
21
+ step: string;
22
+ why: string;
23
+ safe_to_auto_execute: false;
24
+ })[];
25
+ /** The step a large JSON answer adds: the same read, one line per item. */
26
+ export declare const largeAnswerSteps: (command: Command) => NextStep[];
@@ -0,0 +1,73 @@
1
+ import { entityRows, GENERIC_ROW, rowShapeOf } from "./compact.js";
2
+ import { environmentOf } from "./context.js";
3
+ import { rootOf } from "./steps.js";
4
+ /** About a third of a 200k-token context at ~3 bytes per token (inferred, not measured). */
5
+ export const SOFT_OUTPUT_BYTES = 256 * 1024;
6
+ const CARRIED = ["json", "compact", "offline", "timeout", "full", "brief", "maxOutputBytes"];
7
+ const NOT_WITH_COMPACT = new Set(["json", "brief"]);
8
+ const snake = (name) => name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
9
+ const typed = (command, key) => command.getOptionValueSource(key) === "cli";
10
+ /**
11
+ * The words the caller typed, as step arguments: never a default, never text an answer carried (WC-OUT-9). A
12
+ * `--no-x` flag and `--json=<fields>` have no form a step can carry, so a line holding one gets no step.
13
+ */
14
+ const typedArgs = (command) => {
15
+ const root = rootOf(command);
16
+ if (root.getOptionValue("fields") !== undefined)
17
+ return undefined;
18
+ if (command.options.some((option) => option.negate && typed(command, option.attributeName())))
19
+ return undefined;
20
+ const positional = command.registeredArguments.flatMap((argument, index) => {
21
+ const value = command.processedArgs[index];
22
+ const word = Array.isArray(value) ? value.join(" ") : value;
23
+ return word === undefined || word === "" ? [] : [[argument.name(), word]];
24
+ });
25
+ const own = command.options
26
+ .map((option) => option.attributeName())
27
+ .filter((key) => typed(command, key))
28
+ .map((key) => [snake(key), command.getOptionValue(key)]);
29
+ const global = CARRIED.filter((key) => typed(root, key)).map((key) => [snake(key), root.getOptionValue(key)]);
30
+ return Object.fromEntries([...positional, ...own, ...global]);
31
+ };
32
+ const declaresRow = (command) => {
33
+ const { rows } = environmentOf(command);
34
+ return rowShapeOf(command, rows && entityRows(rows)) !== GENERIC_ROW;
35
+ };
36
+ const pathOf = (command) => {
37
+ const words = [];
38
+ for (let at = command; at.parent; at = at.parent)
39
+ words.unshift(at.name());
40
+ return words.join(" ");
41
+ };
42
+ const step = (command, id, args, why) => ({
43
+ kind: "command",
44
+ step: id,
45
+ command: pathOf(command),
46
+ args,
47
+ why,
48
+ safe_to_auto_execute: true,
49
+ });
50
+ const compactStep = (command, args, why) => rootOf(command).getOptionValue("compact") === true || !declaresRow(command)
51
+ ? []
52
+ : [
53
+ step(command, "output.compact", { ...Object.fromEntries(Object.entries(args).filter(([key]) => !NOT_WITH_COMPACT.has(key))), compact: true }, why),
54
+ ];
55
+ /** The same read in a form that fits the cap: one line per item, then a `--limit` scaled down to fit (WC-OUT-22). */
56
+ export const tooLargeSteps = (command, { maxBytes, neededBytes }) => {
57
+ const args = typedArgs(command);
58
+ if (!args)
59
+ return [];
60
+ const limit = Number(command.getOptionValue("limit"));
61
+ const fits = Math.floor(((limit * maxBytes) / neededBytes) * 0.9);
62
+ return [
63
+ ...compactStep(command, args, "the answer is over the output cap as JSON; one line per item keeps every ref"),
64
+ ...(Number.isSafeInteger(limit) && fits >= 1 && fits < limit
65
+ ? [step(command, "output.limit", { ...args, limit: fits }, `about ${fits} items fit under the output cap`)]
66
+ : []),
67
+ ];
68
+ };
69
+ /** The step a large JSON answer adds: the same read, one line per item. */
70
+ export const largeAnswerSteps = (command) => {
71
+ const args = typedArgs(command);
72
+ return args ? compactStep(command, args, "the answer is large as JSON; one line per item keeps every ref") : [];
73
+ };
@@ -29,6 +29,7 @@ import { installHelp } from "./help/first-screen.js";
29
29
  import { showInputFields } from "./input-fields.js";
30
30
  import { MAX_BUFFERED_INPUT, provideInputPolicy } from "./input-policy.js";
31
31
  import { checkJson, liftJson } from "./json-fields.js";
32
+ import { tooLargeSteps } from "./output-budget.js";
32
33
  import { PreviewComplete, preview } from "./preview.js";
33
34
  import { commandWords, isProfileName, liftProfile, sampleCommand, takesProfile, valuedOptions } from "./profile.js";
34
35
  import { absolutePaths, relayTarget } from "./relay-route.js";
@@ -705,6 +706,15 @@ const commandPath = (program, rest) => {
705
706
  }
706
707
  return words.join(" ");
707
708
  };
709
+ const budgetSteps = (program, path, failure) => {
710
+ const command = path ? commandAt(program, path) : undefined;
711
+ if (!command || !(failure instanceof CliError) || failure.details?.reason !== "output.too-large")
712
+ return [];
713
+ const { max_bytes: maxBytes, needed_bytes: neededBytes } = failure.details;
714
+ return typeof maxBytes === "number" && typeof neededBytes === "number"
715
+ ? tooLargeSteps(command, { maxBytes, neededBytes })
716
+ : [];
717
+ };
708
718
  const failed = ({ app }, program, rest, error, settled, debug) => {
709
719
  const path = commandPath(program, program.args.length > 0 ? program.args : rest);
710
720
  const told = error instanceof NoProfileHere && !commandAt(program, "setup") ? error.withoutSetup(path) : error;
@@ -718,7 +728,10 @@ const failed = ({ app }, program, rest, error, settled, debug) => {
718
728
  ...(effects ? { effects } : {}),
719
729
  });
720
730
  const { next: _named, ...said } = built.error;
721
- const steps = failureSteps(program, built.error);
731
+ const steps = failureSteps(program, {
732
+ ...built.error,
733
+ next: [...(built.error.next ?? []), ...budgetSteps(program, path, failure)],
734
+ });
722
735
  const object = { ...built, error: { ...said, ...(steps.length > 0 ? { next: steps } : {}) } };
723
736
  showInputFields(object, (path && commandAt(program, path)) || program);
724
737
  const command = path || app.command;
@@ -7,6 +7,7 @@ import { type Level, type PermissionKey } from "../sends/permissions.js";
7
7
  import { type HistoryLimits } from "../services/history-limits.js";
8
8
  import { type ConfigIssue } from "./config-issues.js";
9
9
  import type { CommandPermissions } from "./permission-keys.js";
10
+ import { type DeclaredSetting } from "./setting-defaults.js";
10
11
  export declare const plain: (rule: string) => (issue: v.BaseIssue<unknown>) => string;
11
12
  export declare const count: v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.BaseIssue<unknown>) => string>, v.IntegerAction<number, (issue: v.BaseIssue<unknown>) => string>, v.MinValueAction<number, 1, (issue: v.BaseIssue<unknown>) => string>]>;
12
13
  export declare const flag: v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>;
@@ -32,6 +33,8 @@ export interface SettingsExtension<T extends object = Record<never, never>> {
32
33
  profile?: v.ObjectEntries;
33
34
  /** For the whole program only, never per profile. */
34
35
  defaults?: v.ObjectEntries;
36
+ /** Its own scalar settings, written into the file's `defaults` beside the shared ones (WC-CMD-30). */
37
+ settings?: readonly DeclaredSetting[];
35
38
  /** Profiles this messenger keeps in files of its own, beyond the config file and the remembered accounts. */
36
39
  profiles?: (env: NodeJS.ProcessEnv) => string[];
37
40
  /**
@@ -229,6 +232,8 @@ export declare const settingsFor: <T extends object = Record<never, never>>(app:
229
232
  kind?: ProfileKind | undefined;
230
233
  root?: boolean;
231
234
  }) => unknown;
235
+ migrate: (path: string) => string[];
236
+ declaredSettings: DeclaredSetting[];
232
237
  allSettings: string[];
233
238
  defaultsOnlyKeys: string[];
234
239
  schema: v.StrictObjectSchema<{
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { CliError, configFilePath, resolvePaths, saveConfigFile } from "@wirecat/cli-base";
3
3
  import * as v from "valibot";
4
- import { AI_ENTRIES, resolveAISettings } from "../analysis/settings.js";
4
+ import { AI_DEFAULTS, AI_ENTRIES, resolveAISettings } from "../analysis/settings.js";
5
5
  import { parseDuration } from "../domain/duration.js";
6
6
  import { DEFAULT_RERANK_MODEL, RERANK_MODELS } from "../embeddings/models.js";
7
7
  import { envName, readOnlyLockOf } from "../ports/app.js";
@@ -14,6 +14,7 @@ import { checkConfigText, issueMessage, readConfigFile, readConfigText, savedPer
14
14
  import { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
15
15
  import { profilesHere } from "./messenger/accounts.js";
16
16
  import { usableProfileName } from "./profile.js";
17
+ import { withSettingDefaults } from "./setting-defaults.js";
17
18
  import { permissionCeiling } from "./setup-answers.js";
18
19
  const DEFAULT_LIMIT = DEFAULT_CONFIG.defaults.limit;
19
20
  const DEFAULT_KEEP_RUNS_FOR_DAYS = DEFAULT_CONFIG.defaults.keep_runs_for_days;
@@ -107,6 +108,70 @@ const SHARED_DEFAULTS_ONLY = {
107
108
  update_check: v.optional(flag),
108
109
  skill_hint: v.optional(flag),
109
110
  };
111
+ /**
112
+ * Written into the file's `defaults` at the value the code applies when the file says nothing (WC-CMD-30). Left out:
113
+ * objects, and settings whose absence means something no value says (`timeout_ms`, `color`, `requests_per_minute`,
114
+ * the embedding model), since writing any value would change what they do.
115
+ */
116
+ const SHARED_SETTINGS = [
117
+ { key: "limit", default: DEFAULT_LIMIT, description: "how many items a list shows at once" },
118
+ { key: "keep_runs_for_days", default: DEFAULT_KEEP_RUNS_FOR_DAYS, description: "days a run is kept" },
119
+ {
120
+ key: "sends_per_hour",
121
+ default: DEFAULT_SENDS_PER_HOUR,
122
+ description: "the most messages an account sends in an hour; a bot has none unless its section sets one",
123
+ },
124
+ { key: "update_check", default: true, description: "check once a day for a newer version" },
125
+ {
126
+ key: "skill_hint",
127
+ default: true,
128
+ description: "tell an agent once a day that `skill install` would give it this tool's guide",
129
+ },
130
+ { key: "record", default: true, description: "keep every run in runs list; false keeps failed runs only" },
131
+ {
132
+ key: "record_input",
133
+ default: true,
134
+ description: 'keep what was typed in a run\'s command line: true, false, or "failed" for failed runs only',
135
+ },
136
+ {
137
+ key: "keep_successful_runs_for_days",
138
+ default: KEEP_SUCCESSFUL_RUNS_FOR_DAYS,
139
+ description: "days a successful run is kept",
140
+ },
141
+ { key: "runs_size_limit_mb", default: RUNS_SIZE_LIMIT_MB, description: "the most megabytes all runs may take" },
142
+ { key: "sender_colors", default: false, description: "color each sender's name in a conversation" },
143
+ { key: "search_catch_up", default: false, description: "prepare local search after a gaps repair" },
144
+ {
145
+ key: "history_dialog_days",
146
+ default: DEFAULT_HISTORY_LIMITS.dialogDays,
147
+ description: "days of a one-to-one chat's pushed history the store keeps",
148
+ },
149
+ {
150
+ key: "history_group_days",
151
+ default: DEFAULT_HISTORY_LIMITS.groupDays,
152
+ description: "days of a group's pushed history the store keeps",
153
+ },
154
+ {
155
+ key: "history_group_members_max",
156
+ default: DEFAULT_HISTORY_LIMITS.groupMembersMax,
157
+ description: "a group with more people than this keeps no pushed history",
158
+ },
159
+ {
160
+ key: "picture_search",
161
+ default: false,
162
+ description: "search and images embed read the pictures themselves, with a model on this computer",
163
+ },
164
+ {
165
+ key: "search_reranker",
166
+ default: DEFAULT_RERANK_MODEL,
167
+ description: "the reranker that orders search's first hits, or off",
168
+ },
169
+ {
170
+ key: "embedding_provider",
171
+ default: AI_DEFAULTS.embeddingProvider,
172
+ description: "local or openai: what turns text into vectors for search by meaning",
173
+ },
174
+ ];
110
175
  /** Where `profile` came from when none was named and the command needs none. */
111
176
  export const NONE_NAMED = "none named";
112
177
  /** A name for a command that speaks for no account; never chosen for one that does. */
@@ -174,12 +239,18 @@ export const settingsFor = (app, extension = {}) => {
174
239
  const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys].filter((setting) => app.messenger !== false ||
175
240
  !MESSENGER_ONLY_SETTINGS.includes(setting) ||
176
241
  (app.sends === true && setting === "sends_per_hour"));
242
+ const readSchema = extension.schema ?? schema;
243
+ // An app with a schema of its own may refuse a shared key in `defaults`; writing it would break that file.
244
+ const declaredSettings = [...SHARED_SETTINGS, ...(extension.settings ?? [])]
245
+ .filter(({ key, default: value }) => allSettings.includes(key) && v.safeParse(readSchema, { defaults: { [key]: value } }).success)
246
+ .map((setting) => ({ ...setting, key: `defaults.${setting.key}` }));
247
+ // `defaults` needs no field of its own, so it is the one section created: every key then shows (RISK-2125).
248
+ const withDefaults = (raw, except = []) => withSettingDefaults(isScope(raw) && !Object.hasOwn(raw, "defaults") ? { ...raw, defaults: {} } : raw, declaredSettings.filter(({ key }) => !except.includes(key)));
177
249
  const PROFILE = envName(app, "PROFILE");
178
250
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
179
251
  const TIMEOUT = envName(app, "TIMEOUT");
180
252
  const REQUESTS_PER_MINUTE = envName(app, "REQUESTS_PER_MINUTE");
181
253
  const configPathFor = ({ env = process.env, configDir }) => configFilePath(configDir ?? resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).config);
182
- const readSchema = extension.schema ?? schema;
183
254
  const context = (file) => ({ file, tool: app.command });
184
255
  /** The file with every problem found; what loads is the file without the settings they name. */
185
256
  const loadConfig = (path) => {
@@ -225,7 +296,7 @@ export const settingsFor = (app, extension = {}) => {
225
296
  const configPath = configPathFor(options);
226
297
  try {
227
298
  if (!options.readOnly)
228
- ensureDefaultConfig(configPath);
299
+ ensureDefaultConfig(configPath, withDefaults(DEFAULT_CONFIG).config);
229
300
  }
230
301
  catch (error) {
231
302
  throw new CliError("configuration_error", error instanceof Error ? error.message : String(error));
@@ -435,7 +506,7 @@ export const settingsFor = (app, extension = {}) => {
435
506
  throw new CliError("validation_error", `${setting} has no problem to remove — name the profile, or add --defaults, to unset a setting`);
436
507
  if (!known && valueAt(raw, at) === undefined)
437
508
  throw new CliError("validation_error", `no setting called "${setting}" — one of: ${allSettings.join(", ")}`);
438
- saveConfigFile(path, withoutPath(raw, at).config);
509
+ saveConfigFile(path, withDefaults(withoutPath(raw, at).config, [at.join(".")]).config);
439
510
  return null;
440
511
  }
441
512
  if (!known)
@@ -456,13 +527,25 @@ export const settingsFor = (app, extension = {}) => {
456
527
  if (added)
457
528
  throw new CliError("validation_error", `${setting} cannot be "${value}": ${added.what}`);
458
529
  const checked = v.safeParse(readSchema, changed);
459
- saveConfigFile(path, checked.success ? checked.output : changed);
530
+ saveConfigFile(path, withDefaults(checked.success ? checked.output : changed).config);
460
531
  return valueAt(checked.success ? checked.output : changed, at) ?? null;
461
532
  };
533
+ /** Each declared setting the file lacks, at its default, on the file as written; the keys added. */
534
+ const migrate = (path) => {
535
+ const read = readConfigFile(path);
536
+ if ("issue" in read)
537
+ throw new CliError("configuration_error", issueMessage([read.issue]));
538
+ const { config, added } = withDefaults(read.raw);
539
+ if (added.length > 0)
540
+ saveConfigFile(path, config);
541
+ return added;
542
+ };
462
543
  return {
463
544
  resolveSettings,
464
545
  configuredProfiles,
465
546
  changeSetting,
547
+ migrate,
548
+ declaredSettings,
466
549
  allSettings,
467
550
  defaultsOnlyKeys,
468
551
  schema,