@wirecat/cli-core 0.347.0 → 0.349.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 (76) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/cli/bot/copy.d.ts +1 -1
  3. package/dist/cli/bot/copy.js +1 -1
  4. package/dist/cli/embedding-choice.js +2 -1
  5. package/dist/cli/envelope.d.ts +4 -0
  6. package/dist/cli/envelope.js +4 -3
  7. package/dist/cli/guide.js +4 -0
  8. package/dist/cli/help/first-screen.js +13 -1
  9. package/dist/cli/help/sections.d.ts +4 -3
  10. package/dist/cli/help/sections.js +5 -10
  11. package/dist/cli/index.d.ts +1 -0
  12. package/dist/cli/index.js +1 -0
  13. package/dist/cli/messenger/backfill-command.js +6 -6
  14. package/dist/cli/messenger/context.d.ts +3 -3
  15. package/dist/cli/messenger/context.js +44 -17
  16. package/dist/cli/messenger/doctor-command.js +3 -3
  17. package/dist/cli/messenger/flooded.js +1 -1
  18. package/dist/cli/messenger/mcp-command.js +1 -1
  19. package/dist/cli/messenger/messages-delete-command.js +2 -1
  20. package/dist/cli/messenger/observed.js +24 -7
  21. package/dist/cli/messenger/schedule-command.js +4 -3
  22. package/dist/cli/messenger/serve-relay.js +1 -0
  23. package/dist/cli/messenger/store-maintenance-command.js +3 -3
  24. package/dist/cli/messenger/stored.js +36 -30
  25. package/dist/cli/program.js +10 -9
  26. package/dist/cli/runs/command.js +3 -2
  27. package/dist/cli/steps.d.ts +4 -0
  28. package/dist/cli/steps.js +9 -0
  29. package/dist/cli/store-settings.js +4 -3
  30. package/dist/context/evidence.d.ts +10 -0
  31. package/dist/domain/models.d.ts +11 -1
  32. package/dist/kit/contract.d.ts +2 -2
  33. package/dist/kit/contract.js +25 -0
  34. package/dist/kit/fake.d.ts +1 -0
  35. package/dist/kit/fake.js +15 -16
  36. package/dist/mcp/meeting-preparation.d.ts +1 -0
  37. package/dist/mcp/meeting-project-candidates.d.ts +1 -0
  38. package/dist/mcp/meeting-series-inventory.d.ts +1 -0
  39. package/dist/mcp/tool.js +2 -0
  40. package/dist/mcp/tools/messages-photo.js +2 -1
  41. package/dist/meetings/mcp/passage.d.ts +1 -0
  42. package/dist/meetings/mcp/project-evidence.d.ts +1 -0
  43. package/dist/meetings/mcp/search.d.ts +1 -0
  44. package/dist/meetings/reads.d.ts +1 -0
  45. package/dist/ports/messenger.d.ts +10 -2
  46. package/dist/runs/recording.js +1 -0
  47. package/dist/runs/scope.d.ts +7 -0
  48. package/dist/runs/scope.js +5 -0
  49. package/dist/services/archive.js +2 -1
  50. package/dist/services/chats.js +3 -2
  51. package/dist/services/context-evidence.d.ts +1 -1
  52. package/dist/services/conversations.js +2 -1
  53. package/dist/services/deps.js +2 -1
  54. package/dist/services/embeddings-maintenance.js +3 -2
  55. package/dist/services/index.d.ts +6 -4
  56. package/dist/services/index.js +1 -0
  57. package/dist/services/project-gathering-around.d.ts +37 -0
  58. package/dist/services/project-gathering-around.js +57 -0
  59. package/dist/services/project-gathering-request.d.ts +13 -1
  60. package/dist/services/project-gathering-request.js +23 -5
  61. package/dist/services/project-gathering.d.ts +15 -1
  62. package/dist/services/project-gathering.js +32 -1
  63. package/dist/services/search-all-meetings.d.ts +3 -4
  64. package/dist/services/search-all.d.ts +8 -1
  65. package/dist/services/search-all.js +1 -1
  66. package/dist/speech/transcribe.js +2 -1
  67. package/dist/store/chats.d.ts +2 -2
  68. package/dist/store/sqlite/chats.d.ts +2 -2
  69. package/dist/store/sqlite/chats.js +7 -7
  70. package/dist/store/sqlite/context-evidence.d.ts +4 -1
  71. package/dist/store/sqlite/context-evidence.js +12 -0
  72. package/dist/store/store.d.ts +2 -1
  73. package/dist/store/store.js +5 -1
  74. package/dist/store/types.d.ts +2 -2
  75. package/package.json +3 -3
  76. package/parity.json +2 -12
