@wirecat/cli-core 0.507.0 → 0.509.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,57 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.509.0 — 31.10.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **The MCP agent log names the agent that made the call, not the server.** A row in `agent_actions` was always the
15
+ bot `<command>-mcp` (`tg-mcp`); it is now the client as it named itself (`clientInfo.name` from `initialize`, or the
16
+ per-request `io.modelcontextprotocol/clientInfo`), else the coding agent whose environment started a stdio server
17
+ (`claude-code`), else `<command>-mcp`. A filter on `tg-mcp` no longer finds new rows. The name is the client's own
18
+ claim: a label, never a permission. `callingAgent(fallback, caller?)` from `@wirecat/cli-core/mcp` gives the same
19
+ name to a CLI that runs its own server, and `Registration.log` receives the call's caller.
20
+ - **Any proposed or accepted fact can be corrected.** `facts.add({ supersedes })` took only a decision; it now takes a
21
+ fact of any type, and refuses one already superseded, reversed or rejected. When the new fact is accepted (at once,
22
+ for the owner's), the old one becomes `superseded` whether it was proposed or accepted; a proposed one used to stay
23
+ proposed.
24
+
25
+ ## 0.508.0 — 31.10.2026
26
+
27
+ ### Added
28
+
29
+ - **`events list --page <n>`: the next page of stored events.** `events list` said `hasMore` and had no way to read on;
30
+ it now takes `--page` from 1 with `--limit` events a page (100), and the answer's `page` is the number shown, so a
31
+ host's `listOf` can offer `page.next` (WC-OUT-5). `eventsAnswer` takes the page as a third argument.
32
+ - **`search events --backend both`** asks the store and the provider's own search together, where `--backend` is
33
+ offered (`deps.serverSearch`). The stored hits come first in the stored search's order, `found_by` gaining
34
+ `server` on each the provider also found; then the hits only the provider found, earliest first. One page, with no
35
+ `next_offset`; `has_more` when either side has more. It refuses `--offline`, `--cursor` and `--newest` as
36
+ `--backend server` does, and takes `--exact` for the stored side. `calendar_search_hit` allows `server` in a stored
37
+ hit's `found_by`. An occurrence the store keeps under another id than the provider's search names shows twice.
38
+
39
+ ### Changed — may break callers
40
+
41
+ - **`events create --id` and `events update --following --id` are now `--operation-id`** (WC-CMD-10, WC-OUT-10): the
42
+ value is the `operation_id` the answer and an `outcome_unknown` already name. `--id` is refused as an unknown
43
+ option; errors name `--operation-id`. The calendar MCP tools keep their `id` argument.
44
+ - **`search events` no longer refuses `--backend both`** with `search.one-backend`; see Added.
45
+
46
+ ### Fixed
47
+
48
+ - **`search events --cursor` help** said "a page's next_offset", a key the answer a host prints does not have; it now
49
+ says to go on from where the last page stopped, as `page.next` gives it.
50
+
51
+ - **A budgeted `syncMail` waits out a locked store.** When another process (memo's scheduled sync) holds the store's
52
+ 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
53
+ most 5 times, each a request of the budget) and reads the round again. When the budget has no room the run ends as
54
+ `partial`. `options.wait` lets a test skip the pause.
55
+ - **A bot token, a bot's named secret or an AI provider's key the owner sets again goes back to the keyring.** After
56
+ one keyring failure such a secret stayed in the private file for good, as cli-base 0.68.0 keeps a plain write where
57
+ the secret is. `BotTokenStore.write`, `writeSecret` and `embeddingKeys().write` now write with `{ signIn: true }`:
58
+ the keyring is tried and, once it takes the secret, the file copy is removed (WC-SECR-4). `writeKeyring` was
59
+ keyring-only already; the CAS key is only read here.
60
+
10
61
  ## 0.507.0 — 31.10.2026
11
62
 
12
63
  ### 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}`);
@@ -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");
@@ -1,5 +1,11 @@
1
1
  import { type RunCaller } from "../runs/run-context.js";
2
2
  export { currentCaller, withCaller } from "../runs/run-context.js";
3
+ /**
4
+ * The name the audit log keeps for the agent behind one call: the client as it named itself, else the coding agent
5
+ * whose environment started a stdio server, else `fallback`. A client's name is its own claim, a label and never a
6
+ * permission (MCP's `clientInfo` is self-reported).
7
+ */
8
+ export declare const callingAgent: (fallback: string, caller?: RunCaller | undefined) => string;
3
9
  /** The `_meta` key the every-profile server names the real caller under, for the profile server it passes a call to. */
4
10
  export declare const CALLER_META_KEY = "wirecat.dev/caller";
5
11
  /** The name the every-profile server gives itself in `initialize`; only a connection from it may name a caller. */
@@ -1,6 +1,18 @@
1
1
  import { CLIENT_INFO_META_KEY } from "@modelcontextprotocol/server";
2
- import { agentOf, clientHash } from "../runs/run-context.js";
2
+ import { agentOf, clientHash, currentCaller, } from "../runs/run-context.js";
3
3
  export { currentCaller, withCaller } from "../runs/run-context.js";
4
+ /**
5
+ * The name the audit log keeps for the agent behind one call: the client as it named itself, else the coding agent
6
+ * whose environment started a stdio server, else `fallback`. A client's name is its own claim, a label and never a
7
+ * permission (MCP's `clientInfo` is self-reported).
8
+ */
9
+ export const callingAgent = (fallback, caller = currentCaller()) => {
10
+ const client = caller?.agent?.client?.name;
11
+ if (client && client !== "unknown")
12
+ return client;
13
+ const kind = caller?.agent?.kind;
14
+ return kind && kind !== "mcp-client" ? kind : fallback;
15
+ };
4
16
  /** The `_meta` key the every-profile server names the real caller under, for the profile server it passes a call to. */
5
17
  export const CALLER_META_KEY = "wirecat.dev/caller";
6
18
  /** The name the every-profile server gives itself in `initialize`; only a connection from it may name a caller. */
@@ -2,6 +2,7 @@ export { mcpUrlOf, portOf, publicUrlOf } from "../http/public-url.js";
2
2
  export { type AgentAnswer, type AgentContext, type AgentProgram, type AgentTool, type CatalogueOptions, type CommandAvailability, runCommandForAgent, toolsFromCommands, } from "./agent.js";
3
3
  export { type AllProfilesOptions, allProfilesServer, type ChildSpec, serveAllProfilesOverHttp, serveAllProfilesOverStdio, } from "./all-profiles.js";
4
4
  export { type BotServerOptions, createBotServer, type RunBotCommand } from "./bot/server.js";
5
+ export { callingAgent } from "./caller.js";
5
6
  export { revokeAll } from "./http/oauth.js";
6
7
  export { type HttpOptions, MCP_PATH, serveOverHttp } from "./http/serve.js";
7
8
  export { httpLockFile } from "./http/state.js";
package/dist/mcp/index.js CHANGED
@@ -3,6 +3,7 @@ export { mcpUrlOf, portOf, publicUrlOf } from "../http/public-url.js";
3
3
  export { runCommandForAgent, toolsFromCommands, } from "./agent.js";
4
4
  export { allProfilesServer, serveAllProfilesOverHttp, serveAllProfilesOverStdio, } from "./all-profiles.js";
5
5
  export { createBotServer } from "./bot/server.js";
6
+ export { callingAgent } from "./caller.js";
6
7
  export { revokeAll } from "./http/oauth.js";
7
8
  export { MCP_PATH, serveOverHttp } from "./http/serve.js";
8
9
  export { httpLockFile } from "./http/state.js";
@@ -26,7 +26,7 @@ import { levelFor, readKeysForCommand } from "../sends/permissions.js";
26
26
  import { openStore } from "../store/store.js";
27
27
  import { runCommandForAgent, toolsFromCommands } from "./agent.js";
28
28
  import { agentLog } from "./agent-log.js";
29
- import { callerOf, withCaller } from "./caller.js";
29
+ import { callerOf, callingAgent, withCaller } from "./caller.js";
30
30
  import { mcpHttpMarker } from "./http/state.js";
31
31
  import { instructions } from "./instructions.js";
32
32
  import { registerPrompts } from "./prompts.js";
@@ -182,10 +182,10 @@ export const createServer = (command, context, messenger, sessionOptions) => {
182
182
  });
