@gavin-hu/my-pi-agent 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
  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 +554 -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} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -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 +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The shapes `team` registers and reports.
3
+ *
4
+ * A `team` result is one card plus what happened to the handoff, so the details say
5
+ * both: `assigned` is who the card went to, `delivered` is the labels the bus wrote
6
+ * a message file for, and `messageError` is the send that never landed — a card is
7
+ * never left in a state the report does not describe.
8
+ */
9
+
10
+ import type { Card } from "../../lib/board/card.ts";
11
+ import type { CardOwner } from "../../lib/board/card.ts";
12
+
13
+ /** The tool's name. `board` owns the cards; `team` owns the round. */
14
+ export const TEAM_TOOL = "team";
15
+
16
+ /** The three actions: the lead's whole surface — hand out, offer, take back. */
17
+ export const TEAM_ACTIONS = ["assign", "offer", "reclaim"] as const;
18
+
19
+ /** One action. */
20
+ export type TeamAction = (typeof TEAM_ACTIONS)[number];
21
+
22
+ /** What one call did. */
23
+ export interface TeamDetails {
24
+ action: TeamAction;
25
+ /** The card after the action, or the one a refusal left alone. */
26
+ card?: Card;
27
+ /** Who the card was assigned to, when the assignment happened. */
28
+ assigned?: CardOwner;
29
+ /**
30
+ * The labels a message file was written for, when the send landed: the one peer for
31
+ * `assign`, every other live session for `offer`.
32
+ */
33
+ delivered?: string[];
34
+ /**
35
+ * The peers the message reached whose record says they are not listening, so it waits
36
+ * for their next turn instead of being raised as one. Absent when every peer listens.
37
+ */
38
+ notListening?: string[];
39
+ /** Why the handoff was not sent; the card is untouched when this is set. */
40
+ messageError?: string;
41
+ /** A refusal's reason, which the renderer draws instead of the details. */
42
+ error?: string;
43
+ }
44
+
45
+ /** Whether a value is one of the actions, so an unknown one is a refusal and not a crash. */
46
+ export function normalizeTeamAction(raw: unknown): TeamAction | undefined {
47
+ return typeof raw === "string" && TEAM_ACTIONS.includes(raw as TeamAction) ? (raw as TeamAction) : undefined;
48
+ }
@@ -4,8 +4,9 @@
4
4
  * Registers the `watch` tool and `/watch` command. Watchers read a file or a
5
5
  * command's merged output, match each new line against a regular expression,
6
6
  * and alert the user; matching lines are reported to the model — and shown as a
7
- * bytefly notice — at the next turn, or wake a turn immediately when the
8
- * watcher opted into `wake`. Watchers are session-only and stopped on shutdown.
7
+ * bytefly notice — at the next turn, or wake a turn as soon as the agent is idle
8
+ * when the watcher opted into `wake`, including right after a running turn
9
+ * settles. Watchers are session-only and stopped on shutdown.
9
10
  *
10
11
  * Load with: pi --extension ./extensions/watch
11
12
  */
@@ -14,7 +15,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
15
  import { isExtensionEnabled } from "../../lib/env.ts";
15
16
  import { WATCH_MATCH_CHANNEL, type WatchMatchEvent } from "../../lib/events.ts";
16
17
  import { sanitizeLine } from "../../lib/format.ts";
