@wirecat/cli-core 0.551.0 → 0.552.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/dist/background/schedules.d.ts +12 -1
  4. package/dist/background/schedules.js +65 -24
  5. package/dist/background/units.d.ts +16 -9
  6. package/dist/background/units.js +26 -11
  7. package/dist/cli/context.d.ts +2 -0
  8. package/dist/cli/meeting-preparation-answer.d.ts +3 -0
  9. package/dist/cli/meeting-preparation-projects.d.ts +3 -0
  10. package/dist/cli/meeting-preparation-read.d.ts +3 -0
  11. package/dist/cli/messenger/search-answers.js +8 -1
  12. package/dist/cli/messenger/serve-catch-up.d.ts +43 -0
  13. package/dist/cli/messenger/serve-catch-up.js +90 -0
  14. package/dist/cli/messenger/serve-command.js +54 -1
  15. package/dist/cli/messenger/server-command.d.ts +4 -3
  16. package/dist/cli/messenger/server-command.js +30 -12
  17. package/dist/cli/program.js +9 -4
  18. package/dist/cli/schedule/schedule-command.js +5 -1
  19. package/dist/entities/hit.d.ts +5 -0
  20. package/dist/entities/hit.js +5 -0
  21. package/dist/entities/index.d.ts +27 -0
  22. package/dist/entities/listener.d.ts +6 -0
  23. package/dist/entities/listener.js +1 -0
  24. package/dist/entities/meeting-preparation.d.ts +3 -0
  25. package/dist/errors/reasons.js +1 -0
  26. package/dist/mcp/meeting-preparation.d.ts +3 -0
  27. package/dist/meetings/answers.d.ts +5 -0
  28. package/dist/meetings/answers.js +8 -0
  29. package/dist/meetings/cli/index.js +14 -0
  30. package/dist/meetings/index.d.ts +2 -1
  31. package/dist/meetings/index.js +1 -0
  32. package/dist/meetings/ingest.js +2 -1
  33. package/dist/meetings/language.d.ts +5 -0
  34. package/dist/meetings/language.js +57 -0
  35. package/dist/meetings/mcp/search.d.ts +3 -0
  36. package/dist/meetings/project-evidence-answer.d.ts +3 -0
  37. package/dist/meetings/result-items.d.ts +9 -0
  38. package/dist/meetings/result-items.js +7 -0
  39. package/dist/meetings/store.d.ts +22 -0
  40. package/dist/meetings/testing/memory.js +3 -0
  41. package/dist/meetings/transcription.d.ts +2 -0
  42. package/dist/meetings/transcription.js +5 -1
  43. package/dist/search/lucene/types.d.ts +7 -0
  44. package/dist/search/lucene/types.js +11 -1
  45. package/dist/services/messages-archive-search.js +3 -2
  46. package/dist/services/messages-combined.js +2 -2
  47. package/dist/services/messages-discovery.js +2 -2
  48. package/dist/services/notes-search.js +4 -5
  49. package/dist/services/search-all-meetings.d.ts +3 -0
  50. package/dist/services/search-all-meetings.js +6 -1
  51. package/dist/speech/local.d.ts +2 -0
  52. package/dist/speech/local.js +8 -1
  53. package/dist/speech/models.d.ts +2 -0
  54. package/dist/speech/models.js +2 -0
  55. package/dist/store/driver.d.ts +11 -0
  56. package/dist/store/driver.js +13 -0
  57. package/dist/store/drivers/node-sqlite.d.ts +1 -1
  58. package/dist/store/drivers/node-sqlite.js +3 -0
  59. package/dist/store/sqlite/lucene.d.ts +1 -1
  60. package/dist/store/sqlite/lucene.js +33 -7
  61. package/dist/store/sqlite/meeting-hit-search.js +10 -3
  62. package/dist/store/sqlite/meeting-languages.d.ts +4 -0
  63. package/dist/store/sqlite/meeting-languages.js +36 -0
  64. package/dist/store/sqlite/meetings.d.ts +2 -1
  65. package/dist/store/sqlite/meetings.js +9 -3
  66. package/dist/store/sqlite/note-search.js +2 -2
  67. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,65 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.552.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`serve` reads back on start what arrived while nothing listened.** A provider's own catch-up drops a gap it
