@wirecat/cli-core 0.390.0 → 0.392.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 +60 -0
  2. package/dist/cli/bot/command.js +33 -4
  3. package/dist/cli/bot/port.d.ts +35 -1
  4. package/dist/cli/bot/token.d.ts +8 -1
  5. package/dist/cli/bot/token.js +16 -3
  6. package/dist/cli/bot/watch.d.ts +2 -0
  7. package/dist/cli/bot/watch.js +54 -12
  8. package/dist/cli/bot/webhook-receiver.d.ts +22 -0
  9. package/dist/cli/bot/webhook-receiver.js +68 -0
  10. package/dist/cli/effects.d.ts +2 -0
  11. package/dist/cli/effects.js +8 -2
  12. package/dist/cli/index.d.ts +1 -1
  13. package/dist/cli/messenger/backfill-command.js +5 -1
  14. package/dist/cli/messenger/catch-up.d.ts +0 -19
  15. package/dist/cli/messenger/catch-up.js +0 -43
  16. package/dist/cli/messenger/context.js +6 -3
  17. package/dist/cli/messenger/conversations-command.js +4 -1
  18. package/dist/cli/messenger/download-command.js +4 -1
  19. package/dist/cli/messenger/inbox.js +6 -11
  20. package/dist/cli/messenger/lacks.d.ts +1 -0
  21. package/dist/cli/messenger/lacks.js +6 -1
  22. package/dist/cli/messenger/mcp-command.js +6 -28
  23. package/dist/cli/messenger/messages-command.js +17 -18
  24. package/dist/cli/messenger/messages-comments-command.js +4 -1
  25. package/dist/cli/messenger/messages-delete-command.js +4 -1
  26. package/dist/cli/messenger/messages-edit-command.js +4 -1
  27. package/dist/cli/messenger/messages-evidence-command.js +4 -1
  28. package/dist/cli/messenger/messages-forward-command.js +4 -1
  29. package/dist/cli/messenger/messages-link-command.js +4 -1
  30. package/dist/cli/messenger/messages-scheduled-command.js +4 -1
  31. package/dist/cli/messenger/messages-send-command.js +4 -1
  32. package/dist/cli/messenger/review.d.ts +1 -1
  33. package/dist/cli/messenger/review.js +5 -10
  34. package/dist/cli/messenger/tags-command.js +2 -2
  35. package/dist/cli/messenger/transcribe-command.js +4 -1
  36. package/dist/cli/settings.d.ts +0 -4
  37. package/dist/cli/settings.js +7 -5
  38. package/dist/domain/providers.js +2 -0
  39. package/dist/{mcp/http/node.d.ts → http/listen.d.ts} +3 -2
  40. package/dist/{mcp/http/node.js → http/listen.js} +5 -5
  41. package/dist/http/public-url.d.ts +10 -0
  42. package/dist/http/public-url.js +32 -0
  43. package/dist/mcp/http/serve.d.ts +1 -1
  44. package/dist/mcp/http/serve.js +1 -1
  45. package/dist/meetings/index.d.ts +2 -0
  46. package/dist/meetings/index.js +2 -0
  47. package/dist/meetings/links.d.ts +18 -0
  48. package/dist/meetings/links.js +1 -0
  49. package/dist/meetings/review.d.ts +45 -0
  50. package/dist/meetings/review.js +1 -0
  51. package/dist/search/lucene/registry.d.ts +5 -1
  52. package/dist/search/lucene/registry.js +10 -4
  53. package/dist/search/lucene/types.d.ts +8 -0
  54. package/dist/search/lucene/types.js +4 -0
  55. package/dist/search/question-plan.js +3 -3
  56. package/dist/services/excerpt.js +4 -1
  57. package/dist/services/index.d.ts +2 -0
  58. package/dist/services/index.js +2 -0
  59. package/dist/services/meeting-links.d.ts +33 -0
  60. package/dist/services/meeting-links.js +41 -0
  61. package/dist/services/meeting-review.d.ts +12 -0
  62. package/dist/services/meeting-review.js +8 -0
  63. package/dist/services/messages-search.js +2 -2
  64. package/dist/services/searches.js +3 -3
  65. package/dist/store/sqlite/lucene-fields.d.ts +10 -3
  66. package/dist/store/sqlite/lucene-fields.js +85 -13
  67. package/dist/store/sqlite/lucene.js +16 -11
  68. package/dist/store/sqlite/meeting-chat-lines.d.ts +3 -0
  69. package/dist/store/sqlite/meeting-chat-lines.js +34 -0
  70. package/dist/store/sqlite/meeting-review.d.ts +4 -0
  71. package/dist/store/sqlite/meeting-review.js +97 -0
  72. package/dist/store/sqlite/meetings.d.ts +2 -2
  73. package/dist/store/sqlite/meetings.js +4 -0
  74. package/dist/store/sqlite/mention-drain.js +3 -2
  75. package/package.json +1 -1
  76. package/parity.json +339 -118
