@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.
- package/CHANGELOG.md +59 -0
- package/README.md +1 -1
- package/dist/background/schedules.d.ts +12 -1
- package/dist/background/schedules.js +65 -24
- package/dist/background/units.d.ts +16 -9
- package/dist/background/units.js +26 -11
- package/dist/cli/context.d.ts +2 -0
- package/dist/cli/meeting-preparation-answer.d.ts +3 -0
- package/dist/cli/meeting-preparation-projects.d.ts +3 -0
- package/dist/cli/meeting-preparation-read.d.ts +3 -0
- package/dist/cli/messenger/search-answers.js +8 -1
- package/dist/cli/messenger/serve-catch-up.d.ts +43 -0
- package/dist/cli/messenger/serve-catch-up.js +90 -0
- package/dist/cli/messenger/serve-command.js +54 -1
- package/dist/cli/messenger/server-command.d.ts +4 -3
- package/dist/cli/messenger/server-command.js +30 -12
- package/dist/cli/program.js +9 -4
- package/dist/cli/schedule/schedule-command.js +5 -1
- package/dist/entities/hit.d.ts +5 -0
- package/dist/entities/hit.js +5 -0
- package/dist/entities/index.d.ts +27 -0
- package/dist/entities/listener.d.ts +6 -0
- package/dist/entities/listener.js +1 -0
- package/dist/entities/meeting-preparation.d.ts +3 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/mcp/meeting-preparation.d.ts +3 -0
- package/dist/meetings/answers.d.ts +5 -0
- package/dist/meetings/answers.js +8 -0
- package/dist/meetings/cli/index.js +14 -0
- package/dist/meetings/index.d.ts +2 -1
- package/dist/meetings/index.js +1 -0
- package/dist/meetings/ingest.js +2 -1
- package/dist/meetings/language.d.ts +5 -0
- package/dist/meetings/language.js +57 -0
- package/dist/meetings/mcp/search.d.ts +3 -0
- package/dist/meetings/project-evidence-answer.d.ts +3 -0
- package/dist/meetings/result-items.d.ts +9 -0
- package/dist/meetings/result-items.js +7 -0
- package/dist/meetings/store.d.ts +22 -0
- package/dist/meetings/testing/memory.js +3 -0
- package/dist/meetings/transcription.d.ts +2 -0
- package/dist/meetings/transcription.js +5 -1
- package/dist/search/lucene/types.d.ts +7 -0
- package/dist/search/lucene/types.js +11 -1
- package/dist/services/messages-archive-search.js +3 -2
- package/dist/services/messages-combined.js +2 -2
- package/dist/services/messages-discovery.js +2 -2
- package/dist/services/notes-search.js +4 -5
- package/dist/services/search-all-meetings.d.ts +3 -0
- package/dist/services/search-all-meetings.js +6 -1
- package/dist/speech/local.d.ts +2 -0
- package/dist/speech/local.js +8 -1
- package/dist/speech/models.d.ts +2 -0
- package/dist/speech/models.js +2 -0
- package/dist/store/driver.d.ts +11 -0
- package/dist/store/driver.js +13 -0
- package/dist/store/drivers/node-sqlite.d.ts +1 -1
- package/dist/store/drivers/node-sqlite.js +3 -0
- package/dist/store/sqlite/lucene.d.ts +1 -1
- package/dist/store/sqlite/lucene.js +33 -7
- package/dist/store/sqlite/meeting-hit-search.js +10 -3
- package/dist/store/sqlite/meeting-languages.d.ts +4 -0
- package/dist/store/sqlite/meeting-languages.js +36 -0
- package/dist/store/sqlite/meetings.d.ts +2 -1
- package/dist/store/sqlite/meetings.js +9 -3
- package/dist/store/sqlite/note-search.js +2 -2
- 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
|
-
|
|
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
|
-
/**
|
|
47
|
-
|
|
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.
|
package/dist/background/units.js
CHANGED
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
@@ -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
|
-
//
|
|
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) => {
|
package/dist/cli/context.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
};
|