15
+ finds too long (Telegram's `differenceTooLong`, and `channelDifferenceTooLong` past 100 updates in a channel), so
16
+ 30 seconds after connecting `serve` lists the chats once and reads each chat whose newest message is newer than the
17
+ newest one held from before it started back to that one; a chat never held, back to the newest message held
18
+ anywhere. At most 7 days back, 500 messages a chat and 5 minutes; an empty store is left to `messages sync --all`,
19
+ and a messenger that pushes its history (`history: "store"`) is skipped. The summary gains
20
+ `caught_up: { chats, fetched, failed, stopped }`; the log gains `start_sync`, `start_sync_chat_failed` and
21
+ `start_sync_failed`.
22
+ - **A meeting transcript knows its language.** `transcriptInput` (provider fetches and imports) and local
23
+ transcription fill `language` instead of `null`: a one-language speech model gives its own (`gigaam-v3` and
24
+ `gigaam-v3-ctc` write Russian, `LocalSpeechRecognitionModel.language`), any other transcript is read from its
25
+ letters — Cyrillic is `ru` (`uk` when Ukrainian letters show), Latin is told apart by frequent words (`en`, `es`,
26
+ `de`, `fr`, `it`, `pt`), and a mixed, short or undecided one stays `null`. A meeting that is mostly Russian with a
27
+ few English lines is `ru`. `transcriptLanguage(texts)` is exported for providers that build their own transcripts.
28
+ - **Meeting search hits say the transcript's language and where in it the line is**: `language`, `line` (from 1) and
29
+ `lines_total` on transcript hits of `search meetings` and `search all` ("line 161 of 192").
30
+ - **`meetings repair`** previews filling what older builds left out of stored transcripts — their language, for
31
+ transcripts that have none; `--yes` stores it (`meeting_repair`, `MeetingStore.fillTranscriptLanguages`). Nothing is
32
+ written on a read.
33
+
34
+ ### Changed — may break callers
35
+
36
+ - **A search that runs out of time fails with `timeout` and the reason `query.time-limit`**, no longer with
37
+ `validation_error`, `query.too-large` and `budget: "time"` or `"milliseconds"`. The message says the query is valid
38
+ and the time limit was reached. The details keep `budget: "time"` and `complete: false`, and add `limit_ms`. A caller
39
+ that told the person to change the query on `query.too-large` no longer does it for a slow search.
40
+ - **`server install` runs `systemctl --user` (or `launchctl enable`)**, and fails where that does not work, after it
41
+ wrote the file. **Under launchd `server stop` only unloads the agent**: it starts again at the next login, as a
42
+ systemd unit does; `server uninstall` stops that. `Platform` (`@wirecat/cli-core/background`) drops `atLogin` and
43
+ requires `install` and `uninstall`; its `state` may answer `enabled`.
44
+
45
+ ### Fixed
46
+
47
+ - **A search stops at its time limit even inside one long SQLite statement.** A statement runs to its end before any
48
+ timer can fire, so a search over a large store ran far past the limit: 66 s against a 2 s limit, and past a
49
+ command's `--timeout 60s`. Under Node the message search statements now carry a check of the limit on every row
50
+ (the SQL function `wc_time_limit`) and stop when it passes. Bun has no SQL functions; there the limit is still
51
+ checked only between statements.
52
+ - **`server install` enables the unit, so serve starts with the owner's session after a reboot.** It wrote the unit
53
+ and never enabled it, and after a reboot nothing started. Under systemd it runs `systemctl --user enable` (still
54
+ `WantedBy=default.target`, so it also starts at boot under linger and over ssh); under launchd it clears a
55
+ `launchctl disable` and the agent no longer carries `Disabled`. Nothing starts now; `server start` does that.
56
+ A unit written with moved folders (a development checkout) is not enabled, so a branch build never starts at
57
+ login on the owner's keyring. `server uninstall` disables before it removes the file. `server status` answers
58
+ `starts_at_login` and `unit.enabled`. A unit installed before this needs `server install` once more.
59
+ - **A timer that fires before the login unlocks the keyring tries again.** A daily or one-off schedule that catches up
60
+ at boot ran before the keyring was unlocked and failed for the day. `schedule exec` now runs it again when it fails
61
+ with reason `credentials.unreachable` (tg and max give it when a profile that has logged in here cannot read its
62
+ sign-in): a minute later, then up to 15 minutes apart, for up to 12 hours, and notifies once, with how it ended.
63
+ `schedule run` and repeating schedules do not wait. `runSchedule` takes `untilUnlocked` and `wait` for this.
64
+ - **A sync whose reader goes away finishes its work.** A closed stdout or stderr (EPIPE) aborted every command, so a
65
+ desktop that closed the pipe ended an 18-minute `messages sync` with `write EPIPE`. A cooperative command (a sync)
66
+ and a command waiting in an `untilStopped()` phase (`schedule run`, `schedule exec`) now keep working, their later
67
+ output goes nowhere, and the run record says how they ended. Other commands still end with 0, as `| head` expects.
68
+
10
69
  ## 0.551.0 — 02.11.2026
11
70
 
12
71
  ### Added
package/README.md CHANGED
@@ -172,7 +172,7 @@ run a read, run a write — so an agent's context does not fill with a hundred t
172
172
  over HTTP on 127.0.0.1 behind the owner's tunnel, with a one-owner OAuth login.
173
173
 
174
174
  `serve` keeps the store current, one process per profile by a lock file; `server install` adds a systemd user unit
175
- or a launchd agent.
175
+ or a launchd agent that starts at every login.
176
176
 
177
177
  ### Every write behind a guard
178
178
 
@@ -97,14 +97,25 @@ export declare const timerFor: (app: AppIdentity, system: ServerSystem, env: Nod
97
97
  export declare const notify: (system: ServerSystem, env: NodeJS.ProcessEnv, title: string, body: string) => Promise<boolean>;
98
98
  /** Finds a program on PATH, as the timer will not have the shell's PATH to find it by. */
99
99
  export declare const findProgram: (name: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => string | undefined;
100
+ /**
101
+ * The reason tg and max give when a profile that has logged in here cannot read its sign-in: the keyring is
102
+ * locked or out of reach, which a later try fixes and a new login does not.
103
+ */
104
+ export declare const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
100
105
  /**
101
106
  * One run: the command or the agent with the schedule's variables, its output kept beside the schedule,
102
107
  * the last result in `last.json`. An agent runs under `<PREFIX>_READ_ONLY`, so nothing it is talked
103
108
  * into can send. A one-off removes itself afterwards.
109
+ *
110
+ * `untilUnlocked` is for the timer's run: a timer that catches up at boot runs before the login unlocks the
111
+ * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
112
+ * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
104
113
  */
105
- export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, }?: {
114
+ export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, }?: {
106
115
  now?: () => Date;
107
116
  timer?: Timer;
108
117
  signal?: AbortSignal;
109
118
  killAfterMs?: number;
119
+ untilUnlocked?: boolean;
120
+ wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
110
121
  }) => Promise<LastRun>;
@@ -328,16 +328,79 @@ export const findProgram = (name, env, platform) => {
328
328
  const KEEP_RUNS = 30;
329
329
  /** How long a stopped child has to save and answer after SIGTERM before it is killed. */
330
330
  const KILL_AFTER_MS = 10_000;
331
+ /**
332
+ * The reason tg and max give when a profile that has logged in here cannot read its sign-in: the keyring is
333
+ * locked or out of reach, which a later try fixes and a new login does not.
334
+ */
335
+ export const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
336
+ const LOCKED_FIRST_WAIT_MS = 60_000;
337
+ const LOCKED_LONGEST_WAIT_MS = 15 * 60_000;
338
+ /** A night's catch-up waits for the morning's login; past that the run fails as it is. */
339
+ const LOCKED_GIVE_UP_MS = 12 * 60 * 60_000;
340
+ const lockedOut = (last) => last.reason?.endsWith(`(${CREDENTIALS_UNREACHABLE})`) === true;
341
+ const pause = (ms, signal) => new Promise((resolve) => {
342
+ if (signal?.aborted)
343
+ return resolve();
344
+ const done = () => {
345
+ clearTimeout(timer);
346
+ signal?.removeEventListener("abort", done);
347
+ resolve();
348
+ };
349
+ const timer = setTimeout(done, ms);
350
+ signal?.addEventListener("abort", done, { once: true });
351
+ });
331
352
  /**
332
353
  * One run: the command or the agent with the schedule's variables, its output kept beside the schedule,
333
354
  * the last result in `last.json`. An agent runs under `<PREFIX>_READ_ONLY`, so nothing it is talked
334
355
  * into can send. A one-off removes itself afterwards.
356
+ *
357
+ * `untilUnlocked` is for the timer's run: a timer that catches up at boot runs before the login unlocks the
358
+ * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
359
+ * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
335
360
  */
336
- export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, } = {}) => {
361
+ export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, } = {}) => {
337
362
  const schedule = readSchedule(file);
338
363
  const folder = join(file, "..");
339
364
  const runs = join(folder, "runs");
340
365
  mkdirSync(runs, { recursive: true, mode: 0o700 });
366
+ const agent = schedule.kind === "agent";
367
+ const retries = untilUnlocked && !("every" in schedule.when);
368
+ const firstAt = now().getTime();
369
+ let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
370
+ for (let delay = LOCKED_FIRST_WAIT_MS; retries && lockedOut(last) && !signal?.aborted && now().getTime() - firstAt + delay <= LOCKED_GIVE_UP_MS; delay = Math.min(delay * 2, LOCKED_LONGEST_WAIT_MS)) {
371
+ await wait(delay, signal);
372
+ if (signal?.aborted)
373
+ break;
374
+ last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
375
+ }
376
+ const { exitCode, output } = last;
377
+ const errors = agent ? output.replace(/\.md$/, ".log") : output;
378
+ if (agent && exitCode === 0)
379
+ copyFileSync(output, join(folder, "latest.md"));
380
+ const stampOf = (file) => file.split(".")[0] ?? file;
381
+ const kept = new Set([...new Set(readdirSync(runs).map(stampOf))].sort().slice(-KEEP_RUNS));
382
+ for (const file of readdirSync(runs))
383
+ if (!kept.has(stampOf(file)))
384
+ rmSync(join(runs, file), { force: true });
385
+ const title = `${app.command} ${schedule.name}`;
386
+ if (agent && exitCode === 0) {
387
+ const first = readFileSync(output, "utf8")
388
+ .split("\n")
389
+ .find((line) => line.trim()) ?? "report ready";
390
+ await notify(system, env, title, `${first.trim().slice(0, 180)}\n${join(folder, "latest.md")}`);
391
+ }
392
+ else if (exitCode !== 0) {
393
+ await notify(system, env, title, `failed (exit ${exitCode}) — ${errors}`);
394
+ }
395
+ if ("once" in schedule.when) {
396
+ await timer?.remove(schedule.name);
397
+ rmSync(file, { force: true });
398
+ }
399
+ return last;
400
+ };
401
+ /** One run of the schedule's command or agent: its output in `runs/`, its result in `last.json`. */
402
+ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs }) => {
403
+ const runs = join(folder, "runs");
341
404
  const stamp = now()
342
405
  .toISOString()
343
406
  .replaceAll(":", "-")
@@ -398,33 +461,11 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
398
461
  exitCode,
399
462
  durationMs: ended.getTime() - began,
400
463
  });
