@huynle/hub 0.8.6 → 0.9.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 (184) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
  8. package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
  9. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
  10. package/packages/cli/src/cli.ts +23 -10
  11. package/packages/cli/src/http.ts +1 -0
  12. package/packages/cli/src/launches.ts +5 -1
  13. package/packages/cli/src/mcp.ts +28 -8
  14. package/packages/cli/src/messaging.ts +14 -0
  15. package/packages/cli/src/team-intake-mcp.ts +26 -2
  16. package/packages/codex/package.json +1 -1
  17. package/packages/node/package.json +1 -1
  18. package/packages/preset/package.json +1 -1
  19. package/packages/protocol/package.json +1 -1
  20. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  21. package/packages/protocol/src/artifacts.ts +43 -0
  22. package/packages/protocol/src/credentials.ts +38 -0
  23. package/packages/protocol/src/index.ts +56 -5
  24. package/packages/protocol/src/launch.ts +7 -2
  25. package/packages/protocol/src/markdown.ts +753 -0
  26. package/packages/protocol/src/media-allowlist.ts +240 -0
  27. package/packages/protocol/src/media.ts +120 -0
  28. package/packages/protocol/src/preview-bundle.ts +221 -0
  29. package/packages/protocol/src/voice-recording.ts +8 -0
  30. package/packages/runtime-openshell/package.json +1 -1
  31. package/packages/runtime-sdk/package.json +1 -1
  32. package/packages/sdk/package.json +1 -1
  33. package/packages/sdk/src/client.ts +17 -3
  34. package/packages/sdk/src/errors.ts +1 -0
  35. package/packages/sdk/src/team-intake.ts +8 -0
  36. package/packages/sdk/src/types.ts +16 -2
  37. package/packages/spoke-sdk/package.json +1 -1
  38. package/src/central/agent/delivery.ts +35 -6
  39. package/src/central/agent/errors.ts +2 -0
  40. package/src/central/agent/events.ts +11 -2
  41. package/src/central/agent/launch-engine.ts +65 -4
  42. package/src/central/agent/launch-routes.ts +8 -0
  43. package/src/central/agent/launch-spec.ts +18 -1
  44. package/src/central/agent/launch-store.ts +2 -2
  45. package/src/central/agent/message-routes.ts +49 -11
  46. package/src/central/agent/messages.ts +17 -4
  47. package/src/central/agent/not-after.ts +28 -0
  48. package/src/central/agent/placement.ts +36 -4
  49. package/src/central/agent/routes.ts +3 -1
  50. package/src/central/agent/webhook-routes.ts +21 -9
  51. package/src/central/agent/webhooks.ts +27 -9
  52. package/src/central/artifacts/host.ts +3 -1
  53. package/src/central/artifacts/maintenance.ts +2 -1
  54. package/src/central/artifacts/preview-bundle.ts +81 -0
  55. package/src/central/artifacts/previews.ts +94 -0
  56. package/src/central/artifacts/routes.ts +12 -1
  57. package/src/central/artifacts/store.ts +198 -8
  58. package/src/central/config.ts +34 -0
  59. package/src/central/fetch-budget.ts +59 -0
  60. package/src/central/fetcher.ts +372 -0
  61. package/src/central/http.ts +277 -22
  62. package/src/central/media-remote.ts +248 -0
  63. package/src/central/media-timeline.ts +317 -0
  64. package/src/central/media-unfurl.ts +119 -0
  65. package/src/central/profiles/content.ts +3 -35
  66. package/src/central/recording/routes.ts +2 -2
  67. package/src/central/recording/schema.ts +3 -1
  68. package/src/central/registry.ts +449 -21
  69. package/src/central/spoke-ws.ts +17 -3
  70. package/src/central/store.ts +21 -0
  71. package/src/central/teams/intake-routes.ts +2 -1
  72. package/src/central/teams/intake.ts +200 -15
  73. package/src/central/unfurl-parse.ts +313 -0
  74. package/src/index.ts +68 -8
  75. package/src/speakable.ts +33 -1
  76. package/src/spoke/conversation.ts +7 -4
  77. package/src/spoke/deliver.ts +19 -0
  78. package/src/spoke/media-bytes.ts +171 -0
  79. package/src/spoke/media-policy.ts +394 -0
  80. package/src/spoke/media.ts +189 -0
  81. package/src/spoke/own-server.ts +30 -0
  82. package/src/spoke/preview-bundle.ts +538 -0
  83. package/src/spoke/spoke.ts +387 -17
  84. package/src/spoke/timeline.ts +59 -5
  85. package/src/spoke/voice-front.ts +335 -0
  86. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  87. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  88. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  89. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  90. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  91. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  92. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  93. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  94. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  95. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  96. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  97. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  98. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  99. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  100. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  101. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  102. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  103. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  104. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  105. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  106. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  107. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  108. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  109. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  110. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  111. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  112. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  113. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  114. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  115. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  116. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  117. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  118. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  119. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  120. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  121. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  122. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  123. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  124. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  125. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  126. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  127. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  128. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  129. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  130. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  131. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  132. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  133. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  134. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  135. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  136. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  137. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  138. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  139. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  140. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  141. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  142. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  143. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  144. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  145. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  146. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  147. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  148. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  149. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  150. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  151. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  152. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  153. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  154. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  155. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  156. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  157. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  158. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  159. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  160. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  161. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  162. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  163. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  164. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  165. package/web/dist/assets/source-BWlvaODa.js +1 -0
  166. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  167. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  168. package/web/dist/index.html +8 -6
  169. package/web/src/voice/turn/LICENSE +28 -0
  170. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  171. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  172. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  173. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  174. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  175. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  176. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  177. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  178. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  179. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  180. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  181. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  182. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  183. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  184. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -0,0 +1,313 @@
