@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
@@ -3,6 +3,7 @@ import { annotate, annotateOption } from "@wirecat/cli-base/commands";
3
3
  import { Command, Option } from "commander";
4
4
  import { parseDuration } from "../../domain/duration.js";
5
5
  import { FETCHING } from "../../services/archive.js";
6
+ import { BUILT_IN_PURPOSES, purposeList } from "../../services/project-purposes.js";
6
7
  import { projectStatus, STATUS_SIGNALS } from "../../services/project-status/index.js";
7
8
  import { gatherStoredProject } from "../../services/stored-project-gathering.js";
8
9
  import { answerEntity } from "../answer-entity.js";
@@ -61,6 +62,8 @@ const archiveFetcher = (withServices, messenger) => {
61
62
  },
62
63
  };
63
64
  };
65
+ const purposeHelp = (messenger) => `the linked chats' purposes to read, comma separated (${(messenger.purposes?.purposes ?? BUILT_IN_PURPOSES).join(", ")}), ` +
66
+ "or all; the configured defaults if not given, and a chat left out is counted, never dropped silently";
64
67
  /** Off MCP: a tool call ends at about a minute, too soon for a sync, which would fail the whole answer. */
65
68
  const cliOnly = (flags, description, value) => {
66
69
  const option = new Option(flags, description);
@@ -88,6 +91,8 @@ export const projectsCommand = (messenger) => {
88
91
  .option("--alias <name>", "another name of the project, searched on its own; repeat for more", collect, [])
89
92
  .option("--context-query <words>", "more words that all appear in one text; repeat for another", collect, [])
90
93
  .option("--contact <person>", "a name to search, or person:<id> to read what that person wrote", collect, [])
94
+ .option("--purpose <purposes>", purposeHelp(messenger), purposeList, [])
95
+ .option("--include-proposed", "also read chats and members proposed for the project and not yet confirmed; each item they bring is marked")
91
96
  .option("--since-time <time>", "how far back: days (90d, at most 730d) or a start date (2026-09-01)", "60d")
92
97
  .option("--before-time <time>", "fixed exclusive end: ISO 8601 timestamp with UTC offset")
93
98
  .option("--limit <n>", "new references at most, up to 500; 100 if not given", positiveCount("--limit"))
@@ -116,20 +121,31 @@ export const projectsCommand = (messenger) => {
116
121
  ...(options.contact.length ? { people: options.contact } : {}),
117
122
  ...(options.maxPages === undefined ? {} : { maxReads: options.maxPages }),
118
123
  ...(options.cursor === undefined ? {} : { checkpoint: options.cursor }),
124
+ ...(options.purpose.length ? { purposes: options.purpose } : {}),
125
+ ...(messenger.purposes ? { purposeConfig: messenger.purposes } : {}),
126
+ ...(options.includeProposed ? { includeProposed: true } : {}),
119
127
  ...(fetcher ? { fetcher, maxFetchMs, onFetchProgress: progressNote(context.renderer.note) } : {}),
120
128
  }));
121
129
  const fetcher = options.syncFirst
122
130
  ? archiveFetcher((work) => context.withServices(work, { name: "projects evidence fetch" }), messenger)
123
131
  : undefined;
124
- const found = await context.withStore((store, account) => gather(store, account, fetcher));
125
- const parts = projectAnswerParts(options.sinceTime, found);
132
+ let profile;
133
+ const found = await context.withStore((store, account) => {
134
+ profile = account;
135
+ return gather(store, account, fetcher);
136
+ });
137
+ const root = rootOf(this);
126
138
  const hints = {
127
139
  sync: (one, since) => one.provider === messenger.provider
128
140
  ? lineOf(this, `projects evidence ${quote(project)} --since-time ${since} --sync-first`)
129
141
  : null,
142
+ read: (one, since) => one.provider === profile?.provider && one.account === profile.account
143
+ ? lineOf(this, wordsOf(root, readStep(one.chat, since)))
144
+ : null,
130
145
  showDocument: "the notes app that imported it",
131
146
  };