401
- const last = recordRun(join(folder, "last.json"), {
464
+ return recordRun(join(folder, "last.json"), {
402
465
  at: ended.toISOString(),
403
466
  exitCode,
404
467
  output,
405
468
  traceId: trace.traceId,
406
469
  ...(exitCode === 0 ? {} : failureOf(readFileSync(errors, "utf8"), exitCode)),
407
470
  });
408
- if (agent && exitCode === 0)
409
- copyFileSync(output, join(folder, "latest.md"));
410
- const stampOf = (file) => file.split(".")[0] ?? file;
411
- const kept = new Set([...new Set(readdirSync(runs).map(stampOf))].sort().slice(-KEEP_RUNS));
412
- for (const file of readdirSync(runs))
413
- if (!kept.has(stampOf(file)))
414
- rmSync(join(runs, file), { force: true });
415
- const title = `${app.command} ${schedule.name}`;
416
- if (agent && exitCode === 0) {
417
- const first = readFileSync(output, "utf8")
418
- .split("\n")
419
- .find((line) => line.trim()) ?? "report ready";
420
- await notify(system, env, title, `${first.trim().slice(0, 180)}\n${join(folder, "latest.md")}`);
421
- }
422
- else if (exitCode !== 0) {
423
- await notify(system, env, title, `failed (exit ${exitCode}) — ${errors}`);
424
- }
425
- if ("once" in schedule.when) {
426
- await timer?.remove(schedule.name);
427
- rmSync(file, { force: true });
428
- }
429
- return last;
430
471
  };
@@ -33,21 +33,28 @@ export interface Platform {
33
33
  text: (unit: Unit, app: AppIdentity) => string;
34
34
  start: (unit: Unit) => string[][];
35
35
  stop: (unit: Unit) => string[][];
36
- state: (unit: Unit) => Promise<{
37
- loaded: boolean;
38
- active: boolean;
39
- pid?: number;
40
- detail?: string;
41
- exitCode?: number;
42
- }>;
36
+ state: (unit: Unit) => Promise<UnitState>;
43
37
  logs: (unit: Unit, lines: number) => Promise<string>;
44
38
  /** What must exist before the unit first runs. */
45
39
  prepare?: (unit: Unit) => void;
46
- /** How to have it start at every login too. */
47
- atLogin: (unit: Unit) => string;
40
+ /** Run once the file is written: the unit starts with every login from then on, and nothing starts now. */
41
+ install: (unit: Unit) => string[][];
42
+ /** Run before the file is removed: it no longer starts at login. */
43
+ uninstall: (unit: Unit) => string[][];
44
+ }
45
+ export interface UnitState {
46
+ loaded: boolean;
47
+ active: boolean;
48
+ /** It starts with the owner's session: at login, or at boot when systemd keeps the user's services (linger). */
49
+ enabled?: boolean;
50
+ pid?: number;
51
+ detail?: string;
52
+ exitCode?: number;
48
53
  }
49
54
  export declare const systemd: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv) => Platform;