package/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## 0.392.0 — 23.10.2026
9
+
10
+ ### Added
11
+
12
+ - Message commands include concrete examples in help and command discovery.
13
+
14
+ ### Fixed
15
+
16
+ - **Agents are no longer offered channel comments for a messenger that cannot read them.** Direct calls
17
+ fail before connecting when the messenger declares comments unsupported.
18
+
19
+ ## 0.391.0 — 23.10.2026
20
+
21
+ ### Changed — may break callers
22
+
23
+ - Reads never mark chats read: `messages list`, `inbox` and `review` no longer take `--mark-read` or
24
+ `--no-mark-read`. Remove `catch_up_marks_read` from configuration; it is now refused. Use
25
+ `<cli> <profile> chats mark-read <chat>` when you intend to send a read receipt.
26
+ - `tags auto` declares its messenger read, including the preparatory read requested with `--sync-first`.
27
+
28
+ ### Added
29
+
30
+ - **`bot watch --public-url <url> [--port <n>]` receives a bot's updates by webhook.** For a messenger that pushes and
31
+ cannot be polled — WhatsApp's Cloud API — the command listens on 127.0.0.1 (port 8766 by default) behind the owner's
32
+ tunnel, answers the subscription handshake, refuses a delivery whose signature does not match (401) or that comes
33
+ for another host (403), and answers 200 only after the batch is kept, so the messenger sends again what was not.
34
+ The same copy, redelivery check and `keepUpdates` run as for polling. A messenger opts in with the new
35
+ `BotWebhookIntake` port piece (`webhookHandshake`, `webhookAuthentic`, `webhookEvents`); one without it refuses.
36
+ - **`BotMessenger.secrets` and `bot auth set --secret <name>`** keep a secret beside the bot token, the same way
37
+ (keyring account `bot:<profile>:<name>`, `<PREFIX>_BOT_<NAME>`, then a 0600 file) — a webhook's signing key.
38
+ `BotTokenStore` gains `readSecret`, `writeSecret` and `removeSecret`; `bot auth show` says where each comes from and
39
+ `bot auth remove` forgets them too. Without `secrets` nothing changes.
40
+ - **`--source` knows `whatsapp` (`wa`) and `whatsapp-bot` (`wa-bot`).**
41
+
42
+ - `readMeetingReview` (`@wirecat/cli-core/services`) and `MeetingStore.meetingReview`: the newest meetings of a
43
+ selection, each with its newest summary (overview, next steps), transcript lines and words, the three who talked
44
+ longest and its confirmed projects. No transcript text is read out.
45
+ - `readMeetingLinks` (`@wirecat/cli-core/services`) and `MeetingStore.meetingChatLines`: links shared in the chat of
46
+ the selected meetings, most shared first, with who shared them, in which meetings, and the Google file each names.
47
+ Found when read, not stored; at most 100000 chat messages, more is an error.
48
+ - A local read with an unknown profile now lists the profiles on this computer in its error and
49
+ `details.profiles`, so a typo does not suggest linking a new account. A configured profile with no
50
+ saved account still explains how to fill its local history.
51
+
52
+ - Unknown permission keys now refuse messenger commands with `configuration_error`, including typos after
53
+ the resource name such as `messages.sedn`. Correct the key or remove it with `config unset`; a warning
54
+ no longer leaves the intended restriction unenforced.
55
+ - **Message search reads `"a b"~N`: the words at most N words apart in one message, in any order.** It was a
56
+ `query.unsupported-operator` error; now it is syntax for `text:`, `exact:`, `transcript:`, `content:` and a bare
57
+ word, N up to 50 (`query.invalid-number` above). Words match in any form, as in a phrase; `exact:` keeps the typed
58
+ forms. Unlike Lucene, order costs nothing: `"a b"~0` finds "b a" too. `word~1` stays an error. A saved search may
59
+ now hold `~N`. `QUERY_OPERATORS` splits the `fuzzy / proximity` row into `proximity` and `fuzzy`.
60
+
61
+ ### Fixed
62
+
63
+ - **An agent's text on a voice note is its transcript.** `transcript:`, the transcripts-first ranking, `matched_in`
64
+ and a mention's `transcript` field counted only text speech recognition wrote (`speech:<model>`), so a voice note
65
+ an agent transcribed was found as file text. A transcript is now the text of a voice attachment (`voice`,
66
+ `video_note` or `audio/ogg`), whoever wrote it.
67
+
8
68
  ## 0.389.0 — 22.10.2026
9
69
 
10
70
  ### Added
@@ -19,11 +19,35 @@ import { botCallbacksCommand, botMenuCommand, botWebhooksCommand } from "./setup
19
19
  import { botStoreCommand } from "./store.js";
20
20
  import { BotTokenStore } from "./token.js";
21
21
  import { botWatchCommand } from "./watch.js";