132
- renderAnswer(this, context, itemOf(snakeKeys(withSyncCommands(found, hints.sync)), parts), () => projectEvidenceText(found, hints));
147
+ const parts = projectAnswerParts(options.sinceTime, found, hints);
148
+ renderAnswer(this, context, itemOf(snakeKeys(withSyncCommands(found, hints.sync, hints.read)), parts), () => projectEvidenceText(found, hints));
133
149
  }));
134
150
  projects.addCommand(statusCommand(messenger));
135
151
  return projects;
@@ -149,26 +165,37 @@ const statusCommand = (messenger) => answerEntity(annotate(new Command("status")
149
165
  .argument("<project>", "an existing project's id, key, exact name or alias")
150
166
  .option("--since-time <time>", "how far back: days (60d, at most 730d) or a start date (2026-09-01)", "60d")
151
167
  .option("--before-time <time>", "fixed exclusive end: ISO 8601 timestamp with UTC offset")
168
+ .option("--purpose <purposes>", purposeHelp(messenger), purposeList, [])
152
169
  .option("--context-query <words>", "words that all appear in one text; reports their last mention per side. Repeat for another", collect, [])
153
170
  .action(async function (project) {
154
171
  const options = this.opts();
155
172
  const context = messengerContext(this, messenger);
156
- const found = await context.withStore((store, account) => projectStatus(store, {
157
- project,
158
- accounts: [account],
159
- ...projectWindowOf(options.sinceTime),
160
- ...(options.beforeTime === undefined ? {} : { asOf: projectCutoffTime(options.beforeTime) }),
161
- contextQueries: options.contextQuery,
162
- }));
173
+ let profile;
174
+ const found = await context.withStore((store, account) => {
175
+ profile = account;
176
+ return projectStatus(store, {
177
+ project,
178
+ accounts: [account],
179
+ ...projectWindowOf(options.sinceTime),
180
+ ...(options.beforeTime === undefined ? {} : { asOf: projectCutoffTime(options.beforeTime) }),
181
+ contextQueries: options.contextQuery,
182
+ ...(options.purpose.length ? { purposes: options.purpose } : {}),
183
+ ...(messenger.purposes ? { purposeConfig: messenger.purposes } : {}),
184
+ });
185
+ });
163
186
  const root = rootOf(this);
164
187
  const hints = {
165
188
  sync: (chat, since) => nextStep("messages.sync", { args: { chat: chat.chatId, since_time: since } }),
166
- read: (chat, since) => nextStep("messages.list", { args: { chat: chat.chatId, query: `date>=${since}` } }),
189
+ read: (chat, since) => readStep(chat.chatId, since),
190
+ readChat: (chat, since) => chat.account.provider === profile?.provider && chat.account.account === profile.account
191
+ ? readStep(chat.chatId, since)
192
+ : null,
167
193
  line: (step) => (step.kind === "command" ? lineOf(this, wordsOf(root, step)) : step.why),
168
194
  };
169
195
  const { item, ...parts } = projectStatusAnswer(found, hints);
170
196
  renderAnswer(this, context, itemOf(item, parts), () => projectStatusText(found, hints));
171
197
  }), "project_status", projectStatusSchema, { row: projectStatusRow(STATUS_SIGNALS) });
198
+ const readStep = (chat, since) => nextStep("messages.list", { args: { chat, query: `date>=${since}` } });
172
199
  /** A step's words after the tool and the profile, so `lineOf` names the profile the run used. */
173
200
  const wordsOf = (root, step) => {
174
201
  const profile = root.getOptionValue("profile");
@@ -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
+ };
@@ -2,7 +2,9 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command } from "commander";
4
4
  import { holdLock, lockPath, releaseLock, takeLock } from "../../background/lock.js";
5
+ import { parseDuration } from "../../domain/duration.js";
5
6
  import { failureKey } from "../../errors/failures.js";