@@ -11,6 +11,7 @@ export const lentConnection = (connection, watchers) => throughWrapper(connectio
11
11
  close: async () => { },
12
12
  feed: undefined,
13
13
  listen: undefined,
14
+ listenHistory: undefined,
14
15
  ...(connection.watch && watchers
15
16
  ? {
16
17
  // A watcher that throws (its output limit, a closed reader) ends its own watch, never serve's.
@@ -15,7 +15,7 @@ import { drainNoteIndex, fillSearchIndex, fillStems, involvementStoreOver, noteI
15
15
  import { environmentOf, outputFor } from "../context.js";
16
16
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
17
17
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
18
- import { nextStep } from "../steps.js";
18
+ import { lineOf, nextStep } from "../steps.js";
19
19
  import { answerOf } from "./ask.js";
20
20
  import { ENCRYPT_OPTION, passwordOf } from "./password.js";
21
21
  import { servingProfiles } from "./serve-command.js";
@@ -443,7 +443,7 @@ const reindexCommand = (messenger) => destroysNothing(annotate(new Command("rein
443
443
  `${schema.minCompatible}; this one speaks ${SPEAKS}) — upgrade this tool`);
444
444
  }
445
445
  if (schema.version < SPEAKS) {
446
- throw new CliError("validation_error", `the store is behind this build — \`${messenger.app.command} store migrate\` first`);
446
+ throw new CliError("validation_error", `the store is behind this build — \`${lineOf(this, "store migrate")}\` first`);
447
447
  }
448
448
  resetSearchIndex(database);
449
449
  const fileTexts = resetAttachmentWords(database);
@@ -591,7 +591,7 @@ const restoreFrom = async (command, messenger, backup, given) => {
591
591
  // One that has not touched the store yet holds nothing open, and was not seen.
592
592
  renderer.note("restart every serve and mcp of either CLI that was running, so they read the restored store");
593
593
  if (schema.version < SPEAKS) {
594
- renderer.note(`the backup is behind this build; the next command migrates it — \`${messenger.app.command} store migrate\` now`);
594
+ renderer.note(`the backup is behind this build; the next command migrates it — \`${lineOf(command, "store migrate")}\` now`);
595
595
  }
596
596
  };
597
597
  const backupSchema = async (backup) => {
@@ -49,6 +49,36 @@ export const stored = (messenger, { account, store, warn, events, pending, delet
49
49
  return undefined;
50
50
  }
51
51
  };
52
+ const saveBatches = (operation, onBatch) => {
53
+ // In order: a batch's messages may belong to chats only an earlier batch named.
54
+ let previous = Promise.resolve();
55
+ return (batch) => {
56
+ if (namesFromStore) {
57
+ for (const { from, to } of batch.rekeys ?? []) {
58
+ const title = titles.get(from);
59
+ titles.delete(from);
60
+ if (title && !titles.has(to))
61
+ titles.set(to, title);
62
+ }
63
+ for (const chat of batch.chats ?? [])
64
+ if (chat.title)
65
+ titles.set(chat.id, chat.title);
66
+ }
67
+ // Each re-key apart: one refused leaves the chat split, never the batch's history unsaved.
68
+ const saving = previous.then(async () => {
69
+ for (const { from, to } of batch.rekeys ?? [])
70
+ await attempt("history.rekey", async (opened) => {
71
+ await opened.chats.rekeyChat(account, from, to);
72
+ await rekeyed?.(from, to);
73
+ });
74
+ await attempt(operation, (opened) => fill(opened, account, batch));
75
+ });
76
+ previous = saving;
77
+ pending?.add(saving);
78
+ void saving.finally(() => pending?.delete(saving));
79
+ onBatch(batch);
80
+ };
81
+ };
52
82
  // A method not listed here saves nothing and passes through; a lane that should save adds its line.
53
83
  return throughWrapper(messenger, {
54
84
  ...(messenger.forumState
@@ -213,36 +243,12 @@ export const stored = (messenger, { account, store, warn, events, pending, delet
213
243
  : {}),
214
244
  ...(messenger.feed
215
245
  ? {
216
- feed: (onBatch, signal) => {
217
- // In order: a batch's messages may belong to chats only an earlier batch named.
218
- let previous = Promise.resolve();
219
- return (messenger.feed?.((batch) => {
220
- if (namesFromStore) {
221
- for (const { from, to } of batch.rekeys ?? []) {
222
- const title = titles.get(from);
223
- titles.delete(from);
224
- if (title && !titles.has(to))
225
- titles.set(to, title);
226
- }
227
- for (const chat of batch.chats ?? [])
228
- if (chat.title)
229
- titles.set(chat.id, chat.title);
230
- }
231
- // Each re-key apart: one refused leaves the chat split, never the batch's history unsaved.
232
- const saving = previous.then(async () => {
233
- for (const { from, to } of batch.rekeys ?? [])
234
- await attempt("history.rekey", async (opened) => {
235
- await opened.chats.rekeyChat(account, from, to);
236
- await rekeyed?.(from, to);
237
- });
238
- await attempt("history.feed", (opened) => fill(opened, account, batch));
239
- });
240
- previous = saving;
241
- pending?.add(saving);
242
- void saving.finally(() => pending?.delete(saving));
243
- onBatch(batch);
244
- }, signal) ?? Promise.resolve());
245
- },
246
+ feed: (onBatch, signal) => messenger.feed?.(saveBatches("history.feed", onBatch), signal) ?? Promise.resolve(),
247
+ }
248
+ : {}),
249
+ ...(messenger.listenHistory
250
+ ? {
251
+ listenHistory: (onBatch, signal) => messenger.listenHistory?.(saveBatches("history.listen", onBatch), signal) ?? Promise.resolve(),
246
252
  }
247
253
  : {}),
248
254
  ...(messenger.edit
@@ -19,7 +19,6 @@ import { changesAuthorityNow, isLogin, isStoreChange, judgedEffects, judgedTarge
19
19
  import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
20
20
  import { byteCount, DEFAULT_COMMAND_MS, DEFAULT_OUTPUT_BYTES, execution } from "./execution.js";
21
21
  import { installHelp } from "./help/first-screen.js";
22
- import { sectionCoreGroups } from "./help/sections.js";
23
22
  import { MAX_BUFFERED_INPUT, provideInputPolicy } from "./input-policy.js";
24
23
  import { checkJson, liftJson } from "./json-fields.js";
25
24
  import { movedPath, movedPointer } from "./moved.js";
@@ -27,7 +26,7 @@ import { PreviewComplete, preview } from "./preview.js";
27
26
  import { commandWords, liftProfile, PROFILE_FREE, valuedOptions } from "./profile.js";
28
27
  import { absolutePaths, relayTarget } from "./relay-route.js";
29
28
  import { settingsFor } from "./settings.js";
30
- import { commandAt } from "./steps.js";
29
+ import { commandAt, lineOf, withLines } from "./steps.js";
31
30
  /**
32
31
  * The command tree, built fresh on each call. Commander is global state by default —
33
32
  * `exitOverride` and the write hooks turn it into a value a test can drive.
@@ -63,13 +62,13 @@ export const createProgram = ({ app, commands, configure, firstScreen }, { out,
63
62
  for (const command of commands())
64
63
  program.addCommand(command);
65
64
  configure?.(program);
66
- sectionCoreGroups(program);
67
65
  installHelp(program, { pitch: app.description, ...firstScreen });
68
66
  // Commander builds a usage line from the command tree, where the profile is not a word.
69
67
  for (const top of program.commands) {
70
68
  if (PROFILE_FREE.has(top.name()))
71
69
  continue;
72
70
  forEachCommand(top, (command) => command.configureHelp({
71
+ ...command.configureHelp(),
73
72
  commandUsage: (shown) => `${app.command} <profile>${new Help().commandUsage(shown).slice(app.command.length)}`,
74
73
  }));
75
74
  }
@@ -327,7 +326,7 @@ export const run = async (argv, definition, options = {}) => {
327
326
  }
328
327
  // Not inside the scheduled run itself: its own failure is already on its way to last.json.
329
328
  if (!flags.quiet && commandEnv[envName(definition.app, "SCHEDULED")] === undefined && path[0] !== "schedule") {
330
- const notice = failingNoticeFor(definition.app, commandEnv);
329
+ const notice = failingNoticeFor(definition.app, commandEnv, program);
331
330
  if (notice)
332
331
  streams.diagnostic(notice);
333
332
  }
@@ -450,10 +449,10 @@ const quietBrokenPipes = () => {
450
449
  throw error;
451
450
  });
452
451
  };
453
- const failingNoticeFor = (app, env) => {
452
+ const failingNoticeFor = (app, env, program) => {
454
453
  try {
455
454
  const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state;
456
- return failingNotice(app.command, scheduledRuns(state), `${app.command} schedule list`);
455
+ return failingNotice(app.command, scheduledRuns(state), lineOf(program, "schedule list"));
457
456
  }
458
457
  catch {
459
458
  return undefined;
@@ -572,6 +571,7 @@ const failed = ({ app }, program, rest, error, settled, debug) => {
572
571
  return {
573
572
  object,
574
573
  cli: app.command,
574
+ program,
575
575
  ...(debug
576
576
  ? { record: errorRecordOf(failure, object, { command, service: app.command, version: app.version }) }
577
577
  : {}),
@@ -581,12 +581,13 @@ const failed = ({ app }, program, rest, error, settled, debug) => {
581
581
  * **A failure never reaches stdout.** It goes to stderr — as JSON when nobody is watching, because
582
582
  * an exit code says which kind of thing went wrong and nothing about which chat or how long to wait.
583
583
  */
584
- const report = (streams, options, { object, record, cli }) => {
584
+ const report = (streams, options, { object, record, cli, program }) => {
585
585
  if (options.agent) {
586
586
  options.agent.failure(object);
587
587
  return exitCodeOfError(object);
588
588
  }
589
589
  const interactive = options.tty ?? process.stdout.isTTY === true;
590
- streams.diagnostic(renderError(object, { cli, interactive, ...(record ? { record } : {}) }));
591
- return exitCodeOfError(object);
590
+ const lined = object.error.next ? { error: { ...object.error, next: withLines(program, object.error.next) } } : object;
591
+ streams.diagnostic(renderError(lined, { cli, interactive, ...(record ? { record } : {}) }));
592
+ return exitCodeOfError(lined);
592
593
  };
@@ -6,6 +6,7 @@ import { environmentOf, outputFor } from "../context.js";
6
6
  import { LOCAL_READ, NO_EFFECTS } from "../effects.js";
7
7
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
8
8
  import { positiveCount } from "../paging.js";
9
+ import { lineOf } from "../steps.js";
9
10
  import { runsFound, searchRuns } from "./search.js";
10
11
  /** Reading the log is not itself worth recording, so **nothing here starts a run**. */
11
12
  export const runsCommand = (app) => {
@@ -14,7 +15,7 @@ export const runsCommand = (app) => {
14
15
  const runOrRefuse = (from, id) => {
15
16
  const found = findRun(dir(from), id);
16
17
  if (!found)
17
- throw new CliError("not_found", `no recorded run "${id}" — \`${app.command} runs list\` shows them`);
18
+ throw new CliError("not_found", `no recorded run "${id}" — \`${lineOf(from, "runs list")}\` shows them`);
18
19
  return found;
19
20
  };
20
21
  annotate(command.command("list"), { effects: LOCAL_READ, mcp: false })
@@ -50,7 +51,7 @@ export const runsCommand = (app) => {
50
51
  events: shown,
51
52
  }));
52
53
  if (shown.length < events.length)
53
- renderer.note(`${events.length - shown.length} other events — \`${app.command} runs show ${id}\` shows them all`);
54
+ renderer.note(`${events.length - shown.length} other events — \`${lineOf(this, `runs show ${id}`)}\` shows them all`);
54
55
  });
55
56
  annotate(command.command("search"), { effects: LOCAL_READ })
56
57
  .description("search recorded diagnostic metadata and events; no message contents")
@@ -20,6 +20,8 @@ export declare const STEPS: ({
20
20
  })[];
21
21
  /** A step from its registered definition; an argument left undefined is left out, as the command line would. */
22
22
  export declare const nextStep: (id: string, input: StepInput) => NextStep;
23
+ /** `typedCommand` with the profile this command line named, else the run's. */
24
+ export declare const lineOf: (command: Command, words: string) => string;
23
25
  export declare const rootOf: (command: Command) => Command;
24
26
  /** The command a path names in this program, or nothing when this CLI does not have it. */
25
27
  export declare const commandAt: (root: Command, path: string) => Command | undefined;
@@ -35,6 +37,8 @@ type Targets = NonNullable<StepInput["targets"]>;
35
37
  export declare const runnable: (root: Command, steps: readonly NextStep[] | undefined) => NextStep[];
36
38
  /** Each command step judged by its command's effects: safe to run unasked only when it writes nothing remotely. */
37
39
  export declare const withSafety: (steps: readonly NextStep[], targets: Targets) => NextStep[];
40
+ /** Each command step with `line`, the whole command to type — only the CLI knows the profile, so MCP gets none. */
41
+ export declare const withLines: (root: Command, steps: readonly NextStep[]) => NextStep[];
38
42
  /**
39
43
  * The step as a person types it: the profile the command ran with, positional arguments in their place, then the
40
44
  * options. A CLI that assumes no profile refuses the line without it.
package/dist/cli/steps.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { STEPS as BASE_STEPS, defineSteps, MAX_NEXT_STEPS, stepOf, writes, } from "@wirecat/cli-base/standards";
2
+ import { typedCommand } from "../runs/scope.js";
2
3
  import { judgedTargets } from "./effects.js";
3
4
  /**
4
5
  * Every step an answer or an error may name. A step's command must exist in tg and max alike, and its
@@ -211,6 +212,12 @@ export const nextStep = (id, input) => {
211
212
  const args = Object.fromEntries(Object.entries(input.args ?? {}).filter(([, value]) => value !== undefined));
212
213
  return stepOf(definition, { ...input, args });
213
214
  };
215
+ /** `typedCommand` with the profile this command line named, else the run's. */
216
+ export const lineOf = (command, words) => {
217
+ const root = rootOf(command);
218
+ const profile = root.getOptionValue("profile");
219
+ return typedCommand(root.name(), words, typeof profile === "string" && profile !== "" ? profile : undefined);
220
+ };
214
221
  export const rootOf = (command) => {
215
222
  let root = command;
216
223
  while (root.parent)
@@ -239,6 +246,8 @@ export const withSafety = (steps, targets) => steps.map((step) => {
239
246
  const effects = targets(step.command)?.effects;
240
247
  return { ...step, safe_to_auto_execute: effects !== undefined && !writes(effects) };
241
248
  });
249
+ /** Each command step with `line`, the whole command to type — only the CLI knows the profile, so MCP gets none. */
250
+ export const withLines = (root, steps) => steps.map((step) => (step.kind === "command" ? { ...step, line: stepLine(root, step) } : step));
242
251
  const kebab = (name) => name.replaceAll("_", "-");
243
252
  const quoted = (value) => {
244
253
  const text = String(value);
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { DEFAULT_PHONE_COUNTRIES, parsePhoneCountries } from "../domain/phones.js";
4
4
  import { DEFAULT_TASK_CATEGORIES, taskCategoryValue, unlistedNote, withTaskCategory, } from "../domain/task-categories.js";
5
+ import { typedCommand } from "../runs/scope.js";
5
6
  import { ACCOUNT_GROUPS_SETTING, groupMembers, groupName } from "../search/account-list.js";
6
7
  import { DEFAULT_STEMMERS, parseStemmers } from "../search/stem.js";
7
8
  import { openCache } from "../store/open.js";
@@ -82,7 +83,7 @@ const changePhoneCountries = async (app, env, value) => {
82
83
  value: (countries ?? DEFAULT_PHONE_COUNTRIES).join(","),
83
84
  },
84
85
  note: "store-wide — a number written without + is read in these countries, the first that fits; numbers already " +
85
- `found keep their old reading until \`${app.command} mentions rebuild phone\``,
86
+ `found keep their old reading until \`${typedCommand(app.command, "mentions rebuild phone")}\``,
86
87
  rebuild: true,
87
88
  };
88
89
  };
@@ -150,7 +151,7 @@ const changeFirstPass = async (app, env, value) => {
150
151
  return {
151
152
  result: { store: storePath(env), scope: "store", setting: FIRST_PASS, value: now },
152
153
  note: `store-wide — an account's first sync opens at most ${now} task(s) per chat from history; later syncs read ` +
153
- `only new messages. \`${app.command} tasks scan <chat>\` builds tasks from a chat's history when asked`,
154
+ `only new messages. \`${typedCommand(app.command, "tasks scan <chat>")}\` builds tasks from a chat's history when asked`,
154
155
  };
155
156
  }
156
157
  finally {
@@ -199,7 +200,7 @@ const changeGroup = async (app, env, setting, value) => {
199
200
  return {
200
201
  result: { store: storePath(env), scope: "store", setting, value: members ?? null },
201
202
  note: `store-wide — \`--source group:${name}\` searches these accounts from every app; ` +
202
- `a profile is found once its app has saved something (\`${app.command} config show\`)`,
203
+ `a profile is found once its app has saved something (\`${typedCommand(app.command, "config show")}\`)`,
203
204
  };
204
205
  }
205
206
  finally {
@@ -53,6 +53,16 @@ export interface ContextEvidencePage {
53
53
  export interface ContextEvidenceReader {
54
54
  read(request: ContextEvidenceRequest): Promise<ContextEvidencePage>;
55
55
  }
56
+ /** Brings the word index of stored mail and imported documents up to date, so the reads that follow find new text. */
57
+ export interface ContextIndex {
58
+ catchUp(request: {
59
+ kinds: readonly ContextSource["kind"][];
60
+ signal?: AbortSignal;
61
+ }): Promise<{
62
+ mail: number;
63
+ documents: number;
64
+ }>;
65
+ }
56
66
  export declare const contextCursorScope: (request: Pick<ContextEvidenceRequest, "source" | "query" | "after" | "before">) => string;
57
67
  export declare const contextSource: (value: ContextSource) => ContextSource;
58
68
  export declare const contextEvidenceRequest: (input: ContextEvidenceRequest) => {
@@ -25,7 +25,10 @@ export interface Chat {
25
25
  id: Id;
26
26
  title: string | null;
27
27
  kind: ChatKind;
28
- /** `null` means the provider did not say, which is not the same as zero. */
28
+ /**
29
+ * `null` means the provider did not say, which is not the same as zero. A save that does not know it leaves it
30
+ * out.
31
+ */
29
32
  unreadCount: number | null;
30
33
  /** ISO 8601, or `null` when the chat has never had a message. */
31
34
  lastMessageAt: string | null;
@@ -43,6 +46,13 @@ export interface Chat {
43
46
  */
44
47
  providerMetadata?: ProviderMetadata;
45
48
  }
49
+ /**
50
+ * A chat as an adapter saves it. A `title`, `unreadCount`, `lastMessageAt` or `participantsCount` left out keeps the
51
+ * stored value; `null` stores none. So a push that knows only some of them, such as a notice that a group left its
52
+ * community, leaves the rest out.
53
+ */
54
+ export type ChatUpdate = Omit<Chat, KeptFields> & Partial<Pick<Chat, KeptFields>>;
55
+ type KeptFields = "title" | "unreadCount" | "lastMessageAt" | "participantsCount";
46
56
  /** A group's switches; `null` where the messenger does not say, or has no such switch. */
47
57
  export interface GroupSettings {
48
58
  allCanPin: boolean | null;
@@ -16,13 +16,13 @@ export interface ContractOptions {
16
16
  ids?: IdMaker;
17
17
  /** As the messenger's `fetching.orderBy`: whether `history`'s `before` is a message id or an ISO time. */
18
18
  orderBy?: "id" | "time";
19
- /** How long `watch` may take to say it listens, and `watch`, `listen` and `feed` to end once aborted. */
19
+ /** How long `watch` may take to say it listens, and `watch`, `listen`, `listenHistory` and `feed` to end on abort. */
20
20
  waitMs?: number;
21
21
  /** As the messenger's `history`: a `"server"` messenger's adapter must have every `ServerReads` method. */
22
22
  history?: "server" | "store";
23
23
  }
24
24
  /** Every method of the optional groups, which `capability` hands over or refuses. */
25
- export declare const OPTIONAL_METHODS: readonly ["fetchCounters", "chats", "history", "around", "contact", "historyAfter", "historyBefore", "topics", "inspect", "edit", "forward", "delete", "pin", "unpin", "star", "react", "markRead", "markTopicRead", "poll", "vote", "closePoll", "createPoll", "watch", "listen", "feed", "download", "transcribe", "scheduled", "members", "admins", "chatEvents", "sessions", "lookup", "addressBook", "people", "createGroup", "join", "leave", "group", "updateGroup", "resetInviteLink", "addMembers", "removeMembers", "addAdmin", "removeAdmin", "folders", "createFolder", "updateFolder", "deleteFolder", "addContact", "removeContact", "block", "unblock", "renameContact", "importContacts", "updateProfile", "endOtherSessions"];
25
+ export declare const OPTIONAL_METHODS: readonly ["fetchCounters", "chats", "history", "around", "contact", "historyAfter", "historyBefore", "topics", "inspect", "edit", "forward", "delete", "pin", "unpin", "star", "react", "markRead", "markTopicRead", "poll", "vote", "closePoll", "createPoll", "watch", "listen", "listenHistory", "feed", "download", "transcribe", "scheduled", "members", "admins", "chatEvents", "sessions", "lookup", "addressBook", "people", "createGroup", "join", "leave", "group", "updateGroup", "resetInviteLink", "addMembers", "removeMembers", "addAdmin", "removeAdmin", "folders", "createFolder", "updateFolder", "deleteFolder", "addContact", "removeContact", "block", "unblock", "renameContact", "importContacts", "updateProfile", "endOtherSessions"];
26
26
  /**
27
27
  * The port's promises as cases any test runner can run: each connects through `connect`, checks
28
28
  * with `node:assert/strict` and closes the adapter. None needs a live service.
@@ -29,6 +29,7 @@ export const OPTIONAL_METHODS = [
29
29
  "createPoll",
30
30
  "watch",
31
31
  "listen",
32
+ "listenHistory",
32
33
  "feed",
33
34
  "download",
34
35
  "transcribe",
@@ -430,6 +431,30 @@ export const contractCases = ({ connect, ids = wordIds, orderBy = "id", waitMs =
430
431
  await within(listening, "listen did not end when the signal aborted", waitMs);
431
432
  },
432
433
  ],
434
+ [
435
+ "listenHistory on a signal already aborted resolves at once and does not reject",
436
+ async (adapter) => {
437
+ const skipped = lacking(adapter, "listenHistory");
438
+ if (skipped)
439
+ return skipped;
440
+ const controller = new AbortController();
441
+ controller.abort();
442
+ await within(Promise.resolve(adapter.listenHistory?.(() => { }, controller.signal)), "listenHistory did not resolve on a signal already aborted", waitMs);
443
+ },
444
+ ],
445
+ [
446
+ "listenHistory keeps listening until the signal aborts, then resolves",
447
+ async (adapter) => {
448
+ const skipped = lacking(adapter, "listenHistory");
449
+ if (skipped)
450
+ return skipped;
451
+ const controller = new AbortController();
452
+ const listening = Promise.resolve(adapter.listenHistory?.(() => { }, controller.signal));
453
+ assert.equal(await settledSoon(listening), false, "listenHistory ended before the signal aborted");
454
+ controller.abort();
455
+ await within(listening, "listenHistory did not end when the signal aborted", waitMs);
456
+ },
457
+ ],
433
458
  [
434
459
  "feed pushes only the seed's chats and messages, with ids as strings, and ends when the signal aborts",
435
460
  async (adapter, seed) => {
@@ -5,4 +5,5 @@ export type FakeAdapter = MessengerCore & ServerReads & ChatReading & MessageEdi
5
5
  export declare const fakeAdapter: (seed?: Seed, options?: {
6
6
  feed?: boolean;
7
7
  listen?: boolean;
8
+ listenHistory?: boolean;
8
9
  }) => FakeAdapter;
package/dist/kit/fake.js CHANGED
@@ -28,7 +28,8 @@ const missing = (what) => new CliError("not_found", `the fake adapter has no ${w
28
28
  *
29
29
  * With `{ feed: true }` it also pushes its history, as a messenger with `history: "store"` does:
30
30
  * the chats and people in one batch, then each chat's messages in a batch of their own.
31
- * With `{ listen: true }` it also has `listen`, which hands on what `watch` does.
31
+ * With `{ listen: true }` it also has `listen`, which hands on what `watch` does, and with `{ listenHistory: true }`
32
+ * `listenHistory`, which hands on what `feed` does.
32
33
  */
33
34
  const withRules = (folder, { emoji, include, skip, exclude, pin }) => {
34
35
  if (emoji !== undefined)
@@ -57,6 +58,17 @@ export const fakeAdapter = (seed = contractSeed(), options = {}) => {
57
58
  const folders = [];
58
59
  const listeners = new Set();
59
60
  let counter = 0;
61
+ const pushHistory = async (onBatch, signal) => {
62
+ if (signal.aborted)
63
+ return;
64
+ onBatch({ chats: structuredClone(state.chats), people: state.people.map(asMember) });
65
+ for (const chat of state.chats) {
66
+ const messages = messagesOf(chat.id);
67
+ if (messages.length > 0)
68
+ onBatch({ messages: structuredClone(messages) });
69
+ }
70
+ await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
71
+ };
60
72
  const ownId = () => {
61
73
  if (!account)
62
74
  throw new CliError("authentication_error", "the fake adapter is not logged in");
@@ -323,21 +335,8 @@ export const fakeAdapter = (seed = contractSeed(), options = {}) => {
323
335
  },
324
336
  }
325
337
  : {}),
326
- ...(options.feed
327
- ? {
328
- feed: async (onBatch, signal) => {
329
- if (signal.aborted)
330
- return;
331
- onBatch({ chats: structuredClone(state.chats), people: state.people.map(asMember) });
332
- for (const chat of state.chats) {
333
- const messages = messagesOf(chat.id);
334
- if (messages.length > 0)
335
- onBatch({ messages: structuredClone(messages) });
336
- }
337
- await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
338
- },
339
- }
340
- : {}),
338
+ ...(options.feed ? { feed: pushHistory } : {}),
339
+ ...(options.listenHistory ? { listenHistory: pushHistory } : {}),
341
340
  download: async (reference, messageId) => {
342
341
  const message = messageOf(chatOf(reference).id, messageId);
343
342
  return {
@@ -42,6 +42,7 @@ export declare const callMeetingPreparationTool: (read: MeetingPreparationReader
42
42
  args: {
43
43
  [x: string]: unknown;
44
44
  };
45
+ line?: string | undefined;
45
46
  why: string;
46
47
  safe_to_auto_execute: boolean;
47
48
  } | {
@@ -37,6 +37,7 @@ export declare const callMeetingProjectCandidatesTool: (read: StoredMeetingProje
37
37
  args: {
38
38
  [x: string]: unknown;
39
39
  };
40
+ line?: string | undefined;
40
41
  why: string;
41
42
  safe_to_auto_execute: boolean;
42
43
  } | {
@@ -37,6 +37,7 @@ export declare const callMeetingSeriesInventoryTool: (read: MeetingSeriesInvento
37
37
  args: {
38
38
  [x: string]: unknown;
39
39
  };
40
+ line?: string | undefined;
40
41
  why: string;
41
42
  safe_to_auto_execute: boolean;
42
43
  } | {
package/dist/mcp/tool.js CHANGED
@@ -116,10 +116,12 @@ export const entryRunner = (registration) => {
116
116
  ...(traceId === undefined ? {} : { traceId }),
117
117
  })
118
118
  : undefined;
119
+ const profile = registration.defaults.settings.profile;
119
120
  const scope = {
120
121
  runId: recording?.runId ?? newRunId(new Date(startedAt), command),
121
122
  command,
122
123
  startedAt,
124
+ ...(profile === undefined ? {} : { profile }),
123
125
  ...(traceId === undefined ? {} : { traceId }),
124
126
  };
125
127
  const result = await inRun(scope, () => {
@@ -3,6 +3,7 @@ import * as v from "valibot";
3
3
  import { itemOf } from "../../cli/envelope.js";
4
4
  import { keepRefreshedRefs } from "../../cli/messenger/refreshed-refs.js";
5
5
  import { capability } from "../../ports/messenger.js";
6
+ import { typedCommand } from "../../runs/scope.js";
6
7
  import { chatOf, message, Picture, READ, tool } from "../tool.js";
7
8
  /** Larger than this and a client may refuse the answer, or spend the model's context on one picture. */
8
9
  export const PHOTO_LIMIT = 512 * 1024;
@@ -26,7 +27,7 @@ export const messagesPhotoTools = (messenger) => ({
26
27
  annotations: READ,
27
28
  online: async (adapter, args, defaults) => {
28
29
  const { id: chatId } = await adapter.resolve(args.chat);
29
- const saveIt = `the owner can save it with \`${messenger.app.command} messages download ${chatId} ${args.message}\``;
30
+ const saveIt = `the owner can save it with \`${typedCommand(messenger.app.command, `messages download ${chatId} ${args.message}`)}\``;
30
31
  const { files } = await capability(adapter, "download", "download attachments")(chatId, args.message);
31
32
  const photo = args.index === undefined ? files.find((file) => file.kind === "photo") : files[args.index];
32
33
  if (!photo) {
@@ -71,6 +71,7 @@ export declare const callMeetingPassageTool: (read: MeetingPassageReader, input:
71
71
  args: {
72
72
  [x: string]: unknown;
73
73
  };
74
+ line?: string | undefined;
74
75
  why: string;
75
76
  safe_to_auto_execute: boolean;
76
77
  } | {
@@ -65,6 +65,7 @@ export declare const callMeetingProjectEvidenceTool: (read: MeetingProjectDiscov
65
65
  args: {
66
66
  [x: string]: unknown;
67
67
  };
68
+ line?: string | undefined;
68
69
  why: string;
69
70
  safe_to_auto_execute: boolean;
70
71
  } | {
@@ -107,6 +107,7 @@ export declare const callMeetingSearchTool: (search: MeetingHitSearch, input: un
107
107
  args: {
108
108
  [x: string]: unknown;
109
109
  };
110
+ line?: string | undefined;
110
111
  why: string;
111
112
  safe_to_auto_execute: boolean;
112
113
  } | {
@@ -51,6 +51,7 @@ export declare const meetingPeople: (store: MeetingStore & Partial<MeetingPartic
51
51
  args: {
52
52
  [x: string]: unknown;
53
53
  };
54
+ line?: string | undefined;
54
55
  why: string;
55
56
  safe_to_auto_execute: boolean;
56
57
  } | {
@@ -3,7 +3,7 @@ import type { CounterField, CounterObservations } from "../domain/counters.js";
3
3
  import type { HtmlFormatting, MarkdownFormatting, TextSpan } from "../domain/formatting.js";
4
4
  import type { Markup } from "../domain/markdown.js";
5
5
  import type { MessagePermalink } from "../domain/message-link.js";
6
- import type { Account, AccountSession, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
6
+ import type { Account, AccountSession, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, ChatUpdate, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
7
7
  /** One attachment's bytes, fetched over the adapter's connection when `bytes` is read — so read it before the command closes. */
8
8
  export interface RemoteFile {
9
9
  kind: string;
@@ -316,6 +316,14 @@ export interface PassiveUpdates {
316
316
  * be handed on.
317
317
  */
318
318
  listen(onEvent: (event: MessageEvent) => void, signal: AbortSignal): Promise<void>;
319
+ /**
320
+ * What the open connection is pushed of the messenger's history, as `feed` hands it, until `signal` aborts — on
321
+ * the same connections as `listen`, under the same rules: it never connects or waits for the connection, and
322
+ * resolves once nothing more will be handed on. A messenger that pushes history once (`history: "store"`) then
323
+ * has it saved by a command that never feeds. Optional within the group, so an adapter typed as having
324
+ * `PassiveUpdates` before it existed still is one.
325
+ */
326
+ listenHistory?(onBatch: (batch: HistoryBatch) => void, signal: AbortSignal): Promise<void>;
319
327
  }
320
328
  /** One voter's vote in the poll message `messageId` of `chatId` carries, as `PollVoteEvent` has it. */
321
329
  export interface HistoryVote {
@@ -346,7 +354,7 @@ export interface HistoryBatch {
346
354
  from: Id;
347
355
  to: Id;
348
356
  }[];
349
- chats?: Chat[];
357
+ chats?: ChatUpdate[];
350
358
  people?: Member[];
351
359
  messages?: Message[];
352
360
  votes?: HistoryVote[];
@@ -19,6 +19,7 @@ export const recorded = async (options, body) => {
19
19
  runId: recording.runId,
20
20
  command: options.command,
21
21
  startedAt: Date.now(),
22
+ profile: options.profile,
22
23
  ...(recording.traceId ? { traceId: recording.traceId } : {}),
23
24
  }, () => body(recording.events));
24
25
  await recording.succeed(answer);
@@ -5,6 +5,8 @@ export interface RunScope {
5
5
  startedAt?: number;
6
6
  /** The caller's trace — an MCP client's `_meta.traceparent`, or `TRACEPARENT` from whatever started this process. */
7
7
  traceId?: string;
8
+ /** The profile the run is for, so a hint deep in a service can name the command with it. */
9
+ profile?: string;
8
10
  }
9
11
  /**
10
12
  * The run a log line written now belongs to, for a writer that is not handed one — the send journal,
@@ -12,6 +14,11 @@ export interface RunScope {
12
14
  */
13
15
  export declare const currentRun: () => RunScope;
14
16
  export declare const inRun: <T>(scope: RunScope, body: () => T) => T;
17
+ /**
18
+ * A command as a person types it: `max personal store migrate`. A hint without the profile fails when copied,
19
+ * since no profile is assumed; outside a run with a profile it stays `max store migrate`.
20
+ */
21
+ export declare const typedCommand: (tool: string, words: string, profile?: string | undefined) => string;
15
22
  /**
16
23
  * For a one-shot command whose action runs outside `inRun` — max's `max bot`, recorded from commander's hooks, which
17
24
  * cannot wrap the action — so its answers' `meta.run_id` names the run `runs show` finds. Never in a server.