@wirecat/cli-core 0.506.0 → 0.508.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,65 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.508.0 — 31.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`events list --page <n>`: the next page of stored events.** `events list` said `hasMore` and had no way to read on;
15
+ it now takes `--page` from 1 with `--limit` events a page (100), and the answer's `page` is the number shown, so a
16
+ host's `listOf` can offer `page.next` (WC-OUT-5). `eventsAnswer` takes the page as a third argument.
17
+ - **`search events --backend both`** asks the store and the provider's own search together, where `--backend` is
18
+ offered (`deps.serverSearch`). The stored hits come first in the stored search's order, `found_by` gaining
19
+ `server` on each the provider also found; then the hits only the provider found, earliest first. One page, with no
20
+ `next_offset`; `has_more` when either side has more. It refuses `--offline`, `--cursor` and `--newest` as
21
+ `--backend server` does, and takes `--exact` for the stored side. `calendar_search_hit` allows `server` in a stored
22
+ hit's `found_by`. An occurrence the store keeps under another id than the provider's search names shows twice.
23
+
24
+ ### Changed — may break callers
25
+
26
+ - **`events create --id` and `events update --following --id` are now `--operation-id`** (WC-CMD-10, WC-OUT-10): the
27
+ value is the `operation_id` the answer and an `outcome_unknown` already name. `--id` is refused as an unknown
28
+ option; errors name `--operation-id`. The calendar MCP tools keep their `id` argument.
29
+ - **`search events` no longer refuses `--backend both`** with `search.one-backend`; see Added.
30
+
31
+ ### Fixed
32
+
33
+ - **`search events --cursor` help** said "a page's next_offset", a key the answer a host prints does not have; it now
34
+ says to go on from where the last page stopped, as `page.next` gives it.
35
+
36
+ - **A budgeted `syncMail` waits out a locked store.** When another process (memo's scheduled sync) holds the store's
37
+ write lock past its 5 s busy timeout, the sync used to fail with `database is locked`; it now waits 2 s, 4 s, ... (at
38
+ most 5 times, each a request of the budget) and reads the round again. When the budget has no room the run ends as
39
+ `partial`. `options.wait` lets a test skip the pause.
40
+ - **A bot token, a bot's named secret or an AI provider's key the owner sets again goes back to the keyring.** After
41
+ one keyring failure such a secret stayed in the private file for good, as cli-base 0.68.0 keeps a plain write where
42
+ the secret is. `BotTokenStore.write`, `writeSecret` and `embeddingKeys().write` now write with `{ signIn: true }`:
43
+ the keyring is tried and, once it takes the secret, the file copy is removed (WC-SECR-4). `writeKeyring` was
44
+ keyring-only already; the CAS key is only read here.
45
+
46
+ ## 0.507.0 — 31.10.2026
47
+
48
+ ### Added
49
+
50
+ - **`messages sync --json` prints its progress on stderr**, for a window to draw a bar instead of an endless one
51
+ (WireCat Desktop). One JSON line each, counts only — never a chat's id or title:
52
+ `{"event":"progress","step":"sync","chats":3,"chats_done":1,"fetched":250}`. With `--all`, one once the chat list
53
+ is read (`chats_done` 0), then one after each chat, those passed as already current included; for one chat, one per
54
+ page with `chats` and `chats_done` `null`. At most about two a second; the first and last of `--all` always come.
55
+ Only in machine output (`--json`, `--jsonl`, or stdout not a terminal) and not in a `--background` job's log;
56
+ stdout still carries the one answer. Archive services take an optional `onChat` for the same counts.
57
+
58
+ ### Changed — may break callers
59
+
60
+ - **Needs `@wirecat/cli-base` 0.68.0** (peer range `>=0.68.0 <0.69.0`). cli-base's `Credentials` in `auto` now reads
61
+ a sign-in the private file holds before the keyring and keeps its refreshes there (WC-SECR-4).
62
+
63
+ ### Fixed
64
+
65
+ - **A new OAuth sign-in moves a file sign-in back to the keyring.** `TokenStorage.write` takes `{ signIn: true }`, and
66
+ the keeper's `signIn` and `save` pass it: the keyring is tried again and, once it takes the sign-in, the file copy
67
+ is removed. A refresh stays where the sign-in is, so a locked keyring is not tried for it on every refresh.
68
+
10
69
  ## 0.506.0 — 31.10.2026
11
70
 
12
71
  ### Added
@@ -156,7 +156,10 @@ export declare const calendarEvent: v.StrictObjectSchema<{
156
156
  }, undefined>, undefined>;
157
157
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
158
158
  }, undefined>;
