@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.
- package/extensions/wechat/README.md +33 -11
- package/extensions/wechat/admission.ts +104 -0
- package/extensions/wechat/bridge.ts +186 -626
- package/extensions/wechat/commands.ts +121 -9
- package/extensions/wechat/deliver.ts +207 -0
- package/extensions/wechat/doctor.ts +121 -0
- package/extensions/wechat/errors.ts +11 -0
- package/extensions/wechat/format.ts +1 -72
- package/extensions/wechat/inbound.ts +152 -0
- package/extensions/wechat/index.ts +6 -2
- package/extensions/wechat/interaction.ts +40 -2
- package/extensions/wechat/lock.ts +11 -2
- package/extensions/wechat/login.ts +2 -20
- package/extensions/wechat/notices.ts +76 -0
- package/extensions/wechat/peer-worker.ts +13 -17
- package/extensions/wechat/poll.ts +146 -0
- package/extensions/wechat/presence.ts +274 -0
- package/extensions/wechat/slash.ts +2 -1
- package/extensions/wechat/status-screen.ts +192 -31
- package/extensions/wechat/timer.ts +25 -5
- package/package.json +1 -1
|
@@ -61,7 +61,9 @@ How the `send_wechat` call and its result look in the transcript — the call li
|
|
|
61
61
|
can drive the agent; `allowedPeers` opts in others. An entry matches either id
|
|
62
62
|
form (`alice` or `alice@im.wechat`), and a refused sender is named in
|
|
63
63
|
`/wechat status` and in one terminal notice, so the id to allowlist can be
|
|
64
|
-
copied from either.
|
|
64
|
+
copied from either. `a` on the control screen or `/wechat allow [peer]` writes
|
|
65
|
+
one entry into the project config; the running bridge picks it up on the next
|
|
66
|
+
`/reload` or restart, and the confirmation names the file.
|
|
65
67
|
- **Session-bound** — the bridge runs while an account is stored and the Pi
|
|
66
68
|
process is alive; it starts on `session_start`, and `/wechat logout` clears the
|
|
67
69
|
account and stops it. The peer sessions outlive it on disk.
|
|
@@ -74,8 +76,11 @@ How the `send_wechat` call and its result look in the transcript — the call li
|
|
|
74
76
|
| `/wechat login` | QR login; on scan, stores credentials, replaces a running poller, and starts the bridge. |
|
|
75
77
|
| `/wechat new` | Start a fresh *host* session. Peer conversations are independent and survive it; they use an inbound `/new`. |
|
|
76
78
|
| `/wechat status` | The same control screen as a bare `/wechat` (the one-line summary while a peer turn runs). |
|
|
79
|
+
| `/wechat doctor` | Print a read-only health report: connection and token age, last poll and hold, lock holder, per-peer session/queue, recent failures. |
|
|
77
80
|
| `/wechat stop <peer>` | Stop that peer's running turn; reports when nothing was running. |
|
|
78
81
|
| `/wechat reset <peer>` | Start a fresh conversation for that peer; refused while its turn runs. |
|
|
82
|
+
| `/wechat history <peer> [n]` | Print that peer's recent messages (default 10, max 20); read-only, and works without a live session. |
|
|
83
|
+
| `/wechat allow [peer]` | Add a refused sender to `wechat.allowedPeers` in the project `.pi/bytefly.json`, after a confirmation that names the file. Without an argument it offers the refused senders. |
|
|
79
84
|
| `/wechat logout` | Confirm, then clear credentials, drop the bridge, and stop polling. |
|
|
80
85
|
| `/wechat help` | List the subcommands with a line each. |
|
|
81
86
|
|
|
@@ -98,10 +103,15 @@ one-step recovery.
|
|
|
98
103
|
|
|
99
104
|
In the TUI, `/wechat` opens a control screen with the account, connection
|
|
100
105
|
state, owner, per-peer conversation rows (what each peer is doing, how long, and
|
|
101
|
-
what is queued behind it), activity counters, and
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
what is queued behind it), activity counters, and the waiting remote prompt
|
|
107
|
+
(peer, question, and age).
|
|
108
|
+
`j`/`k` (or the arrows) move the focus over peer rows; `s` stops the focused
|
|
109
|
+
peer's turn and `d` starts a fresh conversation for it, both in place; `h` closes
|
|
110
|
+
the screen and prints that peer's history; `a` closes it and offers the refused
|
|
111
|
+
senders to allow; `c` withdraws the waiting prompt. `l` logs in or reconnects,
|
|
112
|
+
`n` starts a new session, `o` logs out, `r` refreshes, and Esc closes. The
|
|
113
|
+
subcommands above are shortcuts for the same actions and the only route outside
|
|
114
|
+
the TUI; an unknown subcommand reports the valid ones.
|
|
105
115
|
|
|
106
116
|
A peer turn runs in its own session, but it still owns the bridge, so a `/wechat`
|
|
107
117
|
command typed at the terminal declines while one is running: `status` prints the
|
|
@@ -182,7 +192,7 @@ inside the answer it is already waiting for — not with a second “working on
|
|
|
182
192
|
| Situation | What the peer gets |
|
|
183
193
|
|---|---|
|
|
184
194
|
| 1st message, session idle | typing, then the answer (no acknowledgement: there is nothing to wait for) |
|
|
185
|
-
| 1st message,
|
|
195
|
+
| 1st message, peer busy | typing, `收到,正在处理…` at 4 s, `前面还有一条正在处理…` at 60 s, the answer when the slot frees |
|
|
186
196
|
| 2nd message after the acknowledgement, still queued | nothing new; it is merged into the same turn and the one answer covers both in order |
|
|
187
197
|
| 2nd message while a turn runs | no new acknowledgement; it becomes its own turn and answer after the first |
|
|
188
198
|
| 3rd…Nth in a burst | same as the queued case; past eight queued, one line asking to wait |
|
|
@@ -304,6 +314,9 @@ never touches the stored cursor.
|
|
|
304
314
|
|
|
305
315
|
## Troubleshooting
|
|
306
316
|
|
|
317
|
+
- **First, run `/wechat doctor`.** It prints the connection, token age, last
|
|
318
|
+
poll, poller lock, per-peer queue/session age, and the last failure, which
|
|
319
|
+
answers most "why is the bot quiet?" questions in one screen.
|
|
307
320
|
- **`Another Pi session is already polling this WeChat account.`** — one runtime
|
|
308
321
|
owns the account at a time, tracked by `<agent-dir>/wechat/poller.lock`.
|
|
309
322
|
Quit the other `pi` process. A lock left by a process that already exited is
|
|
@@ -382,7 +395,7 @@ sessions are in-process).
|
|
|
382
395
|
|
|
383
396
|
| Integration point | Detail |
|
|
384
397
|
|---|---|
|
|
385
|
-
| Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `stop <peer>`, `reset <peer>`, `logout`, `help`; bare `/wechat` opens the control screen. |
|
|
398
|
+
| Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
|
|
386
399
|
| Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
|
|
387
400
|
| Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
|
|
388
401
|
| Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
|
|
@@ -465,12 +478,17 @@ status, which is what the old start/stop guidance used to do.
|
|
|
465
478
|
| File | Responsibility |
|
|
466
479
|
|---|---|
|
|
467
480
|
| `index.ts` | Factory: gate, register the command and tool, wire the lifecycle events, build the bridge, open peer sessions from the host context. |
|
|
468
|
-
| `bridge.ts` | The
|
|
481
|
+
| `bridge.ts` | The composition root: wires the poll loop, delivery, presence, admission, and inbound modules to the live client/account/cursor/state; owns the connection lifecycle, slash dispatch, the status snapshot, and `sendToOwner`. |
|
|
482
|
+
| `poll.ts` | The long-poll loop: `getupdates`, the watchdog and retry, backoff, cursor persistence, and the expiry stop. |
|
|
483
|
+
| `deliver.ts` | The one outbound text path (reply, dialog preface, `send_wechat`) plus file delivery: flatten, split, number, expiry, rejection, and the once-per-outage notice. |
|
|
484
|
+
| `presence.ts` | Typing, acknowledgement, and the slow/busy lines; all per-peer wait timers in one registry. |
|
|
485
|
+
| `admission.ts` | The rolling-hour turn budget and the per-peer serial admission chain. |
|
|
486
|
+
| `inbound.ts` | Inbound media resolution (download, decrypt, save, attach) and the hand-off to a peer's session. |
|
|
469
487
|
| `peer-session.ts` | One SDK `AgentSession` per peer: workspace, model, history under the state dir, dialogs bound to that peer, this bridge excluded from its resources, and the channel prompt appended. |
|
|
470
488
|
| `channel.ts` | The phone-chat contract appended to a peer session's system prompt. |
|
|
471
489
|
| `peer-worker.ts` | Per-peer queue, one turn at a time (`concurrency`), prompt/abort/reset, streamed reply parts, idle-session sweep. |
|
|
472
490
|
| `reply-pipeline.ts` | Where a streamed answer is split (paragraph, quiet period, size), sent in order, and finished without duplicating a part. |
|
|
473
|
-
| `commands.ts` | `/wechat login|new|status|logout|help`: the verb table that drives the help text and completions, the dispatch, the panel, login, and
|
|
491
|
+
| `commands.ts` | `/wechat login|new|status|stop|reset|logout|help`: the verb table that drives the help text and completions, the dispatch, the panel, login/logout, and the per-peer stop/reset; `login`/`new`/`logout` decline and `status` degrades to the summary while a peer turn runs. |
|
|
474
492
|
| `tools.ts` | The `send_wechat` tool (owner-only proactive text). |
|
|
475
493
|
| `client.ts` | iLink HTTP client (QR, getupdates, sendmessage, file sendmessage, getuploadurl, getconfig, sendtyping, CDN download/upload) with an injected runner. |
|
|
476
494
|
| `media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, MIME sniff, filename sanitize, and save helpers. |
|
|
@@ -478,17 +496,21 @@ status, which is what the old start/stop guidance used to do.
|
|
|
478
496
|
| `login.ts` | QR login state machine. |
|
|
479
497
|
| `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
|
|
480
498
|
| `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
|
|
481
|
-
| `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`) and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `paint`). |
|
|
499
|
+
| `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`): live facts, a focusable peer list with stop/reset/history keys, and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `listRow`, `paint`, `IdCursor`). |
|
|
482
500
|
| `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
|
|
483
501
|
| `credentials.ts` | `credentials.json` (`0600`, every stored account and the active one). |
|
|
484
502
|
| `state.ts` | `state.json` (cursor, owner, per-peer tokens, recent message ids). |
|
|
485
503
|
| `lock.ts` | Poller lockfile. |
|
|
486
|
-
| `format.ts` | Sanitize, chunk, and label helpers. |
|
|
504
|
+
| `format.ts` | Sanitize, chunk, and label helpers (text only). |
|
|
505
|
+
| `notices.ts` | The peer-facing notice catalog: one line per kind, in the peer's language. |
|
|
506
|
+
| `errors.ts` | `messageOf`, the one error-to-line helper every notice and tool error uses. |
|
|
507
|
+
| `timer.ts` | The timer seam: the cancellable one-shot `ArmTimer`, `defaultTimer`, and the abortable `defaultSleep`. |
|
|
487
508
|
| `interaction.ts` | The WeChat `InteractionChannel`: pending-prompt state machine and reply routing. |
|
|
488
509
|
| `prompt.ts` | Pure prompt formatting and reply parsing. |
|
|
489
510
|
| `remote-ui.ts` | The peer session's UI context: dialogs and notices bound to that peer, the `askHuman` marker that delivers the file/text preface, and the `remotePrompts` switch. |
|
|
490
511
|
| `slash.ts` | Inbound slash commands: the built-in registry (`/new`, `/stop`, `/status`, `/history`, `/help`), the session controls an action may invoke, and the pure parse/lookup/expand helpers. |
|
|
491
512
|
| `history.ts` | Read-only `/history`: opens the peer's newest session, formats the last user/assistant messages, and never writes. |
|
|
513
|
+
| `doctor.ts` | The plain-text `/wechat doctor` report: connection/token age, poll, lock, peer sessions, recent failures, and config. |
|
|
492
514
|
| `types.ts` | Wire and domain types. |
|
|
493
515
|
|
|
494
516
|
## Testing
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-peer admission bookkeeping: the rolling-hour turn budget and the serial
|
|
3
|
+
* admission chain.
|
|
4
|
+
*
|
|
5
|
+
* A burst of media messages must keep its arrival order and count against the
|
|
6
|
+
* queue cap before its download completes, so a slot is reserved when the
|
|
7
|
+
* message is accepted and each peer's admissions run one after another. The
|
|
8
|
+
* budget is in memory; a reload starts a fresh hour.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { InboundItem } from "./types.ts";
|
|
12
|
+
import { peerKey } from "./format.ts";
|
|
13
|
+
|
|
14
|
+
/** A rolling-hour turn budget per peer. */
|
|
15
|
+
export interface TurnBudget {
|
|
16
|
+
/** Reserve a turn; false when the peer has used its hourly allowance. */
|
|
17
|
+
allow(peer: string): boolean;
|
|
18
|
+
/** Drop every counter (a new connection). */
|
|
19
|
+
reset(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createTurnBudget(limit: number, now: () => number): TurnBudget {
|
|
23
|
+
const turnTimes = new Map<string, number[]>();
|
|
24
|
+
return {
|
|
25
|
+
allow(peer: string): boolean {
|
|
26
|
+
if (limit <= 0) return true;
|
|
27
|
+
const key = peerKey(peer);
|
|
28
|
+
const at = now();
|
|
29
|
+
const cutoff = at - 60 * 60 * 1000;
|
|
30
|
+
const times = (turnTimes.get(key) ?? []).filter((entry) => entry >= cutoff);
|
|
31
|
+
if (times.length >= limit) {
|
|
32
|
+
turnTimes.set(key, times);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
times.push(at);
|
|
36
|
+
turnTimes.set(key, times);
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
reset(): void {
|
|
40
|
+
turnTimes.clear();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AdmissionQueueDeps {
|
|
46
|
+
/** Resolve media and hand the message to the peer's session. */
|
|
47
|
+
admit: (item: InboundItem) => Promise<void>;
|
|
48
|
+
/** A message that could not be admitted at all. */
|
|
49
|
+
notify: (message: string, kind?: "info" | "warning" | "error") => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The per-peer serial admission chain. */
|
|
53
|
+
export interface AdmissionQueue {
|
|
54
|
+
/** Reserve a slot and admit in arrival order. */
|
|
55
|
+
enqueue(item: InboundItem): void;
|
|
56
|
+
/** Slots reserved but not yet queued (downloads in flight). */
|
|
57
|
+
pending(peer: string): number;
|
|
58
|
+
/** Drop the bookkeeping (a new connection). */
|
|
59
|
+
reset(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createAdmissionQueue(deps: AdmissionQueueDeps): AdmissionQueue {
|
|
63
|
+
/** Messages being resolved (media download) before they reach the queue. */
|
|
64
|
+
const admitting = new Map<string, number>();
|
|
65
|
+
/** Per-peer admission chain, so two media messages keep their arrival order. */
|
|
66
|
+
const chains = new Map<string, Promise<void>>();
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
enqueue(item: InboundItem): void {
|
|
70
|
+
const peer = item.peer;
|
|
71
|
+
admitting.set(peer, (admitting.get(peer) ?? 0) + 1);
|
|
72
|
+
const previous = chains.get(peer) ?? Promise.resolve();
|
|
73
|
+
const next = previous
|
|
74
|
+
.then(async () => {
|
|
75
|
+
await deps.admit(item);
|
|
76
|
+
})
|
|
77
|
+
.catch((error) => {
|
|
78
|
+
// One bad admission must not stall the peer's chain forever.
|
|
79
|
+
deps.notify(
|
|
80
|
+
`WeChat could not accept a message: ${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
"warning",
|
|
82
|
+
);
|
|
83
|
+
})
|
|
84
|
+
.finally(() => {
|
|
85
|
+
const left = (admitting.get(peer) ?? 1) - 1;
|
|
86
|
+
if (left > 0) admitting.set(peer, left);
|
|
87
|
+
else admitting.delete(peer);
|
|
88
|
+
});
|
|
89
|
+
chains.set(peer, next);
|
|
90
|
+
void next.finally(() => {
|
|
91
|
+
if (chains.get(peer) === next) chains.delete(peer);
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
pending(peer: string): number {
|
|
96
|
+
return admitting.get(peer) ?? 0;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
reset(): void {
|
|
100
|
+
admitting.clear();
|
|
101
|
+
chains.clear();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|