183
183
  const skill = messenger.skill ? skillResource(app, messenger.skill) : undefined;
184
184
  const log = agentLog(() => openStore({ env: context.env, command: app.command, version: app.version }));
185
- const audit = async (call) => {
185
+ const audit = async (call, caller) => {
186
186
  if (!recalledAccount(app, provider, settings.profile, context.env))
187
187
  return;
188
- await log.record({ actor: { bot: `${app.command}-mcp` }, ...call });
188
+ await log.record({ actor: { bot: callingAgent(`${app.command}-mcp`, caller) }, ...call });
189
189
  };
190
190
  const targets = judgedTargets(root);
191
191
  const passed = Object.fromEntries(Object.entries(environment).filter(([key]) => !PER_RUN.has(key)));
@@ -267,7 +267,7 @@ export const createServer = (command, context, messenger, sessionOptions) => {
267
267
  ...outcomeOf(result),
268
268
  startedAt,
269
269
  finishedAt: Date.now(),
270
- }).catch(() => undefined);
270
+ }, caller).catch(() => undefined);
271
271
  return result;
272
272
  },
273
273
  });
@@ -9,6 +9,7 @@ import type { WarmEmbedders } from "../embeddings/embed.js";
9
9
  import { type EntityName } from "../entities/index.js";