50
55
  export declare const launchd: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv) => Platform;
56
+ /** `launchctl print-disabled` lists overrides as `"label" => disabled` (older macOS: `=> true`); none is enabled. */
57
+ export declare const launchdEnabled: (listing: string, label: string) => boolean;
51
58
  /**
52
59
  * serve's log lines for a person: a log record as `time severity body`, anything else — a line from an
53
60
  * older serve, the summary it prints on stop — as it is. A journal's own prefix is kept.
@@ -61,9 +61,16 @@ export const systemd = (app, system, env) => {
61
61
  ...(unit.noRestartOn?.length ? [`RestartPreventExitStatus=${unit.noRestartOn.join(" ")}`] : []),
62
62
  "",
63
63
  "[Install]",
64
+ // Not graphical-session.target: a desktop without systemd's session targets, or a machine reached only
65
+ // over ssh, would never start it. A keyring still locked at boot ends serve with a code that restarts.
64
66
  "WantedBy=default.target",
65
67
  "",
66
68
  ].join("\n"),
69
+ install: (unit) => [
70
+ ["systemctl", "--user", "daemon-reload"],
71
+ ["systemctl", "--user", "enable", unit.name],
72
+ ],
73
+ uninstall: (unit) => [["systemctl", "--user", "disable", unit.name]],
67
74
  start: (unit) => [
68
75
  ["systemctl", "--user", "daemon-reload"],
69
76
  ["systemctl", "--user", "start", unit.name],
@@ -87,6 +94,8 @@ export const systemd = (app, system, env) => {
87
94
  "ExecMainCode",
88
95
  "-p",
89
96
  "ExecMainStatus",
97
+ "-p",
98
+ "UnitFileState",
90
99
  ], env);
91
100
  const fields = Object.fromEntries(ran.stdout
92
101
  .split("\n")
@@ -98,13 +107,13 @@ export const systemd = (app, system, env) => {
98
107
  return {
99
108
  loaded: fields.LoadState === "loaded",
100
109
  active: fields.ActiveState === "active",
110
+ ...(fields.UnitFileState ? { enabled: fields.UnitFileState === "enabled" } : {}),
101
111
  ...(pid > 0 ? { pid } : {}),
102
112
  ...(exitCode > 0 ? { exitCode } : {}),
103
113
  ...(fields.ActiveState ? { detail: `${fields.ActiveState} (${fields.SubState})` } : {}),
104
114
  };
105
115
  },
106
116
  logs: (unit, lines) => checked(["journalctl", "--user", "-u", unit.name, "-n", String(lines), "--no-pager"]),
107
- atLogin: (unit) => `at every login too: systemctl --user enable ${unit.name.replace(/\.service$/, "")}`,
108
117
  };
109
118
  };
110
119
  const xml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
@@ -130,8 +139,6 @@ export const launchd = (app, system, env) => {
130
139
  " <dict>",
131
140
  ...Object.entries(unit.environment).map(([name, value]) => ` <key>${xml(name)}</key><string>${xml(value)}</string>`),
132
141
  " </dict>",
133
- // launchd loads every agent here at login; disabled, it waits for `server start` to enable it.
134
- " <key>Disabled</key><true/>",
135
142
  // biome-ignore lint/security/noSecrets: launchd plist template, not a key
136
143
  " <key>RunAtLoad</key><true/>",
137
144
  ...(unit.noRestartOn?.length ? [] : [" <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>"]),
@@ -142,25 +149,27 @@ export const launchd = (app, system, env) => {
142
149
  "</plist>",
143
150
  "",
144
151
  ].join("\n"),
145
- // RunAtLoad makes loading the agent the start; `enable` overrides the file's Disabled until `disable`.
152
+ // launchd loads every agent in the folder at login, RunAtLoad starts it; `enable` clears a `disable` left by
153
+ // an older stop or uninstall, which would outrank the file.
154
+ install: (unit) => [["launchctl", "enable", `${domain}/${unit.name}`]],
155
+ uninstall: (unit) => [["launchctl", "disable", `${domain}/${unit.name}`]],
146
156
  start: (unit) => [
147
157
  ["launchctl", "enable", `${domain}/${unit.name}`],
148
158
  ["launchctl", "bootstrap", domain, unit.path],
149
159
  ],
150
- stop: (unit) => [
151
- ["launchctl", "bootout", `${domain}/${unit.name}`],
152
- ["launchctl", "disable", `${domain}/${unit.name}`],
153
- ],
160
+ stop: (unit) => [["launchctl", "bootout", `${domain}/${unit.name}`]],
154
161
  state: async (unit) => {
162
+ const enabled = launchdEnabled((await system.run(["launchctl", "print-disabled", domain], env)).stdout, unit.name);
155
163
  const ran = await system.run(["launchctl", "print", `${domain}/${unit.name}`], env);
156
164
  if (ran.code !== 0)
157
- return { loaded: false, active: false };
165
+ return { loaded: false, active: false, enabled };
158
166
  const state = /^\s*state = (.+)$/m.exec(ran.stdout)?.[1]?.trim();
159
167
  const pid = Number(/^\s*pid = (\d+)$/m.exec(ran.stdout)?.[1]);
160
168
  const exitCode = Number(/^\s*last exit code = (\d+)/m.exec(ran.stdout)?.[1]);
161
169
  return {
162
170
  loaded: true,
163
171
  active: state === "running",
172
+ enabled,
164
173
  ...(pid > 0 ? { pid } : {}),
165
174
  ...(exitCode > 0 ? { exitCode } : {}),
166
175
  ...(state ? { detail: state } : {}),
@@ -172,9 +181,14 @@ export const launchd = (app, system, env) => {
172
181
  mkdirSync(dirname(unit.logPath), { recursive: true, mode: 0o700 });
173
182
  },
174
183
  logs: async (unit, lines) => tail(unit.logPath, lines),
175
- atLogin: (unit) => `from then on it also starts at every login, until \`${app.command} ${unit.profile} server stop\``,
176
184
  };
177
185
  };
186
+ /** `launchctl print-disabled` lists overrides as `"label" => disabled` (older macOS: `=> true`); none is enabled. */
187
+ export const launchdEnabled = (listing, label) => {
188
+ const quoted = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
189
+ const value = new RegExp(`"${quoted}"\\s*=>\\s*(\\w+)`).exec(listing)?.[1];
190
+ return value !== "disabled" && value !== "true";
191
+ };
178
192
  /**
179
193
  * serve's log lines for a person: a log record as `time severity body`, anything else — a line from an
180
194
  * older serve, the summary it prints on stop — as it is. A journal's own prefix is kept.
@@ -207,9 +221,10 @@ export const noUnits = (app, system) => {
207
221
  text: refuse,
208
222
  start: refuse,
209
223
  stop: refuse,
224
+ install: refuse,
225
+ uninstall: refuse,
210
226
  state: async () => ({ loaded: false, active: false }),
211
227
  logs: async () => "",
212
- atLogin: () => "",
213
228
  };
214
229
  };
215
230
  export const platformFor = (app, system, env) => {
@@ -64,6 +64,8 @@ export interface BaseEnvironment {
64
64
  * message was handled, and nothing was sent" instead of on the next event or a sleep.
65
65
  */