159
- /** A hit of the stored search, or with `--backend server` one the provider's own search found and nothing stored. */
159
+ /**
160
+ * A hit of the stored search (with `--backend both`, `server` too when the provider also found it), or one the
161
+ * provider's own search found and nothing stored.
162
+ */
160
163
  export declare const calendarSearchHit: v.UnionSchema<[v.StrictObjectSchema<{
161
164
  readonly provider: v.NullableSchema<v.StringSchema<undefined>, undefined>;
162
165
  readonly account: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -172,7 +175,7 @@ export declare const calendarSearchHit: v.UnionSchema<[v.StrictObjectSchema<{
172
175
  readonly excerpt: v.StringSchema<undefined>;
173
176
  readonly occurrences: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
174
177
  readonly updated_at: 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`">]>;
175
- readonly found_by: v.ArraySchema<v.PicklistSchema<["words", "meaning"], undefined>, undefined>;
178
+ readonly found_by: v.ArraySchema<v.PicklistSchema<["words", "meaning", "server"], undefined>, undefined>;
176
179
  }, undefined>, v.StrictObjectSchema<{
177
180
  readonly provider: v.NullableSchema<v.StringSchema<undefined>, undefined>;
178
181
  readonly account: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -138,7 +138,10 @@ const hitFields = {
138
138
  url: v.optional(text),
139
139
  untrusted,
140
140
  };
141
- /** A hit of the stored search, or with `--backend server` one the provider's own search found and nothing stored. */
141
+ /**
142
+ * A hit of the stored search (with `--backend both`, `server` too when the provider also found it), or one the
143
+ * provider's own search found and nothing stored.
144
+ */
142
145
  export const calendarSearchHit = v.union([
143
146
  v.strictObject({
144
147
  ...hitFields,
@@ -146,7 +149,7 @@ export const calendarSearchHit = v.union([
146
149
  excerpt: text,
147
150
  occurrences: count,
148
151
  updated_at: time,
149
- found_by: v.array(v.picklist(["words", "meaning"])),
152
+ found_by: v.array(v.picklist(["words", "meaning", "server"])),
150
153
  }),
151
154
  v.strictObject({
152
155
  ...hitFields,
@@ -12,7 +12,7 @@ import { linkPeople } from "../../services/people.js";
12
12
  import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
13
13
  import { CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, weekFilter, } from "../reads.js";
14
14
  import { REPEAT_HELP } from "../recurrence.js";
15
- import { serverSearchAnswer } from "../server-search.js";
15
+ import { bothSearchAnswer, serverSearchAnswer } from "../server-search.js";
16
16
  import { calendarStatus, syncCalendars } from "../sync.js";
17
17
  import { createEvent, deleteEvent, EVENT_RESPONSES, moveEvent, respondToEvent, updateEvent, } from "../writes.js";
18
18
  import { CALENDARS_QUERY, declareCalendarAnswers, PEOPLE_QUERY } from "./answers.js";
@@ -201,8 +201,9 @@ const addCalendarCommandsDefinition = (program, deps) => {
201
201
  }), command);
202
202
  });
203
203
  const events = annotate(program.command("events").description("the account's stored events"), { effects: GROUP });
204
- example(withQuery(annotate(events.command("list"), { effects: LOCAL_READ }).description("list stored events, earliest first"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events list starts_at>=2026-11-02 starts_at<2026-11-07 --json", "events list ends_at>=today person:ana@example.com")
205
- .option("--limit <n>", "events at most, 1–1000", integer, 100)
204
+ example(withQuery(annotate(events.command("list"), { effects: LOCAL_READ }).description("list stored events, earliest first"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events list starts_at>=2026-11-02 starts_at<2026-11-07 --json", "events list ends_at>=today person:ana@example.com", "events list starts_at>=2026-11-02 --limit 50 --page 2")
205
+ .option("--limit <n>", "events per page, 1–1000", integer, 100)
206
+ .option("--page <n>", "which page, starting at 1", integer, 1)
206
207
  .action(async (words, opts, command) => {
207
208
  const filter = await eventsFilter(words, {
208
209
  store: deps.store,
@@ -211,7 +212,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
211
212
  now: now(),
212
213
  personOf: personId,
213
214
  });
214
- print(eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }), EVENT_ITEM_UNTRUSTED), command);
215
+ print(eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED), command);
215
216
  });
216
217
  example(annotate(events.command("show"), { effects: LOCAL_READ })
217
218
  .description("one event: its calendar copies, attendees with their person, Drive files and history")
@@ -225,49 +226,59 @@ const addCalendarCommandsDefinition = (program, deps) => {
225
226
  const search = example(annotate(searches.command("events"), { effects: deps.serverSearch ? LOCAL_OR_REMOTE_READ : LOCAL_READ })
226
227
  .description(deps.serverSearch
227
228
  ? "find stored events by words, and by meaning when its model is downloaded; with --backend server, ask " +
228
- "the provider instead"
229
+ "the provider instead, with --backend both, both"
229
230
  : "find stored events by words, and by meaning when its model is downloaded")
230
- .argument("<query...>", "what to find in titles, descriptions, places and attendees"), "search events roadmap planning --json", ...(deps.serverSearch ? ["search events --backend server quarterly review"] : []))
231
+ .argument("<query...>", "what to find in titles, descriptions, places and attendees"), "search events roadmap planning --json", ...(deps.serverSearch
232
+ ? ["search events --backend server quarterly review", "search events --backend both quarterly review"]
233
+ : []))
231
234
  .option("--limit <n>", "events per page", integer, 20)
232
- .option("--cursor <cursor>", "continue from a page's next_offset", (text) => (text === "0" ? 0 : integer(text)), 0)
235
+ .option("--cursor <cursor>", "go on from where the last page stopped; page.next gives it", (text) => (text === "0" ? 0 : integer(text)), 0)
233
236
  .option("--exact", "every word as written, no word stems")
234
237
  .option("--newest", "latest start first instead of best first");
235
238
  if (deps.serverSearch)
236
239
  search.addOption(new Option("--backend <archive|server|both>", "where to search: archive, the local store, or server, the provider's own search over the synced calendars, " +
237
- "earliest first")
240
+ "earliest first; both gives the store's hits, each the provider also found marked server, then the " +
241
+ "provider's other hits, one page with no cursor")
238
242
  .choices(["archive", "server", "both"])
239
243
  .default("archive"));
240
244
  search.action(async (words, opts, command) => {
241
- if (opts.backend === "both")
242
- throw new CliError("validation_error", "search events --backend both is not offered: the store's search and the provider's rank and page events " +
243
- "differently, so one merged list would have no single order or next page; run --backend archive and " +
244
- "--backend server", { reason: "search.one-backend" });
245
- if (opts.backend === "server") {
245
+ const archived = () => searchEvents(messages("search"), words.join(" "), {
246
+ accounts: [deps.account],
247
+ limit: opts.limit,
248
+ offset: opts.cursor,
249
+ timezone: zone(),
250
+ command: program.name(),
251
+ ...(opts.exact ? { exact: true } : {}),
252
+ ...(opts.newest ? { newest: true } : {}),
253
+ });
254
+ if (opts.backend === "server" || opts.backend === "both") {
255
+ const flag = `search events --backend ${opts.backend}`;
246
256
  if (command.optsWithGlobals().offline)
247
- throw new CliError("permission_error", "search events --backend server asks the provider; leave out --offline", {
257
+ throw new CliError("permission_error", `${flag} asks the provider; leave out --offline`, {
248
258
  reason: "search.offline",
249
259
  });
250
- if (opts.exact || opts.cursor || opts.newest)
251
- throw new CliError("validation_error", "search events --backend server takes neither --exact, --cursor nor --newest: the provider ranks and pages it", { reason: "search.server-options" });
252
- const found = await serverSearchAnswer(source(), deps.store, {
260
+ if (opts.backend === "server" && opts.exact)
261
+ throw new CliError("validation_error", `${flag} takes no --exact: the provider matches words its own way`, {
262
+ reason: "search.server-options",
263
+ });
264
+ if (opts.cursor || opts.newest)
265
+ throw new CliError("validation_error", `${flag} takes neither --cursor nor --newest: the provider ranks and pages its own hits`, { reason: "search.server-options" });
266
+ const served = await serverSearchAnswer(source(), deps.store, {
253
267
  accountId: deps.accountId,
254
268
  account: deps.account,
255
269
  query: words.join(" "),
256
270
  limit: opts.limit,
257
271
  });
258
- print({ ...found, untrusted: SERVER_SEARCH_UNTRUSTED }, command);
272
+ if (opts.backend === "server")
273
+ print({ ...served, untrusted: SERVER_SEARCH_UNTRUSTED }, command);
274
+ else
275
+ print({
276
+ ...(await bothSearchAnswer(deps.store, deps.accountId, await archived(), served, opts.limit)),
277
+ untrusted: SEARCH_UNTRUSTED,
278
+ }, command);
259
279
  return;
260
280
  }
261
- const found = await searchEvents(messages("search"), words.join(" "), {
262
- accounts: [deps.account],
263
- limit: opts.limit,
264
- offset: opts.cursor,
265
- timezone: zone(),
266
- command: program.name(),
267
- ...(opts.exact ? { exact: true } : {}),
268
- ...(opts.newest ? { newest: true } : {}),
269
- });
270
- print({ ...found, untrusted: SEARCH_UNTRUSTED }, command);
281
+ print({ ...(await archived()), untrusted: SEARCH_UNTRUSTED }, command);
271
282
  });
272
283
  if (deps.writes)
273
284
  addEventWrites(events, deps.writes, { deps, now, zone, print, example });
@@ -322,7 +333,7 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
322
333
  zone: zone(),
323
334
  names: {
324
335
  notify: "--notify or --no-notify",
325
- id: "--id",
336
+ id: "--operation-id",
326
337
  calendar: "--calendar",
327
338
  series: "--series",
328
339
  following: "--following",
@@ -362,10 +373,10 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
362
373
  .option("--calendar <id>", "the calendar, when the event is on more than one of the account's");
363
374
  example(notify(fields(write("create"), false)
364
375
  .description("add an event to a calendar; with --repeat, one that repeats")
365
- .argument("<calendar>", "the calendar's id, or the provider's id")).option("--id <id>", "the id a create that ended in outcome_unknown named: a retry finds that event"), "events create 3 --title 'Example planning' --start-time 2026-11-02T10:00 --end-time 2026-11-02T11:00", "events create 3 --title 'Example review' --start-date 2026-11-03 --end-date 2026-11-03 " +
376
+ .argument("<calendar>", "the calendar's id, or the provider's id")).option("--operation-id <id>", "the operation_id a create that ended in outcome_unknown named: a retry finds that event"), "events create 3 --title 'Example planning' --start-time 2026-11-02T10:00 --end-time 2026-11-02T11:00", "events create 3 --title 'Example review' --start-date 2026-11-03 --end-date 2026-11-03 " +
366
377
  "--attendee ana@example.com --no-notify", "events create 3 --title 'Example standup' --start-time 2026-11-02T09:30 --end-time 2026-11-02T09:45 " +
367
378
  "--repeat 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12'").action(async (calendar, opts, command) => {
368
- const { notify: choice, id, ...rest } = opts;
379
+ const { notify: choice, operationId: id, ...rest } = opts;
369
380
  printWritten(await createEvent(context(command), calendar, fieldsOf(rest), {
370
381
  ...(choice === undefined ? {} : { notify: choice }),
371
382
  ...(id === undefined ? {} : { id }),
@@ -377,8 +388,8 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
377
388
  .option("--series", "change the whole repeating event, not only this occurrence")
378
389
  .option("--following", "change this occurrence and every later one: the repeating event ends before it and a new one starts " +
379
390
  "with the change")
380
- .option("--id <id>", "with --following, the id an outcome_unknown named: a retry finds that new series")), "events update event:42 --start-time 2026-11-02T14:00 --end-time 2026-11-02T15:00 --notify", "events update event:42 --series --repeat 'FREQ=WEEKLY;BYDAY=TU;UNTIL=20261231T230000Z' --no-notify", "events update event:42 --following --start-time 2026-11-09T10:00 --end-time 2026-11-09T10:30 --no-notify").action(async (reference, opts, command) => {
381
- const { calendar: _calendar, series: _series, following: _following, notify: _notify, id, ...rest } = opts;
391
+ .option("--operation-id <id>", "with --following, the operation_id an outcome_unknown named: a retry finds that new series")), "events update event:42 --start-time 2026-11-02T14:00 --end-time 2026-11-02T15:00 --notify", "events update event:42 --series --repeat 'FREQ=WEEKLY;BYDAY=TU;UNTIL=20261231T230000Z' --no-notify", "events update event:42 --following --start-time 2026-11-09T10:00 --end-time 2026-11-09T10:30 --no-notify").action(async (reference, opts, command) => {
392
+ const { calendar: _calendar, series: _series, following: _following, notify: _notify, operationId: id, ...rest } = opts;
382
393
  printWritten(await updateEvent(context(command), reference, fieldsOf(rest), {
383
394
  ...choicesOf(opts),
384
395
  ...(id === undefined ? {} : { id }),
@@ -182,7 +182,8 @@ export declare const resolveAnswer: (store: EventStore, accountId: number, revie
182
182
  status: "apart" | "linked" | "open";
183
183
  chosen: string | null;
184
184
  }>;
185
- export declare const eventsAnswer: (store: EventStore, filter: EventFilter | null) => Promise<{
185
+ /** One page of the events, from 1: `filter.limit` events each. */
186
+ export declare const eventsAnswer: (store: EventStore, filter: EventFilter | null, number?: number) => Promise<{
186
187
  limit: number;
187
188
  hasMore: boolean;
188
189
  items: {
@@ -331,10 +331,11 @@ const overlapsOf = async (store, rows, filter) => {
331
331
  other.endsAt > event.startsAt),
332
332
  ]));
333
333
  };
334
- export const eventsAnswer = async (store, filter) => {
334
+ /** One page of the events, from 1: `filter.limit` events each. */
335
+ export const eventsAnswer = async (store, filter, number = 1) => {
335
336
  if (filter === null)
336
337
  return listed([]);
337
- const { rows, limit, hasMore } = await page(store, filter);
338
+ const { rows, limit, hasMore } = await page(store, { ...filter, offset: (number - 1) * (filter.limit ?? 100) });
338
339
  const changes = filter.changedSince === undefined ? null : await changesOf(store, rows, filter, filter.changedSince);
339
340
  const overlaps = filter.overlapping ? await overlapsOf(store, rows, filter) : null;
340
341
  return listed(rows.map((event) => {
@@ -350,5 +351,5 @@ export const eventsAnswer = async (store, filter) => {
350
351
  : {}),
351
352
  ...(overlaps ? { overlaps: (overlaps.get(event.id) ?? []).map(({ id }) => `event:${id}`) } : {}),
352
353
  };
353
- }), { limit, hasMore });
354
+ }), { limit, hasMore, page: number });
354
355
  };
@@ -1,4 +1,5 @@
1
1
  import type { EventStore } from "../meetings/index.js";
2
+ import type { EventsFound, FoundEvent } from "../services/events-search.js";
2
3
  import type { AccountKey } from "../store/store.js";
3
4
  import type { CalendarSource } from "./source.js";
4
5
  /**
@@ -29,3 +30,33 @@ export declare const serverSearchAnswer: (source: CalendarSource, store: EventSt
29
30
  }[];
30
31
  has_more: boolean;
31
32
  }>;
33
+ type FoundBy = FoundEvent["found_by"][number] | "server";
34
+ type ServerFound = Awaited<ReturnType<typeof serverSearchAnswer>>;
35
+ /**
36
+ * `--backend both`: the stored search's hits in its own order, each one the provider also found marked `server`, then
37
+ * what only the provider found, earliest first. One page and no cursor: the two sides rank and page differently.
38
+ * A provider hit is matched by its stored copy, so an occurrence the store keeps under another id shows twice.
39
+ */
40
+ export declare const bothSearchAnswer: (store: EventStore, accountId: number, archived: EventsFound, served: ServerFound, limit: number) => Promise<{
41
+ query: string;
42
+ by: "both";
43
+ meaning_skipped?: string | undefined;
44
+ hits: ({
45
+ provider: string;
46
+ account: string;
47
+ calendar: string;
48
+ external_id: string;
49
+ title: string | null;
50
+ starts_at: string | null;
51
+ ends_at: string | null;
52
+ all_day: boolean;
53
+ status: "confirmed" | "tentative";
54
+ location: string | null;
55
+ url?: string | undefined;
56
+ found_by: "server"[];
57
+ } | (Omit<FoundEvent, "found_by"> & {
58
+ found_by: FoundBy[];
59
+ }))[];
60
+ has_more: boolean;
61
+ }>;
62
+ export {};
@@ -39,3 +39,31 @@ export const serverSearchAnswer = async (source, store, { accountId, account, qu
39
39
  hits.sort((a, b) => (a.starts_at ?? "").localeCompare(b.starts_at ?? ""));
40
40
  return { query, by: "server", hits: hits.slice(0, limit), has_more: more || hits.length > limit };
41
41
  };
42
+ /**
43
+ * `--backend both`: the stored search's hits in its own order, each one the provider also found marked `server`, then
44
+ * what only the provider found, earliest first. One page and no cursor: the two sides rank and page differently.
45
+ * A provider hit is matched by its stored copy, so an occurrence the store keeps under another id shows twice.
46
+ */
47
+ export const bothSearchAnswer = async (store, accountId, archived, served, limit) => {
48
+ const calendars = new Map((await store.calendars(accountId)).map((calendar) => [calendar.externalId, calendar]));
49
+ const hits = archived.hits.map((hit) => ({ ...hit }));
50
+ const byRef = new Map(hits.map((hit) => [hit.ref, hit]));
51
+ const onlyServer = [];
52
+ for (const hit of served.hits) {
53
+ const calendar = calendars.get(hit.calendar);
54
+ const entry = calendar ? await store.entry(calendar.id, hit.external_id) : null;
55
+ const held = entry?.eventId == null ? undefined : byRef.get(`event:${entry.eventId}`);
56
+ if (held)
57
+ held.found_by = [...held.found_by, "server"];
58
+ else
59
+ onlyServer.push(hit);
60
+ }
61
+ const merged = [...hits, ...onlyServer];
62
+ return {
63
+ query: archived.query,
64
+ by: "both",
65
+ ...(archived.meaning_skipped === undefined ? {} : { meaning_skipped: archived.meaning_skipped }),
66
+ hits: merged.slice(0, limit),
67
+ has_more: archived.has_more || served.has_more || merged.length > limit,
68
+ };
69
+ };
@@ -40,7 +40,7 @@ export class BotTokenStore {
40
40
  return stored ? { token: stored.secret, source: stored.source } : undefined;
41
41
  }
42
42
  write(token) {
43
- return this.#credentials.write(this.#account, token);
43
+ return this.#credentials.write(this.#account, token, { signIn: true });
44
44
  }
45
45
  readKeyring() {
46
46
  const stored = this.readStored();
@@ -69,7 +69,7 @@ export class BotTokenStore {
69
69
  return this.#secretOf(name).read(`${this.#account}:${name}`);
70
70
  }
71
71
  writeSecret(name, secret) {
72
- return this.#secretOf(name).write(`${this.#account}:${name}`, secret);
72
+ return this.#secretOf(name).write(`${this.#account}:${name}`, secret, { signIn: true });
73
73
  }
74
74
  removeSecret(name) {
75
75
  return this.#secretOf(name).remove(`${this.#account}:${name}`);
@@ -12,6 +12,7 @@ import { JOBS_QUERY, jobItems } from "../../services/job-query.js";
12
12
  import { dateWindow } from "../../services/moment.js";
13
13
  import { validateCatchUpBounds } from "../../services/search-catchup.js";
14
14
  import { applyTaskRulesSinceLastPass } from "../../services/task-rules.js";
15
+ import { stepLines } from "../../terminal/step-lines.js";
15
16
  import { environmentOf } from "../context.js";
16
17
  import { destroysNothing, FETCH, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
17
18
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
@@ -41,7 +42,8 @@ export const syncCommand = (messenger) => {
41
42
  cooperative: true,
42
43
  })
43
44
  .summary("fetch and save a chat's message history")
44
- .description("bring a chat's history in the local store up to date, newest first; run it again to continue; --all syncs every chat")
45
+ .description("bring a chat's history in the local store up to date, newest first; run it again to continue; --all syncs every chat; " +
46
+ 'with --json, stderr carries progress lines: {"event":"progress","step":"sync","chats","chats_done","fetched"}, counts only')
45
47
  .argument("[chat]", messenger.chatArgument)
46
48
  .option("--all", `every chat, most recently active first — what search needs; the last ${ALL_SINCE} unless --since-time or --last`)
47
49
  .option("--limit <n>", `at most this many messages in this run, per chat with --all; ${fetching.maxPages * fetching.page} if not given`, wholeNumber)
@@ -139,6 +141,8 @@ export const syncCommand = (messenger) => {
139
141
  const jobs = jobsDir(messenger.app, context.env);
140
142
  const jobId = context.env[envName(messenger.app, "BACKFILL_JOB")];
141
143
  const stop = stopOnSignal(this);
144
+ // A background job keeps its log as it was; its progress is the job file.
145
+ const progress = syncProgress(stepLines(context.streams.diagnostic, !jobId && context.format !== "pretty"));
142
146
  try {
143
147
  const options = {
144
148
  limit,
@@ -149,10 +153,13 @@ export const syncCommand = (messenger) => {
149
153
  ...(last === undefined ? {} : { last }),
150
154
  note: context.renderer.note,
151
155
  stop: stop.signal,
152
- onPage: (progress) => {
156
+ onPage: (page) => {
153
157
  if (jobId)
154
- updateJob(jobs, jobId, { progress: { ...progress, at: new Date().toISOString() } });
158
+ updateJob(jobs, jobId, { progress: { ...page, at: new Date().toISOString() } });
159
+ if (chat !== undefined)
160
+ progress({ chats: null, chats_done: null, fetched: page.fetched });
155
161
  },
162
+ onChat: ({ chats, done, fetched }) => progress({ chats, chats_done: done, fetched }, done === 0 || done === chats),
156
163
  };
157
164
  const result = await context.withServices((services) => chat === undefined ? services.archive.fetchAll(options) : services.archive.fetch(chat, options));
158
165
  if (jobId)
@@ -197,6 +204,22 @@ export const syncCommand = (messenger) => {
197
204
  }
198
205
  });
199
206
  };
207
+ /** At most this often a progress line, so a run of fast pages or of chats already current does not flood the window. */
208
+ const PROGRESS_EVERY_MS = 500;
209
+ /**
210
+ * `messages sync`'s progress for a window to draw a bar by: counts only, because the lines reach a desktop app and
211
+ * its logs — never a chat's id or title. `always` keeps the first and the last line of `--all` through the throttle.
212
+ */
213
+ const syncProgress = (emit) => {
214
+ let last = Number.NEGATIVE_INFINITY;
215
+ return (counts, always = false) => {
216
+ const now = Date.now();
217
+ if (!always && now - last < PROGRESS_EVERY_MS)
218
+ return;
219
+ last = now;
220
+ emit({ event: "progress", step: "sync", ...counts });
221
+ };
222
+ };
200
223
  /**
201
224
  * What the sync stored, through the task rules (owner, 2026-10-14). A failure is noted, never the sync's failure:
202
225
  * the messages are already kept, and the next sync's pass starts from the same stop point.
@@ -33,7 +33,7 @@ export const embeddingKeys = (app, env = process.env, keyring) => {
33
33
  : undefined;
34
34
  return shared ? { key: shared, source: "env" } : undefined;
35
35
  },
36
- write: (provider, key) => credentials(provider).write(account(provider), key),
36
+ write: (provider, key) => credentials(provider).write(account(provider), key, { signIn: true }),
37
37
  remove: (provider) => credentials(provider).remove(account(provider)),
38
38
  };
39
39
  };
@@ -101,5 +101,7 @@ export interface MailSyncOptions {
101
101
  * is tried once more at the end of the run and, if it still fails, left for the next one. `max` defaults to no limit.
102
102
  */
103
103
  budget?: RunBudget;
104
+ /** Pauses before a round is read again after the store was locked; tests pass one that does not wait. */
105
+ wait?: (ms: number) => Promise<void>;
104
106
  }
105
107
  export declare const syncMail: (source: MailSource, store: MessageStore, options: MailSyncOptions) => Promise<MailSyncResult>;
package/dist/mail/sync.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { setTimeout } from "node:timers/promises";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { importEngine } from "../attachments/extract.js";
4
+ import { isBusy } from "../store/sqlite/transaction.js";
4
5
  import { addressOf, FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
5
6
  import { BULK, bulkBox } from "./bulk.js";
6
7
  import { factsOf } from "./facts.js";
@@ -26,6 +27,8 @@ const RESUME_FROM = 5_000;
26
27
  export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
27
28
  export { RETRYABLE_ATTACHMENTS } from "./retry.js";
28
29
  const LIST_SLICE = 200;
30
+ const STORE_BUSY_TRIES = 5;
31
+ const STORE_BUSY_WAIT_MS = 2_000;
29
32
  export const syncMail = async (source, store, options) => {
30
33
  const { budget, index } = options;
31
34
  if (budget === undefined || options.retryAttachments || options.account.mode === "mbox")
@@ -45,14 +48,31 @@ export const syncMail = async (source, store, options) => {
45
48
  asked: 0,
46
49
  };
47
50
  const rounds = [];
51
+ let busyWaits = 0;
48
52
  const goOn = async () => {
49
53
  for (;;) {
50
54
  plan.asked = 0;
51
55
  plan.remaining = 0;
56
+ const triedBefore = plan.tried.size;
52
57
  try {
53
58
  rounds.push(await syncRound(source, store, { ...options, index: undefined, max: options.max ?? Number.POSITIVE_INFINITY }, plan));
54
59
  }
55
60
  catch (error) {
61
+ // Another process (memo's scheduled sync) held the store's write lock past its busy timeout. The slices this
62
+ // run committed stay; the round's messages are asked for again after a pause, while the budget has room.
63
+ if (isBusy(error) && busyWaits < STORE_BUSY_TRIES && !options.signal?.aborted) {
64
+ busyWaits++;
65
+ for (const key of [...plan.tried].slice(triedBefore))
66
+ plan.tried.delete(key);
67
+ const stop = budget.take();
68
+ if (stop === null) {
69
+ await (options.wait ?? setTimeout)(STORE_BUSY_WAIT_MS * busyWaits);
70
+ continue;
71
+ }
72
+ plan.stopped = stop;
73
+ plan.remaining = Math.max(plan.remaining, 1);
74
+ return;
75
+ }
56
76
  // A request cut at the deadline, or failed after the budget was spent, is the run's end, not its failure: what
57
77
  // earlier rounds saved is kept and the next run goes on. While the budget has room the error is real.
58
78
  const transient = error instanceof CliError && (error.code === "timeout" || error.code === "network_error");
@@ -13,6 +13,7 @@ export declare const meetingShow: (store: MeetingStore, id: number, accountId?:
13
13
  export declare const listed: <T>(items: T[], page?: {
14
14
  limit: number;
15
15
  hasMore: boolean;
16
+ page?: number;
16
17
  }) => {
17
18
  limit: number;
18
19
  hasMore: boolean;
@@ -137,13 +137,13 @@ export const tokenKeeper = (options) => {
137
137
  const extra = (await accept?.(received)) ?? undefined;
138
138
  await locked(async () => {
139
139
  forget();
140
- storage.write({ tokens: received, client, ...(extra ? { extra } : {}) });
140
+ storage.write({ tokens: received, client, ...(extra ? { extra } : {}) }, { signIn: true });
141
141
  });
142
142
  return { scopes: received.scopes };
143
143
  },
144
144
  save: (saved, extra) => locked(async () => {
145
145
  forget();
146
- storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) });
146
+ storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) }, { signIn: true });
147
147
  }),
148
148
  signOut: ({ revoke = false } = {}) => locked(async () => {
149
149
  forget();
@@ -1,4 +1,4 @@
1
- import { type CredentialSource, type CredentialsOptions } from "@wirecat/cli-base";
1
+ import { type CredentialSource, type CredentialsOptions, type WriteOptions } from "@wirecat/cli-base";
2
2
  import type { OAuthClient, TokenSet } from "./provider.js";
3
3
  export interface StoredTokens {
4
4
  tokens: TokenSet;
@@ -29,7 +29,8 @@ export interface TokenStorage {
29
29
  /** Throws when the entry cannot be saved, so a sign-in fails before the browser, not after consent. */
30
30
  probe(): void;
31
31
  read(): StoredTokens | undefined;
32
- write(input: Omit<EncodeInput, "previous">): void;
32
+ /** `signIn` moves an entry the file holds back to the keyring when it takes it (WC-SECR-4); a refresh leaves it. */
33
+ write(input: Omit<EncodeInput, "previous">, options?: WriteOptions): void;
33
34
  remove(): CredentialSource[];
34
35
  }
35
36
  export declare const oauthAccount: (providerId: string, profile: string) => string;
@@ -162,9 +162,9 @@ export const credentialStorage = (options) => {
162
162
  throw keyringFailure(error);
163
163
  }
164
164
  };
165
- const save = (value) => {
165
+ const save = (value, writeOptions) => {
166
166
  try {
167
- credentials.write(account, value);
167
+ credentials.write(account, value, writeOptions);
168
168
  }
169
169
  catch (error) {
170
170
  throw keyringFailure(error, value);
@@ -195,7 +195,7 @@ export const credentialStorage = (options) => {
195
195
  const value = current();
196
196
  return value === undefined ? undefined : codec.decode(value);
197
197
  },
198
- write: (input) => save(codec.encode({ ...input, previous: raw(account) })),
198
+ write: (input, writeOptions) => save(codec.encode({ ...input, previous: raw(account) }), writeOptions),
199
199
  remove: () => {
200
200
  try {
201
201
  return credentials.remove(account);
@@ -39,6 +39,12 @@ export interface FetchOptions {
39
39
  chatId: string;
40
40
  oldest: number;
41
41
  }) => void;
42
+ /** `fetchAll` only: once the chat list is read, then after each chat, current or not; counts across every chat. */
43
+ onChat?: (progress: {
44
+ chats: number;
45
+ done: number;
46
+ fetched: number;
47
+ }) => void;
42
48
  }
43
49
  /** A type, not an interface: a job keeps it as a plain record. */
44
50
  export type Fetched = {
@@ -148,6 +148,8 @@ export const archiveService = (deps) => {
148
148
  ...options,
149
149
  catchUp: catchingUp ? (options.catchUp ?? {}) : false,
150
150
  });
151
+ const finished = () => options.onChat?.({ chats: chats.length, done: answer.items.length, fetched: answer.fetched });
152
+ finished();
151
153
  for (const chat of chats) {
152
154
  if (options.stop.aborted || batch.stopped) {
153
155
  answer.stopped = true;
@@ -155,6 +157,7 @@ export const archiveService = (deps) => {
155
157
  }
156
158
  if (await upToDate(chat)) {
157
159
  answer.items.push({ chat: chat.id, title: chat.title, fetched: 0, complete: true, current: true });
160
+ finished();
158
161
  continue;
159
162
  }
160
163
  try {
@@ -183,6 +186,7 @@ export const archiveService = (deps) => {
183
186
  const issue = batch.fail(chat.id, "history", error);
184
187
  answer.items.push({ chat: chat.id, title: chat.title, fetched: 0, complete: false, error: issue.code, issue });
185
188
  }
189
+ finished();
186
190
  }
187
191
  if (batch.failed > 0)
188
192
  answer.batch = batch.result();
@@ -1,4 +1,5 @@
1
1
  import type { CacheDatabase } from "../driver.js";
2
+ export declare const isBusy: (error: unknown) => boolean;
2
3
  /**
3
4
  * One write transaction. The outermost takes the write lock at once (`BEGIN IMMEDIATE`): a deferred one that
4
5
  * reads first fails with SQLITE_BUSY when another process writes in between, and tg, max, memo and zm share
@@ -3,7 +3,7 @@ import { drainMentions } from "./mention-drain.js";
3
3
  import { drainStems, stemmerCache } from "./stems.js";
4
4
  const SQLITE_BUSY = 5;
5
5
  const pause = new Int32Array(new SharedArrayBuffer(4));
6
- const isBusy = (error) => {
6
+ export const isBusy = (error) => {
7
7
  const { errcode, errno } = (error ?? {});
8
8
  const code = errcode ?? errno;
9
9
  return typeof code === "number" && (code & 0xff) === SQLITE_BUSY;
@@ -2,6 +2,7 @@ import { StringDecoder } from "node:string_decoder";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { Option } from "commander";
4
4
  import { MAX_SECRET_INPUT } from "../cli/input-policy.js";
5
+ import { stepLines } from "./step-lines.js";
5
6
  /**
6
7
  * Opt-in, because a read nobody answers hangs: an agent running `session start` in a shell must get the refusal it
7
8
  * gets today, and only a caller that holds stdin open and answers each `needs` line (WireCat Desktop) passes it.
@@ -10,10 +11,7 @@ export const answerStdinOption = () => new Option("--answer-stdin", "with --json
10
11
  export const signInSteps = ({ diagnostic, json, answers, input = process.stdin }) => {
11
12
  if (answers && !json)
12
13
  throw new CliError("validation_error", "--answer-stdin needs --json");
13
- const emit = (event) => {
14
- if (json)
15
- diagnostic(JSON.stringify(event));
16
- };
14
+ const emit = stepLines(diagnostic, json);
17
15
  const next = answers ? lineReader(input) : undefined;
18
16
  return {
19
17
  json,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Machine-readable step lines: one JSON object per line on the diagnostic stream, only in `--json` / `--jsonl`, so
3
+ * stdout keeps its single answer and a person at a terminal sees nothing new. Sign-in and `messages sync` share it.
4
+ */
5
+ export declare const stepLines: (diagnostic: (line: string) => void, json: boolean) => (event: Record<string, unknown> & {
6
+ event: string;
7
+ }) => void;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Machine-readable step lines: one JSON object per line on the diagnostic stream, only in `--json` / `--jsonl`, so
3
+ * stdout keeps its single answer and a person at a terminal sees nothing new. Sign-in and `messages sync` share it.
4
+ */
5
+ export const stepLines = (diagnostic, json) => (event) => {
6
+ if (json)
7
+ diagnostic(JSON.stringify(event));
8
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.506.0",
3
+ "version": "0.508.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",
@@ -247,7 +247,7 @@
247
247
  "@types/node": "^22.20.5",
248
248
  "@types/pngjs": "6.0.5",
249
249
  "@vitest/coverage-v8": "^5.0.5",
250
- "@wirecat/cli-base": "0.67.0",
250
+ "@wirecat/cli-base": "0.68.0",
251
251
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
252
252
  "commander": "15.0.0",
253
253
  "cspell": "10.4.0",
@@ -267,7 +267,7 @@
267
267
  },
268
268
  "peerDependencies": {
269
269
  "@napi-rs/canvas": ">=0.1.69 <2",
270
- "@wirecat/cli-base": ">=0.67.0 <0.68.0",
270
+ "@wirecat/cli-base": ">=0.68.0 <0.69.0",
271
271
  "commander": ">=15"
272
272
  },
273
273
  "bin": {