1
+ // The link-preview parser of GET /api/media/unfurl (design §4, task RF3). The hub reads at most the first 512 KiB of
2
+ // a page and keeps only plain text: the title (og:title > twitter:title > <title>), the description (og:description
3
+ // > twitter:description > meta description), the site name (og:site_name) and one preview image URL (og:image >
4
+ // twitter:image). No DOM: a small tolerant scanner, linear in the page's length also on hostile input (every search
5
+ // moves forward; an unterminated comment, quote, script or tag ends the scan), test/central-unfurl-perf.test.ts
6
+ // holds the budgets. Comments, CDATA, doctype/processing instructions and the raw text of script, style and similar
7
+ // elements are skipped, so markup inside them never counts. Text is entity-decoded once (no double decoding),
8
+ // control and bidi-override characters are dropped, whitespace is collapsed, and each field is capped
9
+ // (UNFURL_TEXT_LIMITS) with an ellipsis. The image is resolved against the page's final URL (after redirects) and kept
10
+ // only when the safe fetcher could request it (http/https, default port, no userinfo; see fetcher.checkURL).
11
+ import { checkURL } from "./fetcher"
12
+
13
+ export interface UnfurlMeta {
14
+ readonly title?: string
15
+ readonly description?: string
16
+ readonly siteName?: string
17
+ readonly image?: string
18
+ }
19
+
20
+ /** Characters (UTF-16 units, never splitting a pair) kept per field, the ellipsis included. */
21
+ export const UNFURL_TEXT_LIMITS = { title: 200, description: 300, siteName: 100 } as const
22
+
23
+ // ---------- charset ----------
24
+
25
+ const decoderFor = (label: string | undefined): TextDecoder | undefined => {
26
+ if (!label) return undefined
27
+ try {
28
+ return new TextDecoder(label.trim().toLowerCase())
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
33
+
34
+ /** The text of a page's bytes: a BOM, else the Content-Type charset, else a <meta> charset in the first 1024 bytes,
35
+ * else UTF-8. An unknown charset reads as UTF-8; a <meta> naming UTF-16 does too (the HTML rule). */
36
+ export function decodeHtml(bytes: Uint8Array, contentType: string | null): string {
37
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return new TextDecoder("utf-8").decode(bytes)
38
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder("utf-16be").decode(bytes)
39
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder("utf-16le").decode(bytes)
40
+ const header = /charset\s*=\s*["']?\s*([^\s;"']+)/i.exec(contentType ?? "")?.[1]
41
+ let decoder = decoderFor(header)
42
+ if (!decoder) {
43
+ let head = ""
44
+ for (let i = 0; i < Math.min(bytes.length, 1024); i++) head += String.fromCharCode(bytes[i]!)
45
+ const meta = /<meta[^>]*?charset\s*=\s*["']?\s*([A-Za-z0-9._:-]+)/i.exec(head)?.[1]
46
+ decoder = meta && /^utf-?16/i.test(meta) ? undefined : decoderFor(meta)
47
+ }
48
+ return (decoder ?? new TextDecoder("utf-8")).decode(bytes)
49
+ }
50
+
51
+ // ---------- text ----------
52
+
53
+ /** Latin-1 entity names, U+00A0 to U+00FF in order. */
54
+ const LATIN1 =
55
+ "nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml".split(" ")
56
+
57
+ const NAMED: Record<string, string> = {
58
+ ...Object.fromEntries(LATIN1.map((name, i) => [name, String.fromCharCode(0xa0 + i)])),
59
+ amp: "&",
60
+ lt: "<",
61
+ gt: ">",
62
+ quot: '"',
63
+ apos: "'",
64
+ hellip: "…",
65
+ mdash: "—",
66
+ ndash: "–",
67
+ lsquo: "‘",
68
+ rsquo: "’",
69
+ sbquo: "‚",
70
+ ldquo: "“",
71
+ rdquo: "”",
72
+ bdquo: "„",
73
+ lsaquo: "‹",
74
+ rsaquo: "›",
75
+ bull: "•",
76
+ trade: "™",
77
+ euro: "€",
78
+ dagger: "†",
79
+ Dagger: "‡",
80
+ permil: "‰",
81
+ prime: "′",
82
+ Prime: "″",
83
+ larr: "←",
84
+ rarr: "→",
85
+ uarr: "↑",
86
+ darr: "↓",
87
+ harr: "↔",
88
+ ensp: " ",
89
+ emsp: " ",
90
+ thinsp: " ",
91
+ zwnj: "\u200c",
92
+ zwj: "\u200d",
93
+ OElig: "Œ",
94
+ oelig: "œ",
95
+ Scaron: "Š",
96
+ scaron: "š",
97
+ Yuml: "Ÿ",
98
+ fnof: "ƒ",
99
+ circ: "ˆ",
100
+ tilde: "˜",
101
+ }
102
+
103
+ const ENTITY = /&(?:#(\d{1,8});?|#[xX]([0-9a-fA-F]{1,8});?|([A-Za-z][A-Za-z0-9]{1,31});)/g
104
+
105
+ const codePoint = (n: number) => (n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? "\uFFFD" : String.fromCodePoint(n))
106
+
107
+ /** Character references decoded once: `&amp;lt;` is `&lt;`. Unknown names stay as written. */
108
+ function decodeEntities(text: string): string {
109
+ if (!text.includes("&")) return text
110
+ return text.replace(ENTITY, (whole, dec: string | undefined, hex: string | undefined, name: string | undefined) => {
111
+ if (dec !== undefined) return codePoint(Number(dec))
112
+ if (hex !== undefined) return codePoint(parseInt(hex, 16))
113
+ return NAMED[name!] ?? whole
114
+ })
115
+ }
116
+
117
+ /** C0/C1 controls (whitespace ones become spaces below) and the bidi embedding, override and isolate characters. */
118
+ const CONTROL = /[\u0000-\u0008\u000b\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
119
+ const SPACE = /\s+/g
120
+
121
+ /** At most `max` UTF-16 units, an ellipsis ending a cut one, never half a surrogate pair. */
122
+ function cap(text: string, max: number): string {
123
+ if (text.length <= max) return text
124
+ let cut = max - 1
125
+ const last = text.charCodeAt(cut - 1)
126
+ if (last >= 0xd800 && last <= 0xdbff) cut--
127
+ return `${text.slice(0, cut).trimEnd()}…`
128
+ }
129
+
130
+ /** Plain text of a field: entities decoded, controls dropped, whitespace collapsed, capped; undefined when empty. */
131
+ function clean(raw: string, max: number): string | undefined {
132
+ const text = decodeEntities(raw).replace(CONTROL, "").replace(SPACE, " ").trim()
133
+ return text ? cap(text, max) : undefined
134
+ }
135
+
136
+ /** The image URL a preview may show: resolved against the page, fetchable by the safe fetcher; else undefined. */
137
+ function imageURL(raw: string, base: URL): string | undefined {
138
+ const value = decodeEntities(raw).replace(CONTROL, "").trim()
139
+ if (!value) return undefined
140
+ try {
141
+ return checkURL(new URL(value, base)).href
142
+ } catch {
143
+ return undefined
144
+ }
145
+ }
146
+
147
+ // ---------- scanner ----------
148
+
149
+ /** Elements whose content is raw text up to their end tag (markup inside never counts). */
150
+ const RAW_TEXT = new Set(["script", "style", "textarea", "xmp", "iframe", "noembed", "noframes"])
151
+
152
+ const isSpace = (c: number) => c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0c || c === 0x0d
153
+ const isLetter = (c: number) => (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a)
154
+ const isNameChar = (c: number) => isLetter(c) || (c >= 0x30 && c <= 0x39) || c === 0x2d || c === 0x3a || c === 0x5f
155
+
156
+ /** The first match of `</name` (any case, then a delimiter or the end) at or after `from`, or -1. */
157
+ const END_TAGS = new Map<string, RegExp>()
158
+ function endTag(html: string, name: string, from: number): number {
159
+ let re = END_TAGS.get(name)
160
+ if (!re) END_TAGS.set(name, (re = new RegExp(`</${name}(?=[\\s/>]|$)`, "gi")))
161
+ re.lastIndex = from
162
+ return re.exec(html)?.index ?? -1
163
+ }
164
+
165
+ /**
166
+ * A start tag's attributes from `at` (just after its name) up to its `>`: the index after it and, with `collect`, the
167
+ * attributes (lowercased names, the first of a name wins). Undefined when the tag never ends (an unterminated quote
168
+ * included): the scan stops there.
169
+ */
170
+ function scanAttributes(html: string, at: number, collect: boolean): { end: number; attrs?: Map<string, string> } | undefined {
171
+ const n = html.length
172
+ const attrs = collect ? new Map<string, string>() : undefined
173
+ let i = at
174
+ while (i < n) {
175
+ const c = html.charCodeAt(i)
176
+ if (c === 0x3e) return { end: i + 1, attrs }
177
+ if (isSpace(c) || c === 0x2f) {
178
+ i++
179
+ continue
180
+ }
181
+ const start = i
182
+ i++ // the first character is part of the name, even "=" (HTML's rule)
183
+ while (i < n) {
184
+ const d = html.charCodeAt(i)
185
+ if (isSpace(d) || d === 0x2f || d === 0x3e || d === 0x3d) break
186
+ i++
187
+ }
188
+ const name = collect ? html.slice(start, i).toLowerCase() : ""
189
+ while (i < n && isSpace(html.charCodeAt(i))) i++
190
+ let value = ""
191
+ if (html.charCodeAt(i) === 0x3d) {
192
+ i++
193
+ while (i < n && isSpace(html.charCodeAt(i))) i++
194
+ const q = html.charCodeAt(i)
195
+ if (q === 0x22 || q === 0x27) {
196
+ const close = html.indexOf(q === 0x22 ? '"' : "'", i + 1)
197
+ if (close < 0) return undefined
198
+ value = collect ? html.slice(i + 1, close) : ""
199
+ i = close + 1
200
+ } else {
201
+ const from = i
202
+ while (i < n && !isSpace(html.charCodeAt(i)) && html.charCodeAt(i) !== 0x3e) i++
203
+ value = collect ? html.slice(from, i) : ""
204
+ }
205
+ }
206
+ if (attrs && !attrs.has(name)) attrs.set(name, value)
207
+ }
208
+ return undefined
209
+ }
210
+
211
+ type TextKey = "og:title" | "twitter:title" | "title" | "og:description" | "twitter:description" | "description" | "og:site_name"
212
+
213
+ const TEXT_KEYS: Record<string, { key: TextKey; max: number }> = {
214
+ "og:title": { key: "og:title", max: UNFURL_TEXT_LIMITS.title },
215
+ "twitter:title": { key: "twitter:title", max: UNFURL_TEXT_LIMITS.title },
216
+ "og:description": { key: "og:description", max: UNFURL_TEXT_LIMITS.description },
217
+ "twitter:description": { key: "twitter:description", max: UNFURL_TEXT_LIMITS.description },
218
+ description: { key: "description", max: UNFURL_TEXT_LIMITS.description },
219
+ "og:site_name": { key: "og:site_name", max: UNFURL_TEXT_LIMITS.siteName },
220
+ }
221
+
222
+ const IMAGE_KEYS: Record<string, "og" | "twitter"> = {
223
+ "og:image": "og",
224
+ "og:image:url": "og",
225
+ "og:image:secure_url": "og",
226
+ "twitter:image": "twitter",
227
+ "twitter:image:src": "twitter",
228
+ }
229
+
230
+ /** Image candidates (og:image, twitter:image, …) a page may offer before the rest are ignored. */
231
+ const MAX_IMAGE_TRIES = 64
232
+
233
+ /** The preview of a page's (decoded) HTML; `base` is the page's final URL. */
234
+ export function parseUnfurl(html: string, base: URL): UnfurlMeta {
235
+ const text = new Map<TextKey, string>()
236
+ const images: { og?: string; twitter?: string } = {}
237
+ // Unusable image candidates are skipped, but only up to a bound, so a page of them costs no more URL parsing.
238
+ let imageTries = 0
239
+ let seenTitle = false
240
+ const n = html.length
241
+
242
+ const meta = (attrs: Map<string, string>) => {
243
+ const content = attrs.get("content")
244
+ if (content === undefined) return
245
+ for (const which of ["property", "name"]) {
246
+ const key = attrs.get(which)?.trim().toLowerCase()
247
+ if (!key) continue
248
+ const field = TEXT_KEYS[key]
249
+ if (field && !text.has(field.key)) {
250
+ const value = clean(content, field.max)
251
+ if (value !== undefined) text.set(field.key, value)
252
+ }
253
+ const image = IMAGE_KEYS[key]
254
+ if (image && images[image] === undefined && imageTries < MAX_IMAGE_TRIES) {
255
+ imageTries++
256
+ const url = imageURL(content, base)
257
+ if (url !== undefined) images[image] = url
258
+ }
259
+ }
260
+ }
261
+
262
+ let i = 0
263
+ while (i < n) {
264
+ const lt = html.indexOf("<", i)
265
+ if (lt < 0) break
266
+ const next = html.charCodeAt(lt + 1)
267
+ if (next === 0x21 /* ! */) {
268
+ const [open, close] = html.startsWith("<!--", lt) ? ["<!--", "-->"] : html.startsWith("<![CDATA[", lt) ? ["<![CDATA[", "]]>"] : ["<!", ">"]
269
+ const end = html.indexOf(close, lt + open.length)
270
+ if (end < 0) break
271
+ i = end + close.length
272
+ continue
273
+ }
274
+ if (next === 0x3f /* ? */ || next === 0x2f /* / */) {
275
+ const end = html.indexOf(">", lt + 2)
276
+ if (end < 0) break
277
+ i = end + 1
278
+ continue
279
+ }
280
+ if (!isLetter(next)) {
281
+ i = lt + 1
282
+ continue
283
+ }
284
+ let j = lt + 2
285
+ while (j < n && isNameChar(html.charCodeAt(j))) j++
286
+ const name = html.slice(lt + 1, j).toLowerCase()
287
+ const tag = scanAttributes(html, j, name === "meta")
288
+ if (!tag) break
289
+ i = tag.end
290
+ if (name === "meta") meta(tag.attrs!)
291
+ else if (name === "title" || RAW_TEXT.has(name)) {
292
+ const close = endTag(html, name, i)
293
+ if (name === "title" && !seenTitle) {
294
+ seenTitle = true
295
+ const value = clean(html.slice(i, close < 0 ? n : close), UNFURL_TEXT_LIMITS.title)
296
+ if (value !== undefined) text.set("title", value)
297
+ }
298
+ if (close < 0) break
299
+ i = close
300
+ }
301
+ }
302
+
303
+ const title = text.get("og:title") ?? text.get("twitter:title") ?? text.get("title")
304
+ const description = text.get("og:description") ?? text.get("twitter:description") ?? text.get("description")
305
+ const siteName = text.get("og:site_name")
306
+ const image = images.og ?? images.twitter
307
+ return {
308
+ ...(title !== undefined ? { title } : {}),
309
+ ...(description !== undefined ? { description } : {}),
310
+ ...(siteName !== undefined ? { siteName } : {}),
311
+ ...(image !== undefined ? { image } : {}),
312
+ }
313
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { jobFromInfo, type JobInfo } from "./protocol"
12
12
  import { checkServer, parseServerInput } from "./setup"
13
13
  import { defineVoiceAgent, orchestratorParts, STOP_TOOL, STOP_TOOL_DESCRIPTION } from "./spoke/conversation"
14
14
  import { defineConciergeAgent } from "./spoke/concierge-agent"
15
+ import { defineVoiceFrontAgent, FRONT_SEND_DESCRIPTION, FRONT_SEND_TOOL, FRONT_STOP_DESCRIPTION, FRONT_STOP_TOOL, FRONT_TOOLS, VOICE_FRONT_AGENT } from "./spoke/voice-front"
15
16
 
16
17
  import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../packages/protocol/src/concierge.ts"
17
18
  import { defaultHubBinDir, envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
@@ -28,7 +29,7 @@ import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
28
29
  import { PromptReferences } from "./spoke/prompt-refs"
29
30
  import { PROFILE_TOOL, PROFILE_TOOL_DESCRIPTION, PROFILE_TOOL_INPUT } from "./spoke/profile-apply"
30
31
  import { listedNames, namesReader, replyPermission, serverNames, vcsReader, type PermissionLocation, type VcsReader } from "./spoke/locations"
31
- import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
32
+ import { fetchOwnMedia, foregroundEndpoint, formOutcome } from "./spoke/own-server"
32
33
  import { joinProcess, type Membership } from "./spoke/singleton"
33
34
  import { applySessionContext, sortTools } from "./spoke/session-context"
34
35
  import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
@@ -90,16 +91,22 @@ async function listRecent(kind: "service" | "standalone", scope: () => string[],
90
91
  /** This process can list sessions: the background service, or a foreground `opencode serve` that knows its password. */
91
92
  const canList = (kind: "service" | "standalone") => kind === "service" || foregroundEndpoint(process.argv, process.env) !== undefined
92
93
 
93
- /** A client for this process's own OpenCode server, when it can be reached (see src/spoke/own-server.ts). */
94
- async function ownClient(kind: "service" | "standalone") {
95
- const { OpenCode } = await import("@opencode/client")
94
+ /** URL and auth headers of this process's own OpenCode server, when it can be reached (see src/spoke/own-server.ts). */
95
+ async function ownEndpoint(kind: "service" | "standalone"): Promise<{ url: string; headers: Record<string, string> } | undefined> {
96
96
  if (kind === "service") {
97
97
  const { Service } = await import("@opencode/client/service")
98
98
  const endpoint = await Service.discover()
99
- return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} }) : undefined
99
+ return endpoint ? { url: endpoint.url, headers: Service.headers(endpoint) ?? {} } : undefined
100
100
  }
101
101
  const endpoint = foregroundEndpoint(process.argv, process.env)
102
- return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: { authorization: endpoint.authorization } }) : undefined
102
+ return endpoint ? { url: endpoint.url, headers: { authorization: endpoint.authorization } } : undefined
103
+ }
104
+
105
+ /** A client for this process's own OpenCode server, when it can be reached (see src/spoke/own-server.ts). */
106
+ async function ownClient(kind: "service" | "standalone") {
107
+ const { OpenCode } = await import("@opencode/client")
108
+ const endpoint = await ownEndpoint(kind)
109
+ return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: endpoint.headers }) : undefined
103
110
  }
104
111
 
105
112
  /** The own server's client, or an error saying it cannot be reached. */
@@ -288,8 +295,11 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
288
295
  revertStage: async (input: { sessionID: string; messageID: string; files: boolean }) => (await requireOwnClient(kind)).session.revert.stage(input),
289
296
  revertClear: async (sessionID: string) => (await requireOwnClient(kind)).session.revert.clear({ sessionID }),
290
297
  revertCommit: async (sessionID: string) => (await requireOwnClient(kind)).session.revert.commit({ sessionID }),
298
+ // (S4 M2) media:<sha256> tool output for timelineMedia; none when the server cannot be reached.
299
+ fetchMedia: async (hash: string) => fetchOwnMedia(await ownEndpoint(kind).catch(() => undefined), hash),
291
300
  }
292
301
  : {}),
302
+ removeSession: (sessionID) => session.remove({ sessionID }),
293
303
  createSession: async ({ directory, title }) =>
294
304
  unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
295
305
  ...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
@@ -379,6 +389,7 @@ export default definePlugin({
379
389
  ctx.agent.transform((editor) => {
380
390
  defineVoiceAgent(editor as any)
381
391
  defineConciergeAgent(editor as any)
392
+ defineVoiceFrontAgent(editor as any)
382
393
  }),
383
394
  ),
384
395
  attempt("register the context hook", () =>
@@ -391,7 +402,8 @@ export default definePlugin({
391
402
  if (!locked && spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
392
403
  const parts = locked ? [] : orchestratorParts({
393
404
  agent: event.agent,
394
- conversation: spoke?.inConversation(event.sessionID) ?? false,
405
+ // A session with a voice front is left alone: only today's agent swap makes it the orchestrator.
406
+ conversation: spoke?.orchestrates(event.sessionID) ?? false,
395
407
  subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
396
408
  workModel: spoke?.workModelFor(event.sessionID),
397
409
  taskBoard: spoke?.taskBoard(event.sessionID),
@@ -406,6 +418,17 @@ export default definePlugin({
406
418
  if (event.tools) spoke?.contexts.filterTools(event.sessionID, event.tools as Record<string, unknown>)
407
419
  // The concierge agent keeps exactly CONCIERGE_TOOLS even without a hub context (a spoke that restarted before
408
420
  // the hub re-sent it): OpenCode 2.2.3 offers `skill` whatever the agent's rules say.
421
+ // (voice front) The front hears about its main session each turn, and keeps only its two relay tools. No
422
+ // other session ever sees them; a voice-front agent session that is no linked front (a spoke that restarted,
423
+ // or a failed front) keeps no tools at all.
424
+ const frontNote = spoke?.frontContext(event.sessionID)
425
+ // After the conversation, as the Concierge's board (V-01: the prompt cache survives the note's changes).
426
+ if (frontNote) applySessionContext(event as any, [], [frontNote])
427
+ if (event.tools) {
428
+ const keep = (name: string) => (frontNote ? (FRONT_TOOLS as readonly string[]).includes(name) : event.agent !== VOICE_FRONT_AGENT && !(FRONT_TOOLS as readonly string[]).includes(name))
429
+ for (const name of Object.keys(event.tools)) if (!keep(name)) delete event.tools[name]
430
+ if (frontNote) sortTools(event.tools as Record<string, unknown>)
431
+ }
409
432
  if (event.tools && event.agent === CONCIERGE_AGENT) {
410
433
  for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
411
434
  sortTools(event.tools as Record<string, unknown>)
@@ -436,6 +459,43 @@ export default definePlugin({
436
459
  }),
437
460
  ),
438
461
  ),
462
+ // The voice front relays the user's request to its main session, or stops it (src/spoke/voice-front.ts).
463
+ attempt("register the voice front tools", () =>
464
+ (ctx as any).tool?.transform?.((editor: any) => {
465
+ editor.add({
466
+ name: FRONT_SEND_TOOL,
467
+ description: FRONT_SEND_DESCRIPTION,
468
+ input: {
469
+ type: "object",
470
+ properties: {
471
+ text: { type: "string", description: "The user's request for the main session, as a clear instruction." },
472
+ delivery: { type: "string", enum: ["auto", "steer", "queue"], description: 'auto (default), steer (join main\'s running turn) or queue (after main\'s current turn).' },
473
+ },
474
+ required: ["text"],
475
+ additionalProperties: false,
476
+ },
477
+ options: { codemode: false },
478
+ execute: async (value: unknown, context: { sessionID: string }) => {
479
+ const spoke = membership.active()
480
+ if (!spoke) return { content: "The hub is not running in this OpenCode process; nothing was sent." }
481
+ const { text, inboxID } = await spoke.frontSend(context.sessionID, (value ?? {}) as Record<string, unknown>)
482
+ return { content: text, ...(inboxID ? { metadata: { inboxID } } : {}) }
483
+ },
484
+ })
485
+ editor.add({
486
+ name: FRONT_STOP_TOOL,
487
+ description: FRONT_STOP_DESCRIPTION,
488
+ input: { type: "object", properties: {}, additionalProperties: false },
489
+ options: { codemode: false },
490
+ execute: async (_value: unknown, context: { sessionID: string }) => {
491
+ const spoke = membership.active()
492
+ if (!spoke) return { content: "The hub is not running in this OpenCode process; nothing was stopped." }
493
+ const { text, stopped } = await spoke.frontStop(context.sessionID)
494
+ return { content: text, metadata: { stopped } }
495
+ },
496
+ })
497
+ }),
498
+ ),
439
499
  // Agents message and ask other sessions as themselves (the session's own hub token; src/spoke/hub-tool.ts).
440
500
  attempt("register the hub tool", () =>
441
501
  (ctx as any).tool?.transform?.((editor: any) =>
@@ -504,7 +564,7 @@ export default definePlugin({
504
564
  openLink: (value, rpc) =>
505
565
  run(rpc, (s) => s.openLink(input(value).sessionID ? String(input(value).sessionID) : undefined, input(value).voice === true)),
506
566
  conversation: (value, rpc) =>
507
- run(rpc, (s) => s.setConversation(String(input(value).sessionID ?? ""), Boolean(input(value).on))),
567
+ run(rpc, (s) => s.setConversation(String(input(value).sessionID ?? ""), Boolean(input(value).on), input(value).front === true)),
508
568
  formActions: (value, rpc) =>
509
569
  run(rpc, (s) => {
510
570
  const { waitMs = 0 } = input(value)
package/src/speakable.ts CHANGED
@@ -1,6 +1,38 @@
1
+ const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/
2
+ const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/
3
+ /** The console's rule (web/src/lib/mermaid.ts isMermaidLang): exactly mermaid or mmd, any case. */
4
+ const isDiagramLang = (lang: string) => /^(?:mermaid|mmd)$/i.test(lang)
5
+
6
+ /**
7
+ * Mermaid fences (``` or ~~~, closed or still open) become the line "a diagram": the console draws them, so their
8
+ * source is never read aloud. Other fences are left as they are. Kept in step with web/src/lib/mermaid.ts
9
+ * speakDiagrams (the browser's copy for Voice).
10
+ */
11
+ export function speakDiagrams(text: string): string {
12
+ const out: string[] = []
13
+ let fence: { marker: string; diagram: boolean } | undefined
14
+ for (const line of text.split("\n")) {
15
+ if (fence) {
16
+ const close = FENCE_CLOSE.exec(line)
17
+ const closes = !!close && close[1]![0] === fence.marker[0] && close[1]!.length >= fence.marker.length
18
+ if (!fence.diagram) out.push(line)
19
+ if (closes) fence = undefined
20
+ continue
21
+ }
22
+ const open = FENCE_OPEN.exec(line)
23
+ if (open) {
24
+ fence = { marker: open[1]!, diagram: isDiagramLang(open[2]!) }
25
+ out.push(fence.diagram ? "a diagram" : line)
26
+ continue
27
+ }
28
+ out.push(line)
29
+ }
30
+ return out.join("\n")
31
+ }
32
+
1
33
  /** Turns an agent reply into text that sounds natural when read aloud, capped at `max` characters. */
2
34
  export function speakable(text: string, max = Infinity): string {
3
- const lines = text
35
+ const lines = speakDiagrams(text)
4
36
  .replace(/```[\s\S]*?```/g, "\n(code omitted)\n")
5
37
  .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
6
38
  .replace(/https?:\/\/\S+/g, "a link")
@@ -11,14 +11,17 @@ export { PROMPT_SOURCE, isHubPrompt } from "../../packages/spoke-sdk/src/prompt-
11
11
  export const VOICE_AGENT_DESCRIPTION =
12
12
  "Voice front-end: short spoken replies; delegates real work to background subagents. Used by Hub conversation mode."
13
13
 
14
- export const ORCHESTRATOR_PROMPT = `You are a voice agent: the voice front-end of an OpenCode session. The user is talking to you hands-free from their phone. Everything they send is a live speech-to-text transcript, and everything you write is read aloud to them.
15
-
16
- Understanding speech:
14
+ /** How to read a live speech transcript (shared by the voice agent and the voice front). */
15
+ export const SPEECH_RULES = `Understanding speech:
17
16
  - Transcripts contain recognition errors, filler words, false starts and half sentences. Infer the intent; don't comment on the wording.
18
17
  - The user thinks out loud. Their speech is cut at pauses, so one thought can arrive as several consecutive messages, and a message can end mid-sentence. Read your recent messages from the user together as one evolving request.
19
18
  - The user corrects themselves ("no wait", "actually", "I mean", "scratch that", or simply restating it differently). The latest version wins. If you already started work based on the earlier version, redirect or stop it.
20
19
  - If a message is only a fragment and the intent isn't clear yet, don't start any work: reply with at most a few words, like "Go on." or "I'm listening.", and wait for the rest.
21
- - If something is genuinely ambiguous after the user has finished, ask one short question instead of guessing.
20
+ - If something is genuinely ambiguous after the user has finished, ask one short question instead of guessing.`
21
+
22
+ export const ORCHESTRATOR_PROMPT = `You are a voice agent: the voice front-end of an OpenCode session. The user is talking to you hands-free from their phone. Everything they send is a live speech-to-text transcript, and everything you write is read aloud to them.
23
+
24
+ ${SPEECH_RULES}
22
25
 
23
26
  How to reply:
24
27
  - Speak in one or two short sentences of plain speech. No markdown, lists, tables, code, file paths or URLs unless the user asks for them.
@@ -7,7 +7,9 @@
7
7
  // "prompt") gets its provenance envelope here, from the hub's meta, and is the hub's in the inbox (never a human turn).
8
8
  import { createHash } from "node:crypto"
9
9
  import type { DeliverMeta, Delivered, DeliveryMode } from "../protocol"
10
+ import { sanitizeLabel } from "../../packages/sdk/src/index.ts"
10
11
  import { PROMPT_SOURCE } from "./conversation"
12
+ import { messageBody } from "./timeline"
11
13
 
12
14
  export interface DeliveryPlan {
13
15
  /** How OpenCode admits it into a busy session: after the running turn (queue) or into it (steer). */
@@ -75,6 +77,23 @@ export function deliverMetadata(req: { readonly messageID: string; readonly meta
75
77
  return { source: PROMPT_SOURCE, messageID: req.messageID, kind, from, to, ...(replyTo !== undefined ? { replyTo } : {}), ...(grant !== undefined ? { grant } : {}) }
76
78
  }
77
79
 
80
+ /** The longest notice OpenCode shows for a delivered message, in characters. */
81
+ const DESCRIPTION_MAX = 160
82
+
83
+ /**
84
+ * The description of a delivered message: the one-line notice the OpenCode TUI shows for a synthetic message (it
85
+ * never shows the text, so without it a hub message is invisible there; the model never sees it). The kind, the
86
+ * sender and the start of the body: `hub · ask from @api: Which release order?`.
87
+ */
88
+ export function deliverDescription(req: { readonly messageID: string; readonly text: string; readonly meta: DeliverMeta }): string {
89
+ const { kind } = req.meta
90
+ const body = kind === "prompt" ? req.text : kind === "primer" ? req.text.replace(/^\[hub\]\s*/i, "") : messageBody(req.text, req.messageID)
91
+ const heading = kind === "primer" ? "hub · primer" : `hub · ${kind} from ${sanitizeLabel(req.meta.from)}`
92
+ const snippet = sanitizeLabel(body, Number.MAX_SAFE_INTEGER)
93
+ const chars = Array.from(snippet ? `${heading}: ${snippet}` : heading)
94
+ return chars.length <= DESCRIPTION_MAX ? chars.join("") : `${chars.slice(0, DESCRIPTION_MAX - 1).join("").trimEnd()}…`
95
+ }
96
+
78
97
  /**
79
98
  * The refusal of any hub message to a session with a staged revert (admitting it would commit the revert): the hub
80
99
  * keeps it pending and sends it again once the revert is restored or committed (decision 3q94ez94).