7
+ import { capability } from "../../ports/messenger.js";
6
8
  import { senderFacts } from "../../replies/dry-run.js";
7
9
  import { replyRenderer } from "../../replies/rendering.js";
8
10
  import { audienceWarnings, readReplies, repliesPathFor } from "../../replies/rules.js";
@@ -10,14 +12,17 @@ import { NO_RULES, replyTo } from "../../replies/serve.js";
10
12
  import { repliesStatePathFor } from "../../replies/state.js";
11
13
  import { levelFor } from "../../sends/permissions.js";
12
14
  import { newSendId } from "../../sends/send-id.js";
15
+ import { FETCHING } from "../../services/archive.js";
13
16
  import { onlineDeps } from "../../services/deps.js";
14
17
  import { servicesFor } from "../../services/index.js";
18
+ import { patiently } from "../../services/patience.js";
15
19
  import { applyTaskRulesOnArrival, openRequestTask } from "../../services/task-rules.js";
16
20
  import { environmentOf } from "../context.js";
17
21
  import { REMOTE_WRITE } from "../effects.js";
18
22
  import { itemOf, renderAnswer } from "../envelope.js";
19
23
  import { recalledAccount } from "./accounts.js";
20
24
  import { messengerContext } from "./context.js";
25
+ import { START_SYNC_PER_CHAT, startSync } from "./serve-catch-up.js";
21
26
  import { memberFetches } from "./serve-members.js";
22
27
  import { noticeFor, recordDiagnostics } from "./serve-notice.js";
23
28
  import { serveRelay } from "./serve-relay.js";
@@ -75,7 +80,7 @@ export const serveCommand = (messenger) => {
75
80
  const counts = {};
76
81
  const rules = replying(context, messenger, Date.parse(startedAt));
77
82
  const tasks = tasking(context, warn);
78
- const { handled } = environmentOf(this);
83
+ const { handled, startSyncDelayMs } = environmentOf(this);
79
84
  const count = (event) => {
80
85
  counts[event.event] = (counts[event.event] ?? 0) + 1;
81
86
  if (event.event === "message") {
@@ -99,6 +104,50 @@ export const serveCommand = (messenger) => {
99
104
  }).chats.fetchMembers(chatId, {}),
100
105
  warn,
101
106
  });
107
+ const fetching = messenger.fetching ?? FETCHING;
108
+ const catchingUp = startSync({
109
+ chats: async (stop) => {
110
+ const list = capability(connection, "chats", "list its chats");
111
+ const all = [];
112
+ for (let offset = 0;;) {
113
+ const page = await patiently(() => list({ offset, limit: 100 }), context.renderer.note, stop);
114
+ for (const chat of page.items)
115
+ all.push(chat);
116
+ offset += page.items.length;
117
+ if (!page.hasMore || page.items.length === 0)
118
+ return all;
119
+ }
120
+ },
121
+ newestHeld: (chatId) => context.withStore(async (store, account) => {
122
+ // What this serve stored itself would hide the gap before it.
123
+ const [newest] = await store.messages.messagesWindow(account, chatId, {
124
+ at: startedAt,
125
+ before: 1,
126
+ after: 0,
127
+ });
128
+ return newest ? Date.parse(newest.timestamp) : undefined;
129
+ }, { name: "serve catch-up" }),
130
+ fetch: (chatId, sinceMs, stop) => context.withStore((store, account) => servicesFor({
131
+ ...onlineDeps(messenger, connection, context.guard, {
132
+ profile: context.profile,
133
+ env: context.env,
134
+ }),
135
+ store: async () => store,
136
+ account: async () => account,
137
+ }).archive.fetch(chatId, {
138
+ limit: START_SYNC_PER_CHAT,
139
+ pageSize: fetching.page,
140
+ pauseMs: parseDuration(fetching.pause, "pause"),
141
+ sinceMs,
142
+ catchUp: false,
143
+ note: context.renderer.note,
144
+ stop,
145
+ onPage: () => { },
146
+ }), { name: "serve catch-up" }),
147
+ note,
148
+ warn,
149
+ ...(startSyncDelayMs === undefined ? {} : { delayMs: startSyncDelayMs }),
150
+ });
102
151
  const stems = stemFills({
103
152
  withStore: (work) => context.withStore((store) => work(store), { name: "serve stems" }),
104
153
  warn,
@@ -113,6 +162,8 @@ export const serveCommand = (messenger) => {
113
162
  rules.connected(open);
114
163
  members.start();
115
164
  stems.start();
165
+ if (messenger.history !== "store" && typeof open.chats === "function")
166
+ catchingUp.start();
116
167
  },
117
168
  });
