@gavin-hu/my-pi-agent 0.17.0 → 0.18.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.
- package/README.md +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -199,17 +199,16 @@ so every bubble has to carry information.
|
|
|
199
199
|
|
|
200
200
|
### The reply contract
|
|
201
201
|
|
|
202
|
-
One
|
|
203
|
-
|
|
204
|
-
inside the answer it is already waiting for — not with a second “working on it”.
|
|
202
|
+
One message, one turn, one answer. A queued message is never merged into
|
|
203
|
+
another prompt; it waits for its own turn. One acknowledgement per wait episode.
|
|
205
204
|
|
|
206
205
|
| Situation | What the peer gets |
|
|
207
206
|
|---|---|
|
|
208
207
|
| 1st message, session idle | typing, then the answer (no acknowledgement: there is nothing to wait for) |
|
|
209
|
-
| 1st message, peer busy | typing,
|
|
210
|
-
| 2nd message
|
|
211
|
-
| 2nd message
|
|
212
|
-
| 3rd…Nth in a burst |
|
|
208
|
+
| 1st message, peer busy | typing, `收到,排队中,轮到就回。` at 4 s, `还在处理前一条,轮到就回。` at 60 s, the answer when the slot frees |
|
|
209
|
+
| 2nd message while a turn runs | its own turn behind the first; the 4 s ack and 60 s busy line; then its own answer |
|
|
210
|
+
| 2nd message already queued | behind the first in FIFO; no merge |
|
|
211
|
+
| 3rd…Nth in a burst | each is its own turn; past eight queued, one line asking to wait |
|
|
213
212
|
|
|
214
213
|
Two consequences worth knowing: an answer can take up to one long-poll cycle
|
|
215
214
|
(the server holds each `getupdates`; measured ~18 s) before the turn even
|
|
@@ -227,9 +226,9 @@ A dialog raised while a WeChat turn is in flight is forwarded to the sender:
|
|
|
227
226
|
- Reply 取消 (or cancel) to dismiss; `退出`, `退出 plan 模式`, and
|
|
228
227
|
`exit plan mode` are recognized as cancel intent. In a plan review a dismissal
|
|
229
228
|
leaves plan mode without approving.
|
|
230
|
-
- Hints are
|
|
231
|
-
re-sends the prompt with a short “didn’t recognize that”
|
|
232
|
-
repeating it silently.
|
|
229
|
+
- Hints are rendered in the peer’s language, one language per prompt. A reply
|
|
230
|
+
that cannot be read re-sends the prompt with a short “didn’t recognize that”
|
|
231
|
+
notice instead of repeating it silently.
|
|
233
232
|
- A command is not an answer. While a dialog waits the model is blocked on it, so
|
|
234
233
|
the slash commands are matched first: `/status` names the waiting question and
|
|
235
234
|
`/stop` stops the turn, where before both were parsed as an attempt to answer
|
|
@@ -237,12 +236,9 @@ A dialog raised while a WeChat turn is in flight is forwarded to the sender:
|
|
|
237
236
|
so a number, 是/否, or 取消 behaves as above.
|
|
238
237
|
- An unanswered prompt is bounded: a reminder goes out halfway and the question
|
|
239
238
|
is cancelled at the end of its window with a one-line notice, so an ignored
|
|
240
|
-
dialog cannot freeze the session.
|
|
241
|
-
ten
|
|
242
|
-
|
|
243
|
-
reminder names 取消 rather than a duration that assumes the short window. A
|
|
244
|
-
timed-out plan review is treated like a dismissal: plan mode ends without
|
|
245
|
-
approval, and the plan file remains.
|
|
239
|
+
dialog cannot freeze the session. A dialog uses three minutes; the plan review
|
|
240
|
+
uses ten. A timed-out plan review is treated like a dismissal: plan mode ends
|
|
241
|
+
without approval, and the plan file remains.
|
|
246
242
|
- A prompt that goes away before it is answered — a pause, a `/stop`, a shutdown —
|
|
247
243
|
says so (`问题已撤回。` / “That question was withdrawn.”) instead of reusing the
|
|
248
244
|
timeout line: the peer was not slow, the question left.
|
|
@@ -257,25 +253,17 @@ to WeChat. See [Design notes](#design-notes).
|
|
|
257
253
|
|
|
258
254
|
## Outbound files
|
|
259
255
|
|
|
260
|
-
A remote prompt can deliver a local file first.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
answerable from the chat even if the file bubble is never opened, and a file or
|
|
267
|
-
digest the bridge cannot deliver is reported on the terminal instead of vanishing.
|
|
268
|
-
The digest says plan mode is on and that write access is off. The choices also
|
|
269
|
-
include **Send the full plan**, which posts the plan text and asks again, so the
|
|
270
|
-
whole plan is reachable without the attachment, and **Exit plan mode (discard)**,
|
|
271
|
-
which leaves without approving. After a Keep decision the user can say “exit plan
|
|
272
|
-
mode”; the model calls `cancel_plan_mode`, which needs no plan path and records
|
|
273
|
-
no todo or goal.
|
|
256
|
+
A remote prompt can deliver a local file first. The plan review uses this: the
|
|
257
|
+
plan file is sent before one compact editor prompt with the title, first steps,
|
|
258
|
+
mode status, and three actions. Reply 2 to keep planning or 3 to exit; any other
|
|
259
|
+
text is a refinement. If the upload fails, the plan text is sent instead. The
|
|
260
|
+
plan file stays on disk. After a Keep decision the user can say “exit plan mode”; the model
|
|
261
|
+
calls `cancel_plan_mode`, which needs no plan path and records no todo or goal.
|
|
274
262
|
The flow mirrors the
|
|
275
263
|
reference client: `getuploadurl` → AES-128-ECB encrypt → `POST` the ciphertext
|
|
276
264
|
(`Content-Type: application/octet-stream`) → read `x-encrypted-param` → send a
|
|
277
|
-
`type: 4` file item.
|
|
278
|
-
|
|
265
|
+
`type: 4` file item. No current flow uses it; images, video, and voice are not
|
|
266
|
+
sent outbound.
|
|
279
267
|
|
|
280
268
|
## Configuration
|
|
281
269
|
|
|
@@ -529,7 +517,6 @@ status, which is what the old start/stop guidance used to do.
|
|
|
529
517
|
| `media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, MIME sniff, filename sanitize, and save helpers. |
|
|
530
518
|
| `outbound.ts` | Upload and send a local file: hash, encrypt, `getUploadUrl`, upload, `sendFileMessage`. |
|
|
531
519
|
| `login.ts` | QR login state machine. |
|
|
532
|
-
| `qrcode.ts` | QR matrix → Unicode half-block terminal lines; the `qrcode` package is imported on first use, not at registration. |
|
|
533
520
|
| `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
|
|
534
521
|
| `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`). |
|
|
535
522
|
| `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
|
|
@@ -246,7 +246,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
246
246
|
lastSendError = undefined;
|
|
247
247
|
},
|
|
248
248
|
});
|
|
249
|
-
|
|
250
249
|
/** Run a slash action out-of-band and reply to the sender; never starts a turn. */
|
|
251
250
|
async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
|
|
252
251
|
if (!command.run) return;
|
|
@@ -280,27 +279,28 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
280
279
|
}
|
|
281
280
|
|
|
282
281
|
/**
|
|
283
|
-
* Send a
|
|
282
|
+
* Send a prompt to the peer owning the in-flight turn.
|
|
284
283
|
*
|
|
285
284
|
* A failure is reported on the terminal once per peer by the deliverer; a silent
|
|
286
285
|
* failure would leave the peer with only the choices, as if nothing preceded
|
|
287
286
|
* them.
|
|
288
287
|
*/
|
|
289
|
-
async function sendPrompt(text: string, signal?: AbortSignal): Promise<boolean> {
|
|
288
|
+
async function sendPrompt(text: string, signal?: AbortSignal, options?: { force?: boolean }): Promise<boolean> {
|
|
290
289
|
const current = worker?.current();
|
|
291
290
|
if (!current) return false;
|
|
291
|
+
// The model may have produced text before this prompt; put it on the wire first.
|
|
292
|
+
await worker?.flush(current.peer);
|
|
293
|
+
if (!options?.force && !interaction.hasPending()) return false;
|
|
292
294
|
return deliverer.prompt(current, text, signal ?? abort?.signal);
|
|
293
295
|
}
|
|
294
|
-
|
|
295
|
-
/** Upload and send a local file to the peer owning the in-flight turn. */
|
|
296
296
|
async function sendFileToPeer(filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean> {
|
|
297
297
|
const current = worker?.current();
|
|
298
298
|
if (!current) return false;
|
|
299
|
+
await worker?.flush(current.peer);
|
|
299
300
|
return deliverer.file(current, filePath, fileName, signal ?? abort?.signal);
|
|
300
301
|
}
|
|
301
|
-
|
|
302
302
|
const interaction = createWechatInteractionChannel({
|
|
303
|
-
send: (text, signal) => sendPrompt(text, signal),
|
|
303
|
+
send: (text, signal, options) => sendPrompt(text, signal, options),
|
|
304
304
|
sendFile: (path, name, signal) => sendFileToPeer(path, name, signal),
|
|
305
305
|
activePeer: () => worker?.current()?.peer,
|
|
306
306
|
language: () => languageOf(worker?.current()?.peer ?? ""),
|
|
@@ -618,7 +618,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
618
618
|
// not be cancelled by the poll abort that follows.
|
|
619
619
|
const stopping = abort;
|
|
620
620
|
abort = undefined;
|
|
621
|
-
if (interaction.hasPending()) interaction.cancel();
|
|
621
|
+
if (interaction.hasPending()) await interaction.cancel();
|
|
622
622
|
stopping?.abort();
|
|
623
623
|
try {
|
|
624
624
|
await loopPromise;
|
|
@@ -30,7 +30,7 @@ import { peerLabel, peerKey } from "./format.ts";
|
|
|
30
30
|
import { waitForLogin, type LoginOutcome } from "./login.ts";
|
|
31
31
|
import { defaultSleep } from "./timer.ts";
|
|
32
32
|
import { LoginScreenComponent, type LoginViewState } from "./login-screen.ts";
|
|
33
|
-
import { qrLines } from "
|
|
33
|
+
import { qrLines } from "../../lib/qr.ts";
|
|
34
34
|
import { statusLine, statusView, StatusScreenComponent, type StatusAction, type StatusView } from "./status-screen.ts";
|
|
35
35
|
import type { BotCredentials } from "./types.ts";
|
|
36
36
|
|
|
@@ -154,7 +154,7 @@ function resetMessage(bridge: Bridge, peer: string): VerbOutcome {
|
|
|
154
154
|
|
|
155
155
|
function cancelPromptMessage(bridge: Bridge): VerbOutcome {
|
|
156
156
|
if (!bridge.status().promptPending) return { message: "No prompt is waiting.", kind: "info" };
|
|
157
|
-
bridge.channel().cancel();
|
|
157
|
+
void bridge.channel().cancel();
|
|
158
158
|
return { message: "Cancelled the waiting prompt.", kind: "info" };
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -59,7 +59,7 @@ export interface DeliveryDeps {
|
|
|
59
59
|
export interface Deliverer {
|
|
60
60
|
/** Send a reply to a peer; false when the transport rejected it. */
|
|
61
61
|
reply(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean>;
|
|
62
|
-
/** Send a
|
|
62
|
+
/** Send a prompt to a peer. */
|
|
63
63
|
prompt(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean>;
|
|
64
64
|
/** Upload and send a local file to a peer. */
|
|
65
65
|
file(target: MessageTarget, filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean>;
|
|
@@ -72,6 +72,22 @@ export interface Deliverer {
|
|
|
72
72
|
export function createDeliverer(deps: DeliveryDeps): Deliverer {
|
|
73
73
|
/** Peers already reported as unable to receive, until a send gets through. */
|
|
74
74
|
const failureNotified = new Set<string>();
|
|
75
|
+
/** Per-peer FIFO: a reply, prompt, or file cannot start before the previous one. */
|
|
76
|
+
const queues = new Map<string, Promise<void>>();
|
|
77
|
+
|
|
78
|
+
function serialize<T>(peer: string, work: () => Promise<T>): Promise<T> {
|
|
79
|
+
const previous = queues.get(peer) ?? Promise.resolve();
|
|
80
|
+
const run = previous.then(work, work);
|
|
81
|
+
const settled = run.then(
|
|
82
|
+
() => undefined,
|
|
83
|
+
() => undefined,
|
|
84
|
+
);
|
|
85
|
+
queues.set(peer, settled);
|
|
86
|
+
void settled.finally(() => {
|
|
87
|
+
if (queues.get(peer) === settled) queues.delete(peer);
|
|
88
|
+
});
|
|
89
|
+
return run;
|
|
90
|
+
}
|
|
75
91
|
|
|
76
92
|
/**
|
|
77
93
|
* Send `text` in `maxReplyChars` chunks, stopping at the first failed chunk.
|
|
@@ -149,51 +165,55 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
|
|
|
149
165
|
|
|
150
166
|
return {
|
|
151
167
|
reply(target, text, signal) {
|
|
152
|
-
return deliverWithNotice(target, text, "reply", signal);
|
|
168
|
+
return serialize(target.peer, () => deliverWithNotice(target, text, "reply", signal));
|
|
153
169
|
},
|
|
154
170
|
|
|
155
171
|
prompt(target, text, signal) {
|
|
156
|
-
return deliverWithNotice(target, text, "message delivery", signal);
|
|
172
|
+
return serialize(target.peer, () => deliverWithNotice(target, text, "message delivery", signal));
|
|
157
173
|
},
|
|
158
174
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
file(target, filePath, fileName, signal) {
|
|
176
|
+
return serialize(target.peer, async () => {
|
|
177
|
+
const client = deps.client();
|
|
178
|
+
const account = deps.account();
|
|
179
|
+
if (!client || !account) return false;
|
|
180
|
+
const sent = await sendLocalFile({
|
|
181
|
+
client,
|
|
182
|
+
account,
|
|
183
|
+
cdnBaseUrl: deps.cdnBaseUrl,
|
|
184
|
+
maxBytes: deps.maxMediaBytes,
|
|
185
|
+
to: target.peer,
|
|
186
|
+
contextToken: target.contextToken,
|
|
187
|
+
filePath,
|
|
188
|
+
fileName,
|
|
189
|
+
signal,
|
|
190
|
+
});
|
|
191
|
+
// `sendLocalFile` reports only success, and the caller may fall back to text;
|
|
192
|
+
// without this line the operator would never know the attachment was dropped.
|
|
193
|
+
if (!sent && !signal?.aborted) {
|
|
194
|
+
deps.onSendError?.(`file delivery failed: ${fileName}`);
|
|
195
|
+
deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
|
|
196
|
+
}
|
|
197
|
+
if (sent) deps.onSent?.();
|
|
198
|
+
return sent;
|
|
173
199
|
});
|
|
174
|
-
// `sendLocalFile` reports only success, and the caller may fall back to text;
|
|
175
|
-
// without this line the operator would never know the attachment was dropped.
|
|
176
|
-
if (!sent && !signal?.aborted) {
|
|
177
|
-
deps.onSendError?.(`file delivery failed: ${fileName}`);
|
|
178
|
-
deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
|
|
179
|
-
}
|
|
180
|
-
if (sent) deps.onSent?.();
|
|
181
|
-
return sent;
|
|
182
200
|
},
|
|
183
201
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
toOwner(owner, contextToken, text, signal) {
|
|
203
|
+
return serialize(owner, async () => {
|
|
204
|
+
let failure: string | undefined;
|
|
205
|
+
const delivery = await deliverText(owner, contextToken, text, signal, (error, progress) => {
|
|
206
|
+
deps.forgetToken(owner);
|
|
207
|
+
const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
|
|
208
|
+
deps.onSendError?.(messageOf(error));
|
|
209
|
+
failure = `${messageOf(error)}${partial}`;
|
|
210
|
+
});
|
|
211
|
+
if (delivery.expired) return { ok: false, error: "WeChat session expired." };
|
|
212
|
+
if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." };
|
|
213
|
+
if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." };
|
|
214
|
+
deps.onSent?.();
|
|
215
|
+
return { ok: true };
|
|
191
216
|
});
|
|
192
|
-
if (delivery.expired) return { ok: false, error: "WeChat session expired." };
|
|
193
|
-
if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." };
|
|
194
|
-
if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." };
|
|
195
|
-
deps.onSent?.();
|
|
196
|
-
return { ok: true };
|
|
197
217
|
},
|
|
198
218
|
|
|
199
219
|
reset() {
|
|
@@ -28,10 +28,10 @@ import { defaultTimer, type ArmTimer } from "./timer.ts";
|
|
|
28
28
|
export const PROMPT_TIMEOUT_MS = 180_000;
|
|
29
29
|
|
|
30
30
|
export interface WechatInteractionDeps {
|
|
31
|
-
/** Send one text message to the owning peer;
|
|
32
|
-
send: (text: string, signal?: AbortSignal) => Promise<boolean>;
|
|
33
|
-
/** Send a local file to the owning peer;
|
|
34
|
-
sendFile: (path: string, name: string, signal?: AbortSignal) => Promise<boolean>;
|
|
31
|
+
/** Send one text message to the owning peer; `force` bypasses the prompt-active gate. */
|
|
32
|
+
send: (text: string, signal?: AbortSignal, options?: { force?: boolean }) => Promise<boolean>;
|
|
33
|
+
/** Send a local file to the owning peer; `force` bypasses the prompt-active gate. */
|
|
34
|
+
sendFile: (path: string, name: string, signal?: AbortSignal, options?: { force?: boolean }) => Promise<boolean>;
|
|
35
35
|
/** The peer that owns the in-flight turn, or undefined when there is none. */
|
|
36
36
|
activePeer: () => string | undefined;
|
|
37
37
|
/** The language the owning peer writes in, for the timeout notices. */
|
|
@@ -55,7 +55,9 @@ export interface WechatInteractionChannel extends InteractionChannel {
|
|
|
55
55
|
/** The waiting prompt, for the control screen; undefined when none waits. */
|
|
56
56
|
pendingInfo(): PendingPromptInfo | undefined;
|
|
57
57
|
/** Withdraw the waiting prompt: tell the peer and resolve it cancelled. */
|
|
58
|
-
cancel(): void
|
|
58
|
+
cancel(): Promise<void>;
|
|
59
|
+
/** The language prompts are rendered in. */
|
|
60
|
+
language(): PeerLanguage;
|
|
59
61
|
/** Drop any pending prompt (turn settled or the bridge stopped). */
|
|
60
62
|
reset(): void;
|
|
61
63
|
}
|
|
@@ -111,6 +113,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
111
113
|
return deps.activePeer() !== undefined;
|
|
112
114
|
},
|
|
113
115
|
|
|
116
|
+
language(): PeerLanguage {
|
|
117
|
+
return deps.language();
|
|
118
|
+
},
|
|
119
|
+
|
|
114
120
|
hasPending(): boolean {
|
|
115
121
|
return pending !== undefined;
|
|
116
122
|
},
|
|
@@ -127,21 +133,26 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
127
133
|
};
|
|
128
134
|
},
|
|
129
135
|
|
|
130
|
-
cancel(): void {
|
|
131
|
-
|
|
136
|
+
async cancel(): Promise<void> {
|
|
137
|
+
const current = pending;
|
|
138
|
+
if (!current) return;
|
|
132
139
|
// The peer saw the prompt; one line says it was withdrawn, which is not the
|
|
133
140
|
// same as expiry — they were not slow, the question went away. `reset` is the
|
|
134
|
-
// silent cleanup path and deliberately does not send this.
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
// silent cleanup path and deliberately does not send this. Settle after the
|
|
142
|
+
// notice is on the wire, so a caller awaiting the answer knows it was sent.
|
|
143
|
+
try {
|
|
144
|
+
await deps.send(notice("withdrawn", deps.language()), undefined, { force: true });
|
|
145
|
+
} finally {
|
|
146
|
+
if (pending === current) settle({ kind: "cancelled" });
|
|
147
|
+
}
|
|
137
148
|
},
|
|
138
149
|
|
|
139
150
|
async post(text: string, signal?: AbortSignal): Promise<void> {
|
|
140
|
-
await deps.send(text, signal);
|
|
151
|
+
await deps.send(text, signal, { force: true });
|
|
141
152
|
},
|
|
142
153
|
|
|
143
154
|
async postFile(path: string, name: string, signal?: AbortSignal): Promise<boolean> {
|
|
144
|
-
return deps.sendFile(path, name, signal);
|
|
155
|
+
return deps.sendFile(path, name, signal, { force: true });
|
|
145
156
|
},
|
|
146
157
|
|
|
147
158
|
setPromptTimeout(ms: number): () => void {
|
|
@@ -178,15 +189,17 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
178
189
|
// An ignored question must not freeze the session: nudge once, then
|
|
179
190
|
// cancel so the model gets a clean answer instead of a hang.
|
|
180
191
|
const bound = scopedTimeoutMs ?? PROMPT_TIMEOUT_MS;
|
|
181
|
-
cancelTimers.push(
|
|
192
|
+
cancelTimers.push(
|
|
193
|
+
arm(bound / 2, () => void deps.send(notice("promptWaiting", deps.language()), undefined, { force: true })),
|
|
194
|
+
);
|
|
182
195
|
cancelTimers.push(
|
|
183
196
|
arm(bound, () => {
|
|
184
|
-
void deps.send(notice("timeout", deps.language()));
|
|
197
|
+
void deps.send(notice("timeout", deps.language()), undefined, { force: true });
|
|
185
198
|
settle({ kind: "cancelled" });
|
|
186
199
|
}),
|
|
187
200
|
);
|
|
188
201
|
});
|
|
189
|
-
const sent = await deps.send(formatPrompt(request), signal);
|
|
202
|
+
const sent = await deps.send(formatPrompt(request, { language: deps.language() }), signal);
|
|
190
203
|
if (!sent) settle({ kind: "cancelled" });
|
|
191
204
|
return await answer;
|
|
192
205
|
},
|
|
@@ -197,7 +210,9 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
197
210
|
const parsed = parsePromptAnswer(current.request, text);
|
|
198
211
|
switch (parsed.kind) {
|
|
199
212
|
case "retry":
|
|
200
|
-
void deps.send(formatPrompt(current.request, { retry: true })
|
|
213
|
+
void deps.send(formatPrompt(current.request, { retry: true, language: deps.language() }), undefined, {
|
|
214
|
+
force: true,
|
|
215
|
+
});
|
|
201
216
|
return true;
|
|
202
217
|
case "other":
|
|
203
218
|
other.set(peer, parsed.text);
|
|
@@ -27,14 +27,14 @@ export type NoticeKind =
|
|
|
27
27
|
| "newBusy";
|
|
28
28
|
|
|
29
29
|
const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
30
|
-
ack: { zh: "
|
|
30
|
+
ack: { zh: "收到,排队中,轮到就回。", en: "Queued — I'll answer next." },
|
|
31
31
|
busy: {
|
|
32
|
-
zh: "
|
|
33
|
-
en: "
|
|
32
|
+
zh: "还在处理前一条,轮到就回。",
|
|
33
|
+
en: "Still on the previous message; yours is next.",
|
|
34
34
|
},
|
|
35
35
|
stillWorking: {
|
|
36
|
-
zh: "
|
|
37
|
-
en: "Still working —
|
|
36
|
+
zh: "还在处理,/stop 可取消。",
|
|
37
|
+
en: "Still working — /stop cancels.",
|
|
38
38
|
},
|
|
39
39
|
overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
|
|
40
40
|
media: {
|
|
@@ -59,8 +59,8 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
|
59
59
|
en: "You've reached this hour's message limit — please try again later.",
|
|
60
60
|
},
|
|
61
61
|
promptWaiting: {
|
|
62
|
-
zh: "
|
|
63
|
-
en: "
|
|
62
|
+
zh: "等你回复;回复「取消」跳过。",
|
|
63
|
+
en: "Waiting for your reply — reply cancel to skip.",
|
|
64
64
|
},
|
|
65
65
|
timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
|
|
66
66
|
withdrawn: { zh: "问题已撤回。", en: "That question was withdrawn." },
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* - One turn at a time per peer, and by default one turn at a time overall
|
|
14
14
|
* (`concurrency`), so a forwarded dialog always has a single owning peer.
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
15
|
+
* - Every message is its own turn, in arrival order. A message that arrives
|
|
16
|
+
* while a turn runs waits for its own turn and answer; nothing is merged.
|
|
17
17
|
* - A turn's answer is the text it produced. A requested `/stop` says `stopped`
|
|
18
18
|
* and drops the partial answer, a finished turn with no text says `noText`,
|
|
19
19
|
* and a thrown turn says `failed` — never silence.
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
27
27
|
import { messageOf } from "./errors.ts";
|
|
28
28
|
import type { PeerSession } from "./peer-session.ts";
|
|
29
|
-
import { createReplyPipeline } from "./reply-pipeline.ts";
|
|
29
|
+
import { createReplyPipeline, type ReplyPipeline } from "./reply-pipeline.ts";
|
|
30
30
|
import { defaultTimer, type ArmTimer } from "./timer.ts";
|
|
31
31
|
|
|
32
32
|
/** One admitted, media-resolved inbound message. */
|
|
@@ -97,6 +97,8 @@ export interface PeerWorker {
|
|
|
97
97
|
reset(peer: string): boolean;
|
|
98
98
|
/** The turn running right now, if any (one with the default concurrency). */
|
|
99
99
|
current(): PeerItem | undefined;
|
|
100
|
+
/** Flush buffered assistant text for a running peer before a prompt/file. */
|
|
101
|
+
flush(peer: string): Promise<void>;
|
|
100
102
|
state(peer: string): PeerState;
|
|
101
103
|
snapshot(): PeerSnapshot[];
|
|
102
104
|
/** Drop every session; queued messages are dropped with them. */
|
|
@@ -107,8 +109,6 @@ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
107
109
|
|
|
108
110
|
interface PeerEntry {
|
|
109
111
|
queue: PeerItem[];
|
|
110
|
-
/** Messages merged into the waiting item, so `queued` counts real messages. */
|
|
111
|
-
merged: number;
|
|
112
112
|
running?: PeerItem;
|
|
113
113
|
/** Monotonic start time of the running turn, for `runningMs`. */
|
|
114
114
|
startedAt?: number;
|
|
@@ -119,26 +119,14 @@ interface PeerEntry {
|
|
|
119
119
|
disposeTimer?: () => void;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/** Merge a waiting item with a newer one from the same peer. */
|
|
123
|
-
function mergeItems(first: PeerItem, next: PeerItem): PeerItem {
|
|
124
|
-
const images = [...(first.images ?? []), ...(next.images ?? [])];
|
|
125
|
-
return {
|
|
126
|
-
peer: first.peer,
|
|
127
|
-
text: first.text && next.text ? `${first.text}\n${next.text}` : first.text || next.text,
|
|
128
|
-
images: images.length > 0 ? images : undefined,
|
|
129
|
-
// The first item's prefix still describes an attachment the model never saw.
|
|
130
|
-
prefix: first.prefix,
|
|
131
|
-
language: next.language,
|
|
132
|
-
contextToken: next.contextToken ?? first.contextToken,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
122
|
export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
137
123
|
const now = deps.now ?? Date.now;
|
|
138
124
|
const armTimer = deps.armTimer ?? defaultTimer;
|
|
139
125
|
const idleTimeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
140
126
|
const concurrency = Math.max(1, deps.concurrency ?? 1);
|
|
141
127
|
const peers = new Map<string, PeerEntry>();
|
|
128
|
+
/** Active reply pipelines, so a dialog can flush buffered text first. */
|
|
129
|
+
const pipelines = new Map<string, ReplyPipeline>();
|
|
142
130
|
/** Peers whose running turn the peer asked to stop, so it reads as stopped. */
|
|
143
131
|
const aborted = new Set<string>();
|
|
144
132
|
/** Peers whose next session must start empty (`/new`). */
|
|
@@ -150,7 +138,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
150
138
|
function entry(peer: string): PeerEntry {
|
|
151
139
|
const existing = peers.get(peer);
|
|
152
140
|
if (existing) return existing;
|
|
153
|
-
const created: PeerEntry = { queue: []
|
|
141
|
+
const created: PeerEntry = { queue: [] };
|
|
154
142
|
peers.set(peer, created);
|
|
155
143
|
return created;
|
|
156
144
|
}
|
|
@@ -168,12 +156,12 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
168
156
|
if (target.running) {
|
|
169
157
|
return {
|
|
170
158
|
status: "running",
|
|
171
|
-
queued: target.queue.length
|
|
159
|
+
queued: target.queue.length,
|
|
172
160
|
runningMs: target.startedAt === undefined ? 0 : now() - target.startedAt,
|
|
173
161
|
...sessionAge,
|
|
174
162
|
};
|
|
175
163
|
}
|
|
176
|
-
const queued = target.queue.length
|
|
164
|
+
const queued = target.queue.length;
|
|
177
165
|
return { status: queued > 0 ? "queued" : "idle", queued, ...sessionAge };
|
|
178
166
|
}
|
|
179
167
|
|
|
@@ -251,6 +239,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
251
239
|
partChars: deps.partChars,
|
|
252
240
|
quietMs: deps.quietMs,
|
|
253
241
|
});
|
|
242
|
+
pipelines.set(item.peer, pipeline);
|
|
254
243
|
/** The session compacted this turn, so the peer is told its older detail is fuzzy. */
|
|
255
244
|
let compacted = false;
|
|
256
245
|
const unsubscribe = session.subscribe((event) => {
|
|
@@ -289,6 +278,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
289
278
|
}
|
|
290
279
|
} finally {
|
|
291
280
|
unsubscribe();
|
|
281
|
+
pipelines.delete(item.peer);
|
|
292
282
|
}
|
|
293
283
|
if (reason) await sendPart(deps.fallback(item, reason));
|
|
294
284
|
if (compacted) await sendPart(deps.fallback(item, "compacted"));
|
|
@@ -309,7 +299,6 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
309
299
|
if (!next) return;
|
|
310
300
|
const [peer, target] = next;
|
|
311
301
|
const item = target.queue.shift() as PeerItem;
|
|
312
|
-
target.merged = 0;
|
|
313
302
|
target.running = item;
|
|
314
303
|
target.startedAt = now();
|
|
315
304
|
running += 1;
|
|
@@ -340,16 +329,8 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
340
329
|
enqueue(item: PeerItem): void {
|
|
341
330
|
if (closed) return;
|
|
342
331
|
const target = entry(item.peer);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// burst is answered once, in send order. The merged count keeps `queued`
|
|
346
|
-
// honest so the bridge can cap a flood and the panel can report it.
|
|
347
|
-
if (tail) {
|
|
348
|
-
target.queue[target.queue.length - 1] = mergeItems(tail, item);
|
|
349
|
-
target.merged += 1;
|
|
350
|
-
} else {
|
|
351
|
-
target.queue.push(item);
|
|
352
|
-
}
|
|
332
|
+
// One message, one turn: a queued item is never folded into another.
|
|
333
|
+
target.queue.push(item);
|
|
353
334
|
wake();
|
|
354
335
|
},
|
|
355
336
|
|
|
@@ -368,7 +349,6 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
368
349
|
releaseSession(peer);
|
|
369
350
|
if (target) {
|
|
370
351
|
target.queue = [];
|
|
371
|
-
target.merged = 0;
|
|
372
352
|
cancelIdleSweep(target);
|
|
373
353
|
}
|
|
374
354
|
return true;
|
|
@@ -378,6 +358,10 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
378
358
|
return currentItem;
|
|
379
359
|
},
|
|
380
360
|
|
|
361
|
+
async flush(peer: string): Promise<void> {
|
|
362
|
+
await pipelines.get(peer)?.flush();
|
|
363
|
+
},
|
|
364
|
+
|
|
381
365
|
state(peer: string): PeerState {
|
|
382
366
|
return stateOf(peer);
|
|
383
367
|
},
|
|
@@ -388,6 +372,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
388
372
|
|
|
389
373
|
async close(): Promise<void> {
|
|
390
374
|
closed = true;
|
|
375
|
+
pipelines.clear();
|
|
391
376
|
const runningAborts = [...peers.values()]
|
|
392
377
|
.filter((target) => target.session)
|
|
393
378
|
.map((target) => target.session?.session.abort());
|
|
@@ -204,14 +204,13 @@ export function createPresence(deps: PresenceDeps): Presence {
|
|
|
204
204
|
* the same thing without spending a bubble.
|
|
205
205
|
*/
|
|
206
206
|
function armAck(target: PresenceTarget): void {
|
|
207
|
-
// A
|
|
208
|
-
//
|
|
209
|
-
if (isRunning(target.peer)) return;
|
|
207
|
+
// A message can be queued behind a running turn; it still gets one
|
|
208
|
+
// acknowledgement after the delay, but not one per message.
|
|
210
209
|
if (acks.has(target.peer)) return;
|
|
211
210
|
const last = ackedAt.get(target.peer);
|
|
212
211
|
if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return;
|
|
213
212
|
acks.arm(target.peer, ACK_DELAY_MS, () => {
|
|
214
|
-
if (!hasWork(target.peer)
|
|
213
|
+
if (!hasWork(target.peer)) return;
|
|
215
214
|
ackedAt.set(target.peer, deps.now());
|
|
216
215
|
void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer)));
|
|
217
216
|
// Still waiting after a minute behind another turn: say why.
|