@wirecat/cli-core 0.539.0 → 0.541.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.541.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`MailServerAccess.timeMs`**: a mailbox's own default for the server search's time bound, for a server whose
15
+ search is slow; `--server-time` still wins. `MailReads.serverTimeDefault` puts it in the `--server-time` help.
16
+ - **`EmailFilter.externalIds`**: only these Message-IDs, for `emails`, `emailRefs` and `emailFacts`.
17
+
18
+ ### Fixed
19
+
20
+ - **`schedule exec` and `schedule run` wait for their child as long as the child's own deadline allows**, instead of
21
+ answering `timeout` (exit 9) at 30 s while the child went on: a scheduled sync of more than 30 s failed every time
22
+ and kept writing after the answer. A signal or an explicit `--timeout` now stops the child (SIGTERM, then SIGKILL
23
+ after 10 s), and its exit code is the run's.
24
+ - **A mail sync no longer loads every stored email of the account in each round**: it asks the store about the mail
25
+ the round listed. On 260,000 stored emails and a 4,900-message window (`bench/mail-sync-scale`), from 17.7 s and
26
+ 1.1 GB peak memory to 0.3 s and 190 MB.
27
+ - **A mail sync that is stopped while a listing is in flight writes nothing for it**, and reading attachments stops
28
+ at the stop.
29
+ - **The mail server search keeps parsing and attachment reading inside its time bound**, ends that bound before the
30
+ command's deadline so it answers in part instead of timing out, and stops writing once the command is stopped. A
31
+ find larger than 5 MB is left on the server unread, and the answer says so (`search.server-too-large`).
32
+
33
+ ## 0.540.0 — 02.11.2026
34
+
35
+ ### Added
36
+
37
+ - **`events export` without `--output` is a read over MCP and under the read-only lock.** It only prints the `.ics`
38
+ text, and MCP tools never offer `--output`. `writesFileOnlyWith(command, option)` marks a command whose only local
39
+ write is the file an owner-only option names: its effects stay a write (WC-CMD-17), `readsOverMcp` / `readsOverMcpAt`
40
+ count it as a read, and `<PREFIX>_READ_ONLY` refuses only a run that gives the option. Every MCP server now judges
41
+ `read` with `readsOverMcpAt`, which is `fetchesOnly` plus these; a CLI test that snapshots the catalogue with
42
+ `fetchesOnlyAt` should switch to it.
43
+ - **An MCP list's `query` input names its fields.** It said "the fields are below", and no Fields section is sent
44
+ over MCP. It now reads cli-base's `queryDescription`: the fields, enum values, and which fields `-field:value`
45
+ leaves out. `events list`'s query declares `event_type` as `negatable` (cli-base), and `negatableOf(registry)`
46
+ gives `nativeTermLists` the same list, so the description and the filter cannot drift. `queryArgument` uses the
47
+ same sentence.
48
+ - **`events list` and `events prepare` give `meta.now`, `meta.now_local` and `meta.timezone` (WC-TIME-7)**, from the
49
+ host's clock and zone, so an agent plans "tomorrow" from the real date.
50
+
51
+ ### Changed — may break callers
52
+
53
+ - **cli-base 0.72.0.** `metaSchema` accepts `now` and `now_local`; a host that checks meta against an older cli-base's
54
+ strict schema drops the whole meta.
55
+
10
56
  ## 0.539.0 — 01.11.2026
11
57
 
12
58
  ### Added
@@ -102,7 +102,9 @@ export declare const findProgram: (name: string, env: NodeJS.ProcessEnv, platfor
102
102
  * the last result in `last.json`. An agent runs under `<PREFIX>_READ_ONLY`, so nothing it is talked
103
103
  * into can send. A one-off removes itself afterwards.
104
104
  */
105
- export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer }?: {
105
+ export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, }?: {
106
106
  now?: () => Date;
107
107
  timer?: Timer;
108
+ signal?: AbortSignal;
109
+ killAfterMs?: number;
108
110
  }) => Promise<LastRun>;
@@ -326,12 +326,14 @@ export const findProgram = (name, env, platform) => {
326
326
  return undefined;
327
327
  };
328
328
  const KEEP_RUNS = 30;
329
+ /** How long a stopped child has to save and answer after SIGTERM before it is killed. */
330
+ const KILL_AFTER_MS = 10_000;
329
331
  /**
330
332
  * One run: the command or the agent with the schedule's variables, its output kept beside the schedule,
331
333
  * the last result in `last.json`. An agent runs under `<PREFIX>_READ_ONLY`, so nothing it is talked
332
334
  * into can send. A one-off removes itself afterwards.
333
335
  */
334
- export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer } = {}) => {
336
+ export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, } = {}) => {
335
337
  const schedule = readSchedule(file);
336
338
  const folder = join(file, "..");
337
339
  const runs = join(folder, "runs");
@@ -369,11 +371,24 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
369
371
  err.push(Buffer.from(String(error)));
370
372
  resolve(127);
371
373
  });
372
- child.on("close", (code) => {
374
+ // The child keeps its own deadline; a stop of this run stops the child too, so nothing it does outlives the run.
375
+ let kill;
376
+ const stop = () => {
377
+ child.kill("SIGTERM");
378
+ kill = setTimeout(() => child.kill("SIGKILL"), killAfterMs);
379
+ };
380
+ if (signal?.aborted)
381
+ stop();
382
+ else
383
+ signal?.addEventListener("abort", stop, { once: true });
384
+ child.on("close", (code, killedBy) => {
385
+ signal?.removeEventListener("abort", stop);
386
+ if (kill)
387
+ clearTimeout(kill);
373
388
  writeFileSync(output, Buffer.concat(agent ? out : [...out, ...err]), { mode: 0o600 });
374
389
  if (agent)
375
390
  writeFileSync(errors, Buffer.concat(err), { mode: 0o600 });
376
- resolve(code ?? 1);
391
+ resolve(code ?? (killedBy === "SIGKILL" ? 137 : killedBy === "SIGTERM" ? 143 : 1));
377
392
  });
378
393
  });
379
394
  const ended = now();
@@ -1,10 +1,11 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { annotate, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { timezoneOf } from "@wirecat/cli-base/query";
4
+ import { clockMeta } from "@wirecat/cli-base/standards";
4
5
  import { Argument, InvalidArgumentError, Option } from "commander";
5
6
  import { environmentOf, runBudgetOf } from "../../cli/context.js";
6
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
7
- import { ownerOnlyOption } from "../../cli/effects.js";
8
+ import { ownerOnlyOption, writesFileOnlyWith } from "../../cli/effects.js";
8
9
  import { queryTerms, withQuery } from "../../cli/list-query.js";
9
10
  import { terminalAsker } from "../../cli/messenger/ask.js";
10
11
  import { lineOf } from "../../cli/steps.js";
@@ -235,7 +236,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
235
236
  });
236
237
  print({
237
238
  ...eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED),
238
- meta: await accountsMeta(deps.store, accounts, skipped, now()),
239
+ meta: { ...(await accountsMeta(deps.store, accounts, skipped, now())), ...clockMeta(now(), zone()) },
239
240
  }, command);
