@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -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 +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- 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 +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Contract:
|
|
12
12
|
*
|
|
13
|
-
* - One turn at a time per peer, and by default
|
|
14
|
-
* (`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* - One turn at a time per peer, and by default two peers' turns at once
|
|
14
|
+
* (`PEER_CONCURRENCY`), so each answered peer owns its dialog through its own
|
|
15
|
+
* interaction channel. A third peer waits for a slot and is told so.
|
|
16
|
+
* - Every message is its own turn, in arrival order. A message that arrives
|
|
17
|
+
* while a turn runs waits for its own turn and answer; nothing is merged.
|
|
17
18
|
* - A turn's answer is the text it produced. A requested `/stop` says `stopped`
|
|
18
19
|
* and drops the partial answer, a finished turn with no text says `noText`,
|
|
19
20
|
* and a thrown turn says `failed` — never silence.
|
|
21
|
+
* - A close that abandons the session says `abandoned` once per peer that still
|
|
22
|
+
* had work, after its turn stops and before its session goes. The turn's own
|
|
23
|
+
* line is suppressed then, and each notice carries its own deadline, so a
|
|
24
|
+
* connection that never answers cannot hold the exit.
|
|
20
25
|
* - The session is opened lazily, reset on request, and disposed after an idle
|
|
21
26
|
* timeout, so a peer who stops writing does not keep a context alive.
|
|
22
27
|
*
|
|
@@ -24,10 +29,11 @@
|
|
|
24
29
|
*/
|
|
25
30
|
|
|
26
31
|
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
27
|
-
import { messageOf } from "
|
|
32
|
+
import { messageOf } from "../errors.ts";
|
|
33
|
+
import type { NoticeKind } from "../text/notices.ts";
|
|
28
34
|
import type { PeerSession } from "./peer-session.ts";
|
|
29
|
-
import { createReplyPipeline } from "./reply-pipeline.ts";
|
|
30
|
-
import { defaultTimer, type ArmTimer } from "
|
|
35
|
+
import { createReplyPipeline, type ReplyPipeline } from "./reply-pipeline.ts";
|
|
36
|
+
import { defaultTimer, type ArmTimer } from "../timer.ts";
|
|
31
37
|
|
|
32
38
|
/** One admitted, media-resolved inbound message. */
|
|
33
39
|
export interface PeerItem {
|
|
@@ -45,13 +51,13 @@ export interface PeerItem {
|
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** What the worker says to the peer when a turn ends without text of its own. */
|
|
48
|
-
export type TurnFallback = "stopped" | "noText" | "failed" | "compacted"
|
|
54
|
+
export type TurnFallback = Extract<NoticeKind, "stopped" | "abandoned" | "noText" | "failed" | "compacted">;
|
|
49
55
|
|
|
50
56
|
export interface PeerWorkerDeps {
|
|
51
57
|
/** Open (or resume) one peer's session; the bridge owns the directory layout. */
|
|
52
58
|
openSession: (peer: string, options: { fresh: boolean }) => Promise<PeerSession>;
|
|
53
|
-
/** Deliver one
|
|
54
|
-
send: (item: PeerItem, text: string) => Promise<boolean>;
|
|
59
|
+
/** Deliver one line to the peer; false stops the rest of a stream. */
|
|
60
|
+
send: (item: PeerItem, text: string, options?: { signal?: AbortSignal }) => Promise<boolean>;
|
|
55
61
|
/** The fallback line for a turn that produced no text. */
|
|
56
62
|
fallback: (item: PeerItem, reason: TurnFallback) => string;
|
|
57
63
|
/** A turn started / finished for this item, for typing and notices. */
|
|
@@ -59,7 +65,7 @@ export interface PeerWorkerDeps {
|
|
|
59
65
|
onSettled?: (item: PeerItem) => void;
|
|
60
66
|
/** A session or turn failure worth showing on the terminal. */
|
|
61
67
|
notify?: (message: string, kind?: "info" | "warning" | "error") => void;
|
|
62
|
-
/** Turns running at once across peers.
|
|
68
|
+
/** Turns running at once across peers. Defaults to {@link PEER_CONCURRENCY}. */
|
|
63
69
|
concurrency?: number;
|
|
64
70
|
/** Dispose an idle session after this long. `0` disables the idle sweep. */
|
|
65
71
|
idleTimeoutMs?: number;
|
|
@@ -86,7 +92,7 @@ export interface PeerSnapshot {
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
export interface PeerWorker {
|
|
89
|
-
/** Queue one message;
|
|
95
|
+
/** Queue one message; it is never merged into another. */
|
|
90
96
|
enqueue(item: PeerItem): void;
|
|
91
97
|
/** Stop the running turn for `peer`; false when nothing was running. */
|
|
92
98
|
abort(peer: string): boolean;
|
|
@@ -95,20 +101,32 @@ export interface PeerWorker {
|
|
|
95
101
|
* Refused while a turn runs, and the queued messages go with it.
|
|
96
102
|
*/
|
|
97
103
|
reset(peer: string): boolean;
|
|
98
|
-
/** The turn running
|
|
99
|
-
|
|
104
|
+
/** The turn running for this peer, if any. */
|
|
105
|
+
running(peer: string): PeerItem | undefined;
|
|
106
|
+
/** Flush buffered assistant text for a running peer before a prompt/file. */
|
|
107
|
+
flush(peer: string): Promise<void>;
|
|
100
108
|
state(peer: string): PeerState;
|
|
101
109
|
snapshot(): PeerSnapshot[];
|
|
102
110
|
/** Drop every session; queued messages are dropped with them. */
|
|
103
|
-
close(): Promise<void>;
|
|
111
|
+
close(options?: { abandon?: boolean }): Promise<void>;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
107
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Peers whose turns run at once.
|
|
118
|
+
*
|
|
119
|
+
* Two, because a phone chat rarely has more than two conversations in flight and
|
|
120
|
+
* every concurrent turn is a full model run on the user's budget; the rest wait
|
|
121
|
+
* in a fair queue and are acknowledged, exactly as a second peer waits today.
|
|
122
|
+
*/
|
|
123
|
+
export const PEER_CONCURRENCY = 2;
|
|
124
|
+
|
|
125
|
+
/** How long a shutdown waits for its one notice per peer before giving up. */
|
|
126
|
+
const ABANDON_NOTICE_MS = 5000;
|
|
127
|
+
|
|
108
128
|
interface PeerEntry {
|
|
109
129
|
queue: PeerItem[];
|
|
110
|
-
/** Messages merged into the waiting item, so `queued` counts real messages. */
|
|
111
|
-
merged: number;
|
|
112
130
|
running?: PeerItem;
|
|
113
131
|
/** Monotonic start time of the running turn, for `runningMs`. */
|
|
114
132
|
startedAt?: number;
|
|
@@ -119,38 +137,28 @@ interface PeerEntry {
|
|
|
119
137
|
disposeTimer?: () => void;
|
|
120
138
|
}
|
|
121
139
|
|
|
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
140
|
export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
137
141
|
const now = deps.now ?? Date.now;
|
|
138
142
|
const armTimer = deps.armTimer ?? defaultTimer;
|
|
139
143
|
const idleTimeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
140
|
-
const concurrency = Math.max(1, deps.concurrency ??
|
|
144
|
+
const concurrency = Math.max(1, deps.concurrency ?? PEER_CONCURRENCY);
|
|
141
145
|
const peers = new Map<string, PeerEntry>();
|
|
146
|
+
/** Active reply pipelines, so a dialog can flush buffered text first. */
|
|
147
|
+
const pipelines = new Map<string, ReplyPipeline>();
|
|
142
148
|
/** Peers whose running turn the peer asked to stop, so it reads as stopped. */
|
|
143
149
|
const aborted = new Set<string>();
|
|
144
150
|
/** Peers whose next session must start empty (`/new`). */
|
|
145
151
|
const fresh = new Set<string>();
|
|
146
152
|
let running = 0;
|
|
147
|
-
|
|
153
|
+
/** When each queued item arrived, so the next free slot goes to the oldest wait. */
|
|
154
|
+
const arrived = new WeakMap<PeerItem, number>();
|
|
155
|
+
let arrivals = 0;
|
|
148
156
|
let closed = false;
|
|
149
157
|
|
|
150
158
|
function entry(peer: string): PeerEntry {
|
|
151
159
|
const existing = peers.get(peer);
|
|
152
160
|
if (existing) return existing;
|
|
153
|
-
const created: PeerEntry = { queue: []
|
|
161
|
+
const created: PeerEntry = { queue: [] };
|
|
154
162
|
peers.set(peer, created);
|
|
155
163
|
return created;
|
|
156
164
|
}
|
|
@@ -168,12 +176,12 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
168
176
|
if (target.running) {
|
|
169
177
|
return {
|
|
170
178
|
status: "running",
|
|
171
|
-
queued: target.queue.length
|
|
179
|
+
queued: target.queue.length,
|
|
172
180
|
runningMs: target.startedAt === undefined ? 0 : now() - target.startedAt,
|
|
173
181
|
...sessionAge,
|
|
174
182
|
};
|
|
175
183
|
}
|
|
176
|
-
const queued = target.queue.length
|
|
184
|
+
const queued = target.queue.length;
|
|
177
185
|
return { status: queued > 0 ? "queued" : "idle", queued, ...sessionAge };
|
|
178
186
|
}
|
|
179
187
|
|
|
@@ -251,6 +259,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
251
259
|
partChars: deps.partChars,
|
|
252
260
|
quietMs: deps.quietMs,
|
|
253
261
|
});
|
|
262
|
+
pipelines.set(item.peer, pipeline);
|
|
254
263
|
/** The session compacted this turn, so the peer is told its older detail is fuzzy. */
|
|
255
264
|
let compacted = false;
|
|
256
265
|
const unsubscribe = session.subscribe((event) => {
|
|
@@ -289,17 +298,27 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
289
298
|
}
|
|
290
299
|
} finally {
|
|
291
300
|
unsubscribe();
|
|
301
|
+
pipelines.delete(item.peer);
|
|
292
302
|
}
|
|
303
|
+
// A closing worker has already said, once, that the session ended.
|
|
304
|
+
if (closed) return;
|
|
293
305
|
if (reason) await sendPart(deps.fallback(item, reason));
|
|
294
306
|
if (compacted) await sendPart(deps.fallback(item, "compacted"));
|
|
295
307
|
}
|
|
296
308
|
|
|
297
309
|
/** The next peer with work, so a finished turn can hand its slot on. */
|
|
310
|
+
/** The next peer with work, oldest queued message first so no peer starves. */
|
|
298
311
|
function nextRunnable(): [string, PeerEntry] | undefined {
|
|
312
|
+
let picked: [string, PeerEntry] | undefined;
|
|
299
313
|
for (const [peer, target] of peers) {
|
|
300
|
-
if (
|
|
314
|
+
if (target.running || target.queue.length === 0) continue;
|
|
315
|
+
const waiting = target.queue[0];
|
|
316
|
+
const oldest = picked?.[1].queue[0];
|
|
317
|
+
if (!picked || (waiting && oldest && (arrived.get(waiting) ?? 0) < (arrived.get(oldest) ?? 0))) {
|
|
318
|
+
picked = [peer, target];
|
|
319
|
+
}
|
|
301
320
|
}
|
|
302
|
-
return
|
|
321
|
+
return picked;
|
|
303
322
|
}
|
|
304
323
|
|
|
305
324
|
/** Start turns until the concurrency limit or the queues are empty. */
|
|
@@ -309,11 +328,9 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
309
328
|
if (!next) return;
|
|
310
329
|
const [peer, target] = next;
|
|
311
330
|
const item = target.queue.shift() as PeerItem;
|
|
312
|
-
target.merged = 0;
|
|
313
331
|
target.running = item;
|
|
314
332
|
target.startedAt = now();
|
|
315
333
|
running += 1;
|
|
316
|
-
currentItem = item;
|
|
317
334
|
cancelIdleSweep(target);
|
|
318
335
|
deps.onStart?.(item);
|
|
319
336
|
void (async () => {
|
|
@@ -322,12 +339,13 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
322
339
|
await runTurn(item, opened);
|
|
323
340
|
} catch (error) {
|
|
324
341
|
deps.notify?.(`WeChat session failed for ${item.peer}: ${messageOf(error)}`, "error");
|
|
325
|
-
|
|
342
|
+
// A closing worker says one line of its own instead: the peer does not need
|
|
343
|
+
// two reasons, and the second send may well fail on the dying connection.
|
|
344
|
+
if (!closed) await deps.send(item, deps.fallback(item, "failed"));
|
|
326
345
|
} finally {
|
|
327
346
|
target.running = undefined;
|
|
328
347
|
target.startedAt = undefined;
|
|
329
348
|
running -= 1;
|
|
330
|
-
currentItem = undefined;
|
|
331
349
|
deps.onSettled?.(item);
|
|
332
350
|
armIdleSweep(target);
|
|
333
351
|
wake();
|
|
@@ -336,20 +354,51 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
336
354
|
}
|
|
337
355
|
}
|
|
338
356
|
|
|
357
|
+
/** One abandon notice; true when the deadline, not the transport, gave up on it. */
|
|
358
|
+
async function announce(item: PeerItem): Promise<boolean> {
|
|
359
|
+
const controller = new AbortController();
|
|
360
|
+
const cancel = armTimer(ABANDON_NOTICE_MS, () => controller.abort());
|
|
361
|
+
try {
|
|
362
|
+
await deps.send(item, deps.fallback(item, "abandoned"), { signal: controller.signal });
|
|
363
|
+
return controller.signal.aborted;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
// A send the deadline cancelled rejects like any aborted request; that is the
|
|
366
|
+
// deadline's report. Anything else is the transport's own failure.
|
|
367
|
+
if (controller.signal.aborted) return true;
|
|
368
|
+
deps.notify?.(`WeChat could not tell ${item.peer} the session ended: ${messageOf(error)}`, "warning");
|
|
369
|
+
return false;
|
|
370
|
+
} finally {
|
|
371
|
+
cancel();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Tell every peer that still had work that the session ended.
|
|
377
|
+
*
|
|
378
|
+
* One line per peer, sent after the turns have stopped and before the sessions
|
|
379
|
+
* go. Each send carries its own deadline, so a connection that never answers
|
|
380
|
+
* cannot hold the exit; a deadline is reported here because the deliverer keeps
|
|
381
|
+
* an aborted send quiet — an abort is normally the peer's own `/stop`.
|
|
382
|
+
*/
|
|
383
|
+
async function announceAbandoned(items: readonly PeerItem[]): Promise<void> {
|
|
384
|
+
const gaveUp = await Promise.all(items.map((item) => announce(item)));
|
|
385
|
+
for (const [index, timedOut] of gaveUp.entries()) {
|
|
386
|
+
if (!timedOut) continue;
|
|
387
|
+
deps.notify?.(
|
|
388
|
+
`WeChat could not tell ${items[index]?.peer ?? ""} the session ended: no reply in ${ABANDON_NOTICE_MS / 1000} s.`,
|
|
389
|
+
"warning",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
339
394
|
return {
|
|
340
395
|
enqueue(item: PeerItem): void {
|
|
341
396
|
if (closed) return;
|
|
342
397
|
const target = entry(item.peer);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
}
|
|
398
|
+
// One message, one turn: a queued item is never folded into another.
|
|
399
|
+
arrivals += 1;
|
|
400
|
+
arrived.set(item, arrivals);
|
|
401
|
+
target.queue.push(item);
|
|
353
402
|
wake();
|
|
354
403
|
},
|
|
355
404
|
|
|
@@ -368,14 +417,17 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
368
417
|
releaseSession(peer);
|
|
369
418
|
if (target) {
|
|
370
419
|
target.queue = [];
|
|
371
|
-
target.merged = 0;
|
|
372
420
|
cancelIdleSweep(target);
|
|
373
421
|
}
|
|
374
422
|
return true;
|
|
375
423
|
},
|
|
376
424
|
|
|
377
|
-
|
|
378
|
-
return
|
|
425
|
+
running(peer: string): PeerItem | undefined {
|
|
426
|
+
return peers.get(peer)?.running;
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
async flush(peer: string): Promise<void> {
|
|
430
|
+
await pipelines.get(peer)?.flush();
|
|
379
431
|
},
|
|
380
432
|
|
|
381
433
|
state(peer: string): PeerState {
|
|
@@ -386,8 +438,18 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
386
438
|
return [...peers.keys()].map((peer) => ({ peer, state: stateOf(peer) }));
|
|
387
439
|
},
|
|
388
440
|
|
|
389
|
-
async close(): Promise<void> {
|
|
441
|
+
async close(options?: { abandon?: boolean }): Promise<void> {
|
|
390
442
|
closed = true;
|
|
443
|
+
// Captured before the teardown forgets it: a running turn and a queue that
|
|
444
|
+
// never started are both an answer the peer will not get.
|
|
445
|
+
const abandoned: PeerItem[] = [];
|
|
446
|
+
if (options?.abandon) {
|
|
447
|
+
for (const target of peers.values()) {
|
|
448
|
+
const item = target.running ?? target.queue[0];
|
|
449
|
+
if (item) abandoned.push(item);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
pipelines.clear();
|
|
391
453
|
const runningAborts = [...peers.values()]
|
|
392
454
|
.filter((target) => target.session)
|
|
393
455
|
.map((target) => target.session?.session.abort());
|
|
@@ -398,6 +460,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
398
460
|
}
|
|
399
461
|
aborted.clear();
|
|
400
462
|
fresh.clear();
|
|
463
|
+
await announceAbandoned(abandoned);
|
|
401
464
|
},
|
|
402
465
|
};
|
|
403
466
|
}
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
* notice is bounded to one per wait episode.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { BotCredentials } from "
|
|
13
|
-
import type { WechatClient } from "
|
|
12
|
+
import type { BotCredentials } from "../types.ts";
|
|
13
|
+
import type { WechatClient } from "../transport/client.ts";
|
|
14
14
|
import type { PeerState } from "./peer-worker.ts";
|
|
15
|
-
import type { PeerLanguage } from "
|
|
16
|
-
import { notice } from "
|
|
17
|
-
import { createTimerMap, type ArmTimer } from "
|
|
15
|
+
import type { PeerLanguage } from "../text/format.ts";
|
|
16
|
+
import { notice } from "../text/notices.ts";
|
|
17
|
+
import { createTimerMap, type ArmTimer } from "../timer.ts";
|
|
18
18
|
|
|
19
19
|
/** How long a fetched typing ticket is reused. */
|
|
20
20
|
const TYPING_TTL_MS = 5 * 60 * 1000;
|
|
@@ -44,7 +44,8 @@ export interface PresenceDeps {
|
|
|
44
44
|
/** Send one stock line to the peer (chunked by the bridge). */
|
|
45
45
|
send: (peer: string, contextToken: string | undefined, text: string) => Promise<unknown>;
|
|
46
46
|
/** A forwarded dialog is waiting; its own reminder covers the wait. */
|
|
47
|
-
|
|
47
|
+
/** Whether that peer is waiting on a dialog, so "still working" would be noise. */
|
|
48
|
+
promptPending: (peer: string) => boolean;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
/** One message a notice refers to. */
|
|
@@ -180,7 +181,7 @@ export function createPresence(deps: PresenceDeps): Presence {
|
|
|
180
181
|
if (!isRunning(peer)) return;
|
|
181
182
|
// A turn waiting on an unanswered dialog is not "still working": the
|
|
182
183
|
// prompt itself nudges, and saying both reads as noise.
|
|
183
|
-
if (deps.promptPending()) return;
|
|
184
|
+
if (deps.promptPending(peer)) return;
|
|
184
185
|
void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer)));
|
|
185
186
|
});
|
|
186
187
|
}
|
|
@@ -204,14 +205,13 @@ export function createPresence(deps: PresenceDeps): Presence {
|
|
|
204
205
|
* the same thing without spending a bubble.
|
|
205
206
|
*/
|
|
206
207
|
function armAck(target: PresenceTarget): void {
|
|
207
|
-
// A
|
|
208
|
-
//
|
|
209
|
-
if (isRunning(target.peer)) return;
|
|
208
|
+
// A message can be queued behind a running turn; it still gets one
|
|
209
|
+
// acknowledgement after the delay, but not one per message.
|
|
210
210
|
if (acks.has(target.peer)) return;
|
|
211
211
|
const last = ackedAt.get(target.peer);
|
|
212
212
|
if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return;
|
|
213
213
|
acks.arm(target.peer, ACK_DELAY_MS, () => {
|
|
214
|
-
if (!hasWork(target.peer)
|
|
214
|
+
if (!hasWork(target.peer)) return;
|
|
215
215
|
ackedAt.set(target.peer, deps.now());
|
|
216
216
|
void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer)));
|
|
217
217
|
// Still waiting after a minute behind another turn: say why.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* drive it.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { ArmTimer } from "
|
|
14
|
+
import type { ArmTimer } from "../timer.ts";
|
|
15
15
|
|
|
16
16
|
export interface ReplyPipelineDeps {
|
|
17
17
|
/** Send one part to the peer. Parts arrive strictly in order. */
|
|
@@ -27,6 +27,8 @@ export interface ReplyPipelineDeps {
|
|
|
27
27
|
export interface ReplyPipeline {
|
|
28
28
|
/** Add streamed text; parts go out as boundaries are crossed. */
|
|
29
29
|
push(delta: string): void;
|
|
30
|
+
/** Send the current buffer now, in order; used before a dialog or file. */
|
|
31
|
+
flush(): Promise<void>;
|
|
30
32
|
/** Send whatever is left and resolve once every part is out. */
|
|
31
33
|
finish(): Promise<void>;
|
|
32
34
|
/** Drop the unsent remainder (an aborted turn) and cancel the timer. */
|
|
@@ -106,6 +108,13 @@ export function createReplyPipeline(deps: ReplyPipelineDeps): ReplyPipeline {
|
|
|
106
108
|
enqueue(part);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
async function flushBuffer(): Promise<void> {
|
|
112
|
+
cancelQuiet();
|
|
113
|
+
enqueue(buffer);
|
|
114
|
+
buffer = "";
|
|
115
|
+
await chain;
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
return {
|
|
110
119
|
push(delta: string): void {
|
|
111
120
|
if (!delta) return;
|
|
@@ -125,11 +134,10 @@ export function createReplyPipeline(deps: ReplyPipelineDeps): ReplyPipeline {
|
|
|
125
134
|
scheduleQuiet();
|
|
126
135
|
},
|
|
127
136
|
|
|
137
|
+
flush: flushBuffer,
|
|
138
|
+
|
|
128
139
|
async finish(): Promise<void> {
|
|
129
|
-
|
|
130
|
-
enqueue(buffer);
|
|
131
|
-
buffer = "";
|
|
132
|
-
await chain;
|
|
140
|
+
await flushBuffer();
|
|
133
141
|
},
|
|
134
142
|
|
|
135
143
|
drop(): void {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { DEFAULT_BASE_URL } from "
|
|
13
|
-
import type { BotCredentials, CredentialsFile } from "
|
|
12
|
+
import { DEFAULT_BASE_URL } from "../transport/client.ts";
|
|
13
|
+
import type { BotCredentials, CredentialsFile } from "../types.ts";
|
|
14
14
|
|
|
15
15
|
/** The bridge's directory under the agent dir. */
|
|
16
16
|
export function wechatDir(): string {
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
|
-
import type { PeerState, SeenMessage, WechatState } from "
|
|
11
|
+
import type { BotCredentials, PeerState, SeenMessage, WechatState } from "../types.ts";
|
|
12
|
+
import type { MessageTarget } from "../transport/deliver.ts";
|
|
12
13
|
import { wechatDir } from "./credentials.ts";
|
|
13
|
-
import { peerKey } from "
|
|
14
|
+
import { peerKey } from "../text/format.ts";
|
|
14
15
|
|
|
15
16
|
/** How long a handled message id is remembered for a replay. */
|
|
16
17
|
export const SEEN_TTL_MS = 60 * 60 * 1000;
|
|
@@ -114,6 +115,17 @@ export function findPeer(state: WechatState, peer: string): PeerState | undefine
|
|
|
114
115
|
return state.peers[storedKey(state, peer)];
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Who a proactive send goes to, and the token that carries it.
|
|
120
|
+
*
|
|
121
|
+
* Either id form may be stored: the owner id comes from the login response and the
|
|
122
|
+
* token from an inbound `from_user_id`, and they need not agree.
|
|
123
|
+
*/
|
|
124
|
+
export function ownerTarget(state: WechatState, account: BotCredentials): MessageTarget {
|
|
125
|
+
const peer = state.ownerId ?? account.ilinkUserId;
|
|
126
|
+
return { peer, contextToken: findPeer(state, peer)?.lastContextToken };
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
/** Whether this message id was already handled for the peer. */
|
|
118
130
|
export function seenMessage(state: WechatState, peer: string, id: number): boolean {
|
|
119
131
|
const key = peerKey(peer);
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* and shorten peer ids for display. The peer-facing notices live in `notices.ts`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { stripControlChars } from "
|
|
7
|
-
import { sanitizeFileName } from "
|
|
8
|
-
import type { CDNMedia, MediaRef, MessageItem } from "
|
|
6
|
+
import { stripControlChars } from "../../../lib/format.ts";
|
|
7
|
+
import { sanitizeFileName } from "../transport/media.ts";
|
|
8
|
+
import type { CDNMedia, MediaRef, MessageItem } from "../types.ts";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Prepare inbound WeChat text before it becomes a user turn: strip terminal
|
|
@@ -17,6 +17,7 @@ export type NoticeKind =
|
|
|
17
17
|
| "media"
|
|
18
18
|
| "unsupported"
|
|
19
19
|
| "stopped"
|
|
20
|
+
| "abandoned"
|
|
20
21
|
| "noText"
|
|
21
22
|
| "compacted"
|
|
22
23
|
| "budget"
|
|
@@ -27,14 +28,14 @@ export type NoticeKind =
|
|
|
27
28
|
| "newBusy";
|
|
28
29
|
|
|
29
30
|
const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
30
|
-
ack: { zh: "
|
|
31
|
+
ack: { zh: "收到,排队中,轮到就回。", en: "Queued — I'll answer next." },
|
|
31
32
|
busy: {
|
|
32
|
-
zh: "
|
|
33
|
-
en: "
|
|
33
|
+
zh: "还在处理前一条,轮到就回。",
|
|
34
|
+
en: "Still on the previous message; yours is next.",
|
|
34
35
|
},
|
|
35
36
|
stillWorking: {
|
|
36
|
-
zh: "
|
|
37
|
-
en: "Still working —
|
|
37
|
+
zh: "还在处理,/stop 可取消。",
|
|
38
|
+
en: "Still working — /stop cancels.",
|
|
38
39
|
},
|
|
39
40
|
overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
|
|
40
41
|
media: {
|
|
@@ -46,6 +47,10 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
|
46
47
|
en: "Voice without a transcript and video aren't supported — please send text or an image.",
|
|
47
48
|
},
|
|
48
49
|
stopped: { zh: "已停止。", en: "Stopped." },
|
|
50
|
+
abandoned: {
|
|
51
|
+
zh: "会话已停止,这条还没答完。恢复后再发一次即可。",
|
|
52
|
+
en: "The session stopped before this answer finished — send it again when it's back.",
|
|
53
|
+
},
|
|
49
54
|
noText: {
|
|
50
55
|
zh: "这条处理完了,但没有文字输出。",
|
|
51
56
|
en: "That turn finished without any text to send.",
|
|
@@ -59,8 +64,8 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
|
59
64
|
en: "You've reached this hour's message limit — please try again later.",
|
|
60
65
|
},
|
|
61
66
|
promptWaiting: {
|
|
62
|
-
zh: "
|
|
63
|
-
en: "
|
|
67
|
+
zh: "等你回复;回复「取消」跳过。",
|
|
68
|
+
en: "Waiting for your reply — reply cancel to skip.",
|
|
64
69
|
},
|
|
65
70
|
timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
|
|
66
71
|
withdrawn: { zh: "问题已撤回。", en: "That question was withdrawn." },
|
|
@@ -71,6 +76,9 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
|
|
|
71
76
|
},
|
|
72
77
|
};
|
|
73
78
|
|
|
79
|
+
/** Every kind the bridge can send, so a check over the catalogue cannot miss one. */
|
|
80
|
+
export const NOTICE_KINDS = Object.keys(NOTICES) as NoticeKind[];
|
|
81
|
+
|
|
74
82
|
/** Render one notice, with `{n}` replaced when a count applies. */
|
|
75
83
|
export function notice(kind: NoticeKind, lang: PeerLanguage, count?: number): string {
|
|
76
84
|
const text = NOTICES[kind][lang];
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* Model-facing registration for the `send_wechat` tool.
|
|
3
3
|
*
|
|
4
4
|
* The bridge replies to inbound messages automatically; this tool is for the
|
|
5
|
-
* agent to message the owner proactively. It is deliberately owner-only
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* agent to message the owner proactively. It is deliberately owner-only: the
|
|
6
|
+
* model cannot choose a recipient, so untrusted generated text cannot be aimed at
|
|
7
|
+
* an arbitrary WeChat account. It carries one text or one local image, and an
|
|
8
|
+
* image arrives as a picture rather than a document.
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import { basename } from "node:path";
|
|
10
12
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
11
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { Type, type Static } from "typebox";
|
|
@@ -19,11 +21,20 @@ import type { Bridge } from "./bridge.ts";
|
|
|
19
21
|
export const TOOL_NAME = "send_wechat";
|
|
20
22
|
|
|
21
23
|
const WechatSendParams = Type.Object({
|
|
22
|
-
text: Type.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
text: Type.Optional(
|
|
25
|
+
Type.String({
|
|
26
|
+
minLength: 1,
|
|
27
|
+
maxLength: 4000,
|
|
28
|
+
description: "The message to send to the WeChat owner. Plain text.",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
image: Type.Optional(
|
|
32
|
+
Type.String({
|
|
33
|
+
minLength: 1,
|
|
34
|
+
maxLength: 4096,
|
|
35
|
+
description: "Path to a local image to send instead, as a picture. One of text or image.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
27
38
|
});
|
|
28
39
|
type WechatSendArgs = Static<typeof WechatSendParams>;
|
|
29
40
|
|
|
@@ -49,12 +60,13 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
49
60
|
name: TOOL_NAME,
|
|
50
61
|
label: "WeChat send",
|
|
51
62
|
description: [
|
|
52
|
-
"Send a plain
|
|
63
|
+
"Send a WeChat message — plain text, or one local image as a picture — to the account owner from the running bridge (start it with /wechat login).",
|
|
53
64
|
"The recipient is always the owner and cannot be chosen.",
|
|
54
65
|
].join(" "),
|
|
55
|
-
promptSnippet: "Send a
|
|
66
|
+
promptSnippet: "Send a WeChat text or image to the account owner.",
|
|
56
67
|
promptGuidelines: [
|
|
57
68
|
"Use send_wechat only to message the owner proactively, not to answer an inbound WeChat message (the bridge replies automatically).",
|
|
69
|
+
"Give one of text or image: a plot or screenshot goes as a path, not described in words.",
|
|
58
70
|
"Keep messages concise and do not include secrets unless the user asked for them.",
|
|
59
71
|
],
|
|
60
72
|
parameters: WechatSendParams,
|
|
@@ -64,16 +76,21 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
64
76
|
|
|
65
77
|
async execute(_toolCallId, params) {
|
|
66
78
|
const args = params as WechatSendArgs;
|
|
79
|
+
if ((args.text === undefined) === (args.image === undefined)) {
|
|
80
|
+
return sendResult(false, "Give exactly one of text or image.");
|
|
81
|
+
}
|
|
67
82
|
const bridge = deps.getBridge();
|
|
68
83
|
if (!bridge) {
|
|
69
84
|
return sendResult(false, "WeChat bridge is not started. Run /wechat login first.");
|
|
70
85
|
}
|
|
71
|
-
const outcome = await bridge.sendToOwner(args.text);
|
|
86
|
+
const outcome = await bridge.sendToOwner(args.text, args.image);
|
|
72
87
|
return sendResult(outcome.ok, outcome.error);
|
|
73
88
|
},
|
|
74
89
|
|
|
75
90
|
renderCall(args, theme, context) {
|
|
76
|
-
const
|
|
91
|
+
const { text, image } = args as { text?: unknown; image?: unknown };
|
|
92
|
+
// An image is named by its file, so a call line never carries a whole path.
|
|
93
|
+
const body = typeof image === "string" ? basename(image) : text;
|
|
77
94
|
const label = context.argsComplete && typeof body === "string" ? body : "…";
|
|
78
95
|
return callLine(theme, context.lastComponent, TOOL_NAME, label);
|
|
79
96
|
},
|