@wirecat/cli-core 0.551.0 → 0.553.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 (94) hide show
  1. package/CHANGELOG.md +83 -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/compact.d.ts +4 -0
  8. package/dist/cli/compact.js +65 -7
  9. package/dist/cli/context.d.ts +2 -0
  10. package/dist/cli/meeting-preparation-answer.d.ts +3 -0
  11. package/dist/cli/meeting-preparation-projects.d.ts +3 -0
  12. package/dist/cli/meeting-preparation-read.d.ts +3 -0
  13. package/dist/cli/messenger/context.d.ts +3 -0
  14. package/dist/cli/messenger/projects-command.js +38 -11
  15. package/dist/cli/messenger/search-answers.js +8 -1
  16. package/dist/cli/messenger/serve-catch-up.d.ts +43 -0
  17. package/dist/cli/messenger/serve-catch-up.js +90 -0
  18. package/dist/cli/messenger/serve-command.js +54 -1
  19. package/dist/cli/messenger/serve-relay.d.ts +1 -0
  20. package/dist/cli/messenger/server-command.d.ts +4 -3
  21. package/dist/cli/messenger/server-command.js +30 -12
  22. package/dist/cli/program.js +9 -4
  23. package/dist/cli/project-evidence.d.ts +33 -4
  24. package/dist/cli/project-evidence.js +38 -4
  25. package/dist/cli/project-status-rows.js +11 -1
  26. package/dist/cli/project-status.d.ts +24 -0
  27. package/dist/cli/project-status.js +52 -9
  28. package/dist/cli/schedule/schedule-command.js +5 -1
  29. package/dist/entities/hit.d.ts +5 -0
  30. package/dist/entities/hit.js +5 -0
  31. package/dist/entities/index.d.ts +27 -0
  32. package/dist/entities/listener.d.ts +6 -0
  33. package/dist/entities/listener.js +1 -0
  34. package/dist/entities/meeting-preparation.d.ts +3 -0
  35. package/dist/errors/reasons.js +1 -0
  36. package/dist/mcp/meeting-preparation.d.ts +3 -0
  37. package/dist/meetings/answers.d.ts +5 -0
  38. package/dist/meetings/answers.js +8 -0
  39. package/dist/meetings/cli/index.js +14 -0
  40. package/dist/meetings/index.d.ts +2 -1
  41. package/dist/meetings/index.js +1 -0
  42. package/dist/meetings/ingest.js +2 -1
  43. package/dist/meetings/language.d.ts +5 -0
  44. package/dist/meetings/language.js +57 -0
  45. package/dist/meetings/mcp/search.d.ts +3 -0
  46. package/dist/meetings/project-evidence-answer.d.ts +3 -0
  47. package/dist/meetings/result-items.d.ts +9 -0
  48. package/dist/meetings/result-items.js +7 -0
  49. package/dist/meetings/store.d.ts +22 -0
  50. package/dist/meetings/testing/memory.js +3 -0
  51. package/dist/meetings/transcription.d.ts +2 -0
  52. package/dist/meetings/transcription.js +5 -1
  53. package/dist/search/lucene/types.d.ts +7 -0
  54. package/dist/search/lucene/types.js +11 -1
  55. package/dist/services/index.d.ts +4 -3
  56. package/dist/services/index.js +4 -3
  57. package/dist/services/link-scorers/index.d.ts +1 -1
  58. package/dist/services/link-scorers/index.js +1 -1
  59. package/dist/services/link-scorers/scan.d.ts +0 -1
  60. package/dist/services/link-scorers/scan.js +1 -1
  61. package/dist/services/messages-archive-search.js +3 -2
  62. package/dist/services/messages-combined.js +2 -2
  63. package/dist/services/messages-discovery.js +2 -2
  64. package/dist/services/notes-search.js +4 -5
  65. package/dist/services/project-purposes.d.ts +28 -0
  66. package/dist/services/project-purposes.js +41 -0
  67. package/dist/services/project-status/index.d.ts +22 -8
  68. package/dist/services/project-status/index.js +17 -9
  69. package/dist/services/search-all-meetings.d.ts +3 -0
  70. package/dist/services/search-all-meetings.js +6 -1
  71. package/dist/services/stored-project-gathering.d.ts +79 -4
  72. package/dist/services/stored-project-gathering.js +171 -22
  73. package/dist/speech/local.d.ts +2 -0
  74. package/dist/speech/local.js +8 -1
  75. package/dist/speech/models.d.ts +2 -0
  76. package/dist/speech/models.js +2 -0
  77. package/dist/store/driver.d.ts +11 -0
  78. package/dist/store/driver.js +13 -0
  79. package/dist/store/drivers/node-sqlite.d.ts +1 -1
  80. package/dist/store/drivers/node-sqlite.js +3 -0
  81. package/dist/store/messages.d.ts +2 -1
  82. package/dist/store/messages.js +2 -2
  83. package/dist/store/sqlite/lucene.d.ts +1 -1
  84. package/dist/store/sqlite/lucene.js +33 -7
  85. package/dist/store/sqlite/meeting-hit-search.js +10 -3
  86. package/dist/store/sqlite/meeting-languages.d.ts +4 -0
  87. package/dist/store/sqlite/meeting-languages.js +36 -0
  88. package/dist/store/sqlite/meetings.d.ts +2 -1
  89. package/dist/store/sqlite/meetings.js +9 -3
  90. package/dist/store/sqlite/note-search.js +2 -2
  91. package/dist/store/sqlite/reads.d.ts +1 -1
  92. package/dist/store/sqlite/reads.js +2 -2
  93. package/package.json +1 -1
  94. package/parity.json +10 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,89 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.553.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`projects evidence` and `projects status` read the linked chats of the purposes asked.** `--purpose sales,work`