66
66
  handled?: (event: MessageEvent) => void;
67
+ /** A test's hook into `serve`: how long it waits after connecting before its catch-up pass. */
68
+ startSyncDelayMs?: number;
67
69
  /** Set by an MCP server on a command it runs for an agent (`runCommandForAgent`). */
68
70
  agent?: AgentRun;
69
71
  /** Set by `run()`: the words it was given, which a run keeps as `record_input` says (`maskArgv`). */
@@ -309,6 +309,9 @@ export declare const meetingPreparationAnswer: (packet: MeetingPreparation) => i
309
309
  id: string;
310
310
  excerpt: string;
311
311
  offset_ms: number | null;
312
+ language?: string | null | undefined;
313
+ line?: number | undefined;
314
+ lines_total?: number | undefined;
312
315
  match?: "corrected" | "words" | undefined;
313
316
  corrections?: {
314
317
  from: string;
@@ -29,6 +29,9 @@ export declare const mapPreparationProjectEvidence: (section: NonNullable<Meetin
29
29
  id: string;
30
30
  excerpt: string;
31
31
  offset_ms: number | null;
32
+ language?: string | null | undefined;
33
+ line?: number | undefined;
34
+ lines_total?: number | undefined;
32
35
  match?: "corrected" | "words" | undefined;
33
36
  corrections?: {
34
37
  from: string;
@@ -338,6 +338,9 @@ export declare const readMeetingPreparation: (read: MeetingPreparationReader, in
338
338
  id: string;
339
339
  excerpt: string;
340
340
  offset_ms: number | null;
341
+ language?: string | null | undefined;
342
+ line?: number | undefined;
343
+ lines_total?: number | undefined;
341
344
  match?: "corrected" | "words" | undefined;
342
345
  corrections?: {
343
346
  from: string;
@@ -293,7 +293,14 @@ export const allHitItem = (item) => item.kind === "meeting"
293
293
  offset_ms: item.startMs,
294
294
  scope: item.scope,
295
295
  id: String(item.id),
296
- ...(item.cueRef === undefined ? {} : { cue_ref: item.cueRef, sender_name: item.speakerName ?? null }),
296
+ ...(item.cueRef === undefined
297
+ ? {}
298
+ : {
299
+ cue_ref: item.cueRef,
300
+ sender_name: item.speakerName ?? null,
301
+ language: item.language ?? null,
302
+ ...(item.line === undefined ? {} : { line: item.line, lines_total: item.linesTotal }),
303
+ }),
297
304
  untrusted: ALL_HIT_UNTRUSTED.meeting,
298
305
  }
299
306
  : defined({
@@ -0,0 +1,43 @@
1
+ import type { Chat } from "../../domain/models.js";
2
+ import type { ServeNotice } from "./serve-notice.js";
3
+ /** The provider's own catch-up lands first; this pass then finds most chats already current. */
4
+ export declare const START_SYNC_DELAY_MS = 30000;
5
+ export declare const START_SYNC_BUDGET_MS: number;
6
+ /** A longer downtime is `messages sync --all`'s job, not a start's. */
7
+ export declare const START_SYNC_WINDOW_MS: number;
8
+ export declare const START_SYNC_PER_CHAT = 500;
9
+ export interface StartSyncSummary {
10
+ chats: number;
11
+ fetched: number;
12
+ failed: number;
13
+ stopped: boolean;
14
+ }
15
+ export interface StartSync {
16
+ /** Runs the pass once, after the delay; a second call is ignored. */
17
+ start(): void;
18
+ /** Aborts the pass and waits for it, which stops before its next request. */
19
+ stop(): Promise<void>;
20
+ summary(): StartSyncSummary | undefined;
21
+ }
22
+ /**
23
+ * `serve`'s one pass on start over the chats that moved while nothing listened. A provider's own
24
+ * catch-up drops a gap it finds too long (Telegram's `differenceTooLong`, `channelDifferenceTooLong`
25
+ * past 100 updates), so each chat whose newest message is newer than the newest one held is read back
26
+ * to that one. A chat never held is read back to the newest message held anywhere, which is about when
27
+ * the last serve stopped. Bounded by a window, a per-chat limit and a time budget; an empty store is
28
+ * left to `messages sync --all`.
29
+ */
30
+ export declare const startSync: ({ chats, newestHeld, fetch, note, warn, delayMs, budgetMs, windowMs, now, }: {
31
+ chats: (stop: AbortSignal) => Promise<Chat[]>;
32
+ /** The newest message held from before this serve started, epoch milliseconds. */
33
+ newestHeld: (chatId: string) => Promise<number | undefined>;
34
+ fetch: (chatId: string, sinceMs: number, stop: AbortSignal) => Promise<{
35
+ fetched: number;
36
+ }>;
37
+ note: (notice: ServeNotice) => void;
38
+ warn: (notice: ServeNotice) => void;
39
+ delayMs?: number;
40
+ budgetMs?: number;
41
+ windowMs?: number;
42
+ now?: () => number;
43
+ }) => StartSync;
@@ -0,0 +1,90 @@
1
+ import { failureKey } from "../../errors/failures.js";
2
+ /** The provider's own catch-up lands first; this pass then finds most chats already current. */
3
+ export const START_SYNC_DELAY_MS = 30_000;
4
+ export const START_SYNC_BUDGET_MS = 5 * 60_000;
5
+ /** A longer downtime is `messages sync --all`'s job, not a start's. */
6
+ export const START_SYNC_WINDOW_MS = 7 * 86_400_000;
7
+ export const START_SYNC_PER_CHAT = 500;
8
+ /**
9
+ * `serve`'s one pass on start over the chats that moved while nothing listened. A provider's own
10
+ * catch-up drops a gap it finds too long (Telegram's `differenceTooLong`, `channelDifferenceTooLong`
11
+ * past 100 updates), so each chat whose newest message is newer than the newest one held is read back
12
+ * to that one. A chat never held is read back to the newest message held anywhere, which is about when
13
+ * the last serve stopped. Bounded by a window, a per-chat limit and a time budget; an empty store is
14
+ * left to `messages sync --all`.
15
+ */
16
+ export const startSync = ({ chats, newestHeld, fetch, note, warn, delayMs = START_SYNC_DELAY_MS, budgetMs = START_SYNC_BUDGET_MS, windowMs = START_SYNC_WINDOW_MS, now = Date.now, }) => {
17
+ const controller = new AbortController();
18
+ let timer;
19
+ let pass = Promise.resolve();
20
+ let started = false;
21
+ let done;
22
+ const run = async () => {
23
+ const stop = AbortSignal.any([controller.signal, AbortSignal.timeout(budgetMs)]);
24
+ const floor = now() - windowMs;
25
+ const moved = [];
26
+ for (const chat of await chats(stop)) {
27
+ const lastAt = Date.parse(chat.lastMessageAt ?? "");
28
+ if (Number.isNaN(lastAt) || lastAt < floor)
29
+ continue;
30
+ moved.push({ chat, lastAt, held: await newestHeld(chat.id) });
31
+ }
32
+ const anywhere = moved.reduce((top, { held }) => (held === undefined ? top : Math.max(top, held)), -Infinity);
33
+ if (!Number.isFinite(anywhere))
34
+ return;
35
+ const behind = moved
36
+ .filter(({ lastAt, held }) => lastAt > (held ?? anywhere))
37
+ .sort((one, other) => other.lastAt - one.lastAt);
38
+ if (behind.length === 0)
39
+ return;
40
+ const summary = { chats: 0, fetched: 0, failed: 0, stopped: false };
41
+ done = summary;
42
+ for (const { chat, held } of behind) {
43
+ if (stop.aborted)
44
+ break;
45
+ try {
46
+ summary.fetched += (await fetch(chat.id, Math.max(held ?? anywhere, floor), stop)).fetched;
47
+ summary.chats += 1;
48
+ }
49
+ catch (error) {
50
+ if (stop.aborted)
51
+ break;
52
+ summary.failed += 1;
53
+ const errorCode = failureKey(error);
54
+ warn({
55
+ code: "start_sync_chat_failed",
56
+ chatId: chat.id,
57
+ errorCode,
58
+ text: `what ${chat.id} received while serve was down was not fetched: ${errorCode}`,
59
+ });
60
+ }
61
+ }
62
+ summary.stopped = stop.aborted && summary.chats + summary.failed < behind.length;
63
+ note({
64
+ code: "start_sync",
65
+ text: `caught up ${summary.fetched} message(s) in ${summary.chats} chat(s) that moved while serve was down` +
66
+ (summary.stopped ? ` — stopped at the time limit, ${behind.length - summary.chats - summary.failed} left` : ""),
67
+ });
68
+ };
69
+ return {
70
+ start: () => {
71
+ if (started || controller.signal.aborted)
72
+ return;
73
+ started = true;
74
+ timer = setTimeout(() => {
75
+ pass = run().catch((error) => {
76
+ if (controller.signal.aborted)
77
+ return;
78
+ const errorCode = failureKey(error);
79
+ warn({ code: "start_sync_failed", errorCode, text: `the catch-up on start failed: ${errorCode}` });
80
+ });
81
+ }, delayMs);
82
+ },
83
+ stop: async () => {
84
+ controller.abort();
85
+ clearTimeout(timer);
86
+ await pass;
87
+ },
88
+ summary: () => done,
89
+ };
90
+ };