@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.
Files changed (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /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 "./format.ts";
14
- import { notice } from "./notices.ts";
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 the peer that owns the current turn and resolves when that peer
4
- * replies.
3
+ * prompt to one peer and resolves when that peer replies.
5
4
  *
6
- * It depends only on injected `send`/`sendFile`/`activePeer` accessors and a
7
- * timer seam, never on the bridge or HTTP client, so it is unit-tested without
8
- * I/O. The bridge owns the poll loop and hands each inbound message to
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 "../../lib/interaction.ts";
22
- import { type PeerLanguage } from "./format.ts";
23
- import { notice } from "./notices.ts";
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 "./timer.ts";
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
- /** Send one text message to the owning peer; `force` bypasses the prompt-active gate. */
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 owning peer; `force` bypasses the prompt-active gate. */
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
- /** The peer that owns the in-flight turn, or undefined when there is none. */
36
- activePeer: () => string | undefined;
37
- /** The language the owning peer writes in, for the timeout notices. */
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; true when it was one. */
52
- handleInbound(peer: string, text: string): boolean;
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, per peer: the immediately
96
- * following `input` for that peer returns it. Keyed so one peer's free text can
97
- * never become another peer's answer.
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
- const other = new Map<string, string>();
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.activePeer() !== undefined;
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: current.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 for that peer; the
168
- // immediately following `input` from the same peer returns it without
169
- // another round-trip.
170
- const asking = deps.activePeer();
171
- const stashed = asking === undefined ? undefined : other.get(asking);
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" && asking !== undefined) other.delete(asking);
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, peer, startedAt: now(), resolve, signal, onAbort, cancelTimers };
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(peer: string, text: string): boolean {
207
+ handleInbound(text: string): boolean {
208
208
  const current = pending;
209
- if (!current || current.peer !== peer) return false;
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.set(peer, parsed.text);
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.clear();
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 "../../lib/interaction.ts";
13
- import { sanitizeInbound, type PeerLanguage } from "./format.ts";
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 "../../lib/interaction.ts";
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 "./types.ts";
12
- import { peerKey } from "./format.ts";
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 {