15
+ or `--purpose all` picks them; without it, the configured defaults (built in: `work`). A chat left out is one
16
+ `missing` row with its purpose, message count, dates and the command that reads it; an unknown purpose is refused
17
+ with the configured list. `gatherStoredProject` and `projectStatus` take `purposes`, `purposeConfig` and a `count`
18
+ port (`storedChatCount`); `purposesOf`, `BUILT_IN_PURPOSE_CONFIG` and `Messenger.purposes` let a host pass its list.
19
+ When one linked chat with no purpose gives most of a run's items, the answer suggests a purpose once.
20
+ - **`projects evidence --include-proposed` reads chats and members proposed for the project**, after the confirmed
21
+ ones. Each linked chat and member row carries `proposed` and `score`, and each item only they brought `proposed`.
22
+ `gatherStoredProject` takes `includeProposed`; `linkedChatsOf` takes `{ includeProposed }`.
23
+ - **`--compact` rows for `projects evidence`**: items, linked chats, chats left out by purpose and members. A row
24
+ column may take a `prefix`, a shape may print fields `whole` (the cursor), and `time`/`day` columns read epoch ms.
25
+ - `countMessages` takes `until`.
26
+
27
+ ### Changed — may break callers
28
+
29
+ - **`LinkedChat` gains `purpose` and `relationship`, `ProjectStatus` gains `purposes`, and a status chat left out by
30
+ purpose carries its account, chat id and counts.** `StoredProjectGathering` gains `leftOut`, `members`, `purposes`
31
+ and `purposeHint`. A caller that builds these by hand adds the fields. `DEFAULT_PURPOSES` moved to
32
+ `project-purposes`, still exported from `@wirecat/cli-core/services`.
33
+
34
+ ## 0.552.0 — 02.11.2026
35
+
36
+ ### Added
37
+
38
+ - **`serve` reads back on start what arrived while nothing listened.** A provider's own catch-up drops a gap it
39
+ finds too long (Telegram's `differenceTooLong`, and `channelDifferenceTooLong` past 100 updates in a channel), so
40
+ 30 seconds after connecting `serve` lists the chats once and reads each chat whose newest message is newer than the
41
+ newest one held from before it started back to that one; a chat never held, back to the newest message held
42
+ anywhere. At most 7 days back, 500 messages a chat and 5 minutes; an empty store is left to `messages sync --all`,
43
+ and a messenger that pushes its history (`history: "store"`) is skipped. The summary gains
44
+ `caught_up: { chats, fetched, failed, stopped }`; the log gains `start_sync`, `start_sync_chat_failed` and
45
+ `start_sync_failed`.
46
+ - **A meeting transcript knows its language.** `transcriptInput` (provider fetches and imports) and local
47
+ transcription fill `language` instead of `null`: a one-language speech model gives its own (`gigaam-v3` and
48
+ `gigaam-v3-ctc` write Russian, `LocalSpeechRecognitionModel.language`), any other transcript is read from its
49
+ letters — Cyrillic is `ru` (`uk` when Ukrainian letters show), Latin is told apart by frequent words (`en`, `es`,
50
+ `de`, `fr`, `it`, `pt`), and a mixed, short or undecided one stays `null`. A meeting that is mostly Russian with a
51
+ few English lines is `ru`. `transcriptLanguage(texts)` is exported for providers that build their own transcripts.
52
+ - **Meeting search hits say the transcript's language and where in it the line is**: `language`, `line` (from 1) and
53
+ `lines_total` on transcript hits of `search meetings` and `search all` ("line 161 of 192").
54
+ - **`meetings repair`** previews filling what older builds left out of stored transcripts — their language, for
55
+ transcripts that have none; `--yes` stores it (`meeting_repair`, `MeetingStore.fillTranscriptLanguages`). Nothing is
56
+ written on a read.
57
+
58
+ ### Changed — may break callers
59
+
60
+ - **A search that runs out of time fails with `timeout` and the reason `query.time-limit`**, no longer with
61
+ `validation_error`, `query.too-large` and `budget: "time"` or `"milliseconds"`. The message says the query is valid
62
+ and the time limit was reached. The details keep `budget: "time"` and `complete: false`, and add `limit_ms`. A caller
63
+ that told the person to change the query on `query.too-large` no longer does it for a slow search.
64
+ - **`server install` runs `systemctl --user` (or `launchctl enable`)**, and fails where that does not work, after it
65
+ wrote the file. **Under launchd `server stop` only unloads the agent**: it starts again at the next login, as a
66
+ systemd unit does; `server uninstall` stops that. `Platform` (`@wirecat/cli-core/background`) drops `atLogin` and
67
+ requires `install` and `uninstall`; its `state` may answer `enabled`.
68
+
69
+ ### Fixed
70
+
71
+ - **A search stops at its time limit even inside one long SQLite statement.** A statement runs to its end before any
72
+ 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
73
+ command's `--timeout 60s`. Under Node the message search statements now carry a check of the limit on every row
74
+ (the SQL function `wc_time_limit`) and stop when it passes. Bun has no SQL functions; there the limit is still
75
+ checked only between statements.
76
+ - **`server install` enables the unit, so serve starts with the owner's session after a reboot.** It wrote the unit
77
+ and never enabled it, and after a reboot nothing started. Under systemd it runs `systemctl --user enable` (still
78
+ `WantedBy=default.target`, so it also starts at boot under linger and over ssh); under launchd it clears a
79
+ `launchctl disable` and the agent no longer carries `Disabled`. Nothing starts now; `server start` does that.
80
+ A unit written with moved folders (a development checkout) is not enabled, so a branch build never starts at
81
+ login on the owner's keyring. `server uninstall` disables before it removes the file. `server status` answers
82
+ `starts_at_login` and `unit.enabled`. A unit installed before this needs `server install` once more.
83
+ - **A timer that fires before the login unlocks the keyring tries again.** A daily or one-off schedule that catches up
84
+ at boot ran before the keyring was unlocked and failed for the day. `schedule exec` now runs it again when it fails
85
+ with reason `credentials.unreachable` (tg and max give it when a profile that has logged in here cannot read its
86
+ sign-in): a minute later, then up to 15 minutes apart, for up to 12 hours, and notifies once, with how it ended.
87
+ `schedule run` and repeating schedules do not wait. `runSchedule` takes `untilUnlocked` and `wait` for this.
88
+ - **A sync whose reader goes away finishes its work.** A closed stdout or stderr (EPIPE) aborted every command, so a
89
+ desktop that closed the pipe ended an 18-minute `messages sync` with `write EPIPE`. A cooperative command (a sync)
90
+ and a command waiting in an `untilStopped()` phase (`schedule run`, `schedule exec`) now keep working, their later
91
+ output goes nowhere, and the run record says how they ended. Other commands still end with 0, as `| head` expects.
92
+
10
93
  ## 0.551.0 — 02.11.2026
11
94
 
12
95
  ### 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) => {
@@ -8,6 +8,8 @@ export interface RowColumn {
8
8
  as?: RowValue;
9
9
  /** Printed for a field that is `true`, and after a count: `{ field: ["sender_is_me", "sender_name"], tag: "me" }`. */
10
10
  tag?: string;
11
+ /** Printed before a text value, so a bare id says what it is: `proposed 17079`. */
12
+ prefix?: string;
11
13
  }
12
14
  export interface RowShape {
13
15
  /** Printed in order, joined by " · "; the first is the ref. A missing value is left out. */
@@ -16,6 +18,8 @@ export interface RowShape {
16
18
  text?: readonly string[];
17
19
  /** An item answer's lists, drawn as rows of another entity: `{ items: "evidence_item" }`. */
18
20
  sections?: Readonly<Record<string, string>>;
21
+ /** An item answer's fields printed whole, never cut: a cursor that is pasted back. */
22
+ whole?: readonly string[];
19
23
  }
20
24
  export interface CompactOptions {
21
25
  /** IANA zone for times; the answer's `meta.timezone` wins. */
@@ -17,6 +17,61 @@ const messageRow = {
17
17
  ],
18
18
  text: ["text", "transcript", "excerpt"],
19
19
  };
20
+ const linkedChatColumns = [
21
+ { field: "ref" },
22
+ { field: "title" },
23
+ { field: "purpose" },
24
+ { field: "proposed", prefix: "proposed" },
25
+ ];
26
+ /** `projects evidence`: its items, the chats it read and left out, and its members. */
27
+ const EVIDENCE_ROWS = {
28
+ project_evidence: {
29
+ columns: [],
30
+ sections: {
31
+ items: "evidence_item",
32
+ linked_chats: "linked_chat",
33
+ left_out: "linked_chat.left_out",
34
+ members: "evidence_member",
35
+ },
36
+ whole: ["checkpoint"],
37
+ },
38
+ evidence_item: {
39
+ columns: [
40
+ { field: "ref" },
41
+ { field: "at", as: "day" },
42
+ { field: "context" },
43
+ { field: "proposed", prefix: "proposed" },
44
+ ],
45
+ text: ["text"],
46
+ },
47
+ linked_chat: {
48
+ columns: [
49
+ ...linkedChatColumns,
50
+ { field: "state" },
51
+ { field: "items", as: "count", tag: "items" },
52
+ { field: "capped", tag: "capped" },
53
+ { field: "score", prefix: "score" },
54
+ { field: "sync_command" },
55
+ ],
56
+ },
57
+ "linked_chat.left_out": {
58
+ columns: [
59
+ ...linkedChatColumns,
60
+ { field: "messages", as: "count", tag: "messages" },
61
+ { field: "first_at", as: "day" },
62
+ { field: "last_at", as: "day" },
63
+ { field: "read_command" },
64
+ ],
65
+ },
66
+ evidence_member: {
67
+ columns: [
68
+ { field: "person" },
69
+ { field: "state" },
70
+ { field: "proposed", prefix: "proposed" },
71
+ { field: "score", prefix: "score" },
72
+ ],
73
+ },
74
+ };
20
75
  /** Rows for cli-core's shared entities. */
21
76
  export const SHARED_ROWS = {
22
77
  message: messageRow,
@@ -41,6 +96,7 @@ export const SHARED_ROWS = {
41
96
  ],
42
97
  },
43
98
  ...STATUS_ROWS,
99
+ ...EVIDENCE_ROWS,
44
100
  };
45
101
  /** Any record: what names it, what it is called, what state it is in, then what it says. */
46
102
  export const GENERIC_ROW = {
@@ -136,11 +192,13 @@ const cellOf = (item, column, clock) => {
136
192
  switch (column.as) {
137
193
  case "time":
138
194
  case "day": {
139
- if (typeof value !== "string")
195
+ // Some answers keep their times as epoch milliseconds.
196
+ const iso = typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : value;
197
+ if (typeof iso !== "string")
140
198
  return undefined;
141
- const parts = clock.parts(value);
199
+ const parts = clock.parts(iso);
142
200
  if (!parts)
143
- return { text: clean(value) };
201
+ return { text: clean(iso) };
144
202
  const day = parts.date.slice(5);
145
203
  return { text: column.as === "day" ? day : `${day} ${parts.time}`, date: parts.date };
146
204
  }
@@ -164,7 +222,7 @@ const cellOf = (item, column, clock) => {
164
222
  if (value === true)
165
223
  return column.tag ? { text: column.tag } : undefined;
166
224
  if (typeof value === "string" || typeof value === "number")
167
- return { text: clean(String(value)) };
225
+ return { text: clean(column.prefix ? `${column.prefix} ${value}` : String(value)) };
168
226
  return undefined;
169
227
  }
170
228
  };
@@ -233,7 +291,7 @@ const rowsOfItems = (items, shape, clock, chars) => {
233
291
  };
234
292
  const blockLine = (fields, chars) => blockOf(fields, chars).join(" ");
235
293
  /** A record as `field: value` lines; a nested record is indented under its field, a list of records counted. */
236
- const blockOf = (fields, chars, indent = "") => Object.entries(fields)
294
+ const blockOf = (fields, chars, indent = "", whole = []) => Object.entries(fields)
237
295
  .filter(([name]) => !(name === "priority" && "priority_label" in fields))
238
296
  .filter(([, value]) => present(value) || value === false)
239
297
  .flatMap(([name, value]) => {
@@ -244,7 +302,7 @@ const blockOf = (fields, chars, indent = "") => Object.entries(fields)
244
302
  return value.every((one) => !isFields(one) && !Array.isArray(one))
245
303
  ? [`${label} ${cut(clean(value.join(", ")), chars)}`]
246
304
  : [`${label} ${value.length} — --json shows them`];
247
- return [`${label} ${cut(clean(String(value)), chars)}`];
305
+ return [`${label} ${whole.includes(name) ? clean(String(value)) : cut(clean(String(value)), chars)}`];
248
306
  });
249
307
  const footerOf = (body) => {
250
308
  const lines = [];
@@ -300,7 +358,7 @@ export const compactText = (body, shape, options, rowsOf = rowsOfEntity()) => {
300
358
  else if (isFields(body.item)) {
301
359
  const sections = shape.sections ?? {};
302
360
  const rest = Object.fromEntries(Object.entries(body.item).filter(([name]) => !Object.hasOwn(sections, name)));
303
- lines = blockOf(rest, chars).map(guarded);
361
+ lines = blockOf(rest, chars, "", shape.whole).map(guarded);
304
362
  for (const [name, entity] of Object.entries(sections)) {
305
363
  const list = body.item[name];
306
364
  if (!Array.isArray(list))
@@ -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;
@@ -6,6 +6,7 @@ import { type Asker, type SendGuard } from "../../sends/guard.js";
6
6
  import { type PaceRate } from "../../sends/pace.js";
7
7
  import { type PermissionKey } from "../../sends/permissions.js";
8
8
  import { type ServiceMessenger, type Services } from "../../services/index.js";
9
+ import type { PurposeConfig } from "../../services/project-purposes.js";
9
10
  import type { OpenRecognizer } from "../../speech/transcribe.js";
10
11
  import { type AccountKey, type DeletionScope, type MessageStore } from "../../store/store.js";
11
12
  import { type BaseContext, type BaseEnvironment } from "../context.js";
@@ -76,6 +77,8 @@ export interface Messenger extends ServiceMessenger {
76
77
  profileState?: (profile: string, env: NodeJS.ProcessEnv) => ProfileState;
77
78
  /** The help for a `<chat>` argument, in this messenger's words. */
78
79
  chatArgument: string;
80
+ /** The chat purposes `projects evidence` and `projects status` know; core's built-in ones when unset. */
81
+ purposes?: PurposeConfig;
79
82
  /** The group settings this messenger has, as `chats update` offers them; every one when unset. */
80
83
  groupSettings?: readonly GroupSettingKey[];
81
84
  /** Whether `polls create` makes quizzes (`--quiz --correct --solution`); no when unset. */