10
10
  import { type AppErrors } from "../errors/reasons.js";
11
11
  import type { MessengerAdapter } from "../ports/messenger.js";
12
+ import { type RunCaller } from "../runs/run-context.js";
12
13
  import type { SendGuard } from "../sends/guard.js";
13
14
  import { type PermissionKey } from "../sends/permissions.js";
14
15
  import { type Services } from "../services/index.js";
@@ -121,7 +122,7 @@ export interface Registration {
121
122
  /** A host's permission scope encloses local reads and online calls alike. */
122
123
  around?: <T>(name: string, definition: AnyTool, work: () => Promise<T>) => Promise<T>;
123
124
  /** Keeps one audit row per call — tool, tier, outcome, times — never the arguments or the answer. */
124
- log?: (call: ToolCall) => Promise<void>;
125
+ log?: (call: ToolCall, caller: RunCaller | undefined) => Promise<void>;
125
126
  /** The CLI's commands, by which an answer's next steps are judged safe to run unasked. */
126
127
  targets?: NonNullable<StepInput["targets"]>;
127
128
  /** How a call's caller is worked out on this connection (`callerOf`); without it the environment's agent is kept. */
package/dist/mcp/tool.js CHANGED
@@ -167,7 +167,7 @@ export const entryRunner = (registration) => {
167
167
  ...outcomeOf(result),
168
168
  startedAt,
169
169
  finishedAt: Date.now(),
170
- })?.catch(() => undefined);
170
+ }, caller)?.catch(() => undefined);
171
171
  return result;
172
172
  };
173
173
  };
