@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.
- package/CHANGELOG.md +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- 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: `&lt;` is `<`. 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
|
-
/**
|
|
94
|
-
async function
|
|
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 ?
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/spoke/deliver.ts
CHANGED
|
@@ -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).
|