@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 +66 -52
- package/README.zh-CN.md +56 -46
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +507 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
- 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 +561 -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} +62 -52
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
- 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 +298 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +254 -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} +260 -163
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
- package/extensions/board/commands.ts +105 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +170 -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 +388 -0
- package/extensions/board/result.ts +108 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +90 -0
- package/extensions/board/seat.ts +28 -0
- package/extensions/board/snapshot.ts +42 -0
- package/extensions/board/tools.ts +444 -0
- package/extensions/board/transport/router.ts +91 -0
- package/extensions/board/transport/server.ts +138 -0
- package/extensions/board/types.ts +121 -0
- package/extensions/board/view/client.ts +51 -0
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +125 -0
- package/extensions/board/view/live.ts +231 -0
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +118 -0
- package/extensions/board/view/render.ts +519 -0
- package/extensions/board/view/styles.ts +228 -0
- package/extensions/board/view/team.ts +327 -0
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +67 -7
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +75 -43
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- 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/check/commands.ts +2 -1
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +12 -0
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/files/tree.ts +101 -0
- 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 -17
- 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/view/emit.ts +51 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
- 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/job/runtime.ts +4 -4
- 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 +185 -52
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +23 -5
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +27 -34
- package/extensions/plan/policy.ts +30 -17
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +52 -11
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +20 -95
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +15 -8
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -8
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +3 -58
- package/extensions/plan/web-settle.ts +194 -0
- 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} +15 -14
- 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 +11 -5
- package/extensions/session-bus/runtime.ts +220 -267
- package/extensions/session-bus/store.ts +55 -7
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +5 -5
- package/extensions/session-bus/types.ts +23 -14
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +145 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +119 -0
- package/extensions/team/index.ts +34 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +267 -0
- package/extensions/team/types.ts +65 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- 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 +106 -251
- 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/intake.ts +245 -0
- 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/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +18 -290
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +11 -8
- package/lib/board/card.ts +479 -0
- package/lib/board/links.ts +141 -0
- package/lib/board/page-pointer.ts +310 -0
- package/lib/board/round-text.ts +81 -0
- package/lib/board/round.ts +94 -0
- package/lib/board/rules.ts +437 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +497 -0
- package/lib/board/view.ts +234 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +35 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +237 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +135 -0
- package/lib/http/token.ts +38 -0
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +25 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +201 -0
- package/lib/session-tool.ts +184 -0
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +38 -2
- package/lib/tui/glyphs.ts +3 -2
- package/lib/web/README.md +57 -4
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/components.ts +5 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -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/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/files.ts +0 -564
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/job/process.ts +0 -35
- 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
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* Value-only: no module-level state.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { type PeerLanguage } from "
|
|
14
|
-
import { notice } from "
|
|
13
|
+
import { type PeerLanguage } from "../text/format.ts";
|
|
14
|
+
import { notice } from "../text/notices.ts";
|
|
15
15
|
|
|
16
16
|
/** The two command kinds. */
|
|
17
17
|
export type SlashKind = "snippet" | "action";
|
|
@@ -37,6 +37,42 @@ export interface SlashSessionControls {
|
|
|
37
37
|
history?: (count: number) => Promise<string>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** What a builder needs to bind the session controls to one sender. */
|
|
41
|
+
export interface SlashControlsDeps {
|
|
42
|
+
/** What that peer's conversation is doing. */
|
|
43
|
+
state: (peer: string) => SlashPeerState;
|
|
44
|
+
/** The title of a dialog waiting on that peer, if one waits. */
|
|
45
|
+
prompt: (peer: string) => string | undefined;
|
|
46
|
+
/** Drop the peer's conversation, so the next message starts empty. */
|
|
47
|
+
reset: (peer: string) => void;
|
|
48
|
+
/** Stop the peer's running turn; false when nothing was running. */
|
|
49
|
+
stop: (peer: string) => boolean;
|
|
50
|
+
/** The last `count` messages of the peer's own conversation. */
|
|
51
|
+
history: (peer: string, count: number) => Promise<string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The controls one action runs against, for the peer that typed the command.
|
|
56
|
+
*
|
|
57
|
+
* The registry owns what a command may do; the bridge owns the queue and the
|
|
58
|
+
* channels, so it supplies them here and keeps no per-peer session shape of its
|
|
59
|
+
* own.
|
|
60
|
+
*/
|
|
61
|
+
export function slashControls(peer: string, deps: SlashControlsDeps): SlashSessionControls {
|
|
62
|
+
return {
|
|
63
|
+
newSession: () => {
|
|
64
|
+
deps.reset(peer);
|
|
65
|
+
},
|
|
66
|
+
stop: () => deps.stop(peer),
|
|
67
|
+
status: () => {
|
|
68
|
+
const waiting = deps.prompt(peer);
|
|
69
|
+
const state = deps.state(peer);
|
|
70
|
+
return waiting === undefined ? state : { ...state, prompt: waiting };
|
|
71
|
+
},
|
|
72
|
+
history: (count) => deps.history(peer, count),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
40
76
|
/** The environment an action handler runs in. */
|
|
41
77
|
export interface SlashContext {
|
|
42
78
|
/** Text after the command name, trimmed. */
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One interaction channel per peer.
|
|
3
|
+
*
|
|
4
|
+
* Two peers' turns can run at once ({@link PEER_CONCURRENCY}), and a forwarded
|
|
5
|
+
* dialog is only unambiguous if the channel that asks knows which peer it is
|
|
6
|
+
* asking — so each peer owns a channel, and a reply can only answer its own
|
|
7
|
+
* peer's prompt. This registry builds them lazily, routes an inbound message to
|
|
8
|
+
* that peer's channel, and aggregates what waits so the control screen and
|
|
9
|
+
* `/wechat status` can name each waiting peer.
|
|
10
|
+
*
|
|
11
|
+
* It also owns the dialogs' outbound path — the flush of buffered text, the
|
|
12
|
+
* prompt gate, and the deliverer call — because that path is per peer and belongs
|
|
13
|
+
* with the channel that uses it rather than with the bridge that wires it.
|
|
14
|
+
*
|
|
15
|
+
* Value-only: the map lives in the closure.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PendingPromptInfo, WechatInteractionChannel } from "./interaction.ts";
|
|
19
|
+
import { createWechatInteractionChannel } from "./interaction.ts";
|
|
20
|
+
import type { PeerLanguage } from "../text/format.ts";
|
|
21
|
+
import type { ArmTimer } from "../timer.ts";
|
|
22
|
+
|
|
23
|
+
/** What the registry needs from the bridge; the turn's item carries the token. */
|
|
24
|
+
export interface PeerChannelDeps {
|
|
25
|
+
/** Put buffered assistant text on the wire before a prompt or a file. */
|
|
26
|
+
flush: (peer: string) => Promise<void>;
|
|
27
|
+
/** Send one dialog message to a peer as that peer's next bubble. */
|
|
28
|
+
send: (peer: string, text: string, signal?: AbortSignal) => Promise<boolean>;
|
|
29
|
+
/** Send one local file to a peer. */
|
|
30
|
+
sendFile: (peer: string, path: string, name: string, signal?: AbortSignal) => Promise<boolean>;
|
|
31
|
+
/** Whether the peer owns a running turn, so a prompt has somewhere to land. */
|
|
32
|
+
active: (peer: string) => boolean;
|
|
33
|
+
/** The language the peer writes in, for the prompt and the timeout notices. */
|
|
34
|
+
language: (peer: string) => PeerLanguage;
|
|
35
|
+
/** Arm a cancellable one-shot timer. Defaults to a real, unref'd one. */
|
|
36
|
+
armTimer?: ArmTimer;
|
|
37
|
+
/** Clock for a prompt's start time; defaults to `Date.now`. */
|
|
38
|
+
now?: () => number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PeerChannels {
|
|
42
|
+
/** This peer's channel, created on first use. */
|
|
43
|
+
for(peer: string): WechatInteractionChannel;
|
|
44
|
+
/** Route an inbound message to the peer's pending prompt; true when it answered one. */
|
|
45
|
+
handleInbound(peer: string, text: string): boolean;
|
|
46
|
+
/** The prompt one peer is waiting on, if any. */
|
|
47
|
+
pendingFor(peer: string): PendingPromptInfo | undefined;
|
|
48
|
+
/** Every waiting prompt, oldest channel first. */
|
|
49
|
+
prompts(): PendingPromptInfo[];
|
|
50
|
+
/** Whether that peer — or with no argument, any peer — has a prompt waiting. */
|
|
51
|
+
hasPending(peer?: string): boolean;
|
|
52
|
+
/** Withdraw every waiting prompt, telling each peer. */
|
|
53
|
+
cancelAll(): Promise<void>;
|
|
54
|
+
/** Drop every channel and its waiting prompt, silently. */
|
|
55
|
+
reset(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createPeerChannels(deps: PeerChannelDeps): PeerChannels {
|
|
59
|
+
const channels = new Map<string, WechatInteractionChannel>();
|
|
60
|
+
|
|
61
|
+
function build(peer: string): WechatInteractionChannel {
|
|
62
|
+
let channel: WechatInteractionChannel | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Dialog text is only sent while its prompt is registered; the timeout,
|
|
65
|
+
* waiting, and withdrawal lines force their way through. Reading the channel
|
|
66
|
+
* this closure builds keeps one owner for "is a prompt waiting".
|
|
67
|
+
*/
|
|
68
|
+
const allowed = (options?: { force?: boolean }): boolean =>
|
|
69
|
+
options?.force === true || (channel?.hasPending() ?? false);
|
|
70
|
+
|
|
71
|
+
channel = createWechatInteractionChannel({
|
|
72
|
+
peer,
|
|
73
|
+
send: async (text, signal, options) => {
|
|
74
|
+
if (!allowed(options)) return false;
|
|
75
|
+
await deps.flush(peer);
|
|
76
|
+
return deps.send(peer, text, signal);
|
|
77
|
+
},
|
|
78
|
+
sendFile: async (path, name, signal, options) => {
|
|
79
|
+
if (!allowed(options)) return false;
|
|
80
|
+
await deps.flush(peer);
|
|
81
|
+
return deps.sendFile(peer, path, name, signal);
|
|
82
|
+
},
|
|
83
|
+
active: () => deps.active(peer),
|
|
84
|
+
language: () => deps.language(peer),
|
|
85
|
+
armTimer: deps.armTimer,
|
|
86
|
+
now: deps.now,
|
|
87
|
+
});
|
|
88
|
+
return channel;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
for(peer: string): WechatInteractionChannel {
|
|
93
|
+
const existing = channels.get(peer);
|
|
94
|
+
if (existing) return existing;
|
|
95
|
+
const created = build(peer);
|
|
96
|
+
channels.set(peer, created);
|
|
97
|
+
return created;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
handleInbound(peer: string, text: string): boolean {
|
|
101
|
+
// Only a channel that exists can be waiting, so an inbound message never
|
|
102
|
+
// creates one for a peer that has not been asked anything.
|
|
103
|
+
return channels.get(peer)?.handleInbound(text) ?? false;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
pendingFor(peer: string): PendingPromptInfo | undefined {
|
|
107
|
+
return channels.get(peer)?.pendingInfo();
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
prompts(): PendingPromptInfo[] {
|
|
111
|
+
const waiting: PendingPromptInfo[] = [];
|
|
112
|
+
for (const channel of channels.values()) {
|
|
113
|
+
const info = channel.pendingInfo();
|
|
114
|
+
if (info) waiting.push(info);
|
|
115
|
+
}
|
|
116
|
+
return waiting;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
hasPending(peer?: string): boolean {
|
|
120
|
+
if (peer !== undefined) return channels.get(peer)?.hasPending() ?? false;
|
|
121
|
+
for (const channel of channels.values()) if (channel.hasPending()) return true;
|
|
122
|
+
return false;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async cancelAll(): Promise<void> {
|
|
126
|
+
for (const channel of channels.values()) {
|
|
127
|
+
if (channel.hasPending()) await channel.cancel();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
reset(): void {
|
|
132
|
+
for (const channel of channels.values()) channel.reset();
|
|
133
|
+
channels.clear();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound messages: admission, the slash commands a message may carry, and the hand-off to
|
|
3
|
+
* the sender's own session.
|
|
4
|
+
*
|
|
5
|
+
* This is the path a message takes from the poll loop to a turn, and it is the one part of
|
|
6
|
+
* the bridge that is a *sequence of decisions* rather than wiring: who is refused, what is
|
|
7
|
+
* a replay, what is a command, what is an answer to a pending prompt, and what is queued or
|
|
8
|
+
* turned away. The bridge keeps the connection lifecycle and the status snapshot; it hands
|
|
9
|
+
* this module the live state it reads, and nothing here touches the client or the lock.
|
|
10
|
+
*
|
|
11
|
+
* Every dependency whose value changes across a connection is a function, because a
|
|
12
|
+
* reconnect replaces the account, the state, and the worker rather than mutating them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AdmissionQueue, TurnBudget } from "./peer/admission.ts";
|
|
16
|
+
import type { PeerWorker } from "./peer/peer-worker.ts";
|
|
17
|
+
import type { Presence } from "./peer/presence.ts";
|
|
18
|
+
import type { PeerChannels } from "./dialog/channels.ts";
|
|
19
|
+
import { messageOf } from "./errors.ts";
|
|
20
|
+
import { findPeer, recordInbound, rememberMessage, seenMessage } from "./state/state.ts";
|
|
21
|
+
import { parseInbound, peerKey, peerLabel, peerLanguage, type PeerLanguage } from "./text/format.ts";
|
|
22
|
+
import { notice } from "./text/notices.ts";
|
|
23
|
+
import {
|
|
24
|
+
expandSnippet,
|
|
25
|
+
findSlashCommand,
|
|
26
|
+
parseSlash,
|
|
27
|
+
slashControls,
|
|
28
|
+
type SlashCommand,
|
|
29
|
+
type SlashContext,
|
|
30
|
+
} from "./commands/slash.ts";
|
|
31
|
+
import type { BotCredentials, InboundItem, WechatState, WeixinMessage } from "./types.ts";
|
|
32
|
+
import type { MessageTarget } from "./transport/deliver.ts";
|
|
33
|
+
import type { WechatConfig } from "./config.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether `peer` may drive the agent: the owner, or an explicit extra. Either id
|
|
37
|
+
* form is accepted (with or without `@im.wechat`), so a label copied from the
|
|
38
|
+
* control screen is not silently refused.
|
|
39
|
+
*/
|
|
40
|
+
export function isAllowedPeer(peer: string, owner: string | undefined, allowedPeers: string[]): boolean {
|
|
41
|
+
const key = peerKey(peer);
|
|
42
|
+
if (owner !== undefined && peerKey(owner) === key) return true;
|
|
43
|
+
return allowedPeers.some((allowed) => peerKey(allowed) === key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when a message is inbound from a human (not the bot's own echo). */
|
|
47
|
+
export function isInboundMessage(message: WeixinMessage): message is WeixinMessage & { from_user_id: string } {
|
|
48
|
+
return message.message_type !== 2 && typeof message.from_user_id === "string" && message.from_user_id !== "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Inbound messages held while a turn runs; beyond this the peer is told to wait. */
|
|
52
|
+
export const MAX_QUEUE = 8;
|
|
53
|
+
|
|
54
|
+
/** Senders refused since the connection opened, capped so a spam run cannot fill the list. */
|
|
55
|
+
const REFUSED_LABEL_CAP = 5;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The peer's language: from this text, else the last one they wrote.
|
|
59
|
+
*
|
|
60
|
+
* A Chinese message is evidence of Chinese. Its absence is not evidence of English, or a
|
|
61
|
+
* short reply ("ok", "1") would flip an established conversation back; English is the
|
|
62
|
+
* fallback, so it needs no record of its own.
|
|
63
|
+
*/
|
|
64
|
+
export function createLanguageOf(languages: Map<string, PeerLanguage>): (peer: string, text?: string) => PeerLanguage {
|
|
65
|
+
return (peer, text) => {
|
|
66
|
+
if (text && peerLanguage(text) === "zh") languages.set(peer, "zh");
|
|
67
|
+
return languages.get(peer) ?? "en";
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drop a peer's cached reply token, so the next inbound message replaces it. */
|
|
72
|
+
export function createForgetToken(deps: {
|
|
73
|
+
state: () => WechatState;
|
|
74
|
+
saveState: (state: WechatState) => void;
|
|
75
|
+
}): (peer: string) => void {
|
|
76
|
+
return (peer) => {
|
|
77
|
+
const state = deps.state();
|
|
78
|
+
const entry = findPeer(state, peer);
|
|
79
|
+
if (!entry?.lastContextToken) return;
|
|
80
|
+
delete entry.lastContextToken;
|
|
81
|
+
deps.saveState(state);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A counter two modules share: this one bumps it, the status snapshot reads it. */
|
|
86
|
+
export interface Counter {
|
|
87
|
+
value: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Everything the inbound path reads, as the bridge hands it in. */
|
|
91
|
+
export interface IntakeDeps {
|
|
92
|
+
/** The live state: replaced when a re-login changes the owner. */
|
|
93
|
+
state: () => WechatState;
|
|
94
|
+
saveState: (state: WechatState) => void;
|
|
95
|
+
/** The live account, for the owner a message is compared against. */
|
|
96
|
+
account: () => BotCredentials | undefined;
|
|
97
|
+
config: WechatConfig;
|
|
98
|
+
now: () => number;
|
|
99
|
+
notify: (message: string, kind?: "info" | "warning" | "error") => void;
|
|
100
|
+
/** The live worker: a connect or disconnect replaces it. */
|
|
101
|
+
worker: () => PeerWorker | undefined;
|
|
102
|
+
channels: PeerChannels;
|
|
103
|
+
presence: Presence;
|
|
104
|
+
budget: TurnBudget;
|
|
105
|
+
admission: AdmissionQueue;
|
|
106
|
+
languageOf: (peer: string, text?: string) => PeerLanguage;
|
|
107
|
+
/** Send one bubble to a peer for the bridge's own notices. */
|
|
108
|
+
reply: (target: MessageTarget, text: string) => Promise<boolean>;
|
|
109
|
+
readHistory: (peer: string, count: number) => Promise<string>;
|
|
110
|
+
/** Senders refused since the connection opened, and their labels. */
|
|
111
|
+
refused: Counter;
|
|
112
|
+
refusedPeers: Set<string>;
|
|
113
|
+
/** Peers already told the queue is full, until it drains. */
|
|
114
|
+
overflow: Set<string>;
|
|
115
|
+
slashCommands: readonly SlashCommand[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The inbound path: one function per message, with the command runner and the language
|
|
120
|
+
* lookup it needs beside it.
|
|
121
|
+
*/
|
|
122
|
+
export function createIntake(deps: IntakeDeps): { handleMessage: (message: WeixinMessage) => void } {
|
|
123
|
+
const { config, channels, presence, budget, admission, languageOf, notify } = deps;
|
|
124
|
+
|
|
125
|
+
/** Run a slash action out-of-band and reply to the sender; never starts a turn. */
|
|
126
|
+
async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
|
|
127
|
+
if (!command.run) return;
|
|
128
|
+
const worker = deps.worker();
|
|
129
|
+
const ctx: SlashContext = {
|
|
130
|
+
args,
|
|
131
|
+
peer: target.peer,
|
|
132
|
+
reply: async (text) => {
|
|
133
|
+
await deps.reply(target, text);
|
|
134
|
+
},
|
|
135
|
+
activeTurn: worker?.state(target.peer).status === "running",
|
|
136
|
+
language: languageOf(target.peer),
|
|
137
|
+
session: slashControls(target.peer, {
|
|
138
|
+
state: (peer) => worker?.state(peer) ?? { status: "idle", queued: 0 },
|
|
139
|
+
prompt: (peer) => channels.pendingFor(peer)?.title,
|
|
140
|
+
reset: (peer) => {
|
|
141
|
+
worker?.reset(peer);
|
|
142
|
+
},
|
|
143
|
+
stop: (peer) => worker?.abort(peer) ?? false,
|
|
144
|
+
history: (peer, count) => deps.readHistory(peer, count),
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
await command.run(ctx);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
|
|
151
|
+
await deps.reply(target, `Command /${command.name} failed.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function handleMessage(message: WeixinMessage): void {
|
|
156
|
+
if (!isInboundMessage(message)) return;
|
|
157
|
+
const state = deps.state();
|
|
158
|
+
const worker = deps.worker();
|
|
159
|
+
const peer = message.from_user_id;
|
|
160
|
+
const { text: rawText, media, unsupported } = parseInbound(message.item_list);
|
|
161
|
+
const owner = state.ownerId ?? deps.account()?.ilinkUserId;
|
|
162
|
+
// Admission comes first: a refused sender never reaches the state file, a
|
|
163
|
+
// pending prompt, or the queue.
|
|
164
|
+
if (!isAllowedPeer(peer, owner, config.allowedPeers)) {
|
|
165
|
+
deps.refused.value += 1;
|
|
166
|
+
const label = peerLabel(peer);
|
|
167
|
+
// Name a new sender once, so the operator can allowlist it from the
|
|
168
|
+
// terminal; the cap keeps a spam run from filling the list.
|
|
169
|
+
if (deps.refusedPeers.size < REFUSED_LABEL_CAP && !deps.refusedPeers.has(label)) {
|
|
170
|
+
deps.refusedPeers.add(label);
|
|
171
|
+
notify(`WeChat refused a message from ${label}. Add it to allowedPeers to admit.`, "warning");
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// A server replay of an already handled message must not answer twice. The
|
|
176
|
+
// id is persisted, so a reload or reconnect cannot resurrect it.
|
|
177
|
+
const messageId = message.message_id;
|
|
178
|
+
if (typeof messageId === "number" && messageId !== 0 && seenMessage(state, peer, messageId)) return;
|
|
179
|
+
|
|
180
|
+
let text = rawText;
|
|
181
|
+
const cap = config.maxInboundChars;
|
|
182
|
+
if (text.length > cap) {
|
|
183
|
+
const total = text.length;
|
|
184
|
+
text = `${text.slice(0, cap)}\n[WeChat message truncated: showing ${cap} of ${total} characters]`;
|
|
185
|
+
notify(`WeChat message truncated to ${cap} characters.`, "warning");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const now = deps.now();
|
|
189
|
+
recordInbound(state, peer, message.context_token, now);
|
|
190
|
+
if (typeof messageId === "number" && messageId !== 0) rememberMessage(state, peer, messageId, now);
|
|
191
|
+
deps.saveState(state);
|
|
192
|
+
|
|
193
|
+
const target = { peer, contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken };
|
|
194
|
+
// A command is not an answer: while a dialog waits the model is blocked on it,
|
|
195
|
+
// so /stop and /status must reach their handlers or the peer's only way out is
|
|
196
|
+
// the prompt timing out. A message with media is never a command, and an
|
|
197
|
+
// unknown command falls through to the model as ordinary text.
|
|
198
|
+
const slash = media.length === 0 ? parseSlash(text) : undefined;
|
|
199
|
+
const command = slash ? findSlashCommand(deps.slashCommands, slash.name) : undefined;
|
|
200
|
+
if (command && slash) {
|
|
201
|
+
if (command.ownerOnly && peerKey(peer) !== peerKey(owner ?? "")) {
|
|
202
|
+
deps.refused.value += 1;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (command.kind === "snippet") {
|
|
206
|
+
text = expandSnippet(command, slash.args);
|
|
207
|
+
} else {
|
|
208
|
+
void runSlashAction(command, target, slash.args);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// A reply to a pending prompt is consumed here, before queueing, so it never
|
|
213
|
+
// starts a turn of its own. A duplicate answer was stopped by the id check.
|
|
214
|
+
if (channels.handleInbound(peer, text)) return;
|
|
215
|
+
const language = languageOf(peer, text);
|
|
216
|
+
const item: InboundItem = { ...target, text, media, language };
|
|
217
|
+
if (!text && media.length === 0) {
|
|
218
|
+
// Nothing to read. A voice note without a transcript or a video is
|
|
219
|
+
// answered, so the peer knows it did not reach the agent.
|
|
220
|
+
if (unsupported > 0) void deps.reply(item, notice("unsupported", language));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// A slot is reserved here, before the download, so a burst of media cannot
|
|
224
|
+
// all pass the check and then enqueue together.
|
|
225
|
+
if (!budget.allow(peer)) {
|
|
226
|
+
void deps.reply(item, notice("budget", language));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const busy = (worker?.state(peer).queued ?? 0) + admission.pending(peer);
|
|
230
|
+
if (busy >= MAX_QUEUE) {
|
|
231
|
+
// Refuse rather than grow without bound; one notice per episode, not per
|
|
232
|
+
// dropped message.
|
|
233
|
+
if (!deps.overflow.has(peer)) {
|
|
234
|
+
deps.overflow.add(peer);
|
|
235
|
+
void deps.reply(item, notice("overflow", language));
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
presence.keepTyping(peer, item.contextToken);
|
|
240
|
+
presence.armAck(item);
|
|
241
|
+
admission.enqueue(item);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { handleMessage };
|
|
245
|
+
}
|
|
@@ -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 {
|