@gavin-hu/my-pi-agent 0.16.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 +29 -15
- package/README.zh-CN.md +27 -15
- package/extensions/bytefly/README.md +195 -115
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +29 -39
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +89 -22
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +74 -459
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +26 -152
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +27 -6
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +10 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +35 -52
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/README.md +3 -3
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +333 -84
- package/extensions/plan/commands.ts +73 -7
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +184 -58
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +95 -95
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +123 -107
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +17 -12
- package/extensions/plan/tools/exit.ts +148 -59
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +29 -31
- package/extensions/plan/tools.ts +6 -3
- package/extensions/plan/tui.ts +60 -15
- package/extensions/plan/types.ts +45 -3
- package/extensions/plan/web-chrome.ts +102 -0
- package/extensions/plan/web-client.ts +177 -446
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +131 -19
- package/extensions/plan/web-router.ts +148 -32
- 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/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- 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 +26 -13
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +25 -44
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +45 -36
- package/extensions/wechat/bridge.ts +46 -48
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +3 -3
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +54 -18
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +9 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +53 -23
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -40
- package/lib/README.md +12 -7
- package/lib/claims/overlap.ts +43 -0
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +28 -1
- 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 +117 -25
- package/lib/requirements.ts +1 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +30 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +91 -19
- package/lib/tui/gauge.ts +9 -5
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +5 -2
- package/skills/changelog/SKILL.md +57 -45
- 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 +209 -0
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -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. */
|
|
@@ -43,6 +43,11 @@ export interface WechatInteractionDeps {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface WechatInteractionChannel extends InteractionChannel {
|
|
46
|
+
/**
|
|
47
|
+
* Widen the bound for the prompts asked inside the scope, and restore the
|
|
48
|
+
* previous one when it ends: the optional channel member, made concrete here.
|
|
49
|
+
*/
|
|
50
|
+
setPromptTimeout(ms: number): () => void;
|
|
46
51
|
/** Consume an inbound message as a reply; true when it was one. */
|
|
47
52
|
handleInbound(peer: string, text: string): boolean;
|
|
48
53
|
/** Whether a prompt is currently waiting for a reply. */
|
|
@@ -50,7 +55,9 @@ export interface WechatInteractionChannel extends InteractionChannel {
|
|
|
50
55
|
/** The waiting prompt, for the control screen; undefined when none waits. */
|
|
51
56
|
pendingInfo(): PendingPromptInfo | undefined;
|
|
52
57
|
/** Withdraw the waiting prompt: tell the peer and resolve it cancelled. */
|
|
53
|
-
cancel(): void
|
|
58
|
+
cancel(): Promise<void>;
|
|
59
|
+
/** The language prompts are rendered in. */
|
|
60
|
+
language(): PeerLanguage;
|
|
54
61
|
/** Drop any pending prompt (turn settled or the bridge stopped). */
|
|
55
62
|
reset(): void;
|
|
56
63
|
}
|
|
@@ -79,6 +86,11 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
79
86
|
const arm = deps.armTimer ?? defaultTimer;
|
|
80
87
|
const now = deps.now ?? Date.now;
|
|
81
88
|
let pending: Pending | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* The bound for the prompts asked inside the current `setPromptTimeout` scope,
|
|
91
|
+
* set by a caller whose dialog needs reading time before it can be answered.
|
|
92
|
+
*/
|
|
93
|
+
let scopedTimeoutMs: number | undefined;
|
|
82
94
|
/**
|
|
83
95
|
* Free text captured from a `select` "Other" reply, per peer: the immediately
|
|
84
96
|
* following `input` for that peer returns it. Keyed so one peer's free text can
|
|
@@ -101,6 +113,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
101
113
|
return deps.activePeer() !== undefined;
|
|
102
114
|
},
|
|
103
115
|
|
|
116
|
+
language(): PeerLanguage {
|
|
117
|
+
return deps.language();
|
|
118
|
+
},
|
|
119
|
+
|
|
104
120
|
hasPending(): boolean {
|
|
105
121
|
return pending !== undefined;
|
|
106
122
|
},
|
|
@@ -117,20 +133,34 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
117
133
|
};
|
|
118
134
|
},
|
|
119
135
|
|
|
120
|
-
cancel(): void {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
async cancel(): Promise<void> {
|
|
137
|
+
const current = pending;
|
|
138
|
+
if (!current) return;
|
|
139
|
+
// The peer saw the prompt; one line says it was withdrawn, which is not the
|
|
140
|
+
// same as expiry — they were not slow, the question went away. `reset` is the
|
|
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
|
+
}
|
|
126
148
|
},
|
|
127
149
|
|
|
128
150
|
async post(text: string, signal?: AbortSignal): Promise<void> {
|
|
129
|
-
await deps.send(text, signal);
|
|
151
|
+
await deps.send(text, signal, { force: true });
|
|
130
152
|
},
|
|
131
153
|
|
|
132
154
|
async postFile(path: string, name: string, signal?: AbortSignal): Promise<boolean> {
|
|
133
|
-
return deps.sendFile(path, name, signal);
|
|
155
|
+
return deps.sendFile(path, name, signal, { force: true });
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
setPromptTimeout(ms: number): () => void {
|
|
159
|
+
const previous = scopedTimeoutMs;
|
|
160
|
+
scopedTimeoutMs = ms;
|
|
161
|
+
return () => {
|
|
162
|
+
scopedTimeoutMs = previous;
|
|
163
|
+
};
|
|
134
164
|
},
|
|
135
165
|
|
|
136
166
|
async request(request: InteractionRequest, signal?: AbortSignal): Promise<InteractionAnswer> {
|
|
@@ -158,15 +188,18 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
158
188
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
159
189
|
// An ignored question must not freeze the session: nudge once, then
|
|
160
190
|
// cancel so the model gets a clean answer instead of a hang.
|
|
161
|
-
|
|
191
|
+
const bound = scopedTimeoutMs ?? PROMPT_TIMEOUT_MS;
|
|
192
|
+
cancelTimers.push(
|
|
193
|
+
arm(bound / 2, () => void deps.send(notice("promptWaiting", deps.language()), undefined, { force: true })),
|
|
194
|
+
);
|
|
162
195
|
cancelTimers.push(
|
|
163
|
-
arm(
|
|
164
|
-
void deps.send(notice("timeout", deps.language()));
|
|
196
|
+
arm(bound, () => {
|
|
197
|
+
void deps.send(notice("timeout", deps.language()), undefined, { force: true });
|
|
165
198
|
settle({ kind: "cancelled" });
|
|
166
199
|
}),
|
|
167
200
|
);
|
|
168
201
|
});
|
|
169
|
-
const sent = await deps.send(formatPrompt(request), signal);
|
|
202
|
+
const sent = await deps.send(formatPrompt(request, { language: deps.language() }), signal);
|
|
170
203
|
if (!sent) settle({ kind: "cancelled" });
|
|
171
204
|
return await answer;
|
|
172
205
|
},
|
|
@@ -177,7 +210,9 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
177
210
|
const parsed = parsePromptAnswer(current.request, text);
|
|
178
211
|
switch (parsed.kind) {
|
|
179
212
|
case "retry":
|
|
180
|
-
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
|
+
});
|
|
181
216
|
return true;
|
|
182
217
|
case "other":
|
|
183
218
|
other.set(peer, parsed.text);
|
|
@@ -197,6 +232,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
197
232
|
|
|
198
233
|
reset(): void {
|
|
199
234
|
other.clear();
|
|
235
|
+
scopedTimeoutMs = undefined;
|
|
200
236
|
settle({ kind: "cancelled" });
|
|
201
237
|
},
|
|
202
238
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { type Component, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
-
import { screenHint } from "../../lib/tui/chrome.ts";
|
|
11
|
+
import { screenFooter, screenHint } from "../../lib/tui/chrome.ts";
|
|
12
12
|
import { clip } from "../../lib/tui/columns.ts";
|
|
13
13
|
import { isCloseKey } from "../../lib/tui/cursor.ts";
|
|
14
14
|
import { titleRule } from "../../lib/tui/rules.ts";
|
|
@@ -87,8 +87,7 @@ export class LoginScreenComponent implements Component {
|
|
|
87
87
|
titleRule(theme, target, "wechat · login"),
|
|
88
88
|
"",
|
|
89
89
|
...loginBody(this.options.state(), target),
|
|
90
|
-
"",
|
|
91
|
-
screenHint(theme, target, ["Esc cancel"]),
|
|
90
|
+
...screenFooter(theme, target, screenHint(theme, target, ["Esc cancel"])),
|
|
92
91
|
];
|
|
93
92
|
}
|
|
94
93
|
}
|
|
@@ -22,18 +22,19 @@ export type NoticeKind =
|
|
|
22
22
|
| "budget"
|
|
23
23
|
| "promptWaiting"
|
|
24
24
|
| "timeout"
|
|
25
|
+
| "withdrawn"
|
|
25
26
|
| "failed"
|
|
26
27
|
| "newBusy";
|
|
27
28
|
|
|
28
29
|
const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
29
|
-
ack: { zh: "
|
|
30
|
+
ack: { zh: "收到,排队中,轮到就回。", en: "Queued — I'll answer next." },
|
|
30
31
|
busy: {
|
|
31
|
-
zh: "
|
|
32
|
-
en: "
|
|
32
|
+
zh: "还在处理前一条,轮到就回。",
|
|
33
|
+
en: "Still on the previous message; yours is next.",
|
|
33
34
|
},
|
|
34
35
|
stillWorking: {
|
|
35
|
-
zh: "
|
|
36
|
-
en: "Still working
|
|
36
|
+
zh: "还在处理,/stop 可取消。",
|
|
37
|
+
en: "Still working — /stop cancels.",
|
|
37
38
|
},
|
|
38
39
|
overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
|
|
39
40
|
media: {
|
|
@@ -58,10 +59,11 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
|
58
59
|
en: "You've reached this hour's message limit — please try again later.",
|
|
59
60
|
},
|
|
60
61
|
promptWaiting: {
|
|
61
|
-
zh: "
|
|
62
|
-
en: "
|
|
62
|
+
zh: "等你回复;回复「取消」跳过。",
|
|
63
|
+
en: "Waiting for your reply — reply cancel to skip.",
|
|
63
64
|
},
|
|
64
65
|
timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
|
|
66
|
+
withdrawn: { zh: "问题已撤回。", en: "That question was withdrawn." },
|
|
65
67
|
failed: { zh: "处理失败,请稍后重试。", en: "Something went wrong — please try again." },
|
|
66
68
|
newBusy: {
|
|
67
69
|
zh: "正在处理当前消息,稍后再发送 /new。",
|
|
@@ -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.
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* Pure rendering and parsing of remote dialog prompts (no I/O).
|
|
3
3
|
*
|
|
4
4
|
* A prompt is rendered to a WeChat message by {@link formatPrompt}; the reply is
|
|
5
|
-
* interpreted by {@link parsePromptAnswer}. Hints are
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* interpreted by {@link parsePromptAnswer}. Hints are rendered in the peer's
|
|
6
|
+
* language, and {@link formatPrompt} can prepend a short notice when a reply was
|
|
7
|
+
* not understood. The parser is deliberately total:
|
|
8
8
|
* anything it cannot resolve reports `retry`, and free text on a `select` becomes
|
|
9
9
|
* the {@link INTERACTION_OTHER_LABEL} option so the caller can collect it once.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { INTERACTION_OTHER_LABEL, type InteractionRequest } from "../../lib/interaction.ts";
|
|
13
|
-
import { sanitizeInbound } from "./format.ts";
|
|
13
|
+
import { sanitizeInbound, type PeerLanguage } from "./format.ts";
|
|
14
14
|
|
|
15
15
|
/** A parsed reply: a resolved answer, the "other" choice, or a retry. */
|
|
16
16
|
export type PromptParse =
|
|
@@ -20,19 +20,32 @@ export type PromptParse =
|
|
|
20
20
|
| { kind: "cancelled" }
|
|
21
21
|
| { kind: "retry" };
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
"Reply with a number or the option text; anything else
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
"
|
|
23
|
+
const SELECT_OTHER_HINT: Record<PeerLanguage, string> = {
|
|
24
|
+
zh: "回复编号或选项文字;其它内容作为自定义回答;回复「取消」跳过。",
|
|
25
|
+
en: "Reply with a number or the option text; anything else is your own answer; reply cancel to dismiss.",
|
|
26
|
+
};
|
|
27
|
+
const SELECT_CHOICE_HINT: Record<PeerLanguage, string> = {
|
|
28
|
+
zh: "回复编号或选项文字;回复「取消」跳过。",
|
|
29
|
+
en: "Reply with a number or the option text; reply cancel to dismiss.",
|
|
30
|
+
};
|
|
31
|
+
const CONFIRM_HINT: Record<PeerLanguage, string> = {
|
|
32
|
+
zh: "回复「是」或「否」。",
|
|
33
|
+
en: "Reply yes or no.",
|
|
34
|
+
};
|
|
35
|
+
const INPUT_HINT: Record<PeerLanguage, string> = {
|
|
36
|
+
zh: "直接回复内容;回复「取消」跳过。",
|
|
37
|
+
en: "Reply with the text; reply cancel to skip.",
|
|
38
|
+
};
|
|
39
|
+
const EDITOR_HINT: Record<PeerLanguage, string> = {
|
|
40
|
+
zh: "把内容作为一条消息发回;回复「取消」取消。",
|
|
41
|
+
en: "Send the text as one message; reply cancel to cancel.",
|
|
42
|
+
};
|
|
30
43
|
|
|
31
44
|
/** Sent ahead of a re-prompt when a reply could not be interpreted. */
|
|
32
|
-
export const RETRY_NOTICE =
|
|
33
|
-
|
|
45
|
+
export const RETRY_NOTICE = "Sorry, I didn't recognize that reply — please answer again.";
|
|
46
|
+
const RETRY_ZH = "没有识别到有效回复,请重新回答。";
|
|
34
47
|
|
|
35
|
-
const CANCEL_WORDS = new Set(["取消", "cancel", "quit", "exit"]);
|
|
48
|
+
const CANCEL_WORDS = new Set(["取消", "退出", "cancel", "quit", "exit"]);
|
|
36
49
|
const YES_WORDS = new Set(["是", "y", "yes", "1", "确认", "确定", "行", "ok", "好", "对"]);
|
|
37
50
|
const NO_WORDS = new Set(["否", "n", "no", "0", "不", "不是", "不行"]);
|
|
38
51
|
|
|
@@ -41,32 +54,49 @@ function lines(parts: Array<string | undefined>): string {
|
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/** Render a dialog prompt as a WeChat message body, optionally after a retry. */
|
|
44
|
-
export function formatPrompt(
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
export function formatPrompt(
|
|
58
|
+
request: InteractionRequest,
|
|
59
|
+
options: { retry?: boolean; language?: PeerLanguage } = {},
|
|
60
|
+
): string {
|
|
61
|
+
const language = options.language ?? "en";
|
|
62
|
+
const body = promptBody(request, language);
|
|
63
|
+
const retry = language === "zh" ? RETRY_ZH : RETRY_NOTICE;
|
|
64
|
+
return options.retry ? `${retry}\n\n${body}` : body;
|
|
47
65
|
}
|
|
48
66
|
|
|
49
|
-
function promptBody(request: InteractionRequest): string {
|
|
67
|
+
function promptBody(request: InteractionRequest, language: PeerLanguage): string {
|
|
50
68
|
switch (request.kind) {
|
|
51
69
|
case "select": {
|
|
52
70
|
const options = (request.options ?? []).map((label, index) => `${index + 1}. ${label}`);
|
|
53
|
-
|
|
71
|
+
const hasOther = (request.options ?? []).includes(INTERACTION_OTHER_LABEL);
|
|
72
|
+
return lines([request.title, ...options, hasOther ? SELECT_OTHER_HINT[language] : SELECT_CHOICE_HINT[language]]);
|
|
54
73
|
}
|
|
55
74
|
case "confirm":
|
|
56
|
-
return lines([request.title, request.message, CONFIRM_HINT]);
|
|
75
|
+
return lines([request.title, request.message, CONFIRM_HINT[language]]);
|
|
57
76
|
case "input":
|
|
58
|
-
return lines([request.title, request.placeholder, INPUT_HINT]);
|
|
77
|
+
return lines([request.title, request.placeholder, INPUT_HINT[language]]);
|
|
59
78
|
case "editor":
|
|
60
|
-
return lines([request.title, request.prefill, EDITOR_HINT]);
|
|
79
|
+
return lines([request.title, request.prefill, EDITOR_HINT[language]]);
|
|
61
80
|
}
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Cancel intent, including the phrases a phone user naturally types.
|
|
85
|
+
* Exact words keep the old meaning; the two patterns cover “退出 plan 模式” and
|
|
86
|
+
* “exit plan mode” without treating a sentence such as “exit code” as a cancel.
|
|
87
|
+
*/
|
|
88
|
+
function isCancelIntent(text: string, lower: string): boolean {
|
|
89
|
+
if (CANCEL_WORDS.has(lower)) return true;
|
|
90
|
+
if (/^(退出|离开)\s*(计划|plan)/i.test(text)) return true;
|
|
91
|
+
return /^(exit|leave)\s+(the\s+)?plan\b/i.test(text);
|
|
92
|
+
}
|
|
93
|
+
|
|
64
94
|
/** Interpret a WeChat reply to `request`. */
|
|
65
95
|
export function parsePromptAnswer(request: InteractionRequest, raw: string): PromptParse {
|
|
66
96
|
const text = sanitizeInbound(raw);
|
|
67
97
|
const lower = text.toLowerCase();
|
|
68
98
|
|
|
69
|
-
if (
|
|
99
|
+
if (isCancelIntent(text, lower)) {
|
|
70
100
|
return request.kind === "confirm" ? { kind: "confirmed", confirmed: false } : { kind: "cancelled" };
|
|
71
101
|
}
|
|
72
102
|
|