@wirecat/cli-core 0.561.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 (74) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli/app-doctor-command.js +3 -1
  3. package/dist/cli/config-command.js +18 -1
  4. package/dist/cli/envelope.js +13 -1
  5. package/dist/cli/execution.js +1 -0
  6. package/dist/cli/index.d.ts +2 -1
  7. package/dist/cli/index.js +2 -1
  8. package/dist/cli/messenger/reply-stats-command.d.ts +4 -0
  9. package/dist/cli/messenger/reply-stats-command.js +57 -0
  10. package/dist/cli/messenger/search-command.js +8 -0
  11. package/dist/cli/messenger/stats-answers.d.ts +3 -0
  12. package/dist/cli/messenger/stats-answers.js +18 -0
  13. package/dist/cli/messenger/stats-command.js +5 -0
  14. package/dist/cli/output-budget.d.ts +26 -0
  15. package/dist/cli/output-budget.js +73 -0
  16. package/dist/cli/program.js +14 -1
  17. package/dist/cli/setting-defaults.d.ts +27 -0
  18. package/dist/cli/setting-defaults.js +36 -0
  19. package/dist/cli/settings.d.ts +5 -0
  20. package/dist/cli/settings.js +88 -5
  21. package/dist/cli/shared-command-exports.d.ts +8 -0
  22. package/dist/domain/admin-statistics.d.ts +5 -0
  23. package/dist/domain/admin-statistics.js +4 -3
  24. package/dist/entities/index.d.ts +31 -1
  25. package/dist/entities/index.js +4 -1
  26. package/dist/entities/reply-stats.d.ts +33 -0
  27. package/dist/entities/reply-stats.js +19 -0
  28. package/dist/errors/reasons.d.ts +1 -1
  29. package/dist/errors/reasons.js +2 -3
  30. package/dist/mail/attachments.d.ts +15 -2
  31. package/dist/mail/attachments.js +19 -3
  32. package/dist/mail/cli/index.d.ts +17 -1
  33. package/dist/mail/cli/index.js +1 -1
  34. package/dist/mail/cli/review.d.ts +5 -2
  35. package/dist/mail/cli/review.js +47 -39
  36. package/dist/mail/index.d.ts +2 -0
  37. package/dist/mail/index.js +2 -0
  38. package/dist/mail/partial.d.ts +3 -1
  39. package/dist/mail/partial.js +2 -2
  40. package/dist/mail/retry.d.ts +3 -1
  41. package/dist/mail/retry.js +3 -3
  42. package/dist/mail/sync-types.d.ts +3 -0
  43. package/dist/mail/sync.js +5 -3
  44. package/dist/mail/whole.d.ts +28 -0
  45. package/dist/mail/whole.js +69 -0
  46. package/dist/services/index.d.ts +2 -0
  47. package/dist/services/index.js +2 -0
  48. package/dist/services/proposal-tasks.js +3 -12
  49. package/dist/services/proposals/kinds/action.js +3 -2
  50. package/dist/services/proposals/registry.js +8 -4
  51. package/dist/services/proposals/types.d.ts +2 -0
  52. package/dist/services/reply-stats.d.ts +48 -0
  53. package/dist/services/reply-stats.js +137 -0
  54. package/dist/store/decisions.d.ts +14 -11
  55. package/dist/store/decisions.js +9 -2
  56. package/dist/store/index.d.ts +3 -3
  57. package/dist/store/index.js +2 -2
  58. package/dist/store/queries.d.ts +4 -0
  59. package/dist/store/queries.js +3 -0
  60. package/dist/store/sqlite/email-locations.d.ts +4 -0
  61. package/dist/store/sqlite/email-locations.js +26 -11
  62. package/dist/store/sqlite/lucene-fields.d.ts +2 -0
  63. package/dist/store/sqlite/lucene-fields.js +3 -1
  64. package/dist/store/sqlite/mail-findings.js +1 -1
  65. package/dist/store/sqlite/meeting-findings.js +1 -1
  66. package/dist/store/sqlite/proposed-actions.d.ts +2 -0
  67. package/dist/store/sqlite/proposed-actions.js +24 -17
  68. package/dist/store/sqlite/reply-times.d.ts +57 -0
  69. package/dist/store/sqlite/reply-times.js +113 -0
  70. package/dist/store/sqlite/work-tasks.d.ts +12 -2
  71. package/dist/store/sqlite/work-tasks.js +35 -26
  72. package/dist/store/store.d.ts +1 -0
  73. package/package.json +1 -1
  74. package/parity.json +24 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,84 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
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
+
70
+ ## 0.562.0 — 03.11.2026
71
+
72
+ ### Added
73
+
74
+ - **A CLI can write each of its settings into its config file with the default, so the owner sees every setting
75
+ there.** A CLI declares its scalar settings as `DeclaredSetting` (`key`, `default`, `description`).
76
+ `withSettingDefaults(raw, declared)` returns the file with each missing setting added at its default. It keeps the
77
+ owner's values, their order and unknown keys. It does not create a section that is not there.
78
+ `settingRows(raw, declared)` gives `config show` each setting with its value, default, description and `from`
79
+ (`config file` or `default`). memo is the first user (cli-base WC-CMD-30).
80
+ - **`KEEP_SUCCESSFUL_RUNS_FOR_DAYS` and `RUNS_SIZE_LIMIT_MB` are exported** beside `KEEP_RUNS_FOR_DAYS`. A host can
81
+ then name the run-record defaults it writes into its config file.
82
+
83
+ ### Fixed
84
+
85
+ - **The shared `doctor` (`appDoctorCommand`) answers with the clock:** `meta.now`, `meta.now_local` and
86
+ `meta.timezone`, as a setup check should (WC-TIME-7). A CLI that kept its own doctor for this can use the shared one.
87
+
10
88
  ## 0.561.0 — 03.11.2026