22
+ /** Where each of the messenger's other secrets comes from, `null` when it is not set; never the value. */
23
+ const secretSources = (bot, tokens) => bot.secrets?.length
24
+ ? { secrets: Object.fromEntries(bot.secrets.map((one) => [one.name, tokens.readSecret(one.name)?.source ?? null])) }
25
+ : {};
22
26
  const authCommand = (bot, tokenVariable) => {
23
27
  const auth = annotate(new Command("auth"), { mcp: false }).description("the bot token this profile uses");
24
- grantsAuthority(annotate(auth.command("set"), { effects: LOCAL_WRITE }))
25
- .description(`check a bot token with ${bot.name ?? "the messenger"}, then keep it — typed at a hidden prompt or piped on stdin`)
26
- .action(async function () {
28
+ const set = grantsAuthority(annotate(auth.command("set"), { effects: LOCAL_WRITE })).description(`check a bot token with ${bot.name ?? "the messenger"}, then keep it — typed at a hidden prompt or piped on stdin`);
29
+ const secrets = bot.secrets ?? [];
30
+ if (secrets.length > 0)
31
+ set.option("--secret <name>", `keep this secret instead of the token, the same way: ${secrets.map((one) => `${one.name} (${one.description})`).join("; ")}`);
32
+ set.action(async function () {
33
+ const { secret: named } = this.opts();
34
+ if (named !== undefined) {
35
+ const secret = secrets.find((one) => one.name === named);
36
+ if (!secret)
37
+ throw new CliError("validation_error", `no secret called "${named}" — one of: ${secrets.map((one) => one.name).join(", ")}`);
38
+ const context = botContext(this, bot);
39
+ await context.run(async () => {
40
+ const value = (await context.readSecret(`${secret.description}: `)).trim();
41
+ if (!value)
42
+ throw new CliError("validation_error", "no secret given");
43
+ renderItem(this, context, {
44
+ profile: context.profile,
45
+ secret: secret.name,
46
+ stored: context.tokens.writeSecret(secret.name, value),
47
+ });
48
+ });
49
+ return;
50
+ }
27
51
  const context = online(botContext(this, bot), this);
28
52
  await context.run(async (events) => {
29
53
  const token = (await context.readSecret("Bot token: ")).trim();
@@ -57,6 +81,7 @@ const authCommand = (bot, tokenVariable) => {
57
81
  bot: account.name,
58
82
  id: account.id,
59
83
  username: account.username,
84
+ ...secretSources(bot, context.tokens),
60
85
  });
61
86
  });
62
87
  });
@@ -64,7 +89,11 @@ const authCommand = (bot, tokenVariable) => {
64
89
  .description("forget this profile's bot token")
65
90
  .action(async function () {
66
91
  const context = botContext(this, bot);
67
- await context.run(async () => renderAnswer(this, context, itemOf({ profile: context.profile, removed: context.tokens.remove() })));
92
+ await context.run(async () => {
93
+ const removed = context.tokens.remove();
94
+ const secrets = Object.fromEntries((bot.secrets ?? []).map((one) => [one.name, context.tokens.removeSecret(one.name)]));
95
+ renderAnswer(this, context, itemOf({ profile: context.profile, removed, ...(bot.secrets?.length ? { secrets } : {}) }));
96
+ });
68
97
  });
69
98
  return auth;
70
99
  };
@@ -183,6 +183,38 @@ export interface BotUpdateRef {
183
183
  export type BotEvent = (MessageEvent | BotNotice) & {
184
184
  update?: BotUpdateRef;
185
185
  };
186
+ /** One request as it reached `bot watch --public-url`: the body exactly as sent, never parsed and written again. */
187
+ export interface BotWebhookRequest {
188
+ method: string;
189
+ /** Path and query, against the local listener. */
190
+ url: URL;
191
+ headers: Headers;
192
+ body: Uint8Array;
193
+ }
194
+ /** What the receiver answers a request that is not a delivery. */
195
+ export interface BotWebhookAnswer {
196
+ status: number;
197
+ body?: string;
198
+ }
199
+ /**
200
+ * Receiving the bot's updates on a public address the owner's tunnel forwards to this machine, for a
201
+ * messenger that pushes and cannot be polled.
202
+ */
203
+ export interface BotWebhookIntake {
204
+ /** A request that is not a delivery — a subscription handshake — answered here; `undefined` for a delivery. */
205
+ webhookHandshake(request: BotWebhookRequest): BotWebhookAnswer | undefined;
206
+ /** Whether a delivery comes from the messenger: its signature over the raw body, compared in constant time. */
207
+ webhookAuthentic(request: BotWebhookRequest): boolean;
208
+ /** The delivery's events, each with the update it came in, so a redelivery is recognised. Throws on a body it cannot read. */
209
+ webhookEvents(request: BotWebhookRequest): BotEvent[];
210
+ }
211
+ /** A secret beside the token that the messenger's bot needs, kept the same way — a webhook's signing key. */
212
+ export interface BotSecret {
213
+ /** In `--secret <name>` and the keyring account, lowercase with hyphens. */
214
+ name: string;
215
+ /** What it is and where the owner finds it, for the help and the prompt. */
216
+ description: string;
217
+ }
186
218
  export interface BotUpdatesPage {
187
219
  events: BotEvent[];
188
220
  /** Where the next call starts; the same as given when nothing came. Opaque to the caller. */
@@ -200,7 +232,7 @@ export interface BotUpdates {
200
232
  * The part of the personal core a bot can do, and the groups its Bot API has. A bot never gets
201
233
  * `chats` from this type: neither Bot API lists a bot's chats.
202
234
  */
203
- export type BotAdapter = Pick<MessengerCore, "me" | "close"> & Partial<MarkdownFormatting> & Partial<BotNativeApi> & Partial<BotMessaging> & Partial<BotHistory> & Partial<MessagePins> & Partial<BotChatTools> & Partial<BotChatAdmins> & Partial<BotChatMembers> & Partial<BotMenu> & Partial<BotCallbacks> & Partial<BotWebhooks> & Partial<BotUpdates> & Partial<BotPeople>;
235
+ export type BotAdapter = Pick<MessengerCore, "me" | "close"> & Partial<MarkdownFormatting> & Partial<BotNativeApi> & Partial<BotMessaging> & Partial<BotHistory> & Partial<MessagePins> & Partial<BotChatTools> & Partial<BotChatAdmins> & Partial<BotChatMembers> & Partial<BotMenu> & Partial<BotCallbacks> & Partial<BotWebhooks> & Partial<BotUpdates> & Partial<BotWebhookIntake> & Partial<BotPeople>;
204
236
  export interface BotNativeApi {
205
237
  api(method: string, input: {
206
238
  body?: string;
@@ -237,6 +269,8 @@ export interface BotMessenger {
237
269
  adminRights?: readonly AdminRight[];
238
270
  /** Whether `bot webhooks set --add` keeps the other addresses; a messenger with one address has no `--add`. */
239
271
  manyWebhooks?: boolean;
272
+ /** Secrets beside the token, each set with `bot auth set --secret <name>`; none when unset. */
273
+ secrets?: readonly BotSecret[];
240
274
  /** Mount shared bot me; consumers with an existing response migrate explicitly. */
241
275
  identity?: boolean;
242
276
  /**
@@ -9,7 +9,8 @@ export interface BotTokenStoreOptions {
9
9
  }
10
10
  /**
11
11
  * A bot token per profile, beside — never inside — the personal session: keyring account
12
- * `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file.
12
+ * `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file. A secret the
13
+ * messenger's bot needs beside it lives the same way: `bot:<profile>:<name>`, `<PREFIX>_BOT_<NAME>`.
13
14
  */
14
15
  export declare class BotTokenStore {
15
16
  #private;
@@ -27,4 +28,10 @@ export declare class BotTokenStore {
27
28
  } | undefined;
28
29
  writeKeyring(token: string): void;
29
30
  remove(): CredentialSource[];
31
+ readSecret(name: string): {
32
+ secret: string;
33
+ source: CredentialSource;
34
+ } | undefined;
35
+ writeSecret(name: string, secret: string): CredentialSource;
36
+ removeSecret(name: string): CredentialSource[];
30
37
  }
@@ -2,26 +2,30 @@ import { CliError, Credentials, pathsAreOverridden, resolvePaths, } from "@wirec
2
2
  import { envName } from "../../ports/app.js";
3
3
  /**
4
4
  * A bot token per profile, beside — never inside — the personal session: keyring account
5
- * `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file.
5
+ * `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file. A secret the
6
+ * messenger's bot needs beside it lives the same way: `bot:<profile>:<name>`, `<PREFIX>_BOT_<NAME>`.
6
7
  */
7
8
  export class BotTokenStore {
8
9
  profile;
9
10
  #account;
10
11
  #credentials;
11
12
  #keyringCredentials;
13
+ #secretOf;
12
14
  constructor({ app, profile, env = process.env, keyring, configDir }) {
13
15
  const where = { appName: app.appName, prefix: app.envPrefix, env };
14
16
  this.profile = profile;
15
17
  this.#account = `bot:${profile}`;
16
- this.#credentials = new Credentials({
18
+ const credentialsFor = (variable) => new Credentials({
17
19
  configDir: configDir ?? resolvePaths(where).config,
18
20
  service: app.appName,
19
- envVar: envName(app, "BOT_TOKEN"),
21
+ envVar: envName(app, variable),
20
22
  isolated: pathsAreOverridden(where),
21
23
  ...(keyring ? { keyring } : {}),
22
24
  env,
23
25
  warn: (message) => process.stderr.write(`${message}\n`),
24
26
  });
27
+ this.#credentials = credentialsFor("BOT_TOKEN");
28
+ this.#secretOf = (name) => credentialsFor(`BOT_${name.toUpperCase().replaceAll("-", "_")}`);
25
29
  this.#keyringCredentials = new Credentials({
26
30
  configDir: configDir ?? resolvePaths(where).config,
27
31
  service: app.appName,
@@ -61,4 +65,13 @@ export class BotTokenStore {
61
65
  remove() {
62
66
  return this.#credentials.remove(this.#account);
63
67
  }
68
+ readSecret(name) {
69
+ return this.#secretOf(name).read(`${this.#account}:${name}`);
70
+ }
71
+ writeSecret(name, secret) {
72
+ return this.#secretOf(name).write(`${this.#account}:${name}`, secret);
73
+ }
74
+ removeSecret(name) {
75
+ return this.#secretOf(name).remove(`${this.#account}:${name}`);
76
+ }
64
77
  }
@@ -5,5 +5,7 @@ import type { BotMessenger } from "./port.js";
5
5
  * normal end. Each batch is kept before it is printed, and the cursor moves only after that: an end
6
6
  * at any moment never loses a batch. An update the messenger names by id is recorded and skipped when it
7
7
  * comes again after being handled; one without an id may print twice. A read: it changes nothing anyone sees.
8
+ * With `--public-url` the messenger pushes each batch to a local receiver behind the owner's tunnel
9
+ * instead, and a batch is acknowledged only once kept, so the messenger sends again what was not.
8
10
  */
9
11
  export declare const botWatchCommand: (bot: BotMessenger) => Command;
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command } from "commander";
4
+ import { portOf, publicUrlOf } from "../../http/public-url.js";
4
5
  import { renderMessages } from "../../render/messages.js";
5
6
  import { environmentOf } from "../context.js";
6
7
  import { REMOTE_WRITE } from "../effects.js";
@@ -9,6 +10,7 @@ import { botContext, online } from "./context.js";
9
10
  import { botCan, botIdOf } from "./messages.js";
10
11
  import { botFiles } from "./registry.js";
11
12
  import { PressLog, UpdatesCursor } from "./updates.js";
13
+ import { receiveWebhooks, WEBHOOK_PORT } from "./webhook-receiver.js";
12
14
  /** Under Telegram's transport timeout of 30 s, so a quiet poll ends as an answer, not as a failure. */
13
15
  const WAIT_SECONDS = 25;
14
16
  const FIRST_RETRY_MS = 1_000;
@@ -78,17 +80,31 @@ const keep = async (command, context, bot, botId, events, senders) => {
78
80
  * normal end. Each batch is kept before it is printed, and the cursor moves only after that: an end
79
81
  * at any moment never loses a batch. An update the messenger names by id is recorded and skipped when it
80
82
  * comes again after being handled; one without an id may print twice. A read: it changes nothing anyone sees.
83
+ * With `--public-url` the messenger pushes each batch to a local receiver behind the owner's tunnel
84
+ * instead, and a batch is acknowledged only once kept, so the messenger sends again what was not.
81
85
  */
82
86
  export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects: REMOTE_WRITE, lifetime: "persistent" })
83
87
  .description("print new messages as they arrive and keep them, until Ctrl-C or --timeout (either ends it normally)")
84
88
  .option("--events", "also edits, deletions, buttons pressed and people coming and going; every line names its event")
85
89
  .option("--types <types>", "only these update types, comma-separated, in the messenger's words")
90
+ .option("--public-url <url>", "receive by webhook at your tunnel's https address instead of polling, e.g. https://<name>.ts.net")
91
+ .option("--port <port>", `the local port behind the tunnel for --public-url (default ${WEBHOOK_PORT})`)
86
92
  .action(async function () {
87
- const { events: everything, types: typed } = this.opts();
93
+ const flags = this.opts();
94
+ const { events: everything, types: typed } = flags;
88
95
  const context = online(botContext(this, bot), this);
89
96
  if (context.format === "json") {
90
97
  throw new CliError("validation_error", "watch is a stream — use --jsonl for one update per line");
91
98
  }
99
+ if (flags.port !== undefined && flags.publicUrl === undefined)
100
+ throw new CliError("validation_error", "--port is the local port behind --public-url; give both");
101
+ const publicUrl = flags.publicUrl === undefined
102
+ ? undefined
103
+ : publicUrlOf(flags.publicUrl, {
104
+ missing: "--public-url",
105
+ reaches: `${bot.name ?? "the messenger"} delivers the bot's updates`,
106
+ });
107
+ const port = portOf(flags.port, WEBHOOK_PORT);
92
108
  const types = typed
93
109
  ?.split(",")
94
110
  .map((one) => one.trim())
@@ -152,6 +168,42 @@ export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects
152
168
  try {
153
169
  await context.run(async (events) => {
154
170
  const adapter = await context.authenticated({ events, stop: stop.signal });
171
+ const warn = context.renderer.warn;
172
+ /** Keeps, prints and settles one batch; `false` when it was not kept and must come again. */
173
+ const take = async (botId, batch) => {
174
+ const handled = await context.copy.received(botId, batch, warn);
175
+ const fresh = batch.filter((event) => !(event.update && handled.has(event.update.id)));
176
+ if (fresh.length > 0 && !(await keep(this, context, bot, botId, fresh, adapter.senders?.()))) {
177
+ await context.copy.settle(botId, fresh, "not_kept", warn);
178
+ return false;
179
+ }
180
+ for (const event of fresh)
181
+ print(event);
182
+ await context.copy.settle(botId, fresh, null, warn);
183
+ const seen = [...new Set(batch.flatMap((event) => chatOf(event) ?? []))].filter((id) => CHAT_ID.test(id));
184
+ if (seen.length > 0)
185
+ context.registry.observe(seen.map((id) => ({ id })));
186
+ return true;
187
+ };
188
+ if (publicUrl) {
189
+ const intake = {
190
+ webhookHandshake: botCan(adapter, "webhookHandshake", bot, "receive its updates by webhook"),
191
+ webhookAuthentic: botCan(adapter, "webhookAuthentic", bot, "receive its updates by webhook"),
192
+ webhookEvents: botCan(adapter, "webhookEvents", bot, "receive its updates by webhook"),
193
+ };
194
+ const botId = await botIdOf(context, adapter);
195
+ const wanted = (event) => !types || (event.update !== undefined && types.includes(event.update.kind));
196
+ await receiveWebhooks({
197
+ intake,
198
+ publicUrl,
199
+ port,
200
+ take: (batch) => take(botId, batch.filter(wanted)),
201
+ signal: stop.signal,
202
+ warn,
203
+ listening: (local) => context.renderer.note(`receiving at ${publicUrl.origin} through ${local.host} — the tunnel must forward there; Ctrl-C to stop`),
204
+ });
205
+ return;
206
+ }
155
207
  const poll = botCan(adapter, "updates", bot, "take updates");
156
208
  if (adapter.webhooks && (await adapter.webhooks()).length > 0) {
157
209
  throw new CliError("validation_error", `this bot gets its updates by webhook, and ${bot.name ?? "the messenger"} gives polling nothing while one ` +
@@ -182,21 +234,11 @@ export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects
182
234
  await retry(`${bot.name ?? "the messenger"} did not answer (${code ?? "unknown"})`);
183
235
  continue;
184
236
  }
185
- const warn = context.renderer.warn;
186
- const handled = await context.copy.received(botId, page.events, warn);
187
- const fresh = page.events.filter((event) => !(event.update && handled.has(event.update.id)));
188
- if (fresh.length > 0 && !(await keep(this, context, bot, botId, fresh, adapter.senders?.()))) {
189
- await context.copy.settle(botId, fresh, "not_kept", warn);
237
+ if (!(await take(botId, page.events))) {
190
238
  await retry("the updates were not kept");
191
239
  continue;
192
240
  }
193
241
  failures = 0;
194
- for (const event of fresh)
195
- print(event);
196
- await context.copy.settle(botId, fresh, null, warn);
197
- const seen = [...new Set(page.events.flatMap((event) => chatOf(event) ?? []))].filter((id) => CHAT_ID.test(id));
198
- if (seen.length > 0)
199
- context.registry.observe(seen.map((id) => ({ id })));
200
242
  if (page.cursor !== undefined && page.cursor !== at) {
201
243
  at = page.cursor;
202
244
  cursor.write(at);
@@ -0,0 +1,22 @@
1
+ import type { BotEvent, BotWebhookIntake } from "./port.js";
2
+ /** The local port behind the tunnel; MCP's `--http` has 8765. */
3
+ export declare const WEBHOOK_PORT = 8766;
4
+ /** Meta sends at most 3 MB in one delivery; anything larger is refused unread. */
5
+ export declare const WEBHOOK_BODY: number;
6
+ export interface WebhookReceiver {
7
+ intake: BotWebhookIntake;
8
+ publicUrl: URL;
9
+ port: number;
10
+ /** Keeps a batch: `true` once it is safe to tell the messenger it arrived. */
11
+ take: (events: BotEvent[]) => Promise<boolean>;
12
+ signal: AbortSignal;
13
+ /** Where the receiver listens, said once it does. */
14
+ listening: (local: URL) => void;
15
+ warn: (message: string) => void;
16
+ }
17
+ /**
18
+ * Answers the messenger on 127.0.0.1 until `signal` ends it. A delivery is answered 200 only after
19
+ * `take` kept it: a messenger that pushes retries anything else, so a crash or a full disk loses
20
+ * nothing. Batches are kept one at a time, in the order they arrived.
21
+ */
22
+ export declare const receiveWebhooks: (receiver: WebhookReceiver) => Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { listen } from "../../http/listen.js";
2
+ /** The local port behind the tunnel; MCP's `--http` has 8765. */
3
+ export const WEBHOOK_PORT = 8766;
4
+ /** Meta sends at most 3 MB in one delivery; anything larger is refused unread. */
5
+ export const WEBHOOK_BODY = 3 * 1024 * 1024;
6
+ const LOCAL = ["localhost", "127.0.0.1"];
7
+ const answer = (status, body = "") => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
8
+ /**
9
+ * Answers the messenger on 127.0.0.1 until `signal` ends it. A delivery is answered 200 only after
10
+ * `take` kept it: a messenger that pushes retries anything else, so a crash or a full disk loses
11
+ * nothing. Batches are kept one at a time, in the order they arrived.
12
+ */
13
+ export const receiveWebhooks = async (receiver) => {
14
+ const { intake, publicUrl, take, warn } = receiver;
15
+ const hosts = [publicUrl.host, ...LOCAL.map((host) => `${host}:${receiver.port}`), ...LOCAL];
16
+ let queue = Promise.resolve();
17
+ const inTurn = (events) => {
18
+ const turn = queue.then(() => take(events));
19
+ queue = turn.catch(() => undefined);
20
+ return turn;
21
+ };
22
+ const handle = async (incoming) => {
23
+ // A page in the owner's browser can reach 127.0.0.1 too; only the tunnel and the machine itself may.
24
+ if (!hosts.includes(incoming.headers.get("host") ?? ""))
25
+ return answer(403, "host not allowed");
26
+ const url = new URL(incoming.url);
27
+ if (url.pathname !== "/")
28
+ return answer(404, "not found");
29
+ const request = {
30
+ method: incoming.method,
31
+ url,
32
+ headers: incoming.headers,
33
+ body: new Uint8Array(await incoming.arrayBuffer()),
34
+ };
35
+ if (request.method === "GET") {
36
+ const reply = intake.webhookHandshake(request);
37
+ return reply ? answer(reply.status, reply.body) : answer(404, "not found");
38
+ }
39
+ if (request.method !== "POST")
40
+ return answer(405, "method not allowed");
41
+ if (!intake.webhookAuthentic(request))
42
+ return answer(401, "signature does not match");
43
+ let events;
44
+ try {
45
+ events = intake.webhookEvents(request);
46
+ }
47
+ catch {
48
+ return answer(400, "unreadable delivery");
49
+ }
50
+ try {
51
+ return (await inTurn(events)) ? answer(200) : answer(503, "not kept");
52
+ }
53
+ catch (error) {
54
+ warn(`a delivery was not kept: ${error instanceof Error ? error.message : String(error)}`);
55
+ return answer(503, "not kept");
56
+ }
57
+ };
58
+ const listening = await listen(handle, { host: "127.0.0.1", port: receiver.port, maxBody: WEBHOOK_BODY });
59
+ try {
60
+ receiver.listening(listening.url);
61
+ if (!receiver.signal.aborted)
62
+ await new Promise((resolve) => receiver.signal.addEventListener("abort", () => resolve(), { once: true }));
63
+ await queue;
64
+ }
65
+ finally {
66
+ await listening.close();
67
+ }
68
+ };
@@ -36,6 +36,8 @@ export declare const isLogin: (command: Command) => boolean;
36
36
  * (merge/unmerge, claim/release) or keeps the old value in the task's history; under the lock a close only suggests.
37
37
  */
38
38
  export declare const destroysNothing: <T extends Command>(command: T) => T;
39
+ /** A local edit remains an edit even when it first fetches metadata from the messenger. */
40
+ export declare const changesStore: <T extends Command>(command: T) => T;
39
41
  /**
40
42
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
41
43
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -52,6 +52,12 @@ export const destroysNothing = (command) => {
52
52
  KEPT_UNDER_LOCK.add(command);
53
53
  return command;
54
54
  };
55
+ const STORE_WRITES = new WeakSet();
56
+ /** A local edit remains an edit even when it first fetches metadata from the messenger. */
57
+ export const changesStore = (command) => {
58
+ STORE_WRITES.add(command);
59
+ return command;
60
+ };
55
61
  /**
56
62
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
57
63
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -59,8 +65,8 @@ export const destroysNothing = (command) => {
59
65
  */
60
66
  export const isStoreChange = (command) => {
61
67
  const effects = metaOf(command).effects;
62
- return (effects?.remote === "none" &&
63
- effects.local === "write" &&
68
+ return ((effects?.remote === "none" || STORE_WRITES.has(command)) &&
69
+ effects?.local === "write" &&
64
70
  !KEPT_UNDER_LOCK.has(command) &&
65
71
  !changesAuthority(command));
66
72
  };
@@ -42,7 +42,7 @@ export { type BotContext, botContext, botWords } from "./bot/context.js";
42
42
  export { type BotCopy, botCopy } from "./bot/copy.js";
43
43
  export { botMcpCommand } from "./bot/mcp.js";
44
44
  export { botCan, botIdOf, botMessagesCommand } from "./bot/messages.js";
45
- export { BOT_ACTIONS, type BotAction, type BotAdapter, type BotCallbacks, type BotChatAdmin, type BotChatAdmins, type BotChatMembers, type BotChatRef, type BotChatTools, type BotConnectOptions, type BotEvent, type BotHistory, type BotMcp, type BotMenu, type BotMenuEntry, type BotMessaging, type BotMessenger, type BotNativeApi, type BotNotice, type BotPeople, type BotPress, type BotSendOptions, type BotUpdates, type BotUpdatesPage, type BotWebhook, type BotWebhooks, } from "./bot/port.js";
45
+ export { BOT_ACTIONS, type BotAction, type BotAdapter, type BotCallbacks, type BotChatAdmin, type BotChatAdmins, type BotChatMembers, type BotChatRef, type BotChatTools, type BotConnectOptions, type BotEvent, type BotHistory, type BotMcp, type BotMenu, type BotMenuEntry, type BotMessaging, type BotMessenger, type BotNativeApi, type BotNotice, type BotPeople, type BotPress, type BotSecret, type BotSendOptions, type BotUpdates, type BotUpdatesPage, type BotWebhook, type BotWebhookAnswer, type BotWebhookIntake, type BotWebhookRequest, type BotWebhooks, } from "./bot/port.js";
46
46
  export { botFiles, botsDirectory, ChatRegistry, registryProfiles, type SeenChat } from "./bot/registry.js";
47
47
  export { BotTokenStore, type BotTokenStoreOptions } from "./bot/token.js";
48
48
  export { PressLog, UpdatesCursor } from "./bot/updates.js";
@@ -34,7 +34,11 @@ const ALL_SINCE = "90d";
34
34
  const ALL_CHATS = "--all";
35
35
  export const syncCommand = (messenger) => {
36
36
  const fetching = messenger.fetching ?? FETCHING;
37
- return annotate(new Command("sync"), { effects: FETCH, cooperative: true })
37
+ return annotate(new Command("sync"), {
38
+ examples: [`${messenger.app.command} personal messages sync "Book club" --last 100`],
39
+ effects: FETCH,
40
+ cooperative: true,
41
+ })
38
42
  .description("bring a chat's history in the local store up to date, newest first; run it again to continue; --all syncs every chat")
39
43
  .argument("[chat]", messenger.chatArgument)
40
44
  .option("--all", `every chat, most recently active first — what search needs; the last ${ALL_SINCE} unless --since-time or --last`)
@@ -1,22 +1,3 @@
1
1
  import type { Command } from "commander";
2
- import type { Id, Message } from "../../domain/models.js";
3
- import type { MarkedRead } from "../../services/chats.js";
4
- import type { Services } from "../../services/index.js";
5
- import type { Settings } from "../settings.js";
6
- export declare const MARK_READ_OPTION: readonly ["--mark-read", "also mark each chat shown read, up to the newest message shown; the other side sees it"];
7
- export declare const NO_MARK_READ_OPTION: readonly ["--no-mark-read", "do not, whatever the catch_up_marks_read setting says"];
8
- /**
9
- * The typed flag decides; without one, the owner's `catch_up_marks_read` setting, off unless turned on (NEED-566).
10
- * An agent's run never marks read on that setting: the other side sees it, so only an agent that asks does.
11
- */
12
- export declare const marksRead: (typed: boolean | undefined, settings: Settings, command: Command) => boolean;
13
2
  /** Whose `--new` points a run moves: an agent's are its own, so the owner's `inbox --new` still shows everything. */
14
3
  export declare const pointsOf: <N extends "inbox" | "review">(command: Command, name: N) => N | `mcp-${N}`;
15
- /**
16
- * Marks each chat read up to the newest message shown in it — never further, so what arrived after
17
- * the read stays unread. Through the send guard, so `permissions.chats.mark-read` applies.
18
- */
19
- export declare const markShown: (services: Services, chats: {
20
- id: Id;
21
- messages: Pick<Message, "id" | "timestamp">[];
22
- }[], warn: (message: string) => void) => Promise<MarkedRead[]>;
@@ -1,46 +1,3 @@
1
- import { CliError, isCliError } from "@wirecat/cli-base";
2
1
  import { environmentOf } from "../context.js";
3
- export const MARK_READ_OPTION = [
4
- "--mark-read",
5
- "also mark each chat shown read, up to the newest message shown; the other side sees it",
6
- ];
7
- export const NO_MARK_READ_OPTION = ["--no-mark-read", "do not, whatever the catch_up_marks_read setting says"];
8
- // Neither is offered to an agent (`ownerOnlyOption`): a read tool may run unasked, and the other side sees a mark.
9
- // An agent marks read with `chats mark-read`, a write.
10
- /**
11
- * The typed flag decides; without one, the owner's `catch_up_marks_read` setting, off unless turned on (NEED-566).
12
- * An agent's run never marks read on that setting: the other side sees it, so only an agent that asks does.
13
- */
14
- export const marksRead = (typed, settings, command) => {
15
- const marking = typed ?? (environmentOf(command).agent ? false : settings.catchUpMarksRead);
16
- if (marking && settings.offline) {
17
- throw new CliError("validation_error", "--mark-read tells the messenger; not with --offline");
18
- }
19
- return marking;
20
- };
21
2
  /** Whose `--new` points a run moves: an agent's are its own, so the owner's `inbox --new` still shows everything. */
22
3
  export const pointsOf = (command, name) => environmentOf(command).agent ? `mcp-${name}` : name;
23
- /**
24
- * Marks each chat read up to the newest message shown in it — never further, so what arrived after
25
- * the read stays unread. Through the send guard, so `permissions.chats.mark-read` applies.
26
- */
27
- export const markShown = async (services, chats, warn) => {
28
- const marked = [];
29
- for (const { id, messages } of chats) {
30
- const newest = messages.reduce((last, one) => (last === undefined || Date.parse(one.timestamp) >= Date.parse(last.timestamp) ? one : last), undefined);
31
- if (newest === undefined)
32
- continue;
33
- try {
34
- const { chatId, until } = await services.chats.markRead({ chat: id, until: newest.id });
35
- marked.push({ chatId, until });
36
- }
37
- catch (error) {
38
- // A hold refuses every chat alike; what was read is still the answer, as reads go on during one.
39
- if (!isCliError(error) || error.details.sendBlock === undefined)
40
- throw error;
41
- warn(`${error.message}; nothing more was marked read`);
42
- break;
43
- }
44
- }
45
- return marked;
46
- };
@@ -282,7 +282,10 @@ export const connectedFor = async (command, messenger, base, options) => {
282
282
  }),
283
283
  };
284
284
  };
285
- const unrecorded = (command, messenger, profile, offline) => {
285
+ const unrecorded = (command, messenger, profile, { offline, profilesHere }) => {
286
+ if (!profilesHere.includes(profile)) {
287
+ return new CliError("not_found", `no profile named "${profile}" — profiles here: ${profilesHere.length > 0 ? profilesHere.join(", ") : "none"}`, { profiles: profilesHere });
288
+ }
286
289
  const words = `${messenger.app.command} ${asFirstWord(profile)}`;
287
290
  const setup = rootOf(command).commands.some((one) => one.name() === "setup") ? `\`${words}setup\`` : undefined;
288
291
  if (messenger.history === "store")
@@ -346,14 +349,14 @@ export const messengerContext = (command, messenger, resolving = {}) => {
346
349
  refuseDenied(command, base.settings);
347
350
  refuseLacking(messenger, pathOf(command));
348
351
  for (const key of unknownPermissionKeys(base.settings.permissions, knownPermissionKeys(command, messenger.permissionKey))) {
349
- base.renderer.warn(`permissions.${key} names no command and does nothing — \`config unset permissions.${key}\``);
352
+ throw new CliError("configuration_error", `permissions.${key} names no command — remove it with \`${app.command} ${asFirstWord(profile)}config unset permissions.${key}\``, { permission: key });
350
353
  }
351
354
  // Read when warning, not now: serve swaps the renderer's methods for log records after this.
352
355
  const warn = (text, warning) => base.renderer.warn(text, warning);
353
356
  const accountFor = () => {
354
357
  const account = messenger.accountOf?.(command) ?? recalledAccount(app, provider, profile, base.env);
355
358
  if (!account)
356
- throw unrecorded(command, messenger, profile, base.settings.offline);
359
+ throw unrecorded(command, messenger, profile, base.settings);
357
360
  return account;
358
361
  };
359
362
  const guard = messenger.guard?.(command, base.settings, warn) ??
@@ -473,7 +473,10 @@ const answerOf = (text) => {
473
473
  return answer;
474
474
  };
475
475
  /** `messages links`: why a message sits where it does in its conversation. */
476
- export const linksCommand = (messenger) => annotate(new Command("links"), { effects: LOCAL_READ })
476
+ export const linksCommand = (messenger) => annotate(new Command("links"), {
477
+ examples: [`${messenger.app.command} personal messages links "Book club" 101`],
478
+ effects: LOCAL_READ,
479
+ })
477
480
  .description("why a message is in its conversation: each link it has, and the chain of answers back to the start")
478
481
  .argument("<chat>", messenger.chatArgument)
479
482
  .argument("<message>", "the message id")