240
241
  });
241
242
  const exporter = example(withQuery(annotate(events.command("export"), { effects: deps.writeFile ? LOCAL_WRITE : LOCAL_READ }).description(`write stored events as iCalendar (.ics), at most ${EXPORT_MAX}: one with id:<id>, or those the query ` +
@@ -247,7 +248,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
247
248
  : ["events export id:42", "events export starts_at:[2026-11-02 TO 2026-11-09} NOT event_type:birthday"]));
248
249
  // Not over MCP: an agent would choose where on the owner's disk to write. It gets the text in the answer.
249
250
  if (deps.writeFile)
250
- exporter.addOption(ownerOnlyOption("--output <file>", "the file to write; printed in the answer when absent"));
251
+ writesFileOnlyWith(exporter.addOption(ownerOnlyOption("--output <file>", "the file to write; printed in the answer when absent")), "output");
251
252
  exporter.action(async (words, opts, command) => {
252
253
  const filter = await eventsFilter(words, {
253
254
  store: deps.store,
@@ -297,7 +298,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
297
298
  ...(opts.limit === undefined ? {} : { limit: opts.limit }),
298
299
  ...(window.since === undefined ? {} : { since: window.since }),
299
300
  });
300
- print({ ...prepared, meta: { ...meta, ...window.meta } }, command);
301
+ print({ ...prepared, meta: { ...meta, ...window.meta, ...clockMeta(now(), zone()) } }, command);
301
302
  });
302
303
  const searches = annotate(program.command("search").description("find stored things by text: events"), {
303
304
  effects: GROUP,
@@ -30,7 +30,8 @@ export const calendarTools = [
30
30
  name: "events_list",
31
31
  description: "stored events, earliest first; `query` takes field terms joined by AND: starts_at (>=, <, or " +
32
32
  "[a TO b}), ends_at>=, changed_at>= (events new, moved or cancelled since, each with change and was), " +
33
- "overlapping:true (timed events that clash, each with overlaps), calendar:<id>; a date in the zone given, " +
33
+ "overlapping:true (timed events that clash, each with overlaps), calendar:<id>, event_type:<kind>, and " +
34
+ "-event_type:<kind> to leave one kind out; a date in the zone given, " +
34
35
  "UTC when absent; without starts_at or ends_at, from today on",
35
36
  properties: {
36
37
  account_id: integer,
@@ -126,6 +126,8 @@ export declare const eventPreparation: (details: EventDetails, options: Preparat
126
126
  }[] | undefined;
127
127
  truncated?: boolean | undefined;
128
128
  timezone?: string | undefined;
129
+ now?: string | undefined;
130
+ now_local?: string | undefined;
129
131
  window?: {
130
132
  since?: string | undefined;
131
133
  until?: string | undefined;
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { dateRange, dayBoundary, queryError, queryFieldsOf, } from "@wirecat/cli-base/query";
3
- import { LIST_QUERY_GUIDE, listQuery, nativeTermLists, typedQuery } from "../cli/list-query.js";
3
+ import { LIST_QUERY_GUIDE, listQuery, nativeTermLists, negatableOf, typedQuery, } from "../cli/list-query.js";
4
4
  import { event, eventChange, eventItem, listed, } from "../meetings/index.js";
5
5
  import { entriesOfList, fitting } from "../search/account-list.js";
6
6
  import { listContextOf } from "../services/search-kind.js";
@@ -92,6 +92,7 @@ export const EVENTS_QUERY = {
92
92
  backend: "native",
93
93
  fields: queryFieldsOf(event, {
94
94
  native: ["id", "starts_at", "ends_at", "changed_at", "overlapping", "calendar", "person", "event_type"],
95
+ negatable: ["event_type"],
95
96
  relations: {
96
97
  event_type: {
97
98
  type: "enum",
@@ -156,7 +157,7 @@ export const eventsFilter = async (query, context) => {
156
157
  const { zone, now } = context;
157
158
  const span = (text) => ({ start: 0, end: text.length });
158
159
  const day = (date) => dayBoundary(date, zone, false, span(date));
159
- const terms = nativeTermLists(listQuery(typedQuery(query), EVENTS_QUERY, LIST), LIST, [], ["event_type"]);
160
+ const terms = nativeTermLists(listQuery(typedQuery(query), EVENTS_QUERY, LIST), LIST, [], negatableOf(EVENTS_QUERY));
160
161
  const kinds = (nodes = []) => nodes.map(({ value }) => value.toLowerCase());
161
162
  const excluded = kinds(terms.get("-event_type"));
162
163
  if (excluded.length)
@@ -3,7 +3,7 @@ import { CliError } from "@wirecat/cli-base";
3
3
  import { annotate, describeProgram } from "@wirecat/cli-base/commands";
4
4
  import { Command } from "commander";
5
5
  import { environmentOf } from "../context.js";
6
- import { changesAuthorityAt, fetchesOnlyAt, LOCAL_READ, REMOTE_WRITE } from "../effects.js";
6
+ import { changesAuthorityAt, LOCAL_READ, REMOTE_WRITE, readsOverMcpAt } from "../effects.js";
7
7
  import { itemOf, renderAnswer } from "../envelope.js";
8
8
  import { serverEntry } from "../messenger/mcp-command.js";
9
9
  import { nextStep, rootOf } from "../steps.js";
@@ -37,7 +37,7 @@ export const botMcpCommand = (bot) => {
37
37
  bot,
38
38
  program: { run, commands: describeProgram(rootOf(this)) },
39
39
  authority: changesAuthorityAt(rootOf(this)),
40
- fetchOnly: fetchesOnlyAt(rootOf(this)),
40
+ fetchOnly: readsOverMcpAt(rootOf(this)),
41
41
  settings: context.settings,
42
42
  levels: () => bot.resolveSettings(this.optsWithGlobals(), { env: context.env, kind: "bot" }).permissions,
43
43
  env: context.env,
@@ -50,6 +50,20 @@ export declare const changesStore: <T extends Command>(command: T) => T;
50
50
  export declare const fetchesOnly: (command: Command) => boolean;
51
51
  /** `fetchesOnly` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
52
52
  export declare const fetchesOnlyAt: (root: Command) => (path: readonly string[]) => boolean;
53
+ /**
54
+ * Marks a command whose only local write is the file one owner-only option names (`events export --output`): its
55
+ * effects stay a write, as WC-CMD-17 asks, but a run without the option only reads. An MCP tool never offers the
56
+ * option, so it runs through `read`; `<PREFIX>_READ_ONLY` refuses only a run that gives it. `option` is the option's
57
+ * attribute name, `output`.
58
+ */
59
+ export declare const writesFileOnlyWith: <T extends Command>(command: T, option: string) => T;
60
+ /**
61
+ * What an MCP server runs through `read`: a command that only fetches into the store (`fetchesOnly`), and one whose
62
+ * only write is a file the tool cannot name (`writesFileOnlyWith`).
63
+ */
64
+ export declare const readsOverMcp: (command: Command) => boolean;
65
+ /** `readsOverMcp` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
66
+ export declare const readsOverMcpAt: (root: Command) => (path: readonly string[]) => boolean;
53
67
  /**
54
68
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
55
69
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -1,4 +1,4 @@
1
- import { annotate, annotateOption, metaOf, stepTargets } from "@wirecat/cli-base/commands";
1
+ import { annotate, annotateOption, metaOf, optionMetaOf, stepTargets, } from "@wirecat/cli-base/commands";
2
2
  import { Option } from "commander";
3
3
  import { readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
4
4
  import { keyForCommand } from "../sends/permissions.js";
@@ -93,6 +93,36 @@ export const fetchesOnlyAt = (root) => (path) => {
93
93
  at = at?.commands.find((one) => one.name() === word);
94
94
  return at !== undefined && fetchesOnly(at);
95
95
  };
96
+ const FILE_OPTION = new WeakMap();
97
+ /**
98
+ * Marks a command whose only local write is the file one owner-only option names (`events export --output`): its
99
+ * effects stay a write, as WC-CMD-17 asks, but a run without the option only reads. An MCP tool never offers the
100
+ * option, so it runs through `read`; `<PREFIX>_READ_ONLY` refuses only a run that gives it. `option` is the option's
101
+ * attribute name, `output`.
102
+ */
103
+ export const writesFileOnlyWith = (command, option) => {
104
+ const flag = command.options.find((one) => one.attributeName() === option);
105
+ if (!flag || optionMetaOf(flag).mcp !== false)
106
+ throw new Error(`${command.name()}: writesFileOnlyWith needs --${option} added first, as an owner-only option`);
107
+ FILE_OPTION.set(command, option);
108
+ return command;
109
+ };
110
+ const writesFileNow = (command) => {
111
+ const option = FILE_OPTION.get(command);
112
+ return option === undefined || command.opts()[option] !== undefined;
113
+ };
114
+ /**
115
+ * What an MCP server runs through `read`: a command that only fetches into the store (`fetchesOnly`), and one whose
116
+ * only write is a file the tool cannot name (`writesFileOnlyWith`).
117
+ */
118
+ export const readsOverMcp = (command) => fetchesOnly(command) || FILE_OPTION.has(command);
119
+ /** `readsOverMcp` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
120
+ export const readsOverMcpAt = (root) => (path) => {
121
+ let at = root;
122
+ for (const word of path)
123
+ at = at?.commands.find((one) => one.name() === word);
124
+ return at !== undefined && readsOverMcp(at);
125
+ };
96
126
  /**
97
127
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
98
128
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -103,7 +133,8 @@ export const isStoreChange = (command) => {
103
133
  return ((effects?.remote === "none" || STORE_WRITES.has(command)) &&
104
134
  effects?.local === "write" &&
105
135
  !KEPT_UNDER_LOCK.has(command) &&
106
- !changesAuthority(command));
136
+ !changesAuthority(command) &&
137
+ writesFileNow(command));
107
138
  };
108
139
  export const STORE_CHANGE_REFUSAL = "change the message store";
109
140
  /** ` --dry-run` where the program has it, so an example of a command that calls a provider calls nothing. */
@@ -95,6 +95,8 @@ export declare const answerSchema: (item?: v.GenericSchema) => v.UnionSchema<[v.
95
95
  }, undefined>, undefined>, undefined>;
96
96
  readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
97
97
  readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
98
+ readonly now: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
99
+ readonly now_local: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "an ISO 8601 time with its offset">]>, undefined>;
98
100
  readonly window: v.OptionalSchema<v.StrictObjectSchema<{
99
101
  readonly since: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
100
102
  readonly until: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -148,6 +150,8 @@ export declare const answerSchema: (item?: v.GenericSchema) => v.UnionSchema<[v.
148
150
  }, undefined>, undefined>, undefined>;
149
151
  readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
150
152
  readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
153
+ readonly now: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
154
+ readonly now_local: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "an ISO 8601 time with its offset">]>, undefined>;
151
155
  readonly window: v.OptionalSchema<v.StrictObjectSchema<{
152
156
  readonly since: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
153
157
  readonly until: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -66,7 +66,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
66
66
  export { type Closeable, withDeadline } from "./deadline.js";
67
67
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
68
68
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
69
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
69
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
70
70
  export { type CliEntry, type EntryContext, type EntryIo, startCli } from "./entry.js";
71
71
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
72
72
  export { withAbort } from "./execution.js";
@@ -78,7 +78,7 @@ export { type FirstScreen, type FirstScreenLine, firstScreen, installHelp, WIREC
78
78
  export { type HelpSection, inSection, SECTIONS } from "./help/sections.js";
79
79
  export { bufferedInput, type InputPolicy, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
80
80
  export { answerFields, checkJson, type EntityFields, entityFields, type JsonFieldsResolver, type JsonRequest, liftJson, } from "./json-fields.js";
81
- export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
81
+ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, negatableOf, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
82
82
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
83
83
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
84
84
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
package/dist/cli/index.js CHANGED
@@ -58,7 +58,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
58
58
  export { withDeadline } from "./deadline.js";
59
59
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
60
60
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
61
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
61
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
62
62
  export { startCli } from "./entry.js";
63
63
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
64
64
  export { withAbort } from "./execution.js";
@@ -70,7 +70,7 @@ export { firstScreen, installHelp, WIRECAT_TOOLS } from "./help/first-screen.js"
70
70
  export { inSection, SECTIONS } from "./help/sections.js";
71
71
  export { bufferedInput, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
72
72
  export { answerFields, checkJson, entityFields, liftJson, } from "./json-fields.js";
73
- export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
73
+ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, negatableOf, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
74
74
  export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
75
75
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
76
76
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
@@ -4,6 +4,8 @@ import * as v from "valibot";
4
4
  import { type ListBody, type ListInput } from "./envelope.js";
5
5
  import type { Settings } from "./settings.js";
6
6
  export declare const LIST_QUERY_GUIDE = "https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md";
7
+ /** `[query...]`'s help, which points at the Fields section; an MCP tool has none and says the fields instead. */
8
+ export declare const QUERY_ARGUMENT = "field:value terms that narrow the list \u2014 the fields are below";
7
9
  /**
8
10
  * A list that narrows by one query: `[query...]` last, a Fields section in its help, and the fields in
9
11
  * `commands --json`. The registry is checked here, so a field whose example does not run fails at startup.
@@ -31,7 +33,9 @@ export declare const pageOf: ({ limit, page, all }: Pick<Settings, "limit" | "pa
31
33
  /** A page-numbered list's answer; under `--all` its limit is what it holds. */
32
34
  export declare const pagedList: <T>(command: string, { limit, page, all }: Pick<Settings, "limit" | "page" | "all">, { items, has_more, ...rest }: Omit<ListInput<T>, "limit" | "page" | "cursor">) => ListBody<T>;
33
35
  /** An MCP list's `query` argument: the language the CLI list takes after its arguments. */
34
- export declare const queryArgument: (registry: QueryRegistry) => v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MaxLengthAction<string, 8192, undefined>, v.DescriptionAction<string, "field:value terms; this list filters by none yet, so any term is refused" | `field:value terms joined by AND that narrow the list; fields: ${string}; e.g. ${string}` | `field:value terms that narrow the list (Lucene: AND OR NOT, [a TO b], > <); fields: ${string}; e.g. ${string}`>]>, undefined>;
36
+ export declare const queryArgument: (registry: QueryRegistry) => v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MaxLengthAction<string, 8192, undefined>, v.DescriptionAction<string, string>]>, undefined>;
37
+ /** The fields a native list can also leave out, `-field:value`: what `nativeTermLists` keeps under `-field`. */
38
+ export declare const negatableOf: (registry: QueryRegistry) => string[];
35
39
  /**
36
40
  * The terms of a query a provider answers: field:value joined by AND, each field once. Anything else is a
37
41
  * question the source cannot ask, so it is refused rather than answered from a part of the list.
@@ -1,9 +1,11 @@
1
1
  import { annotate } from "@wirecat/cli-base/commands";
2
- import { dateRange, defineQuery, fieldsHelp, fieldsNamed, OPERATORS_BY_TYPE, parseLucene, QUERY_LIMITS, queryError, queryFieldsOf, timezoneOf, validateQuery, } from "@wirecat/cli-base/query";
2
+ import { dateRange, defineQuery, fieldsHelp, fieldsNamed, OPERATORS_BY_TYPE, parseLucene, QUERY_LIMITS, queryDescription, queryError, queryFieldsOf, timezoneOf, validateQuery, } from "@wirecat/cli-base/query";
3
3
  import * as v from "valibot";
4
4
  import { queryWordsAfterOptions } from "../meetings/cli/index.js";
5
5
  import { listOf } from "./envelope.js";
6
6
  export const LIST_QUERY_GUIDE = "https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md";
7
+ /** `[query...]`'s help, which points at the Fields section; an MCP tool has none and says the fields instead. */
8
+ export const QUERY_ARGUMENT = "field:value terms that narrow the list — the fields are below";
7
9
  /**
8
10
  * A list that narrows by one query: `[query...]` last, a Fields section in its help, and the fields in
9
11
  * `commands --json`. The registry is checked here, so a field whose example does not run fails at startup.
@@ -12,9 +14,7 @@ export const withQuery = (command, registry) => {
12
14
  const query = defineQuery(registry);
13
15
  annotate(command, { query, output: { entity: registry.entity } });
14
16
  const shown = askable(query);
15
- queryWordsAfterOptions(command).argument("[query...]", shown.length > 0
16
- ? "field:value terms that narrow the list — the fields are below"
17
- : "field:value terms; this list filters by none yet");
17
+ queryWordsAfterOptions(command).argument("[query...]", shown.length > 0 ? QUERY_ARGUMENT : "field:value terms; this list filters by none yet");
18
18
  if (shown.length > 0)
19
19
  command.addHelpText("after", `\n${fieldsHelp({ ...query, fields: shown }, LIST_QUERY_GUIDE)}`);
20
20
  return command;
@@ -97,14 +97,11 @@ export const pagedList = (command, { limit, page, all }, { items, has_more, ...r
97
97
  /** The fields a list can be narrowed by: a provider list only those its source answers. */
98
98
  const askable = (registry) => registry.backend === "native" ? registry.fields.filter((field) => field.native) : registry.fields;
99
99
  /** An MCP list's `query` argument: the language the CLI list takes after its arguments. */
100
- export const queryArgument = (registry) => {
101
- const fields = askable(registry);
102
- return v.optional(v.pipe(v.string(), v.maxLength(QUERY_LIMITS.bytes), v.description(fields.length === 0
103
- ? "field:value terms; this list filters by none yet, so any term is refused"
104
- : `${registry.backend === "native"
105
- ? "field:value terms joined by AND that narrow the list"
106
- : "field:value terms that narrow the list (Lucene: AND OR NOT, [a TO b], > <)"}; fields: ${fields.map((field) => field.name).join(", ")}; e.g. ${fields[0]?.example ?? ""}`)));
107
- };
100
+ export const queryArgument = (registry) => v.optional(v.pipe(v.string(), v.maxLength(QUERY_LIMITS.bytes), v.description(queryDescription(registry))));
101
+ /** The fields a native list can also leave out, `-field:value`: what `nativeTermLists` keeps under `-field`. */
102
+ export const negatableOf = (registry) => askable(registry)
103
+ .filter((field) => field.negatable)
104
+ .map((field) => field.name);
108
105
  /**
109
106
  * The terms of a query a provider answers: field:value joined by AND, each field once. Anything else is a
110
107
  * question the source cannot ask, so it is refused rather than answered from a part of the list.
@@ -2,7 +2,7 @@ import type { Command } from "commander";
2
2
  import type { Backend, ServerOptions, ServerSearched } from "../../services/server-search.js";
3
3
  import type { Messenger, MessengerContext } from "./context.js";
4
4
  /** `--backend` and `--server-time`, for any search whose source has a server that searches: `where` names it. */
5
- export declare const serverSearchOptions: (command: Command, where: string) => Command;
5
+ export declare const serverSearchOptions: (command: Command, where: string, defaultTime?: string) => Command;
6
6
  /** Mounted only where the messenger's server can search messages. */
7
7
  export declare const backendOptions: (command: Command, messenger: Messenger) => Command;
8
8
  export declare const backendRequest: (command: Command) => {
@@ -6,9 +6,9 @@ const backendOf = (value) => {
6
6
  return value;
7
7
  };
8
8
  /** `--backend` and `--server-time`, for any search whose source has a server that searches: `where` names it. */
9
- export const serverSearchOptions = (command, where) => command
9
+ export const serverSearchOptions = (command, where, defaultTime = "5s") => command
10
10
  .option("--backend <archive|server|both>", `where to search: the local archive, ${where}, or both`, backendOf)
11
- .option("--server-time <duration>", "stop waiting for the server after this long (default: 5s)");
11
+ .option("--server-time <duration>", `stop waiting for the server after this long (default: ${defaultTime})`);
12
12
  /** Mounted only where the messenger's server can search messages. */
13
13
  export const backendOptions = (command, messenger) => messenger.serverSearch
14
14
  ? serverSearchOptions(command, "the messenger's server (default: both; message discovery uses archive only)")
@@ -45,6 +45,19 @@ const whenOf = ({ every, at, atTime }, minimum) => {
45
45
  return { every: minutes };
46
46
  };
47
47
  /** A schedule as answered: snake_case, but its environment keeps the variables' own names. */
48
+ /**
49
+ * A run waits for its child, which keeps its own deadline (a sync's is 15 minutes): the wait has none of its own, or the
50
+ * run answered `timeout` at 30 s while the child went on. A signal or an explicit --timeout stops the child.
51
+ */
52
+ const untilChildEnds = async (command, wait) => {
53
+ const environment = environmentOf(command);
54
+ try {
55
+ return await wait(environment.untilStopped?.());
56
+ }
57
+ finally {
58
+ environment.endUntilStopped?.();
59
+ }
60
+ };
48
61
  const scheduleAnswer = ({ environment, createdAt, ...schedule }, extra) => ({
49
62
  ...snakeKeys(schedule),
50
63
  environment,
@@ -222,9 +235,10 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
222
235
  .argument("<name>", "the schedule's name, as `schedule list` shows it")
223
236
  .action(async function (name) {
224
237
  const { context, system, state, say, timer } = prepare(this);
225
- const last = await runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
238
+ const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
226
239
  timer: timer(),
227
- });
240
+ ...(signal ? { signal } : {}),
241
+ }));
228
242
  say(snakeKeys({ name, ...last }), [`${name}: exit ${last.exitCode} — ${last.output}`]);
229
243
  if (last.exitCode !== 0)
230
244
  setExitCode(this, 1);
@@ -253,7 +267,7 @@ export const scheduleCommandFor = ({ app, context: contextOf }) => {
253
267
  .action(async function (file) {
254
268
  // A timer starts it with no profile named; the schedule's own environment carries the profile.
255
269
  const { context, system, timer } = prepare(this, { needsProfile: false });
256
- const last = await runSchedule(app, system, context.env, file, { timer: timer() });
270
+ const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, { timer: timer(), ...(signal ? { signal } : {}) }));
257
271
  if (last.exitCode !== 0)
258
272
  setExitCode(this, 1);
259
273
  });
@@ -89,6 +89,7 @@ const OTHERS = [
89
89
  ["search.server-failed", "provider_unavailable", "the server did not answer a search"],
90
90
  ["search.server-timeout", "timeout", "the server did not answer within --server-time"],
91
91
  ["search.server-capped", "outcome_unknown", "the server found more than one search reads, or a branch was not sent"],
92
+ ["search.server-too-large", "outcome_unknown", "a message the server found is too large to download during a search"],
92
93
  validation("store.refused", "the local store refused the document text"),
93
94
  ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
94
95
  [
@@ -8,8 +8,9 @@ export declare const FETCH_CHUNK = 50;
8
8
  export declare const addressOf: ({ email, name }: Address) => EmailAddress;
9
9
  export type AttachmentLog = NonNullable<MailSyncResult["attachments"]>;
10
10
  /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
11
- export declare const readAttachments: (mail: ParsedMail, messageId: string, { loadEngine, maxAttachmentChars, at }: {
11
+ export declare const readAttachments: (mail: ParsedMail, messageId: string, { loadEngine, maxAttachmentChars, at, signal, }: {
12
12
  loadEngine: LoadEngine;
13
13
  maxAttachmentChars: number;
14
14
  at: number;
15
+ signal?: AbortSignal;
15
16
  }, log: AttachmentLog) => Promise<AttachmentInput[]>;
@@ -4,12 +4,14 @@ export const MAX_ATTACHMENT_CHARS = 200_000;
4
4
  export const FETCH_CHUNK = 50;
5
5
  export const addressOf = ({ email, name }) => ({ address: email, name });
6
6
  /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
7
- export const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
7
+ export const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at, signal, }, log) => {
8
8
  const files = [];
9
9
  for (const [position, part] of mail.attachments.entries()) {
10
+ if (signal?.aborted)
11
+ throw signal.reason;
10
12
  const extraction = part.bytes === null
11
13
  ? { status: "unavailable" }
12
- : await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
14
+ : await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine, signal);
13
15
  log.push({ message: messageId, part: part.id, status: extraction.status });
14
16
  const extracted = extraction.status === "extracted" ? extraction : undefined;
15
17
  files.push({
@@ -12,6 +12,8 @@ export interface MailReads {
12
12
  read: <T>(command: Command, work: (store: MessageStore, mailbox: AccountKey) => Promise<T>) => Promise<T>;
13
13
  /** The mailbox's server, for `search messages --backend`; without it the search reads the store only. */
14
14
  server?: (command: Command) => MailServerAccess;
15
+ /** How `--server-time` help states its default when a provider's own differs (`MailServerAccess.timeMs`). */
16
+ serverTimeDefault?: string;
15
17
  }
16
18
  export declare const THREAD_QUERY: {
17
19
  readonly entity: "mail_thread";
@@ -4,7 +4,7 @@ import { queryFieldsOf } from "@wirecat/cli-base/query";
4
4
  import { Command, InvalidArgumentError } from "commander";
5
5
  import * as v from "valibot";
6
6
  import { answerEntity } from "../../cli/answer-entity.js";
7
- import { outputFor } from "../../cli/context.js";
7
+ import { environmentOf, outputFor } from "../../cli/context.js";
8
8
  import { LOCAL_READ, REMOTE_READ } from "../../cli/effects.js";
9
9
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
10
10
  import { listQuery, typedQuery, withQuery } from "../../cli/list-query.js";
@@ -74,8 +74,11 @@ const serverIssues = (server) => {
74
74
  ...FAILED[reason],
75
75
  source: "server",
76
76
  }));
77
- return failed.length
78
- ? failed
77
+ const tooLarge = server.tooLarge
78
+ ? [{ code: "outcome_unknown", reason: "search.server-too-large", source: "server" }]
79
+ : [];
80
+ return failed.length || tooLarge.length
81
+ ? [...failed, ...tooLarge]
79
82
  : [{ code: "outcome_unknown", reason: "search.server-capped", source: "server" }];
80
83
  };
81
84
  const searchCommand = (mail) => {
@@ -114,19 +117,23 @@ found_in says archive, server (new from the server) or both; meta.complete is fa
114
117
  : ""}
115
118
  Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md`);
116
119
  if (mail.server)
117
- serverSearchOptions(leaf, "the mail server (default: both)");
120
+ serverSearchOptions(leaf, "the mail server (default: both)", mail.serverTimeDefault);
118
121
  leaf.action(async function (words) {
119
122
  const options = this.opts();
120
123
  const limit = options.limit ?? DEFAULT_LIMIT;
121
124
  const page = options.page ?? 1;
122
125
  const query = words.join(" ");
123
126
  const { backend, server: bounds } = mail.server ? backendRequest(this) : {};
127
+ const { signal, deadline: deadlineOf } = environmentOf(this);
128
+ const deadline = deadlineOf?.();
124
129
  const found = await mail.read(this, async (store, mailbox) => {
125
130
  const step = mail.server
126
131
  ? await searchMailServer(store, mailbox, mail.server(this), {
127
132
  text: query,
128
133
  ...(backend === undefined ? {} : { backend }),
129
134
  ...(bounds?.timeMs === undefined ? {} : { timeMs: bounds.timeMs }),
135
+ ...(signal ? { signal } : {}),
136
+ ...(deadline ? { deadline } : {}),
130
137
  ...(options.exact ? { exact: true } : {}),
131
138
  ...(options.timezone === undefined ? {} : { timezone: options.timezone }),
132
139
  })
@@ -1,3 +1,4 @@
1
+ import { type Deadline } from "../background/budget.js";
1
2
  import { type Level, type PermissionKey } from "../sends/permissions.js";
2
3
  import { type Backend, type ServerStep } from "../services/server-search.js";
3
4
  import type { AccountKey, MessageStore } from "../store/index.js";
@@ -15,6 +16,8 @@ export interface MailServerAccess {
15
16
  folders: string[];
16
17
  close?: () => Promise<void>;
17
18
  }>;
19
+ /** This mailbox's own default for the time bound, for a server whose search is slow; `--server-time` wins. */
20
+ timeMs?: number;
18
21
  }
19
22
  export interface MailServerRequest {
20
23
  text: string;
@@ -23,6 +26,8 @@ export interface MailServerRequest {
23
26
  backend?: Backend;
24
27
  timeMs?: number;
25
28
  signal?: AbortSignal;
29
+ /** The command's deadline: the time bound ends before it, so the search answers in part instead of timing out. */
30
+ deadline?: Deadline;
26
31
  }
27
32
  /**
28
33
  * Asks the mailbox's server for candidates and saves the new ones, so the strict query that runs next on the store
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { importEngine } from "../attachments/extract.js";
3
+ import { deadlineMargin } from "../background/budget.js";
3
4
  import { levelFor } from "../sends/permissions.js";
4
5
  import { DEFAULT_BACKEND, SERVER_BOUNDS, SERVER_SEARCH_KEY, sourceKey, } from "../services/server-search.js";
5
6
  import { MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
@@ -38,9 +39,13 @@ export const searchMailServer = async (store, mailbox, access, request) => {
38
39
  if (backend === "archive")
39
40
  return undefined;
40
41
  const quiet = request.backend === undefined;
41
- const timeMs = request.timeMs ?? SERVER_BOUNDS.timeMs;
42
- if (!Number.isSafeInteger(timeMs) || timeMs < 1 || timeMs > 60_000)
42
+ const asked = request.timeMs ?? access.timeMs ?? SERVER_BOUNDS.timeMs;
43
+ if (!Number.isSafeInteger(asked) || asked < 1 || asked > 60_000)
43
44
  throw new CliError("validation_error", "the server time takes a whole number of milliseconds from 1 to 60000");
45
+ const { deadline } = request;
46
+ const timeMs = deadline
47
+ ? Math.max(1, Math.min(asked, deadline.endsAt - Date.now() - deadlineMargin(deadline)))
48
+ : asked;
44
49
  const report = { backend, skipped: null, calls: 0, returned: 0, new: 0, failed: [], complete: true };
45
50
  const sources = new Map();
46
51
  const skip = (reason) => {
@@ -77,10 +82,13 @@ export const searchMailServer = async (store, mailbox, access, request) => {
77
82
  const fresh = [];
78
83
  let rules = [];
79
84
  const ruled = new Map();
80
- let raws;
85
+ const inputs = [];
86
+ const saved = [];
81
87
  let opening;
82
88
  let openFailure;
83
89
  let unsupported = false;
90
+ const controller = new AbortController();
91
+ const stop = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
84
92
  // Opening lists the folders, a call to the server, so it runs inside the time bound too.
85
93
  const run = async () => {
86
94
  opening = access.open();
@@ -138,15 +146,63 @@ export const searchMailServer = async (store, mailbox, access, request) => {
138
146
  sources.set(sourceKey(item.threadId, item.messageId), held ? "both" : "server");
139
147
  if (held)
140
148
  continue;
149
+ // A big message would hold the connection and the parser past the bound; sync reads it whole later.
150
+ if ((item.size ?? 0) > SERVER_BOUNDS.maxMessageBytes) {
151
+ report.tooLarge = (report.tooLarge ?? 0) + 1;
152
+ report.complete = false;
153
+ sources.delete(sourceKey(item.threadId, item.messageId));
154
+ continue;
155
+ }
141
156
  fresh.push(item);
142
157
  ruled.set(item, { ruleId: rule?.id ?? null, tags: tagsOf(rules, facts(item)) });
143
158
  }
144
- if (fresh.length)
145
- raws = await source.fetchRaw(fresh);
159
+ const raws = fresh.length && open ? await source.fetchRaw(fresh) : [];
160
+ // Parsing and reading attachments stay inside the bound: after it, nothing more is read or kept.
161
+ const now = Date.now();
162
+ for (const [index, item] of fresh.entries()) {
163
+ if (!open)
164
+ return;
165
+ const raw = raws[index];
166
+ try {
167
+ if (raw === undefined || raw instanceof Error)
168
+ throw raw ?? new Error("not read");
169
+ const mail = await parseMail(raw);
170
+ const files = await readAttachments(mail, item.messageId, { loadEngine: importEngine, maxAttachmentChars: MAX_ATTACHMENT_CHARS, at: now, signal: stop }, []);
171
+ if (!open)
172
+ return;
173
+ const folder = item.folder;
174
+ const validity = validities.get(item);
175
+ inputs.push({
176
+ accountId,
177
+ externalId: item.threadId,
178
+ now,
179
+ emails: [
180
+ {
181
+ ...parsedEmail(mail, item, mailHeadersOf(raw), mailbox.account),
182
+ mailboxes: [
183
+ { externalId: folder, name: folder, kind: null },
184
+ ...(item.labels ?? []).map((name) => ({ externalId: `label:${name}`, name, kind: "label" })),
185
+ ...(item.headers?.bulk ? [bulkBox] : []),
186
+ ],
187
+ ...(validity === undefined
188
+ ? {}
189
+ : { locations: [{ folder, uidvalidity: validity, uid: Number(item.uid) }] }),
190
+ ...(item.seen === undefined ? {} : { read: item.seen }),
191
+ ...(item.flagged === undefined ? {} : { flagged: item.flagged }),
192
+ mailRuleId: ruled.get(item)?.ruleId ?? null,
193
+ attachments: files,
194
+ },
195
+ ],
196
+ });
197
+ saved.push(item);
198
+ }
199
+ catch {
200
+ sources.delete(sourceKey(item.threadId, item.messageId));
201
+ report.complete = false;
202
+ }
203
+ }
146
204
  };
147
- const controller = new AbortController();
148
205
  const timer = setTimeout(() => controller.abort(), timeMs);
149
- const stop = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
150
206
  const bound = new Promise((resolve) => {
151
207
  if (stop.aborted)
152
208
  resolve(BOUND);
@@ -169,56 +225,18 @@ export const searchMailServer = async (store, mailbox, access, request) => {
169
225
  const ended = outcome === BOUND || outcome === "done" ? "time_or_abort_bound" : outcome;
170
226
  for (const call of pending)
171
227
  fail(call, ended);
172
- if (outcome !== "done" || (fresh.length > 0 && raws === undefined)) {
228
+ if (outcome !== "done") {
173
229
  // What was found but not read in time is the archive's to answer: none of it is claimed as the server's.
174
230
  if (pending.size === 0)
175
231
  fail(null, ended);
232
+ const kept = new Set(saved);
176
233
  for (const item of fresh)
177
- sources.delete(sourceKey(item.threadId, item.messageId));
178
- report.returned = sources.size;
179
- return { report, sources };
180
- }
181
- const now = Date.now();
182
- const inputs = [];
183
- const saved = [];
184
- for (const [index, item] of fresh.entries()) {
185
- const raw = raws?.[index];
186
- try {
187
- if (raw === undefined || raw instanceof Error)
188
- throw raw ?? new Error("not read");
189
- const mail = await parseMail(raw);
190
- const files = await readAttachments(mail, item.messageId, { loadEngine: importEngine, maxAttachmentChars: MAX_ATTACHMENT_CHARS, at: now }, []);
191
- const folder = item.folder;
192
- const validity = validities.get(item);
193
- inputs.push({
194
- accountId,
195
- externalId: item.threadId,
196
- now,
197
- emails: [
198
- {
199
- ...parsedEmail(mail, item, mailHeadersOf(raw), mailbox.account),
200
- mailboxes: [
201
- { externalId: folder, name: folder, kind: null },
202
- ...(item.labels ?? []).map((name) => ({ externalId: `label:${name}`, name, kind: "label" })),
203
- ...(item.headers?.bulk ? [bulkBox] : []),
204
- ],
205
- ...(validity === undefined
206
- ? {}
207
- : { locations: [{ folder, uidvalidity: validity, uid: Number(item.uid) }] }),
208
- ...(item.seen === undefined ? {} : { read: item.seen }),
209
- ...(item.flagged === undefined ? {} : { flagged: item.flagged }),
210
- mailRuleId: ruled.get(item)?.ruleId ?? null,
211
- attachments: files,
212
- },
213
- ],
214
- });
215
- saved.push(item);
216
- }
217
- catch {
218
- sources.delete(sourceKey(item.threadId, item.messageId));
219
- report.complete = false;
220
- }
234
+ if (!kept.has(item))
235
+ sources.delete(sourceKey(item.threadId, item.messageId));
221
236
  }
237
+ // The command's own deadline or a cancel ended it: its runner has answered, so nothing more is written.
238
+ if (request.signal?.aborted)
239
+ throw request.signal.reason;
222
240
  if (inputs.length)
223
241
  await store.mail.saveThreads(inputs);
224
242
  // Sync never reads mail whose place it knows again, so a rule's tags go on here or never.
package/dist/mail/sync.js CHANGED
@@ -195,13 +195,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
195
195
  /** A quick folder's stored mail as it was before this run changed anything, with the UID of each. */
196
196
  const folderMail = new Map();
197
197
  const refreshed = new Map();
198
- let holding;
199
- const holdsMail = async (folder) => {
200
- holding ??= new Set((await store.mail.emailRefs({ accountId })).flatMap(({ mailboxes }) => mailboxes
201
- .filter(({ kind }) => kind !== "label" && kind !== ENVELOPE && kind !== BULK)
202
- .map((box) => box.externalId)));
203
- return holding.has(folder);
204
- };
198
+ const holdsMail = async (folder) => (await store.mail.emails({ accountId, mailbox: folder, limit: 1 })).length > 0;
205
199
  for (const folder of folders) {
206
200
  if (mboxPoint !== undefined)
207
201
  break;
@@ -339,6 +333,9 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
339
333
  for (const item of found)
340
334
  collected.push(item);
341
335
  }
336
+ // A listing that came back after the stop writes nothing: the command has answered, and the next run lists again.
337
+ if (signal?.aborted)
338
+ throw signal.reason;
342
339
  const listed = [...new Map(collected.map((item) => [item.messageId, item])).values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt));
343
340
  const present = new Set(listed.map(({ messageId }) => messageId));
344
341
  const factsFor = factsOf(listed, rules, ages);
@@ -449,7 +446,11 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
449
446
  const pending = [];
450
447
  // IMAP lists what exists now, so a mail marked deleted that it lists again is saved again, which brings it back.
451
448
  // An archive is old: what it holds that IMAP proved deleted stays deleted.
452
- const refs = await store.mail.emailRefs({ accountId, includeDeleted: snapshot });
449
+ const refs = await store.mail.emailRefs({
450
+ accountId,
451
+ includeDeleted: snapshot,
452
+ externalIds: listed.map(({ messageId }) => messageId),
453
+ });
453
454
  const known = new Set(refs.map(({ externalId }) => externalId));
454
455
  const enveloped = new Map(refs.flatMap(({ externalId, mailboxes }) => {
455
456
  const boxes = mailboxes.filter(({ kind }) => kind === ENVELOPE).map((box) => box.externalId);
@@ -712,7 +713,11 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
712
713
  }
713
714
  plan?.unreadable.delete(item.messageId);
714
715
  const header = mailHeadersOf(raws[index]);
715
- const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
716
+ // A stop between attachments ends the chunk; what the earlier slices saved stays.
717
+ const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt, ...(signal ? { signal } : {}) }, attachments).catch(async (error) => {
718
+ await stopIfAborted();
719
+ throw error;
720
+ });
716
721
  const input = {
717
722
  accountId,
718
723
  externalId: item.threadId,
package/dist/mcp/agent.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { Readable } from "node:stream";
2
2
  import { CliError, captureStreams, errorObjectOf } from "@wirecat/cli-base";
3
3
  import { flatten, } from "@wirecat/cli-base/commands";
4
+ import { queryDescription } from "@wirecat/cli-base/query";
4
5
  import { writes } from "@wirecat/cli-base/standards";
5
6
  import { VOCABULARY } from "@wirecat/cli-base/standards/tables";
6
7
  import * as v from "valibot";
7
8
  import { commandAvailability } from "../cli/command-availability.js";
8
9
  import { changesMessenger } from "../cli/effects.js";
10
+ import { QUERY_ARGUMENT } from "../cli/list-query.js";
9
11
  import { lentConnection } from "../cli/messenger/serve-relay.js";
10
12
  import { newRunId } from "../runs/run.js";
11
13
  import { enclosingTraceId, inRun, traceOf } from "../runs/scope.js";
@@ -78,10 +80,17 @@ const optionField = (option, long, negated) => {
78
80
  },
79
81
  };
80
82
  };
81
- const argumentField = (argument) => {
83
+ /** A list's query names its fields here: the help's Fields section, which its description points to, is not sent. */
84
+ const argumentField = (argument, query) => {
85
+ const own = argument.description || argument.name;
86
+ const about = query && argument.name === "query"
87
+ ? own === QUERY_ARGUMENT
88
+ ? queryDescription(query)
89
+ : `${own}; ${queryDescription(query)}`
90
+ : own;
82
91
  const one = argument.choices
83
92
  ? v.pipe(v.picklist(argument.choices), v.description(argument.description))
84
- : text(argument.description || argument.name);
93
+ : text(about);
85
94
  return {
86
95
  name: snake(argument.name),
87
96
  required: argument.required,
@@ -109,7 +118,7 @@ const stdinField = ({ name, description, json }) => ({
109
118
  argv: () => [],
110
119
  });
111
120
  const fieldsOf = (info, { omit = [], offline = false, stdin }) => {
112
- const fields = info.arguments.map(argumentField);
121
+ const fields = info.arguments.map((argument) => argumentField(argument, info.query));
113
122
  const read = stdin?.(info.path);
114
123
  if (read)
115
124
  fields.push(stdinField(read));
@@ -4,7 +4,7 @@ import { CliError, mcpErrorResult } from "@wirecat/cli-base";
4
4
  import { describeProgram } from "@wirecat/cli-base/commands";
5
5
  import { skillResource } from "@wirecat/cli-base/skill";
6
6
  import { environmentOf } from "../cli/context.js";
7
- import { changesAuthorityAt, fetchesOnlyAt, judgedTargets } from "../cli/effects.js";
7
+ import { changesAuthorityAt, judgedTargets, readsOverMcpAt } from "../cli/effects.js";
8
8
  import { DEFAULT_OUTPUT_BYTES } from "../cli/execution.js";
9
9
  import { rootOf } from "../cli/steps.js";
10
10
  import { mcpUrlOf } from "../http/public-url.js";
@@ -46,7 +46,7 @@ export const createAppServer = (command, context, options) => {
46
46
  currentLevels: levels,
47
47
  readOnly: readOnly(),
48
48
  authority,
49
- fetchOnly: fetchesOnlyAt(root),
49
+ fetchOnly: readsOverMcpAt(root),
50
50
  permissions: options.permissions,
51
51
  });
52
52
  const availability = (one) => () => {
@@ -8,7 +8,7 @@ import * as v from "valibot";
8
8
  import { findCommand } from "../cli/command-contract.js";
9
9
  import { environmentOf } from "../cli/context.js";
10
10
  import { refuseWriteBeforeConnecting } from "../cli/early-refusal.js";
11
- import { changesAuthorityAt, fetchesOnlyAt, judgedTargets } from "../cli/effects.js";
11
+ import { changesAuthorityAt, judgedTargets, readsOverMcpAt } from "../cli/effects.js";
12
12
  import { itemOf } from "../cli/envelope.js";
13
13
  import { DEFAULT_OUTPUT_BYTES } from "../cli/execution.js";
14
14
  import { recalledAccount } from "../cli/messenger/accounts.js";
@@ -127,7 +127,7 @@ export const createServer = (command, context, messenger, sessionOptions) => {
127
127
  readOnly: readOnlyLockOf(app, context.env) !== undefined,
128
128
  keyOf,
129
129
  authority,
130
- fetchOnly: fetchesOnlyAt(root),
130
+ fetchOnly: readsOverMcpAt(root),
131
131
  offline: true,
132
132
  stdin,
133
133
  }).map((one) => "sync_first" in one.input.entries && !syncAllowed(one.path)
@@ -2,10 +2,12 @@ import type { Id } from "../domain/models.js";
2
2
  import type { ServiceDeps } from "./deps.js";
3
3
  import type { SearchQuery } from "./messages.js";
4
4
  export declare const SERVER_SEARCH_KEY = "messages.server-search";
5
+ /** `maxMessageBytes`: a candidate larger than this is not downloaded; a sync reads it, and the answer says so. */
5
6
  export declare const SERVER_BOUNDS: {
6
7
  timeMs: number;
7
8
  perCall: number;
8
9
  maxCalls: number;
10
+ maxMessageBytes: number;
9
11
  };
10
12
  export declare const DEFAULT_BACKEND = "both";
11
13
  export type Backend = "archive" | "server" | "both";
@@ -24,6 +26,8 @@ export interface ServerSearched {
24
26
  chat: string | null;
25
27
  reason: "rate_limited" | "search_failed" | "time_or_abort_bound";
26
28
  }[];
29
+ /** Candidates larger than `SERVER_BOUNDS.maxMessageBytes`, left on the server unread. */
30
+ tooLarge?: number;
27
31
  complete: boolean;
28
32
  }
29
33
  export type HitSource = "archive" | "server" | "both";
@@ -2,7 +2,8 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { capability } from "../ports/messenger.js";
3
3
  import { prepareLucene } from "./messages-search.js";
4
4
  export const SERVER_SEARCH_KEY = "messages.server-search";
5
- export const SERVER_BOUNDS = { timeMs: 5_000, perCall: 100, maxCalls: 3 };
5
+ /** `maxMessageBytes`: a candidate larger than this is not downloaded; a sync reads it, and the answer says so. */
6
+ export const SERVER_BOUNDS = { timeMs: 5_000, perCall: 100, maxCalls: 3, maxMessageBytes: 5_000_000 };
6
7
  export const DEFAULT_BACKEND = "both";
7
8
  export const sourceKey = (chatId, id) => JSON.stringify([chatId, id]);
8
9
  const merge = (one, other) => {
@@ -161,6 +161,8 @@ export interface EmailFilter extends MailFilter {
161
161
  until?: number;
162
162
  /** Only emails with an attachment whose text extraction failed with one of these errors. */
163
163
  attachmentErrors?: string[];
164
+ /** Only these Message-IDs: a sync asks about what it listed, not about the whole account. */
165
+ externalIds?: readonly string[];
164
166
  }
165
167
  export interface EmailRef {
166
168
  externalId: string;
@@ -313,6 +313,8 @@ const emailWhere = (filter) => {
313
313
  if (filter.attachmentErrors !== undefined)
314
314
  add(`e.id IN (SELECT attachable_id FROM attachments WHERE attachable_type = 'email'
315
315
  AND extraction_error IN (${filter.attachmentErrors.map(() => "?").join(",") || "NULL"}))`, ...filter.attachmentErrors);
316
+ if (filter.externalIds !== undefined)
317
+ add("e.external_id IN (SELECT value FROM json_each(?))", JSON.stringify(filter.externalIds));
316
318
  return [where.join(" AND "), params];
317
319
  };
318
320
  /** An email as a message of its thread, the shape mail had when it was stored as messages. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.539.0",
3
+ "version": "0.541.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",
@@ -251,7 +251,7 @@
251
251
  "@types/node": "^22.20.5",
252
252
  "@types/pngjs": "6.0.5",
253
253
  "@vitest/coverage-v8": "^5.0.5",
254
- "@wirecat/cli-base": "0.71.0",
254
+ "@wirecat/cli-base": "0.72.0",
255
255
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
256
256
  "commander": "15.0.0",
257
257
  "cspell": "10.4.0",
@@ -271,7 +271,7 @@
271
271
  },
272
272
  "peerDependencies": {
273
273
  "@napi-rs/canvas": ">=0.1.69 <2",
274
- "@wirecat/cli-base": ">=0.71.0 <0.72.0",
274
+ "@wirecat/cli-base": ">=0.72.0 <0.73.0",
275
275
  "commander": ">=15"
276
276
  },
277
277
  "bin": {