11
89
 
12
90
  ### Changed — may break callers
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { CliError, configFilePath, resolvePaths } from "@wirecat/cli-base";
4
4
  import { annotate } from "@wirecat/cli-base/commands";
5
+ import { timezoneOf } from "@wirecat/cli-base/query";
6
+ import { clockMeta } from "@wirecat/cli-base/standards";
5
7
  import { isNewer, latestVersion } from "@wirecat/cli-base/update";
6
8
  import { Command } from "commander";
7
9
  import { envName, readOnlyLockOf } from "../ports/app.js";
@@ -149,7 +151,7 @@ export const appDoctorCommand = (app, configuration, options = {}) => profileFre
149
151
  ? await onlineCheck(app, env, environment.fetch ?? fetch, options)
150
152
  : undefined;
151
153
  const output = outputFor(this);
152
- renderAnswer(this, output, itemOf({ ...report, ...local.fields, ...(online ? { online } : {}) }));
154
+ renderAnswer(this, output, itemOf({ ...report, ...local.fields, ...(online ? { online } : {}) }, { meta: clockMeta(Date.now(), timezoneOf()) }));
153
155
  for (const fix of fixes)
154
156
  output.renderer.note(`to make it private: ${fix}`);
155
157
  for (const note of local.notes)
@@ -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;
@@ -26,7 +26,7 @@ export { type CacheEvent, type DiagnosticEvent, type EventSink, providerErrorKey
26
26
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
27
27
  export { openProviderLog, PROVIDER_LOG_BYTES, type ProviderWarnings, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
28
28
  export { crashOf, type Recording, type RecordingOptions, recorded, startRecording, wasSettled, } from "../runs/recording.js";
29
- export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, type RunContext, type RunMetadata, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
29
+ export { findRun, KEEP_RUNS_FOR_DAYS, KEEP_SUCCESSFUL_RUNS_FOR_DAYS, listRuns, pruneRuns, RUNS_SIZE_LIMIT_MB, type RunContext, type RunMetadata, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
30
30
  export { runContextOf, secretInput } from "../runs/run-context.js";
31
31
  export { type Attempt, currentAttempt, currentRun, inRun, type RunScope, runEvent, useAsProcessRun, withAttempt, } from "../runs/scope.js";
32
32
  export { LANGUAGES } from "../search/languages.js";
@@ -119,6 +119,7 @@ export { type ProjectStatusHints, type ProjectStatusItem, projectStatusAnswer, p
119
119
  export { STATUS_ROWS, STATUS_SIGNAL_ROWS } from "./project-status-rows.js";
120
120
  export { fieldsOf, projectFields } from "./result-fields.js";
121
121
  export type { ScheduleHost } from "./schedule/schedule-command.js";
122
+ export { type DeclaredSetting, type SettingRow, settingRows, withSettingDefaults } from "./setting-defaults.js";
122
123
  export { type Config, type Configuration, count, first, flag, fromFile, type GlobalFlags, NONE_NAMED, namedProfile, type ProfileKind, plain, type ResolveOptions, type Settings, type SettingsExtension, type SettingsExtensionContext, type Source, settingsFor, } from "./settings.js";
123
124
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
124
125
  export { AGENTS, type Agent, agentFor, screenFor } from "./setup-screen.js";
package/dist/cli/index.js CHANGED
@@ -22,7 +22,7 @@ export { providerErrorKey, renderEvent, } from "../runs/events.js";
22
22
  export { CORE_LOG_EVENTS } from "../runs/log-events.js";
23
23
  export { openProviderLog, PROVIDER_LOG_BYTES, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
24
24
  export { crashOf, recorded, startRecording, wasSettled, } from "../runs/recording.js";
25
- export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
25
+ export { findRun, KEEP_RUNS_FOR_DAYS, KEEP_SUCCESSFUL_RUNS_FOR_DAYS, listRuns, pruneRuns, RUNS_SIZE_LIMIT_MB, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
26
26
  export { runContextOf, secretInput } from "../runs/run-context.js";
27
27
  export { currentAttempt, currentRun, inRun, runEvent, useAsProcessRun, withAttempt, } from "../runs/scope.js";
28
28
  export { LANGUAGES } from "../search/languages.js";
@@ -108,6 +108,7 @@ export { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWind
108
108
  export { projectStatusAnswer, projectStatusRow, projectStatusSchema, projectStatusText, } from "./project-status.js";
109
109
  export { STATUS_ROWS, STATUS_SIGNAL_ROWS } from "./project-status-rows.js";
110
110
  export { fieldsOf, projectFields } from "./result-fields.js";
111
+ export { settingRows, withSettingDefaults } from "./setting-defaults.js";
111
112
  export { count, first, flag, fromFile, NONE_NAMED, namedProfile, plain, settingsFor, } from "./settings.js";
112
113
  export { readSetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
113
114
  export { AGENTS, agentFor, screenFor } from "./setup-screen.js";
@@ -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;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A scalar setting a CLI writes into its config file with its default, so the owner finds and edits it there without
3
+ * reading the docs (WC-CMD-30). Never declare a secret: `config show` prints every declared value.
4
+ */
5
+ export interface DeclaredSetting {
6
+ /** The dotted path in the file. A nested setting is written only where its section already exists. */
7
+ key: string;
8
+ default: string | number | boolean;
9
+ description: string;
10
+ }
11
+ export interface SettingRow {
12
+ setting: string;
13
+ value: unknown;
14
+ default: string | number | boolean;
15
+ from: "config file" | "default";
16
+ description: string;
17
+ }
18
+ /**
19
+ * The file with every declared setting it lacks, at its default. What the owner wrote stays as written, in its order,
20
+ * unknown keys included; a section that is not there is not created, since it may need fields no default can give.
21
+ */
22
+ export declare const withSettingDefaults: (raw: unknown, declared: readonly DeclaredSetting[]) => {
23
+ config: unknown;
24
+ added: string[];
25
+ };
26
+ /** Every declared setting with what holds and whether the file says it, for `config show`. */
27
+ export declare const settingRows: (raw: unknown, declared: readonly DeclaredSetting[]) => SettingRow[];
@@ -0,0 +1,36 @@
1
+ const isNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2
+ const parentOf = (config, key) => {
3
+ const path = key.split(".");
4
+ const leaf = path.pop();
5
+ return { node: path.reduce((node, part) => (isNode(node) ? node[part] : undefined), config), leaf };
6
+ };
7
+ /**
8
+ * The file with every declared setting it lacks, at its default. What the owner wrote stays as written, in its order,
9
+ * unknown keys included; a section that is not there is not created, since it may need fields no default can give.
10
+ */
11
+ export const withSettingDefaults = (raw, declared) => {
12
+ if (raw !== undefined && !isNode(raw))
13
+ return { config: raw, added: [] };
14
+ const config = raw === undefined ? {} : structuredClone(raw);
15
+ const added = [];
16
+ for (const setting of declared) {
17
+ const { node, leaf } = parentOf(config, setting.key);
18
+ if (!isNode(node) || Object.hasOwn(node, leaf))
19
+ continue;
20
+ node[leaf] = setting.default;
21
+ added.push(setting.key);
22
+ }
23
+ return { config, added };
24
+ };
25
+ /** Every declared setting with what holds and whether the file says it, for `config show`. */
26
+ export const settingRows = (raw, declared) => declared.map((setting) => {
27
+ const { node, leaf } = isNode(raw) ? parentOf(raw, setting.key) : { node: undefined, leaf: "" };
28
+ const written = isNode(node) && Object.hasOwn(node, leaf);
29
+ return {
30
+ setting: setting.key,
31
+ value: written ? node[leaf] : setting.default,
32
+ default: setting.default,
33
+ from: written ? "config file" : "default",
34
+ description: setting.description,
35
+ };
36
+ });
@@ -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<{