@gavin-hu/my-pi-agent 0.18.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 +52 -44
- package/README.zh-CN.md +46 -41
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
- 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} +61 -50
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- 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} +165 -118
- 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} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +258 -162
- 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/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +3 -20
- 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} +1 -1
- 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} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -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} +29 -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/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +147 -38
- package/extensions/plan/index.ts +6 -4
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +8 -11
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +21 -9
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/tools/exit.ts +11 -95
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +0 -8
- package/extensions/plan/web-client.ts +1 -1
- package/extensions/plan/web-page.ts +0 -7
- package/extensions/plan/web-router.ts +3 -57
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- 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 +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +4 -2
- package/extensions/session-bus/runtime.ts +129 -238
- package/extensions/session-bus/store.ts +42 -6
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +4 -4
- package/extensions/session-bus/types.ts +13 -13
- 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/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} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- 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} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- 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} +8 -0
- 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} +72 -28
- 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/guard.ts +3 -5
- package/lib/README.md +8 -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/serve.ts +234 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/policy.ts +12 -2
- package/lib/requirements.ts +6 -7
- 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/glyphs.ts +2 -0
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +5 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- 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 -506
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /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/{live-client.ts → view/live-client.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
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The WeChat implementation of {@link InteractionChannel}: it forwards a dialog
|
|
3
|
-
* prompt to
|
|
4
|
-
* replies.
|
|
3
|
+
* prompt to one peer and resolves when that peer replies.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* One channel serves one peer. That is what lets two peers' turns run at once
|
|
6
|
+
* without a dialog becoming ambiguous: the channel knows who it is asking, so a
|
|
7
|
+
* reply can only answer its own peer's prompt. It depends only on injected
|
|
8
|
+
* `send`/`sendFile`/`active` accessors and a timer seam, never on the bridge or
|
|
9
|
+
* HTTP client, so it is unit-tested without I/O. The bridge owns the poll loop,
|
|
10
|
+
* keeps one channel per peer (`channels.ts`), and hands each inbound message to
|
|
9
11
|
* {@link WechatInteractionChannel.handleInbound}.
|
|
10
12
|
*
|
|
11
13
|
* A prompt is bounded: a peer who never answers cannot hold the session open, so
|
|
@@ -18,23 +20,25 @@ import {
|
|
|
18
20
|
type InteractionAnswer,
|
|
19
21
|
type InteractionChannel,
|
|
20
22
|
type InteractionRequest,
|
|
21
|
-
} from "
|
|
22
|
-
import { type PeerLanguage } from "
|
|
23
|
-
import { notice } from "
|
|
23
|
+
} from "../../../lib/interaction.ts";
|
|
24
|
+
import { type PeerLanguage } from "../text/format.ts";
|
|
25
|
+
import { notice } from "../text/notices.ts";
|
|
24
26
|
import { formatPrompt, parsePromptAnswer } from "./prompt.ts";
|
|
25
|
-
import { defaultTimer, type ArmTimer } from "
|
|
27
|
+
import { defaultTimer, type ArmTimer } from "../timer.ts";
|
|
26
28
|
|
|
27
29
|
/** How long a forwarded prompt waits before it is cancelled. */
|
|
28
30
|
export const PROMPT_TIMEOUT_MS = 180_000;
|
|
29
31
|
|
|
30
32
|
export interface WechatInteractionDeps {
|
|
31
|
-
/**
|
|
33
|
+
/** The one peer this channel asks. */
|
|
34
|
+
peer: string;
|
|
35
|
+
/** Send one text message to the peer; `force` bypasses the prompt-active gate. */
|
|
32
36
|
send: (text: string, signal?: AbortSignal, options?: { force?: boolean }) => Promise<boolean>;
|
|
33
|
-
/** Send a local file to the
|
|
37
|
+
/** Send a local file to the peer; `force` bypasses the prompt-active gate. */
|
|
34
38
|
sendFile: (path: string, name: string, signal?: AbortSignal, options?: { force?: boolean }) => Promise<boolean>;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
/** The language the
|
|
39
|
+
/** Whether the peer owns a turn right now, so a prompt has somewhere to land. */
|
|
40
|
+
active: () => boolean;
|
|
41
|
+
/** The language the peer writes in, for the timeout notices. */
|
|
38
42
|
language: () => PeerLanguage;
|
|
39
43
|
/** Arm a cancellable one-shot timer. Defaults to a real, unref'd `setTimeout`. */
|
|
40
44
|
armTimer?: ArmTimer;
|
|
@@ -48,8 +52,8 @@ export interface WechatInteractionChannel extends InteractionChannel {
|
|
|
48
52
|
* previous one when it ends: the optional channel member, made concrete here.
|
|
49
53
|
*/
|
|
50
54
|
setPromptTimeout(ms: number): () => void;
|
|
51
|
-
/** Consume an inbound message as a reply
|
|
52
|
-
handleInbound(
|
|
55
|
+
/** Consume an inbound message as a reply to this peer's prompt. */
|
|
56
|
+
handleInbound(text: string): boolean;
|
|
53
57
|
/** Whether a prompt is currently waiting for a reply. */
|
|
54
58
|
hasPending(): boolean;
|
|
55
59
|
/** The waiting prompt, for the control screen; undefined when none waits. */
|
|
@@ -73,7 +77,6 @@ export interface PendingPromptInfo {
|
|
|
73
77
|
|
|
74
78
|
interface Pending {
|
|
75
79
|
request: InteractionRequest;
|
|
76
|
-
peer: string;
|
|
77
80
|
startedAt: number;
|
|
78
81
|
resolve: (answer: InteractionAnswer) => void;
|
|
79
82
|
signal?: AbortSignal;
|
|
@@ -92,11 +95,11 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
92
95
|
*/
|
|
93
96
|
let scopedTimeoutMs: number | undefined;
|
|
94
97
|
/**
|
|
95
|
-
* Free text captured from a `select` "Other" reply
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
+
* Free text captured from a `select` "Other" reply: the immediately following
|
|
99
|
+
* `input` for this peer returns it. One value, because one channel asks one peer
|
|
100
|
+
* — another peer's free text cannot reach this channel at all.
|
|
98
101
|
*/
|
|
99
|
-
|
|
102
|
+
let other: string | undefined;
|
|
100
103
|
|
|
101
104
|
function settle(answer: InteractionAnswer): void {
|
|
102
105
|
const current = pending;
|
|
@@ -110,7 +113,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
110
113
|
|
|
111
114
|
return {
|
|
112
115
|
isActive(): boolean {
|
|
113
|
-
return deps.
|
|
116
|
+
return deps.active();
|
|
114
117
|
},
|
|
115
118
|
|
|
116
119
|
language(): PeerLanguage {
|
|
@@ -126,7 +129,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
126
129
|
return current === undefined
|
|
127
130
|
? undefined
|
|
128
131
|
: {
|
|
129
|
-
peer:
|
|
132
|
+
peer: deps.peer,
|
|
130
133
|
kind: current.request.kind,
|
|
131
134
|
title: current.request.title,
|
|
132
135
|
startedAt: current.startedAt,
|
|
@@ -164,27 +167,24 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
164
167
|
},
|
|
165
168
|
|
|
166
169
|
async request(request: InteractionRequest, signal?: AbortSignal): Promise<InteractionAnswer> {
|
|
167
|
-
// A `select` that resolved to "Other" stashes its text
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (request.kind === "input" && asking !== undefined && stashed !== undefined) {
|
|
173
|
-
other.delete(asking);
|
|
170
|
+
// A `select` that resolved to "Other" stashes its text; the immediately
|
|
171
|
+
// following `input` returns it without another round-trip.
|
|
172
|
+
const stashed = other;
|
|
173
|
+
if (request.kind === "input" && stashed !== undefined) {
|
|
174
|
+
other = undefined;
|
|
174
175
|
return { kind: "value", value: stashed };
|
|
175
176
|
}
|
|
176
|
-
if (request.kind !== "input"
|
|
177
|
+
if (request.kind !== "input") other = undefined;
|
|
177
178
|
|
|
178
179
|
if (signal?.aborted || !this.isActive()) return { kind: "cancelled" };
|
|
179
180
|
if (pending) return { kind: "cancelled" };
|
|
180
181
|
|
|
181
182
|
// Register the pending prompt before the send await so a fast reply is
|
|
182
183
|
// never lost to the round-trip, then send and fail closed.
|
|
183
|
-
const peer = deps.activePeer() as string;
|
|
184
184
|
const cancelTimers: Array<() => void> = [];
|
|
185
185
|
const answer = new Promise<InteractionAnswer>((resolve) => {
|
|
186
186
|
const onAbort = () => settle({ kind: "cancelled" });
|
|
187
|
-
pending = { request,
|
|
187
|
+
pending = { request, startedAt: now(), resolve, signal, onAbort, cancelTimers };
|
|
188
188
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
189
189
|
// An ignored question must not freeze the session: nudge once, then
|
|
190
190
|
// cancel so the model gets a clean answer instead of a hang.
|
|
@@ -204,9 +204,9 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
204
204
|
return await answer;
|
|
205
205
|
},
|
|
206
206
|
|
|
207
|
-
handleInbound(
|
|
207
|
+
handleInbound(text: string): boolean {
|
|
208
208
|
const current = pending;
|
|
209
|
-
if (!current
|
|
209
|
+
if (!current) return false;
|
|
210
210
|
const parsed = parsePromptAnswer(current.request, text);
|
|
211
211
|
switch (parsed.kind) {
|
|
212
212
|
case "retry":
|
|
@@ -215,7 +215,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
215
215
|
});
|
|
216
216
|
return true;
|
|
217
217
|
case "other":
|
|
218
|
-
other
|
|
218
|
+
other = parsed.text;
|
|
219
219
|
settle({ kind: "value", value: INTERACTION_OTHER_LABEL });
|
|
220
220
|
return true;
|
|
221
221
|
case "value":
|
|
@@ -231,7 +231,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
|
|
|
231
231
|
},
|
|
232
232
|
|
|
233
233
|
reset(): void {
|
|
234
|
-
other
|
|
234
|
+
other = undefined;
|
|
235
235
|
scopedTimeoutMs = undefined;
|
|
236
236
|
settle({ kind: "cancelled" });
|
|
237
237
|
},
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* the {@link INTERACTION_OTHER_LABEL} option so the caller can collect it once.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { INTERACTION_OTHER_LABEL, type InteractionRequest } from "
|
|
13
|
-
import { sanitizeInbound, type PeerLanguage } from "
|
|
12
|
+
import { INTERACTION_OTHER_LABEL, type InteractionRequest } from "../../../lib/interaction.ts";
|
|
13
|
+
import { sanitizeInbound, type PeerLanguage } from "../text/format.ts";
|
|
14
14
|
|
|
15
15
|
/** A parsed reply: a resolved answer, the "other" choice, or a retry. */
|
|
16
16
|
export type PromptParse =
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type InteractionAnswer,
|
|
25
25
|
type InteractionChannel,
|
|
26
26
|
type InteractionRequest,
|
|
27
|
-
} from "
|
|
27
|
+
} from "../../../lib/interaction.ts";
|
|
28
28
|
|
|
29
29
|
function valueOf(answer: InteractionAnswer): string | undefined {
|
|
30
30
|
return answer.kind === "value" ? answer.value : undefined;
|
|
@@ -19,18 +19,18 @@ import { isPidAlive } from "../../lib/spawn.ts";
|
|
|
19
19
|
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
20
20
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
21
21
|
import { createBridge, type Bridge, type BridgeState } from "./bridge.ts";
|
|
22
|
-
import { createFetchRunner, WechatClient, type WechatClientOptions } from "./client.ts";
|
|
23
|
-
import { registerCommands, type CommandDeps } from "./commands.ts";
|
|
22
|
+
import { createFetchRunner, WechatClient, type WechatClientOptions } from "./transport/client.ts";
|
|
23
|
+
import { registerCommands, type CommandDeps } from "./commands/commands.ts";
|
|
24
24
|
import { messageOf } from "./errors.ts";
|
|
25
25
|
import { loadWechatConfig } from "./config.ts";
|
|
26
|
-
import { clearCredentials, primaryAccount, saveAccount, wechatDir } from "./credentials.ts";
|
|
27
|
-
import { acquireLock, lockPath, readLock, refreshLock, releaseLock } from "./lock.ts";
|
|
28
|
-
import { openPeerSession, type PeerSession, type PeerSessionOptions } from "./peer-session.ts";
|
|
29
|
-
import { createPeerUIContext } from "./remote-ui.ts";
|
|
30
|
-
import { peerKey } from "./format.ts";
|
|
31
|
-
import { readPeerHistory } from "./history.ts";
|
|
32
|
-
import { SLASH_COMMANDS } from "./slash.ts";
|
|
33
|
-
import { readState, statePath, writeState } from "./state.ts";
|
|
26
|
+
import { clearCredentials, primaryAccount, saveAccount, wechatDir } from "./state/credentials.ts";
|
|
27
|
+
import { acquireLock, lockPath, readLock, refreshLock, releaseLock } from "./state/lock.ts";
|
|
28
|
+
import { openPeerSession, type PeerSession, type PeerSessionOptions } from "./peer/peer-session.ts";
|
|
29
|
+
import { createPeerUIContext } from "./dialog/remote-ui.ts";
|
|
30
|
+
import { peerKey } from "./text/format.ts";
|
|
31
|
+
import { readPeerHistory } from "./commands/history.ts";
|
|
32
|
+
import { SLASH_COMMANDS } from "./commands/slash.ts";
|
|
33
|
+
import { readState, statePath, writeState } from "./state/state.ts";
|
|
34
34
|
import { registerTools } from "./tools.ts";
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* budget is in memory; a reload starts a fresh hour.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { InboundItem } from "
|
|
12
|
-
import { peerKey } from "
|
|
11
|
+
import type { InboundItem } from "../types.ts";
|
|
12
|
+
import { peerKey } from "../text/format.ts";
|
|
13
13
|
|
|
14
14
|
/** A rolling-hour turn budget per peer. */
|
|
15
15
|
export interface TurnBudget {
|
|
@@ -32,8 +32,8 @@ import {
|
|
|
32
32
|
SessionManager,
|
|
33
33
|
} from "@earendil-works/pi-coding-agent";
|
|
34
34
|
import { join } from "node:path";
|
|
35
|
-
import { WECHAT_CHANNEL_PROMPT } from "
|
|
36
|
-
import { peerKey } from "
|
|
35
|
+
import { WECHAT_CHANNEL_PROMPT } from "../text/channel.ts";
|
|
36
|
+
import { peerKey } from "../text/format.ts";
|
|
37
37
|
|
|
38
38
|
/** The SDK pieces a peer session needs; injected so tests never load resources. */
|
|
39
39
|
export interface PeerSessionSeams {
|
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Contract:
|
|
12
12
|
*
|
|
13
|
-
* - One turn at a time per peer, and by default
|
|
14
|
-
* (`
|
|
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.
|
|
15
16
|
* - Every message is its own turn, in arrival order. A message that arrives
|
|
16
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
35
|
import { createReplyPipeline, type ReplyPipeline } from "./reply-pipeline.ts";
|
|
30
|
-
import { defaultTimer, type ArmTimer } from "
|
|
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,18 +101,30 @@ 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;
|
|
100
106
|
/** Flush buffered assistant text for a running peer before a prompt/file. */
|
|
101
107
|
flush(peer: string): Promise<void>;
|
|
102
108
|
state(peer: string): PeerState;
|
|
103
109
|
snapshot(): PeerSnapshot[];
|
|
104
110
|
/** Drop every session; queued messages are dropped with them. */
|
|
105
|
-
close(): Promise<void>;
|
|
111
|
+
close(options?: { abandon?: boolean }): Promise<void>;
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
109
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
|
+
|
|
110
128
|
interface PeerEntry {
|
|
111
129
|
queue: PeerItem[];
|
|
112
130
|
running?: PeerItem;
|
|
@@ -123,7 +141,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
123
141
|
const now = deps.now ?? Date.now;
|
|
124
142
|
const armTimer = deps.armTimer ?? defaultTimer;
|
|
125
143
|
const idleTimeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
126
|
-
const concurrency = Math.max(1, deps.concurrency ??
|
|
144
|
+
const concurrency = Math.max(1, deps.concurrency ?? PEER_CONCURRENCY);
|
|
127
145
|
const peers = new Map<string, PeerEntry>();
|
|
128
146
|
/** Active reply pipelines, so a dialog can flush buffered text first. */
|
|
129
147
|
const pipelines = new Map<string, ReplyPipeline>();
|
|
@@ -132,7 +150,9 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
132
150
|
/** Peers whose next session must start empty (`/new`). */
|
|
133
151
|
const fresh = new Set<string>();
|
|
134
152
|
let running = 0;
|
|
135
|
-
|
|
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;
|
|
136
156
|
let closed = false;
|
|
137
157
|
|
|
138
158
|
function entry(peer: string): PeerEntry {
|
|
@@ -280,16 +300,25 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
280
300
|
unsubscribe();
|
|
281
301
|
pipelines.delete(item.peer);
|
|
282
302
|
}
|
|
303
|
+
// A closing worker has already said, once, that the session ended.
|
|
304
|
+
if (closed) return;
|
|
283
305
|
if (reason) await sendPart(deps.fallback(item, reason));
|
|
284
306
|
if (compacted) await sendPart(deps.fallback(item, "compacted"));
|
|
285
307
|
}
|
|
286
308
|
|
|
287
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. */
|
|
288
311
|
function nextRunnable(): [string, PeerEntry] | undefined {
|
|
312
|
+
let picked: [string, PeerEntry] | undefined;
|
|
289
313
|
for (const [peer, target] of peers) {
|
|
290
|
-
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
|
+
}
|
|
291
320
|
}
|
|
292
|
-
return
|
|
321
|
+
return picked;
|
|
293
322
|
}
|
|
294
323
|
|
|
295
324
|
/** Start turns until the concurrency limit or the queues are empty. */
|
|
@@ -302,7 +331,6 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
302
331
|
target.running = item;
|
|
303
332
|
target.startedAt = now();
|
|
304
333
|
running += 1;
|
|
305
|
-
currentItem = item;
|
|
306
334
|
cancelIdleSweep(target);
|
|
307
335
|
deps.onStart?.(item);
|
|
308
336
|
void (async () => {
|
|
@@ -311,12 +339,13 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
311
339
|
await runTurn(item, opened);
|
|
312
340
|
} catch (error) {
|
|
313
341
|
deps.notify?.(`WeChat session failed for ${item.peer}: ${messageOf(error)}`, "error");
|
|
314
|
-
|
|
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"));
|
|
315
345
|
} finally {
|
|
316
346
|
target.running = undefined;
|
|
317
347
|
target.startedAt = undefined;
|
|
318
348
|
running -= 1;
|
|
319
|
-
currentItem = undefined;
|
|
320
349
|
deps.onSettled?.(item);
|
|
321
350
|
armIdleSweep(target);
|
|
322
351
|
wake();
|
|
@@ -325,11 +354,50 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
325
354
|
}
|
|
326
355
|
}
|
|
327
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
|
+
|
|
328
394
|
return {
|
|
329
395
|
enqueue(item: PeerItem): void {
|
|
330
396
|
if (closed) return;
|
|
331
397
|
const target = entry(item.peer);
|
|
332
398
|
// One message, one turn: a queued item is never folded into another.
|
|
399
|
+
arrivals += 1;
|
|
400
|
+
arrived.set(item, arrivals);
|
|
333
401
|
target.queue.push(item);
|
|
334
402
|
wake();
|
|
335
403
|
},
|
|
@@ -354,8 +422,8 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
354
422
|
return true;
|
|
355
423
|
},
|
|
356
424
|
|
|
357
|
-
|
|
358
|
-
return
|
|
425
|
+
running(peer: string): PeerItem | undefined {
|
|
426
|
+
return peers.get(peer)?.running;
|
|
359
427
|
},
|
|
360
428
|
|
|
361
429
|
async flush(peer: string): Promise<void> {
|
|
@@ -370,8 +438,17 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
370
438
|
return [...peers.keys()].map((peer) => ({ peer, state: stateOf(peer) }));
|
|
371
439
|
},
|
|
372
440
|
|
|
373
|
-
async close(): Promise<void> {
|
|
441
|
+
async close(options?: { abandon?: boolean }): Promise<void> {
|
|
374
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
|
+
}
|
|
375
452
|
pipelines.clear();
|
|
376
453
|
const runningAborts = [...peers.values()]
|
|
377
454
|
.filter((target) => target.session)
|
|
@@ -383,6 +460,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
|
383
460
|
}
|
|
384
461
|
aborted.clear();
|
|
385
462
|
fresh.clear();
|
|
463
|
+
await announceAbandoned(abandoned);
|
|
386
464
|
},
|
|
387
465
|
};
|
|
388
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
|
}
|
|
@@ -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"
|
|
@@ -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.",
|
|
@@ -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];
|