@gavin-hu/my-pi-agent 0.11.1 → 0.11.3

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `/wechat` command: `login`, `new`, `status`, `logout`, `help`.
2
+ * The `/wechat` command: `login`, `new`, `status`, `logout`, `allow`, `help`.
3
3
  *
4
4
  * The command is the operator's single entry point. `/wechat` (or `/wechat
5
5
  * status`) opens the control screen, where every action is a key; the verb
@@ -11,13 +11,23 @@
11
11
  * declines while a WeChat peer turn is running.
12
12
  */
13
13
 
14
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ CONFIG_DIR_NAME,
16
+ type ExtensionAPI,
17
+ type ExtensionCommandContext,
18
+ type ExtensionContext,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import { join } from "node:path";
21
+ import { writeConfigValue } from "../../lib/config.ts";
22
+ import { sanitizeLine } from "../../lib/format.ts";
15
23
  import { supportsCustomUI } from "../../lib/interaction.ts";
16
- import type { Bridge } from "./bridge.ts";
24
+ import { isAllowedPeer, type Bridge } from "./bridge.ts";
17
25
  import type { WechatClient } from "./client.ts";
18
26
  import type { WechatConfig } from "./config.ts";
27
+ import { doctorReport } from "./doctor.ts";
19
28
  import { peerLabel, peerKey } from "./format.ts";
20
- import { defaultSleep, waitForLogin, type LoginOutcome } from "./login.ts";
29
+ import { waitForLogin, type LoginOutcome } from "./login.ts";
30
+ import { defaultSleep } from "./timer.ts";
21
31
  import { LoginScreenComponent, type LoginViewState } from "./login-screen.ts";
22
32
  import { qrLines } from "./qrcode.ts";
23
33
  import { statusLine, statusView, StatusScreenComponent, type StatusAction, type StatusView } from "./status-screen.ts";
@@ -30,6 +40,8 @@ export interface CommandDeps {
30
40
  createLoginClient: (cwd: string) => WechatClient;
31
41
  saveAccount: (account: BotCredentials) => void;
32
42
  primaryAccount: () => BotCredentials | undefined;
43
+ /** Read a peer's recent messages for `/wechat history`; read-only. */
44
+ readHistory: (peer: string, count: number) => Promise<string>;
33
45
  logout: () => void;
34
46
  now: () => number;
35
47
  }
@@ -49,8 +61,11 @@ export const WECHAT_COMMANDS = [
49
61
  { verb: "login", description: "Log in by QR; on a scan, store the account and start the bridge" },
50
62
  { verb: "new", description: "Start a fresh host session (peer conversations are independent)" },
51
63
  { verb: "status", description: "Open the control screen (a one-line summary outside the TUI)" },
64
+ { verb: "doctor", description: "Print a health report: token age, last poll, lock, peers, failures" },
52
65
  { verb: "stop", description: "Stop one peer's running turn: /wechat stop <peer>" },
53
66
  { verb: "reset", description: "Start a fresh conversation for one peer: /wechat reset <peer>" },
67
+ { verb: "history", description: "Print one peer's recent messages: /wechat history <peer> [n]" },
68
+ { verb: "allow", description: "Allow a refused sender in the project config: /wechat allow [peer]" },
54
69
  { verb: "logout", description: "Clear the stored account and stop the bridge" },
55
70
  { verb: "help", description: "List these commands" },
56
71
  ] as const satisfies readonly WechatCommand[];
@@ -74,10 +89,7 @@ export function isWechatVerb(value: string): value is WechatVerb {
74
89
  return (WECHAT_VERBS as readonly string[]).includes(value);
75
90
  }
76
91
 
77
- function messageOf(error: unknown): string {
78
- return error instanceof Error ? error.message : String(error);
79
- }
80
-
92
+ import { messageOf } from "./errors.ts";
81
93
  /**
82
94
  * Whether a peer turn is running anywhere on the bridge.
83
95
  *
@@ -100,6 +112,72 @@ function busyWithPeerTurn(ctx: ExtensionContext, deps: CommandDeps, verb: string
100
112
  return true;
101
113
  }
102
114
 
115
+ /** Print the read-only health report, in the TUI, over RPC, or in a script. */
116
+ async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void> {
117
+ ctx.ui.notify(
118
+ doctorReport({
119
+ status: deps.getBridge()?.status(),
120
+ account: deps.primaryAccount(),
121
+ config: deps.loadConfig(ctx.cwd),
122
+ now: deps.now(),
123
+ }),
124
+ );
125
+ }
126
+
127
+ /** Add a refused sender to the project config after the operator confirms. */
128
+ async function runAllow(ctx: ExtensionContext, deps: CommandDeps, peerArg?: string): Promise<void> {
129
+ if (!ctx.hasUI) {
130
+ ctx.ui.notify("WeChat allow needs a confirmation dialog, which this mode has no UI for.", "warning");
131
+ return;
132
+ }
133
+ const candidate = peerArg?.trim() || (await chooseRefused(ctx, deps));
134
+ if (!candidate) return;
135
+ const config = deps.loadConfig(ctx.cwd);
136
+ if (isAllowedPeer(candidate, undefined, config.allowedPeers)) {
137
+ ctx.ui.notify(`${sanitizeLine(peerLabel(candidate))} is already allowed.`);
138
+ return;
139
+ }
140
+ const relative = join(CONFIG_DIR_NAME, "bytefly.json");
141
+ const label = sanitizeLine(peerLabel(candidate));
142
+ const confirmed = await ctx.ui.confirm(`Allow ${label}?`, `Add this sender to wechat.allowedPeers in ${relative}?`);
143
+ if (!confirmed) return;
144
+ try {
145
+ writeConfigValue(join(ctx.cwd, relative), "wechat", "allowedPeers", [...config.allowedPeers, candidate]);
146
+ ctx.ui.notify(`Saved ${label} to ${relative}. Run /reload or restart Pi to apply.`);
147
+ } catch (error) {
148
+ ctx.ui.notify(`Could not save the allowlist: ${messageOf(error)}`, "error");
149
+ }
150
+ }
151
+
152
+ /** Pick one of the refused senders, or explain that there is nothing to allow. */
153
+ async function chooseRefused(ctx: ExtensionContext, deps: CommandDeps): Promise<string | undefined> {
154
+ const refused = deps.getBridge()?.status().refusedPeers ?? [];
155
+ if (refused.length === 0) {
156
+ ctx.ui.notify("No refused sender to allow.", "warning");
157
+ return undefined;
158
+ }
159
+ return ctx.ui.select("Allow which sender?", refused);
160
+ }
161
+
162
+ /** Print one peer's recent messages; read-only, and works without a live session. */
163
+ async function runHistory(ctx: ExtensionCommandContext, deps: CommandDeps, args: string): Promise<void> {
164
+ const [peer = "", countArg] = args.trim().split(/\s+/).filter(Boolean);
165
+ const bridge = deps.getBridge();
166
+ if (!peer) {
167
+ const known = bridge?.status().peerStates.map((entry) => peerLabel(entry.peer)) ?? [];
168
+ ctx.ui.notify(`Usage: /wechat history <peer> [n]. Known: ${known.join(", ") || "none yet"}.`, "warning");
169
+ return;
170
+ }
171
+ const requested = Number.parseInt(countArg ?? "", 10);
172
+ const count = Math.min(20, Math.max(1, Number.isFinite(requested) ? requested : 10));
173
+ try {
174
+ const text = await deps.readHistory(peer, count);
175
+ ctx.ui.notify(text || `No history yet for ${peerLabel(peer)}.`);
176
+ } catch (error) {
177
+ ctx.ui.notify(`WeChat history failed: ${messageOf(error)}`, "warning");
178
+ }
179
+ }
180
+
103
181
  /**
104
182
  * React to a finished login. A confirmed scan stores the account and starts the
105
183
  * bridge immediately, so `/wechat login` is the only command needed to go
@@ -216,9 +294,37 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
216
294
  theme,
217
295
  view,
218
296
  onClose: done,
297
+ actions: {
298
+ stop: (peer) => {
299
+ const bridge = deps.getBridge();
300
+ if (!bridge) return "WeChat bridge is not running.";
301
+ return bridge.stop(peer) ? `Stopping ${peerLabel(peer)}.` : `${peerLabel(peer)} has no running turn.`;
302
+ },
303
+ reset: (peer) => {
304
+ const bridge = deps.getBridge();
305
+ if (!bridge) return "WeChat bridge is not running.";
306
+ return bridge.reset(peer)
307
+ ? `Started a fresh conversation for ${peerLabel(peer)}.`
308
+ : `${peerLabel(peer)} is running; stop it before starting fresh.`;
309
+ },
310
+ cancelPrompt: () => {
311
+ const bridge = deps.getBridge();
312
+ if (!bridge?.status().promptPending) return "No prompt is waiting.";
313
+ bridge.channel().cancel();
314
+ return "Cancelled the waiting prompt.";
315
+ },
316
+ },
219
317
  requestRender: () => tui.requestRender(),
220
318
  }),
221
319
  );
320
+ if (typeof action === "object" && action?.kind === "history") {
321
+ await runHistory(ctx, deps, action.peer);
322
+ return;
323
+ }
324
+ if (typeof action === "object" && action?.kind === "allow") {
325
+ await runAllow(ctx, deps);
326
+ return;
327
+ }
222
328
  switch (action) {
223
329
  case "login":
224
330
  case "new":
@@ -298,6 +404,12 @@ async function dispatch(
298
404
  return runNew(ctx, deps);
299
405
  case "status":
300
406
  return runPanel(ctx, deps);
407
+ case "doctor":
408
+ return runDoctor(ctx, deps);
409
+ case "history":
410
+ return runHistory(ctx, deps, peerArg);
411
+ case "allow":
412
+ return runAllow(ctx, deps, peerArg || undefined);
301
413
  case "stop":
302
414
  return runPeerAction(ctx, deps, "stop", peerArg);
303
415
  case "reset":
@@ -313,7 +425,7 @@ async function dispatch(
313
425
  export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
314
426
  pi.registerCommand("wechat", {
315
427
  description:
316
- "WeChat bridge. /wechat opens the control screen (login | new | status | stop | reset | logout | help)",
428
+ "WeChat bridge. /wechat opens the control screen (login | new | status | doctor | stop | reset | history | allow | logout | help)",
317
429
  getArgumentCompletions: completeSubcommand,
318
430
  handler: async (args, ctx) => {
319
431
  const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Every outbound WeChat message goes through here: a reply, a dialog preface, a
3
+ * file, and the owner-only `send_wechat` tool.
4
+ *
5
+ * The path is shared so Markdown flattening, part numbering, session expiry,
6
+ * body-level rejection, and partial progress are decided once. A failure is
7
+ * reported once per peer until a send gets through, and the peer's stored reply
8
+ * token is forgotten so the next inbound message can replace it.
9
+ */
10
+
11
+ import type { BotCredentials } from "./types.ts";
12
+ import { isSessionExpired, WechatError, type WechatClient } from "./client.ts";
13
+ import { messageOf } from "./errors.ts";
14
+ import { chunkText, toWechatText } from "./format.ts";
15
+ import { sendLocalFile } from "./outbound.ts";
16
+
17
+ /** One addressed text destination. */
18
+ export interface MessageTarget {
19
+ peer: string;
20
+ contextToken?: string;
21
+ }
22
+
23
+ /** The outcome of one outbound text delivery attempt. */
24
+ export interface Delivery {
25
+ ok: boolean;
26
+ /** Parts the transport accepted before stopping. */
27
+ sent: number;
28
+ total: number;
29
+ /** The server reported an expired token; the caller has been notified. */
30
+ expired?: boolean;
31
+ /** Formatting left nothing to send. */
32
+ empty?: boolean;
33
+ }
34
+
35
+ /** The owner-send result shape `send_wechat` returns. */
36
+ export interface DeliveryResult {
37
+ ok: boolean;
38
+ error?: string;
39
+ }
40
+
41
+ /** What the deliverer needs from the bridge; all state is read at call time. */
42
+ export interface DeliveryDeps {
43
+ client: () => WechatClient | undefined;
44
+ account: () => BotCredentials | undefined;
45
+ maxReplyChars: () => number;
46
+ cdnBaseUrl: string;
47
+ maxMediaBytes: number;
48
+ notify: (message: string, kind?: "info" | "warning" | "error") => void;
49
+ onExpired: () => void;
50
+ /** Drop a peer's cached reply token. */
51
+ forgetToken: (peer: string) => void;
52
+ /** The last send failure, for the doctor/status. */
53
+ onSendError?: (message: string) => void;
54
+ /** A message got through; clear the last failure. */
55
+ onSent?: () => void;
56
+ }
57
+
58
+ /** The bridge's outbound surface. */
59
+ export interface Deliverer {
60
+ /** Send a reply to a peer; false when the transport rejected it. */
61
+ reply(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean>;
62
+ /** Send a dialog preface (or plan digest) to a peer. */
63
+ prompt(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean>;
64
+ /** Upload and send a local file to a peer. */
65
+ file(target: MessageTarget, filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean>;
66
+ /** Message the owner; returns the tool's result instead of logging. */
67
+ toOwner(owner: string, contextToken: string | undefined, text: string, signal?: AbortSignal): Promise<DeliveryResult>;
68
+ /** Forget the one-notice-per-outage state (a new connection). */
69
+ reset(): void;
70
+ }
71
+
72
+ export function createDeliverer(deps: DeliveryDeps): Deliverer {
73
+ /** Peers already reported as unable to receive, until a send gets through. */
74
+ const failureNotified = new Set<string>();
75
+
76
+ /**
77
+ * Send `text` in `maxReplyChars` chunks, stopping at the first failed chunk.
78
+ * A session expiry is handled here; a transport or rejection error is handed to
79
+ * `onFailure` with how much went out, so each caller reports it its own way.
80
+ */
81
+ async function deliverText(
82
+ to: string,
83
+ contextToken: string | undefined,
84
+ text: string,
85
+ signal: AbortSignal | undefined,
86
+ onFailure: (error: unknown, progress: { sent: number; total: number }) => void,
87
+ ): Promise<Delivery> {
88
+ const client = deps.client();
89
+ const account = deps.account();
90
+ if (!client || !account) return { ok: false, sent: 0, total: 0 };
91
+ const chunks = chunkText(toWechatText(text), deps.maxReplyChars()).filter((chunk) => chunk !== "");
92
+ if (chunks.length === 0) {
93
+ deps.notify("WeChat message had nothing to send after formatting.", "warning");
94
+ return { ok: false, sent: 0, total: 0, empty: true };
95
+ }
96
+ for (const [index, chunk] of chunks.entries()) {
97
+ // A split answer says so, so the peer knows more is coming and can tell a
98
+ // truncated reply from a complete one.
99
+ const body = chunks.length > 1 ? `[${index + 1}/${chunks.length}] ${chunk}` : chunk;
100
+ try {
101
+ const response = await client.sendMessage(account, { to, text: body, contextToken }, signal);
102
+ if (isSessionExpired(response)) {
103
+ deps.onExpired();
104
+ return { ok: false, sent: index, total: chunks.length, expired: true };
105
+ }
106
+ // A body-level rejection is not a delivery: report it with the part count
107
+ // instead of letting the answer vanish.
108
+ if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
109
+ // Prefer the non-zero value: the body may carry `ret: 0` plus a real
110
+ // `errcode`, and reporting `ret 0` hides the actual reason.
111
+ const code = (response.ret ?? 0) !== 0 ? response.ret : (response.errcode ?? 0);
112
+ // The server's own description is the only way to tell a bad token
113
+ // from a rate limit, so it is part of the error.
114
+ const detail = response.errmsg ? `: ${response.errmsg}` : "";
115
+ onFailure(new WechatError(`Weixin rejected the message (ret ${code}${detail}).`), {
116
+ sent: index,
117
+ total: chunks.length,
118
+ });
119
+ return { ok: false, sent: index, total: chunks.length };
120
+ }
121
+ } catch (error) {
122
+ onFailure(error, { sent: index, total: chunks.length });
123
+ return { ok: false, sent: index, total: chunks.length };
124
+ }
125
+ }
126
+ return { ok: true, sent: chunks.length, total: chunks.length };
127
+ }
128
+
129
+ /** Report a failure once per outage, forget the token, and return the outcome. */
130
+ async function deliverWithNotice(
131
+ target: MessageTarget,
132
+ text: string,
133
+ label: "reply" | "message delivery",
134
+ signal: AbortSignal | undefined,
135
+ ): Promise<boolean> {
136
+ let failed = false;
137
+ const delivery = await deliverText(target.peer, target.contextToken, text, signal, (error, progress) => {
138
+ failed = true;
139
+ if (signal?.aborted) return;
140
+ deps.forgetToken(target.peer);
141
+ if (failureNotified.has(target.peer)) return;
142
+ failureNotified.add(target.peer);
143
+ const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
144
+ deps.onSendError?.(messageOf(error));
145
+ deps.notify(`WeChat ${label} failed${partial}: ${messageOf(error)}`, "warning");
146
+ });
147
+ if (!failed && delivery.ok) {
148
+ failureNotified.delete(target.peer);
149
+ deps.onSent?.();
150
+ }
151
+ return delivery.ok;
152
+ }
153
+
154
+ return {
155
+ reply(target, text, signal) {
156
+ return deliverWithNotice(target, text, "reply", signal);
157
+ },
158
+
159
+ prompt(target, text, signal) {
160
+ return deliverWithNotice(target, text, "message delivery", signal);
161
+ },
162
+
163
+ async file(target, filePath, fileName, signal) {
164
+ const client = deps.client();
165
+ const account = deps.account();
166
+ if (!client || !account) return false;
167
+ const sent = await sendLocalFile({
168
+ client,
169
+ account,
170
+ cdnBaseUrl: deps.cdnBaseUrl,
171
+ maxBytes: deps.maxMediaBytes,
172
+ to: target.peer,
173
+ contextToken: target.contextToken,
174
+ filePath,
175
+ fileName,
176
+ signal,
177
+ });
178
+ // `sendLocalFile` reports only success, and the caller may fall back to text;
179
+ // without this line the operator would never know the attachment was dropped.
180
+ if (!sent && !signal?.aborted) {
181
+ deps.onSendError?.(`file delivery failed: ${fileName}`);
182
+ deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
183
+ }
184
+ if (sent) deps.onSent?.();
185
+ return sent;
186
+ },
187
+
188
+ async toOwner(owner, contextToken, text, signal) {
189
+ let failure: string | undefined;
190
+ const delivery = await deliverText(owner, contextToken, text, signal, (error, progress) => {
191
+ deps.forgetToken(owner);
192
+ const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
193
+ deps.onSendError?.(messageOf(error));
194
+ failure = `${messageOf(error)}${partial}`;
195
+ });
196
+ if (delivery.expired) return { ok: false, error: "WeChat session expired." };
197
+ if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." };
198
+ if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." };
199
+ deps.onSent?.();
200
+ return { ok: true };
201
+ },
202
+
203
+ reset() {
204
+ failureNotified.clear();
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The plain-text health report behind `/wechat doctor`.
3
+ *
4
+ * It answers the operator's first question — why is the bot quiet or slow? — from
5
+ * facts the bridge already has: the connection, the token's age, the last poll,
6
+ * the poller lock, per-peer sessions, and the last send/poll failure. Pure, so
7
+ * the wording and the age math are unit-tested without a bridge.
8
+ */
9
+
10
+ import { formatDuration } from "../../lib/format.ts";
11
+ import type { BridgeStatus } from "./bridge.ts";
12
+ import type { WechatConfig } from "./config.ts";
13
+ import type { BotCredentials } from "./types.ts";
14
+ import { peerLabel } from "./format.ts";
15
+
16
+ export interface DoctorInput {
17
+ /** Undefined when the bridge has never been built in this runtime. */
18
+ status?: BridgeStatus;
19
+ account?: BotCredentials;
20
+ config: WechatConfig;
21
+ now: number;
22
+ }
23
+
24
+ const LABEL = 11;
25
+
26
+ /** One `Label value` line. */
27
+ function row(label: string, value: string): string {
28
+ return `${label.padEnd(LABEL)}${value}`;
29
+ }
30
+
31
+ /** `3.2s` since `at`, or a placeholder. */
32
+ function age(now: number, at: number | undefined): string {
33
+ return at === undefined ? "unknown" : `${formatDuration(Math.max(0, now - at))} ago`;
34
+ }
35
+
36
+ function connection(status: BridgeStatus | undefined, account: BotCredentials | undefined, now: number): string {
37
+ if (!status?.open) {
38
+ return status?.expired ? "expired · run /wechat login" : "closed";
39
+ }
40
+ const owner = status.owner ? peerLabel(status.owner) : "unclaimed";
41
+ const token = account ? `${formatDuration(Math.max(0, now - account.createdAt))} old` : "unknown age";
42
+ return `open · owner ${owner} · token ${token}`;
43
+ }
44
+
45
+ function poll(status: BridgeStatus | undefined, now: number): string {
46
+ if (!status) return "not started";
47
+ const last =
48
+ status.lastPollAt === undefined
49
+ ? "no poll yet"
50
+ : `last ${formatDuration(Math.max(0, now - status.lastPollAt))} ago`;
51
+ const hold = status.pollHoldMs === undefined ? "server hold" : `hold ${formatDuration(status.pollHoldMs)}`;
52
+ const error = status.lastPollError ? ` · error: ${status.lastPollError}` : "";
53
+ return `${last} · ${hold}${error}`;
54
+ }
55
+
56
+ function lockLine(status: BridgeStatus | undefined, now: number): string {
57
+ const lock = status?.lock;
58
+ if (!lock) return status?.open ? "not held" : "not running";
59
+ return `pid ${lock.pid} · ${age(now, lock.at)}`;
60
+ }
61
+
62
+ function peers(status: BridgeStatus | undefined): string[] {
63
+ if (!status || status.peerStates.length === 0) return [row("Peers", "none")];
64
+ const running = status.peerStates.filter((entry) => entry.state.status === "running").length;
65
+ const lines = [row("Peers", `${status.peerStates.length} · ${running} running · ${status.queued} queued`)];
66
+ for (const entry of status.peerStates) {
67
+ const state = entry.state;
68
+ const what =
69
+ state.status === "running"
70
+ ? `running ${formatDuration(state.runningMs ?? 0)}`
71
+ : state.status === "queued"
72
+ ? `queued ${state.queued}`
73
+ : "idle";
74
+ const session = state.sessionAgeMs === undefined ? "" : ` · session ${formatDuration(state.sessionAgeMs)}`;
75
+ lines.push(row(` ${peerLabel(entry.peer)}`, `${what}${session}`));
76
+ }
77
+ return lines;
78
+ }
79
+
80
+ function recent(status: BridgeStatus | undefined): string {
81
+ const errors = [
82
+ status?.lastSendError ? `send: ${status.lastSendError}` : undefined,
83
+ status?.lastPollError ? `poll: ${status.lastPollError}` : undefined,
84
+ ].filter((entry): entry is string => entry !== undefined);
85
+ return row("Recent", errors.length > 0 ? errors.join(" · ") : "no recent failure");
86
+ }
87
+
88
+ function media(status: BridgeStatus | undefined): string {
89
+ const dropped = status?.mediaDropped ?? 0;
90
+ const refused = status?.refused ?? 0;
91
+ const names = status?.refusedPeers.join(", ");
92
+ return row(
93
+ "Media",
94
+ `${dropped} dropped · ${refused} refused${names ? ` (${names})` : ""} · ${status?.promptPending ? "prompt waiting" : "no prompt"}`,
95
+ );
96
+ }
97
+
98
+ function configLine(config: WechatConfig): string {
99
+ const overrides = Object.keys(config.peers).length;
100
+ const limits = config.maxTurnsPerHour > 0 ? `${config.maxTurnsPerHour} turns/h` : "no turn cap";
101
+ return row(
102
+ "Config",
103
+ `typing ${config.typingIndicator ? "on" : "off"} · prompts ${config.remotePrompts ? "on" : "off"} · ` +
104
+ `${config.allowedPeers.length} allowed · ${overrides} peer override(s) · ${limits}`,
105
+ );
106
+ }
107
+
108
+ /** The doctor report, one line per fact. */
109
+ export function doctorReport(input: DoctorInput): string {
110
+ const { status, account, config, now } = input;
111
+ return [
112
+ "wechat — health",
113
+ row("Connection", connection(status, account, now)),
114
+ row("Poll", poll(status, now)),
115
+ row("Lock", lockLine(status, now)),
116
+ ...peers(status),
117
+ recent(status),
118
+ media(status),
119
+ configLine(config),
120
+ ].join("\n");
121
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The one place an unknown thrown value becomes a user-facing line.
3
+ *
4
+ * Every terminal notice and tool error in the extension reports the same way, so
5
+ * an `Error` yields its message and anything else is stringified.
6
+ */
7
+
8
+ /** A user-facing message for an unknown thrown value. */
9
+ export function messageOf(error: unknown): string {
10
+ return error instanceof Error ? error.message : String(error);
11
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Pure text helpers for the bridge: sanitize inbound text, chunk long replies,
3
- * shorten peer ids for display, and render the short peer-facing notices.
3
+ * and shorten peer ids for display. The peer-facing notices live in `notices.ts`.
4
4
  */
5
5
 
6
6
  import { stripControlChars } from "../../lib/format.ts";
@@ -82,27 +82,6 @@ export function chunkText(text: string, max: number): string[] {
82
82
  /** The language a peer is answered in. */
83
83
  export type PeerLanguage = "zh" | "en";
84
84
 
85
- /**
86
- * The notices the bridge sends on its own, outside a reply. Every one is a
87
- * single short line in the peer's language: WeChat pings per message, so a
88
- * notice has to earn its bubble.
89
- */
90
- export type NoticeKind =
91
- | "ack"
92
- | "busy"
93
- | "stillWorking"
94
- | "overflow"
95
- | "media"
96
- | "unsupported"
97
- | "stopped"
98
- | "noText"
99
- | "compacted"
100
- | "budget"
101
- | "promptWaiting"
102
- | "timeout"
103
- | "failed"
104
- | "newBusy";
105
-
106
85
  const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
107
86
 
108
87
  /** The language to answer `text` in: Chinese when it contains CJK characters. */
@@ -110,56 +89,6 @@ export function peerLanguage(text: string): PeerLanguage {
110
89
  return CJK.test(text) ? "zh" : "en";
111
90
  }
112
91
 
113
- const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
114
- ack: { zh: "收到,正在处理…", en: "Got it — working on it." },
115
- busy: {
116
- zh: "前面还有一条正在处理,你的消息排在后面,轮到你就回你。",
117
- en: "Your message is queued behind another conversation; I'll answer as soon as it's free.",
118
- },
119
- stillWorking: {
120
- zh: "还在处理这条,稍等一下…",
121
- en: "Still working on this one — a moment…",
122
- },
123
- overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
124
- media: {
125
- zh: "有 {n} 个附件没能读取,可以重发或直接说明。",
126
- en: "Couldn't open {n} attachment(s) — please resend or describe it.",
127
- },
128
- unsupported: {
129
- zh: "语音(无转写)和视频暂不支持,请发文字或图片。",
130
- en: "Voice without a transcript and video aren't supported — please send text or an image.",
131
- },
132
- stopped: { zh: "已停止。", en: "Stopped." },
133
- noText: {
134
- zh: "这条处理完了,但没有文字输出。",
135
- en: "That turn finished without any text to send.",
136
- },
137
- compacted: {
138
- zh: "对话较长,上下文已压缩;更早的细节可能记不全。",
139
- en: "The conversation was compacted to stay within context; earlier details may be fuzzy.",
140
- },
141
- budget: {
142
- zh: "这一小时的消息有点多,稍后再试。",
143
- en: "You've reached this hour's message limit — please try again later.",
144
- },
145
- promptWaiting: {
146
- zh: "还在等你的回复,约 1 分钟后超时。",
147
- en: "Still waiting for your reply — this expires in about a minute.",
148
- },
149
- timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
150
- failed: { zh: "处理失败,请稍后重试。", en: "Something went wrong — please try again." },
151
- newBusy: {
152
- zh: "正在处理当前消息,稍后再发送 /new。",
153
- en: "Still working on your last message — send /new again after it finishes.",
154
- },
155
- };
156
-
157
- /** Render one notice, with `{n}` replaced when a count applies. */
158
- export function notice(kind: NoticeKind, lang: PeerLanguage, count?: number): string {
159
- const text = NOTICES[kind][lang];
160
- return count === undefined ? text : text.replace("{n}", String(count));
161
- }
162
-
163
92
  /** Drop the `@im.wechat` suffix from a peer id for a compact label. */
164
93
  export function peerLabel(peer: string): string {
165
94
  return peer.replace(/@im\.wechat$/i, "");