@@ -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;
@@ -9,7 +9,7 @@ export interface Fact {
9
9
  ref: string;
10
10
  type: (typeof FACT_TYPES)[number];
11
11
  statement: string;
12
- /** Every fact is proposed or accepted; only a decision is also superseded or reversed. */
12
+ /** Proposed, then accepted or rejected; a correction supersedes it; only a decision is reversed. */
13
13
  status: (typeof FACT_STATUSES)[number];
14
14
  project: string | null;
15
15
  subject: string | null;
@@ -45,7 +45,7 @@ export interface FactInput {
45
45
  occurredAt?: string;
46
46
  evidence?: string[];
47
47
  scope?: Fact["scope"] & string;
48
- /** A decision only: the decision this one replaces, `superseded` once this one is accepted. */
48
+ /** The proposed or accepted fact this one corrects, `superseded` once this one is accepted. */
49
49
  supersedes?: string;
50
50
  /** The memory an agent drew it from: a `created-from` link. */
51
51
  createdFrom?: string;
@@ -60,7 +60,7 @@ export interface FactsStore {
60
60
  status?: Fact["status"];
61
61
  limit?: number;
62
62
  }): Promise<Fact[]>;
63
- /** The owner accepts a proposed fact; a decision it supersedes stops holding. */
63
+ /** The owner accepts a proposed fact; the fact it supersedes stops holding. */
64
64
  accept(reference: string): Promise<Fact>;
65
65
  /** An accepted decision no longer holds and nothing replaced it. */
66
66
  reverse(reference: string): Promise<Fact>;
@@ -45,7 +45,7 @@ export const factOperationsOver = (context) => {
45
45
  const accept = (id) => {
46
46
  const row = database.prepare("SELECT * FROM facts WHERE id = ?").get(id);
47
47
  if (row.status !== "proposed" && row.status !== "accepted")
48
- throw new CliError("validation_error", `a ${String(row.status)} decision cannot be accepted`);
48
+ throw new CliError("validation_error", `a ${String(row.status)} fact cannot be accepted`);
49
49
  const { actor } = authorActor(context, "owner");
50
50
  const at = now();
51
51
  database
@@ -53,7 +53,7 @@ export const factOperationsOver = (context) => {
53
53
  .run(actor.type, actor.id, at, id);
54
54
  if (row.supersedes_id != null)
55
55
  database
56
- .prepare("UPDATE facts SET status = 'superseded', updated_at = ? WHERE id = ? AND status = 'accepted'")
56
+ .prepare("UPDATE facts SET status = 'superseded', updated_at = ? WHERE id = ? AND status IN ('proposed', 'accepted')")
57
57
  .run(at, Number(row.supersedes_id));
58
58
  };
59
59
  return {
@@ -66,16 +66,14 @@ export const factOperationsOver = (context) => {
66
66
  const occurredAt = input.occurredAt === undefined ? null : calendarMoment(input.occurredAt, "occurredAt");
67
67
  if (occurredAt !== null && !Number.isFinite(occurredAt))
68
68
  throw new CliError("validation_error", "occurredAt takes an ISO timestamp");
69
- if (input.supersedes !== undefined && type !== "decision")
70
- throw new CliError("validation_error", "only a decision supersedes another");
71
69
  const id = write(database, () => {
72
70
  const project = input.project === undefined ? undefined : requiredThing(database, input.project, "the project");
73
71
  if (project && project.type !== "project")
74
72
  throw new CliError("validation_error", `${input.project} is not a project`);
75
73
  const subject = input.subject === undefined ? undefined : requiredThing(database, input.subject, "the subject");
76
74
  const replaced = input.supersedes === undefined ? undefined : rowOf(input.supersedes);
77
- if (replaced && replaced.type !== "decision")
78
- throw new CliError("validation_error", `${input.supersedes} is not a decision`);
75
+ if (replaced && replaced.status !== "proposed" && replaced.status !== "accepted")
76
+ throw new CliError("validation_error", `${input.supersedes} is ${String(replaced.status)}; only a proposed or accepted fact can be corrected`);
79
77
  const owner = input.by === "owner";
80
78
  const { actor, model } = authorActor(context, input.by);
81
79
  const at = now();
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.507.0",
3
+ "version": "0.509.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",