17
- import { noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
18
+ import { burstText, noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
18
19
  import { createPendingReporter, registerPendingChannel } from "../../lib/report.ts";
19
20
  import { registerCommands } from "./commands.ts";
20
21
  import { clipLabel } from "../../lib/tui/columns.ts";
@@ -32,10 +33,14 @@ const MAX_CONTEXT_MATCHES = 20;
32
33
  /** Build the model-facing text for the pending-match notice. */
33
34
  export function buildWatchContext(matches: PendingMatch[]): string {
34
35
  const shown = matches.slice(0, MAX_CONTEXT_MATCHES);
35
- const lines = shown.map(formatPendingMatch);
36
36
  const more = matches.length - shown.length;
37
- const suffix = more > 0 ? `\n… ${more} more match${more === 1 ? "" : "es"}` : "";
38
- return `${WATCH_CONTEXT_MARKER}\n${matches.length} watched line${matches.length === 1 ? "" : "s"} matched:\n${lines.join("\n")}${suffix}\nRead more with \`watch matches <id>\`.`;
37
+ return burstText({
38
+ marker: WATCH_CONTEXT_MARKER,
39
+ headline: `${matches.length} watched line${matches.length === 1 ? "" : "s"} matched`,
40
+ lines: shown.map((match) => formatPendingMatch(match)),
41
+ ...(more > 0 ? { more: `… ${more} more match${more === 1 ? "" : "es"}` } : {}),
42
+ hint: "Read more with `watch matches <id>`.",
43
+ });
39
44
  }
40
45
 
41
46
  /** The shared notice for pending matches: model text plus renderer details. */
@@ -64,15 +69,16 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
64
69
  if (!isExtensionEnabled("watch")) return;
65
70
  const runtime = deps.runtime ?? createWatchRuntime();
66
71
 
67
- // Opt-in wake: a `wake` watcher matched while the agent is idle. The reporter
68
- // reports *all* pending matches, not just the waking one, so a non-wake match
69
- // is not silently dropped.
72
+ // Opt-in wake: a `wake` watcher matched. The reporter reports *all* pending
73
+ // matches, not just the waking one, so a non-wake match is not silently
74
+ // dropped, and it retries a burst that arrived mid-turn once the run settles.
70
75
  const reporter = createPendingReporter<PendingMatch>({
71
76
  wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
72
77
  pending: () => runtime.pendingMatches(),
73
78
  markReported: (matches) => runtime.markReported(matches),
74
79
  buildNotice: watchNotice,
75
80
  source: "watch",
81
+ wakeRequested: () => runtime.pendingMatches().some((match) => runtime.get(match.watcherId)?.wake === true),
76
82
  // A wake means the user is away from the terminal, so the alert also leaves
77
83
  // the process. bytefly's notify capability owns delivery; this publishes the
78
84
  // fact and never learns how it is sent. The newest match is the one an alert
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { WebFetchConfig } from "./config.ts";
13
13
  import { extractReadable } from "./extract.ts";
14
+ import { renderStructured } from "./structured.ts";
14
15
  import { extractPdfText, type PdfExtractor } from "../../../lib/pdf.ts";
15
16
  import { createFetchRunner, type HttpRunner, type HttpResponse } from "../http.ts";
16
17
  import { decodeHtml } from "../charset.ts";
@@ -157,7 +158,9 @@ export async function runFetch(
157
158
  title = page.title;
158
159
  text = page.text;
159
160
  } else if (isTextualType(response.contentType)) {
160
- text = response.body;
161
+ // Rendered here, once per fetch, because the result is what the cache stores:
162
+ // paging and `find` then read one stable string instead of a fresh rendering.
163
+ text = renderStructured(response.body, response.contentType) ?? response.body;
161
164
  } else {
162
165
  text = `(binary content: ${response.contentType || "unknown"}, ${response.sizeBytes} bytes; web_fetch returns text only)`;
163
166
  }
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Render a structured body — JSON, CSV, or XML — as readable text.
3
+ *
4
+ * `web_fetch` used to hand these back as the blob it downloaded: a page a reader
5
+ * has to parse in their head. This turns one into the shape a model reads best —
6
+ * a table for records, indented keys for an object, an indented tree for XML — and
7
+ * declines anything it cannot read, so the caller keeps the raw body and nothing
8
+ * usable is lost.
9
+ *
10
+ * A source cap keeps an oversized response out: past it the raw body, which pages,
11
+ * reads better than a rendered one. A rendered-character cap keeps one answer from
12
+ * doubling the cache, and the note says where it stopped. Nothing here throws —
13
+ * every failure is `undefined`, which `page.ts` reads as "return the body
14
+ * unchanged".
15
+ *
16
+ * The declared content type decides first. A body that declares nothing usable is
17
+ * sniffed only when it has a shape — an opening brace, a uniform comma count, a
18
+ * root tag — because a plain-text page is the common case and a mangled table is
19
+ * worse than a blob.
20
+ */
21
+
22
+ import { asRecord } from "../../../lib/config.ts";
23
+ import { decodeEntities } from "../html.ts";
24
+ import { charLength, sliceChars } from "../text.ts";
25
+
26
+ /** Past this, the raw body — which pages — is the better answer. */
27
+ const MAX_SOURCE_CHARS = 1_048_576;
28
+
29
+ /** Rendered characters before the cut; the cached text pays this too. */
30
+ const MAX_RENDERED_CHARS = 60_000;
31
+
32
+ /** One level of nesting, and the gap between two table columns. */
33
+ const INDENT = " ";
34
+
35
+ /** A table cell is one line, so a nested value is compacted to about this width. */
36
+ const CELL_CHARS = 40;
37
+
38
+ /** A list short enough to sit on one line with its key. */
39
+ const INLINE_LIST_CHARS = 80;
40
+
41
+ /**
42
+ * Render a body as text, or `undefined` when it is not one this module reads.
43
+ */
44
+ export function renderStructured(body: string, contentType: string): string | undefined {
45
+ if (body.length > MAX_SOURCE_CHARS) return undefined;
46
+ const declared = contentType.toLowerCase();
47
+ if (isJsonType(declared)) return renderJson(body);
48
+ if (isCsvType(declared)) return renderCsv(body);
49
+ if (isXmlType(declared)) return renderXml(body);
50
+
51
+ if (looksLikeJson(body)) return renderJson(body);
52
+ if (looksLikeCsv(body)) return renderCsv(body);
53
+ if (looksLikeXml(body)) return renderXml(body);
54
+ return undefined;
55
+ }
56
+
57
+ function isJsonType(contentType: string): boolean {
58
+ return /json/.test(contentType);
59
+ }
60
+
61
+ function isCsvType(contentType: string): boolean {
62
+ return /csv/.test(contentType);
63
+ }
64
+
65
+ function isXmlType(contentType: string): boolean {
66
+ return /xml/.test(contentType);
67
+ }
68
+
69
+ function looksLikeJson(body: string): boolean {
70
+ return /^\s*[[{]/.test(body);
71
+ }
72
+
73
+ /** Uniformly comma-separated lines, which is the shape a plain-text export has. */
74
+ function looksLikeCsv(body: string): boolean {
75
+ const lines = body.split(/\r?\n/).filter((line) => line.trim() !== "");
76
+ if (lines.length < 2) return false;
77
+ const commas = lines[0].split(",").length - 1;
78
+ return commas > 0 && lines.every((line) => line.split(",").length - 1 === commas);
79
+ }
80
+
81
+ function looksLikeXml(body: string): boolean {
82
+ return /^\s*<(\?xml\b|[A-Za-z_])/.test(body) && body.includes("</");
83
+ }
84
+
85
+ /** The rendered lines, what they cost, and whether the cap cut anything. */
86
+ interface Output {
87
+ lines: string[];
88
+ used: number;
89
+ cut: boolean;
90
+ }
91
+
92
+ function createOutput(): Output {
93
+ return { lines: [], used: 0, cut: false };
94
+ }
95
+
96
+ /** Append one line within the cap; `false` means the caller should stop. */
97
+ function emit(out: Output, line: string): boolean {
98
+ const cost = line.length + 1;
99
+ if (out.used + cost > MAX_RENDERED_CHARS) {
100
+ out.cut = true;
101
+ return false;
102
+ }
103
+ out.lines.push(line);
104
+ out.used += cost;
105
+ return true;
106
+ }
107
+
108
+ function emitAt(out: Output, text: string, depth: number): boolean {
109
+ return emit(out, `${INDENT.repeat(depth)}${text}`);
110
+ }
111
+
112
+ /** The rendered text, with one note when the cap stopped it early. */
113
+ function finish(out: Output, body: string): string {
114
+ const text = out.lines.join("\n");
115
+ if (!out.cut) return text;
116
+ return `${text}\n\n… (rendered ${out.used} characters of a ${body.length}-character body; the rest was cut)`;
117
+ }
118
+
119
+ /** One line for a value: a string's own line breaks become spaces. */
120
+ function oneLine(text: string): string {
121
+ return text.replace(/\s*\n\s*/g, " ");
122
+ }
123
+
124
+ /** Collapse a text node or an attribute to one line. */
125
+ function collapse(text: string): string {
126
+ return text.replace(/\s+/g, " ").trim();
127
+ }
128
+
129
+ /** A value JSON stores as one atom: no nesting to walk. */
130
+ function isScalar(value: unknown): boolean {
131
+ return value === null || typeof value !== "object";
132
+ }
133
+
134
+ function isScalarList(value: readonly unknown[]): boolean {
135
+ return value.every(isScalar);
136
+ }
137
+
138
+ function isRecordList(value: readonly unknown[]): value is readonly Record<string, unknown>[] {
139
+ return value.every((item) => {
140
+ const record = asRecord(item);
141
+ return record !== undefined && Object.keys(record).length > 0;
142
+ });
143
+ }
144
+
145
+ /** One line of text for a scalar; a string's own line breaks become spaces. */
146
+ function scalarText(value: unknown): string {
147
+ if (value === null) return "null";
148
+ if (typeof value === "string") return oneLine(value);
149
+ return String(value);
150
+ }
151
+
152
+ /** A table cell: a nested value is compacted and clipped. */
153
+ function cellText(value: unknown): string {
154
+ if (value === undefined) return "";
155
+ if (isScalar(value)) return scalarText(value);
156
+ const compact = JSON.stringify(value) ?? "";
157
+ return charLength(compact) <= CELL_CHARS ? compact : `${sliceChars(compact, 0, CELL_CHARS)}…`;
158
+ }
159
+
160
+ /**
161
+ * Lines as a table: each column padded to its widest cell, two spaces between
162
+ * columns, no padding after the last one. Ragged rows are padded with empty cells,
163
+ * so a short record still lines up instead of shifting the columns after it.
164
+ */
165
+ function renderGrid(rows: readonly (readonly string[])[], depth: number, out: Output): void {
166
+ const columns = rows.reduce((widest, row) => Math.max(widest, row.length), 0);
167
+ const widths = Array.from({ length: columns }, (_unused, column) =>
168
+ rows.reduce((widest, row) => Math.max(widest, charLength(row[column] ?? "")), 0),
169
+ );
170
+
171
+ for (const row of rows) {
172
+ const line: string[] = [];
173
+ for (let column = 0; column < columns; column++) {
174
+ const value = oneLine(row[column] ?? "");
175
+ line.push(column === columns - 1 ? value : value + " ".repeat(widths[column] - charLength(value)));
176
+ }
177
+ if (!emitAt(out, line.join(INDENT).trimEnd(), depth)) return;
178
+ }
179
+ }
180
+
181
+ /** Records as a table: the union of their keys, so one record's extra key shows. */
182
+ function renderTable(rows: readonly Record<string, unknown>[], depth: number, out: Output): void {
183
+ const header: string[] = [];
184
+ for (const row of rows) {
185
+ for (const key of Object.keys(row)) if (!header.includes(key)) header.push(key);
186
+ }
187
+ const cells = rows.map((row) => header.map((key) => cellText(row[key])));
188
+ renderGrid([header, ...cells], depth, out);
189
+ }
190
+
191
+ /**
192
+ * The one-line form of a value, when it has one: a scalar, an empty container, or
193
+ * a short list. `undefined` means it needs its own block.
194
+ */
195
+ function inlineValue(value: unknown): string | undefined {
196
+ if (isScalar(value)) return scalarText(value);
197
+ if (Array.isArray(value)) {
198
+ if (value.length === 0) return "[]";
199
+ if (!isScalarList(value)) return undefined;
200
+ const text = `[${value.map(scalarText).join(", ")}]`;
201
+ return charLength(text) <= INLINE_LIST_CHARS ? text : undefined;
202
+ }
203
+ // Only an empty object has a one-line form; anything else needs a block.
204
+ const record = asRecord(value);
205
+ return record !== undefined && Object.keys(record).length === 0 ? "{}" : undefined;
206
+ }
207
+
208
+ function renderValue(value: unknown, depth: number, out: Output): void {
209
+ if (isScalar(value)) {
210
+ emitAt(out, scalarText(value), depth);
211
+ return;
212
+ }
213
+ if (Array.isArray(value)) {
214
+ renderArray(value, depth, out);
215
+ return;
216
+ }
217
+ const record = asRecord(value);
218
+ if (record) {
219
+ renderObject(record, depth, out);
220
+ }
221
+ }
222
+
223
+ function renderObject(value: Record<string, unknown>, depth: number, out: Output): void {
224
+ const keys = Object.keys(value);
225
+ if (keys.length === 0) {
226
+ emitAt(out, "{}", depth);
227
+ return;
228
+ }
229
+ for (const key of keys) {
230
+ const child = value[key];
231
+ const inline = inlineValue(child);
232
+ if (inline !== undefined) {
233
+ if (!emitAt(out, `${key}: ${inline}`, depth)) return;
234
+ continue;
235
+ }
236
+ if (!emitAt(out, key, depth)) return;
237
+ renderValue(child, depth + 1, out);
238
+ if (out.cut) return;
239
+ }
240
+ }
241
+
242
+ function renderArray(value: readonly unknown[], depth: number, out: Output): void {
243
+ if (value.length === 0) {
244
+ emitAt(out, "[]", depth);
245
+ return;
246
+ }
247
+ if (isRecordList(value)) {
248
+ renderTable(value, depth, out);
249
+ return;
250
+ }
251
+ if (isScalarList(value)) {
252
+ for (const item of value) if (!emitAt(out, `- ${scalarText(item)}`, depth)) return;
253
+ return;
254
+ }
255
+ // Mixed shapes keep their order and their nesting rather than pretending to be
256
+ // a table they are not.
257
+ for (const item of value) {
258
+ if (!emitAt(out, "-", depth)) return;
259
+ renderValue(item, depth + 1, out);
260
+ if (out.cut) return;
261
+ }
262
+ }
263
+
264
+ function renderJson(body: string): string | undefined {
265
+ let value: unknown;
266
+ try {
267
+ value = JSON.parse(body);
268
+ } catch {
269
+ return undefined;
270
+ }
271
+ const out = createOutput();
272
+ renderValue(value, 0, out);
273
+ return finish(out, body);
274
+ }
275
+
276
+ /**
277
+ * RFC 4180: commas between fields, a quoted field may hold commas and line breaks,
278
+ * and a doubled quote inside one is a literal quote. An unterminated quote means
279
+ * this is not CSV we can read, so the caller keeps the raw body.
280
+ */
281
+ function parseCsv(body: string): string[][] | undefined {
282
+ const rows: string[][] = [];
283
+ let row: string[] = [];
284
+ let field = "";
285
+ let quoted = false;
286
+
287
+ for (let i = 0; i < body.length; i++) {
288
+ const char = body[i];
289
+ if (quoted) {
290
+ if (char !== '"') field += char;
291
+ else if (body[i + 1] === '"') {
292
+ field += '"';
293
+ i++;
294
+ } else quoted = false;
295
+ continue;
296
+ }
297
+ if (char === '"') quoted = true;
298
+ else if (char === ",") {
299
+ row.push(field);
300
+ field = "";
301
+ } else if (char === "\n" || char === "\r") {
302
+ if (char === "\r" && body[i + 1] === "\n") i++;
303
+ row.push(field);
304
+ rows.push(row);
305
+ row = [];
306
+ field = "";
307
+ } else field += char;
308
+ }
309
+ if (quoted) return undefined;
310
+ if (field !== "" || row.length > 0) {
311
+ row.push(field);
312
+ rows.push(row);
313
+ }
314
+
315
+ const filled = rows.filter((cells) => cells.some((cell) => cell.trim() !== ""));
316
+ const columns = filled.reduce((widest, cells) => Math.max(widest, cells.length), 0);
317
+ return filled.length >= 2 && columns >= 2 ? filled : undefined;
318
+ }
319
+
320
+ function renderCsv(body: string): string | undefined {
321
+ const rows = parseCsv(body);
322
+ if (!rows) return undefined;
323
+ const out = createOutput();
324
+ renderGrid(rows, 0, out);
325
+ return finish(out, body);
326
+ }
327
+
328
+ /** One element: its attributes, its children, and the text directly inside it. */
329
+ interface XmlNode {
330
+ name: string;
331
+ attributes: Array<readonly [string, string]>;
332
+ children: XmlNode[];
333
+ text: string;
334
+ }
335
+
336
+ /** Attributes of one tag; a bare attribute carries no value to show, so it is left out. */
337
+ function parseAttributes(source: string): Array<readonly [string, string]> {
338
+ const attributes: Array<readonly [string, string]> = [];
339
+ for (const match of source.matchAll(/([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
340
+ attributes.push([match[1], decodeEntities(match[3] ?? match[4] ?? "")]);
341
+ }
342
+ return attributes;
343
+ }
344
+
345
+ /** Index just past `end` where it closes a construct opened at `from`, or -1. */
346
+ function after(body: string, from: number, end: string): number {
347
+ const at = body.indexOf(end, from + 1);
348
+ return at < 0 ? -1 : at + end.length;
349
+ }
350
+
351
+ /**
352
+ * The smallest XML reader that answers "what is the tree": tags, attributes, text,
353
+ * comments, CDATA, and the declaration. It returns `undefined` on anything it
354
+ * cannot balance — two roots, a stray close, an unterminated quote or comment — so
355
+ * a body that is not really XML keeps its raw form.
356
+ */
357
+ function parseXml(body: string): XmlNode | undefined {
358
+ const stack: XmlNode[] = [];
359
+ let root: XmlNode | undefined;
360
+ let index = 0;
361
+
362
+ const appendText = (text: string): void => {
363
+ const node = stack[stack.length - 1];
364
+ if (node) node.text += text;
365
+ };
366
+
367
+ while (index < body.length) {
368
+ const open = body.indexOf("<", index);
369
+ if (open < 0) {
370
+ appendText(decodeEntities(body.slice(index)));
371
+ break;
372
+ }
373
+ if (open > index) appendText(decodeEntities(body.slice(index, open)));
374
+
375
+ if (body.startsWith("<!--", open)) {
376
+ const next = after(body, open, "-->");
377
+ if (next < 0) return undefined;
378
+ index = next;
379
+ continue;
380
+ }
381
+ if (body.startsWith("<![CDATA[", open)) {
382
+ const at = body.indexOf("]]>", open);
383
+ if (at < 0) return undefined;
384
+ appendText(body.slice(open + "<![CDATA[".length, at));
385
+ index = at + "]]>".length;
386
+ continue;
387
+ }
388
+ if (body.startsWith("<?", open) || body.startsWith("<!")) {
389
+ const next = after(body, open, body.startsWith("<?", open) ? "?>" : ">");
390
+ if (next < 0) return undefined;
391
+ index = next;
392
+ continue;
393
+ }
394
+
395
+ const close = body.indexOf(">", open + 1);
396
+ if (close < 0) return undefined;
397
+ const tag = body.slice(open + 1, close).trim();
398
+ if (tag.startsWith("/")) {
399
+ const node = stack.pop();
400
+ if (!node || node.name !== tag.slice(1).trim()) return undefined;
401
+ index = close + 1;
402
+ continue;
403
+ }
404
+
405
+ const selfClosing = tag.endsWith("/");
406
+ const inner = selfClosing ? tag.slice(0, -1).trim() : tag;
407
+ const name = /^[^\s/>]+/.exec(inner)?.[0];
408
+ if (!name) return undefined;
409
+ const node: XmlNode = { name, attributes: parseAttributes(inner.slice(name.length)), children: [], text: "" };
410
+ const parent = stack[stack.length - 1];
411
+ if (parent) parent.children.push(node);
412
+ else if (root) return undefined;
413
+ else root = node;
414
+ if (!selfClosing) stack.push(node);
415
+ index = close + 1;
416
+ }
417
+
418
+ if (stack.length > 0 || !root) return undefined;
419
+ return root;
420
+ }
421
+
422
+ /**
423
+ * One element as an indented block: a leaf with only text reads `name: text`,
424
+ * anything with attributes or children gets a line of its own and indented detail.
425
+ */
426
+ function renderXmlNode(node: XmlNode, depth: number, out: Output): void {
427
+ const text = collapse(node.text);
428
+ if (node.children.length === 0 && node.attributes.length === 0) {
429
+ emitAt(out, text ? `${node.name}: ${text}` : node.name, depth);
430
+ return;
431
+ }
432
+ if (!emitAt(out, node.name, depth)) return;
433
+ for (const [name, value] of node.attributes) if (!emitAt(out, `@${name}: ${value}`, depth + 1)) return;
434
+ for (const child of node.children) {
435
+ renderXmlNode(child, depth + 1, out);
436
+ if (out.cut) return;
437
+ }
438
+ if (text) emitAt(out, text, depth + 1);
439
+ }
440
+
441
+ function renderXml(body: string): string | undefined {
442
+ const root = parseXml(body);
443
+ if (!root) return undefined;
444
+ const out = createOutput();
445
+ renderXmlNode(root, 0, out);
446
+ return finish(out, body);
447
+ }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { formatTokens } from "../../../lib/format.ts";
14
+ import { WEB_FETCH_TOOL } from "../../../lib/tool-names.ts";
14
15
  import { reuseText } from "../../../lib/tui/chrome.ts";
15
16
  import {
16
17
  errorLine,
@@ -34,7 +35,7 @@ import { resolveRequest, WebFetchOutput, WebFetchParams, type FetchResponse, typ
34
35
  import type { FetchRequest } from "./types.ts";
35
36
  import { loadFetchConfig } from "../config.ts";
36
37
 
37
- export const TOOL_NAME = "web_fetch";
38
+ export const TOOL_NAME = WEB_FETCH_TOOL;
38
39
 
39
40
  /** The host shown while a fetch is in flight, or `page` for an unparseable URL. */
40
41
  function hostOf(raw: string | undefined): string {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { WEB_SEARCH_PROVIDER_CHANNEL } from "../../../lib/events.ts";
12
+ import { WEB_SEARCH_TOOL } from "../../../lib/tool-names.ts";
12
13
  import { loadSearchConfig } from "../config.ts";
13
14
  import { oneLine } from "../transcript.ts";
14
15
  import { reuseText } from "../../../lib/tui/chrome.ts";
@@ -27,7 +28,7 @@ import { formatResults } from "./format.ts";
27
28
  import { createThrottle, runSearch, type SearchDeps } from "./search.ts";
28
29
  import { resolveRequest, WebSearchOutput, WebSearchParams, type SearchResponse, type WebSearchArgs } from "./schema.ts";
29
30
 
30
- export const TOOL_NAME = "web_search";
31
+ export const TOOL_NAME = WEB_SEARCH_TOOL;
31
32
 
32
33
  export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): void {
33
34
  // One throttle per extension instance, so repeated searches stay polite