118
169
  }
@@ -120,6 +171,7 @@ export const serveCommand = (messenger) => {
120
171
  await relaying;
121
172
  await relay?.close();
122
173
  rules.stop();
174
+ await catchingUp.stop();
123
175
  await members.stop();
124
176
  await stems.stop();
125
177
  await rules.settled();
@@ -135,6 +187,7 @@ export const serveCommand = (messenger) => {
135
187
  ...rules.summary(),
136
188
  ...(members.summary().fetched + members.summary().failed > 0 ? { members: members.summary() } : {}),
137
189
  ...(stems.summary().stemmed > 0 ? { stems: stems.summary() } : {}),
190
+ ...(catchingUp.summary() ? { caught_up: catchingUp.summary() } : {}),
138
191
  ...tasks.summary(),
139
192
  }));
140
193
  });
@@ -41,6 +41,7 @@ export declare const serveRelay: (command: Command, messenger: Messenger) => {
41
41
  resolveSettings: (flags: import("../settings.js").GlobalFlags, options?: import("../settings.js").ResolveOptions) => import("../settings.js").Settings;
42
42
  profileState?: (profile: string, env: NodeJS.ProcessEnv) => import("./context.js").ProfileState;
43
43
  chatArgument: string;
44
+ purposes?: import("../../services/project-purposes.js").PurposeConfig;
44
45
  groupSettings?: readonly import("../../domain/models.js").GroupSettingKey[];
45
46
  pollQuiz?: boolean;
46
47
  pollCloseSeconds?: readonly [min: number, max: number];
@@ -35,8 +35,9 @@ export interface ServerOptions {
35
35
  /**
36
36
  * `serve` in the background: `start`, `stop`, `restart`, `status`, `logs`. Without a unit, `start`
37
37
  * runs `serve` as a process of its own; with one — `install`, a systemd user unit or a launchd
38
- * agent — it goes through systemd or launchd. **Nothing starts until `start` is typed** (NEED-9):
39
- * `install` only writes the file. What the server does stays each CLI's; `options.process` says how
40
- * to find, start and stop it.
38
+ * agent — it goes through systemd or launchd. **Nothing starts now until `start` is typed** (NEED-9):
39
+ * `install` writes the file and enables it, so it starts with every session from the next login (or boot,
40
+ * under linger) until `uninstall`. What the server does stays each CLI's; `options.process` says how to find,
41
+ * start and stop it.
41
42
  */
42
43
  export declare const serverCommand: (messenger: Messenger, options?: ServerOptions) => Command;
@@ -54,9 +54,10 @@ const lockProcess = (app, marker, serveArgv) => (context, system, logPath) => ({
54
54
  /**
55
55
  * `serve` in the background: `start`, `stop`, `restart`, `status`, `logs`. Without a unit, `start`
56
56
  * runs `serve` as a process of its own; with one — `install`, a systemd user unit or a launchd
57
- * agent — it goes through systemd or launchd. **Nothing starts until `start` is typed** (NEED-9):
58
- * `install` only writes the file. What the server does stays each CLI's; `options.process` says how
59
- * to find, start and stop it.
57
+ * agent — it goes through systemd or launchd. **Nothing starts now until `start` is typed** (NEED-9):
58
+ * `install` writes the file and enables it, so it starts with every session from the next login (or boot,
59
+ * under linger) until `uninstall`. What the server does stays each CLI's; `options.process` says how to find,
60
+ * start and stop it.
60
61
  */
61
62
  export const serverCommand = (messenger, options = {}) => {
62
63
  const { app } = messenger;
@@ -226,8 +227,11 @@ export const serverCommand = (messenger, options = {}) => {
226
227
  : undefined;
227
228
  const gaveUpReason = gaveUp === undefined ? undefined : Object.entries(EXIT_CODES).find(([, code]) => code === gaveUp)?.[0];
228
229
  const flood = new FloodMemory(floodPathFor(app, unit.profile, context.env)).read();
230
+ const startsAtLogin = isInstalled && state.enabled === true;
229
231
  const unitLine = isInstalled
230
- ? `Unit: ${tilde(unit.path, context.env)} — ${state.detail ?? (state.active ? "active" : "inactive")}.`
232
+ ? `Unit: ${tilde(unit.path, context.env)} — ${state.detail ?? (state.active ? "active" : "inactive")}, ${startsAtLogin
233
+ ? "starts at login."
234
+ : `does not start at login — \`${app.command} ${unit.profile} server install\` turns that on.`}`
231
235
  : `No unit installed — \`${app.command} ${unit.profile} server install\` adds one, for starting under systemd or launchd.`;
232
236
  say({
233
237
  profile: unit.profile,
@@ -248,6 +252,7 @@ export const serverCommand = (messenger, options = {}) => {
248
252
  ...(gaveUp !== undefined
249
253
  ? { stopped: { exitCode: gaveUp, reason: gaveUpReason ?? null, restarts: false } }
250
254
  : {}),
255
+ startsAtLogin,
251
256
  unit: { name: unit.name, path: unit.path, installed: isInstalled, ...state },
252
257
  flood: { deadlines: flood.deadlines, sendBlock: flood.sendBlock ?? null },
253
258
  }, [
@@ -293,16 +298,28 @@ export const serverCommand = (messenger, options = {}) => {
293
298
  effects: LOCAL_WRITE,
294
299
  examples: [`${app.command} personal server install`],
295
300
  }))
296
- .description("write a systemd user unit or a launchd agent for this profile; starts nothing")
297
- .action(function () {
298
- const { context, platform, unit, say } = prepare(this);
301
+ .summary("install this profile's background server to start at every login")
302
+ .description("write a systemd user unit or a launchd agent for this profile and have it start at every login; starts nothing now")
303
+ .action(async function () {
304
+ const { context, platform, unit, each, say } = prepare(this);
299
305
  const replaced = existsSync(unit.path);
300
306
  writeSecurely(unit.path, platform.text(unit, app), 0o644);
301
307
  platform.prepare?.(unit);
302
- say({ profile: unit.profile, unit: unit.name, path: unit.path, command: unit.command, replaced }, [
303
- `${replaced ? "Rewrote" : "Wrote"} ${tilde(unit.path, context.env)} — nothing started.`,
308
+ // A unit of moved folders runs a development checkout, which must not start at every login on the owner's keyring.
309
+ const moved = Object.keys(locationVariables(app, context.env)).length > 0;
310
+ if (!moved)
311
+ await each(platform.install(unit));
312
+ say({
313
+ profile: unit.profile,
314
+ unit: unit.name,
315
+ path: unit.path,
316
+ command: unit.command,
317
+ replaced,
318
+ startsAtLogin: !moved,
319
+ }, [
320
+ `${replaced ? "Rewrote" : "Wrote"} ${tilde(unit.path, context.env)} — ${moved ? "with moved folders it does not start at login" : "it starts at every login"}, nothing started now.`,
304
321
  `It runs: ${unit.command.map((word) => tilde(word, context.env)).join(" ")}`,
305
- `Start it now: ${app.command} ${unit.profile} server start · ${platform.atLogin(unit)}`,
322
+ `Start it now: ${app.command} ${unit.profile} server start · never at login: ${app.command} ${unit.profile} server uninstall`,
306
323
  // Suggested, never turned on: an agent reading chats every morning is the owner's choice (NEED-161).
307
324
  `A daily follow-up review is off. To turn it on: ${app.command} ${unit.profile} schedule agent follow-ups claude "${followUpsPrompt(app.command)}" --at 08:30`,
308
325
  ]);
@@ -311,13 +328,14 @@ export const serverCommand = (messenger, options = {}) => {
311
328
  effects: LOCAL_WRITE,
312
329
  examples: [`${app.command} personal server uninstall`],
313
330
  }))
314
- .description("remove this profile's unit; stop it first")
331
+ .description("remove this profile's unit, so it no longer starts at login; stop it first")
315
332
  .action(async function () {
316
- const { context, platform, unit, say } = prepare(this);
333
+ const { context, platform, unit, each, say } = prepare(this);
317
334
  installed(unit);
318
335
  if ((await platform.state(unit)).active) {
319
336
  throw new CliError("validation_error", `the unit is running — \`${app.command} ${unit.profile} server stop\` first`);
320
337
  }
338
+ await each(platform.uninstall(unit));
321
339
  rmSync(unit.path, { force: true });
322
340
  say({ profile: unit.profile, unit: unit.name, path: unit.path, removed: true }, [
323
341
  `Removed ${tilde(unit.path, context.env)}.`,
@@ -192,11 +192,15 @@ const runOnce = async (argv, definition, options) => {
192
192
  };
193
193
  const sigint = () => interrupted("SIGINT");
194
194
  const sigterm = () => interrupted("SIGTERM");
195
+ // A sync, or a one-shot's wait on work it started (`schedule run`), outlives its reader: a desktop that closed the
196
+ // pipe 18 minutes in must not lose the sync, and the run record still says how it ended. Its later lines go
197
+ // nowhere. A listening command's stream is its work, so it still ends.
198
+ let outlivesReader = false;
195
199
  const pipeError = (error) => {
196
- if (error.code === "EPIPE") {
197
- signalExit = 0;
198
- control.abort(error);
199
- }
200
+ if (error.code !== "EPIPE" || outlivesReader || (phased && !listens))
201
+ return;
202
+ signalExit = 0;
203
+ control.abort(error);
200
204
  };
201
205
  const externalAbort = () => {
202
206
  const cancelled = new CliError("cancelled", "command cancelled", { retryable: false });
@@ -343,6 +347,7 @@ const runOnce = async (argv, definition, options) => {
343
347
  const { lifetime, cooperative: stopsAtItsPace, partial: answersPartial } = metaOf(action);
344
348
  const persistent = lifetime === "persistent";
345
349
  listens = persistent;
350
+ outlivesReader = stopsAtItsPace === true;
346
351
  const interactiveLogin = !headless && lifetime === "interactive";
347
352
  const timeout = flags.timeout ?? (commandEnv[envName(definition.app, "TIMEOUT")]?.trim() || undefined);
348
353
  // A background fetch was asked to outlive the shell; only an explicit --timeout bounds it.
@@ -1,4 +1,4 @@
1
- import type { StoredProjectGathering } from "../services/stored-project-gathering.js";
1
+ import type { LeftOutChat, StoredProjectGathering } from "../services/stored-project-gathering.js";
2
2
  /** Days back (`90d`) or a start date (`2026-09-01`); either way the run freezes its end, so a resume keeps it. */
3
3
  export declare const projectWindowOf: (value: string) => {
4
4
  days: number;
@@ -10,7 +10,7 @@ export declare const projectCutoffTime: (value: string) => number;
10
10
  * The zone a typed start date was read in, and the window the run used, in UTC. `complete` is "the answer is whole":
11
11
  * reads finished and every linked chat held in full; the answer's own `complete` stays "reads finished".
12
12
  */
13
- export declare const projectAnswerParts: (sinceTime: string, found: StoredProjectGathering) => {
13
+ export declare const projectAnswerParts: (sinceTime: string, found: StoredProjectGathering, hints?: Pick<ProjectTextHints, "read">) => {
14
14
  next?: ({
15
15
  kind: "command";
16
16
  step: string;
@@ -45,7 +45,7 @@ export declare const projectAnswerParts: (sinceTime: string, found: StoredProjec
45
45
  * The gathering with each linked chat's `syncCommand`: the line that syncs a chat held in part back to the window
46
46
  * start, or null when it needs none or no command here can. JSON readers get what the text says per chat.
47
47
  */
48
- export declare const withSyncCommands: (found: StoredProjectGathering, sync: ProjectTextHints["sync"]) => {
48
+ export declare const withSyncCommands: (found: StoredProjectGathering, sync: ProjectTextHints["sync"], read?: ProjectTextHints["read"]) => {
49
49
  schemaVersion: 1;
50
50
  runKey: string;
51
51
  fingerprint: string;
@@ -80,7 +80,6 @@ export declare const withSyncCommands: (found: StoredProjectGathering, sync: Pro
80
80
  pages: number;
81
81
  issue: string | null;
82
82
  }[];
83
- items: import("../services/project-gathering.js").ProjectEvidenceItem[];
84
83
  people: import("../services/project-gathering.js").ProjectEvidencePerson[];
85
84
  peopleOmitted: number;
86
85
  oversized: {
@@ -110,6 +109,9 @@ export declare const withSyncCommands: (found: StoredProjectGathering, sync: Pro
110
109
  complete: boolean;
111
110
  completeness: "complete" | "partial" | "unknown";
112
111
  checkpoint: string | null;
112
+ items: (import("../services/project-gathering.js").ProjectEvidenceItem & {
113
+ proposed?: string;
114
+ })[];
113
115
  projectRef: string | null;
114
116
  unreadSources: {
115
117
  source: string;
@@ -117,6 +119,14 @@ export declare const withSyncCommands: (found: StoredProjectGathering, sync: Pro
117
119
  reason?: "out-of-scope" | "linked-chat-limit" | "member-limit" | "no-identity" | "unknown-provider";
118
120
  }[];
119
121
  mentions: import("../services/project-gathering-mentions.js").EvidenceMentions;
122
+ purposes: "all" | string[];
123
+ members: import("../services/stored-project-gathering.js").ProjectMember[];
124
+ purposeHint: {
125
+ chat: string;
126
+ relationship: string;
127
+ items: number;
128
+ of: number;
129
+ } | null;
120
130
  linkedChats: {
121
131
  provider: string;
122
132
  account: string;
@@ -134,13 +144,32 @@ export declare const withSyncCommands: (found: StoredProjectGathering, sync: Pro
134
144
  } | null;
135
145
  items?: number | undefined;
136
146
  capped?: boolean | undefined;
147
+ purpose: string;
148
+ proposed?: string;
149
+ score?: number;
137
150
  syncCommand: string | null;
138
151
  }[];
152
+ leftOut: {
153
+ ref: string;
154
+ provider: string;
155
+ account: string;
156
+ chat: string;
157
+ title: string | null;
158
+ purpose: string;
159
+ relationship: string | null;
160
+ proposed?: string;
161
+ messages: number;
162
+ firstAt: string | null;
163
+ lastAt: string | null;
164
+ readCommand: string | null;
165
+ }[];
139
166
  };
140
167
  export interface ProjectTextHints {
141
168
  /** The command that syncs one linked chat back to `since` (`YYYY-MM-DD`), or null when no command here can. */
142
169
  sync: (chat: StoredProjectGathering["linkedChats"][number], since: string) => string | null;
143
170
  /** How to open a document too large to read here. */
144
171
  showDocument: string;
172
+ /** The command that reads a chat left out by its purpose from `since`, or null when no command here can. */
173
+ read?: (chat: LeftOutChat, since: string) => string | null;
145
174
  }
146
175
  export declare const projectEvidenceText: (found: StoredProjectGathering, hints: ProjectTextHints) => string;