@huynle/hub 0.8.7 → 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 (181) hide show
  1. package/CHANGELOG.md +129 -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/cli.ts +23 -10
  8. package/packages/cli/src/http.ts +1 -0
  9. package/packages/cli/src/launches.ts +5 -1
  10. package/packages/cli/src/mcp.ts +28 -8
  11. package/packages/cli/src/messaging.ts +14 -0
  12. package/packages/cli/src/team-intake-mcp.ts +26 -2
  13. package/packages/codex/package.json +1 -1
  14. package/packages/node/package.json +1 -1
  15. package/packages/preset/package.json +1 -1
  16. package/packages/protocol/package.json +1 -1
  17. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  18. package/packages/protocol/src/artifacts.ts +43 -0
  19. package/packages/protocol/src/credentials.ts +38 -0
  20. package/packages/protocol/src/index.ts +56 -5
  21. package/packages/protocol/src/launch.ts +7 -2
  22. package/packages/protocol/src/markdown.ts +753 -0
  23. package/packages/protocol/src/media-allowlist.ts +240 -0
  24. package/packages/protocol/src/media.ts +120 -0
  25. package/packages/protocol/src/preview-bundle.ts +221 -0
  26. package/packages/protocol/src/voice-recording.ts +8 -0
  27. package/packages/runtime-openshell/package.json +1 -1
  28. package/packages/runtime-sdk/package.json +1 -1
  29. package/packages/sdk/package.json +1 -1
  30. package/packages/sdk/src/client.ts +17 -3
  31. package/packages/sdk/src/errors.ts +1 -0
  32. package/packages/sdk/src/team-intake.ts +8 -0
  33. package/packages/sdk/src/types.ts +16 -2
  34. package/packages/spoke-sdk/package.json +1 -1
  35. package/src/central/agent/delivery.ts +35 -6
  36. package/src/central/agent/errors.ts +2 -0
  37. package/src/central/agent/events.ts +11 -2
  38. package/src/central/agent/launch-engine.ts +65 -4
  39. package/src/central/agent/launch-routes.ts +8 -0
  40. package/src/central/agent/launch-spec.ts +18 -1
  41. package/src/central/agent/launch-store.ts +2 -2
  42. package/src/central/agent/message-routes.ts +49 -11
  43. package/src/central/agent/messages.ts +17 -4
  44. package/src/central/agent/not-after.ts +28 -0
  45. package/src/central/agent/placement.ts +36 -4
  46. package/src/central/agent/routes.ts +3 -1
  47. package/src/central/agent/webhook-routes.ts +21 -9
  48. package/src/central/agent/webhooks.ts +27 -9
  49. package/src/central/artifacts/host.ts +3 -1
  50. package/src/central/artifacts/maintenance.ts +2 -1
  51. package/src/central/artifacts/preview-bundle.ts +81 -0
  52. package/src/central/artifacts/previews.ts +94 -0
  53. package/src/central/artifacts/routes.ts +12 -1
  54. package/src/central/artifacts/store.ts +198 -8
  55. package/src/central/config.ts +34 -0
  56. package/src/central/fetch-budget.ts +59 -0
  57. package/src/central/fetcher.ts +372 -0
  58. package/src/central/http.ts +277 -22
  59. package/src/central/media-remote.ts +248 -0
  60. package/src/central/media-timeline.ts +317 -0
  61. package/src/central/media-unfurl.ts +119 -0
  62. package/src/central/profiles/content.ts +3 -35
  63. package/src/central/recording/routes.ts +2 -2
  64. package/src/central/recording/schema.ts +3 -1
  65. package/src/central/registry.ts +449 -21
  66. package/src/central/spoke-ws.ts +17 -3
  67. package/src/central/store.ts +21 -0
  68. package/src/central/teams/intake-routes.ts +2 -1
  69. package/src/central/teams/intake.ts +200 -15
  70. package/src/central/unfurl-parse.ts +313 -0
  71. package/src/index.ts +68 -8
  72. package/src/speakable.ts +33 -1
  73. package/src/spoke/conversation.ts +7 -4
  74. package/src/spoke/deliver.ts +19 -0
  75. package/src/spoke/media-bytes.ts +171 -0
  76. package/src/spoke/media-policy.ts +394 -0
  77. package/src/spoke/media.ts +189 -0
  78. package/src/spoke/own-server.ts +30 -0
  79. package/src/spoke/preview-bundle.ts +538 -0
  80. package/src/spoke/spoke.ts +387 -17
  81. package/src/spoke/timeline.ts +59 -5
  82. package/src/spoke/voice-front.ts +335 -0
  83. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  84. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  85. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  86. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  87. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  88. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  89. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  90. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  91. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  92. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  93. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  94. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  95. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  96. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  97. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  98. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  99. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  100. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  101. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  102. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  103. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  104. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  105. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  106. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  107. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  108. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  109. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  110. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  111. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  112. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  113. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  114. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  115. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  116. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  117. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  118. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  119. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  120. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  121. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  122. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  123. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  124. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  125. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  126. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  127. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  128. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  129. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  130. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  131. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  132. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  133. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  134. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  135. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  136. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  137. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  138. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  139. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  140. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  141. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  142. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  143. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  144. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  145. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  146. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  147. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  148. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  149. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  150. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  151. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  152. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  153. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  154. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  155. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  156. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  157. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  158. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  159. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  160. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  161. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  162. package/web/dist/assets/source-BWlvaODa.js +1 -0
  163. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  164. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  165. package/web/dist/index.html +8 -6
  166. package/web/src/voice/turn/LICENSE +28 -0
  167. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  168. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  169. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  170. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  171. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  172. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  173. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  174. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  175. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  176. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  177. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  178. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  179. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  180. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  181. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -0,0 +1,189 @@
1
+ // (S4 M2) The spoke's timelineMedia op: one image of a timeline row by its ref (packages/protocol/src/media.ts),
2
+ // checked (path policy, magic bytes, MEDIA_LIMITS) and answered in chunks. The decoded bytes of an image stay in a
3
+ // small per-session LRU for a minute, so the following chunks do not read the session's whole history again.
4
+ import { createHash } from "node:crypto"
5
+ import { MEDIA_LIMITS, parseMediaRef, type MediaInfo, type MediaRef, type TimelineMediaResult } from "../protocol"
6
+ import { base64Bytes, checkImage, mediaMime, mimeOfPath, parseDataURI } from "./media-bytes"
7
+ import { readMediaPath, touchedPaths } from "./media-policy"
8
+
9
+ const record = (value: unknown): Record<string, any> => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {})
10
+
11
+ /** A session's messages: all of them, those a staged revert leaves shown, and the message of a committed revert. */
12
+ export interface MediaMessages {
13
+ readonly all: readonly unknown[]
14
+ readonly shown: readonly unknown[]
15
+ readonly committed?: string
16
+ }
17
+
18
+ /** Bytes of OpenCode's media store (`media:<sha256>` URIs), from the own server; undefined when unknown. */
19
+ export type FetchMedia = (hash: string) => Promise<{ bytes: Uint8Array; mime?: string } | undefined>
20
+
21
+ export interface CachedMedia {
22
+ readonly info: MediaInfo
23
+ readonly data: Uint8Array
24
+ }
25
+
26
+ /** Decoded images by session and key, least recently used first; entries expire after `ttlMs`. */
27
+ export class MediaCache {
28
+ private readonly entries = new Map<string, CachedMedia & { readonly session: string; readonly at: number }>()
29
+ private total = 0
30
+ private readonly ttlMs: number
31
+ private readonly maxBytes: number
32
+ private readonly now: () => number
33
+
34
+ constructor(options: { ttlMs?: number; maxBytes?: number; now?: () => number } = {}) {
35
+ this.ttlMs = options.ttlMs ?? 60_000
36
+ this.maxBytes = options.maxBytes ?? 32 * 1024 * 1024
37
+ this.now = options.now ?? Date.now
38
+ }
39
+
40
+ /** Bytes held. */
41
+ get bytes(): number {
42
+ return this.total
43
+ }
44
+
45
+ get(session: string, key: string): CachedMedia | undefined {
46
+ const id = `${session}\u0000${key}`
47
+ const entry = this.entries.get(id)
48
+ if (!entry) return undefined
49
+ this.entries.delete(id)
50
+ if (this.now() - entry.at >= this.ttlMs) {
51
+ this.total -= entry.data.length
52
+ return undefined
53
+ }
54
+ this.entries.set(id, entry)
55
+ return entry
56
+ }
57
+
58
+ set(session: string, key: string, media: CachedMedia): void {
59
+ const id = `${session}\u0000${key}`
60
+ this.drop(id)
61
+ if (media.data.length > this.maxBytes) return
62
+ this.entries.set(id, { ...media, session, at: this.now() })
63
+ this.total += media.data.length
64
+ while (this.total > this.maxBytes) this.drop(this.entries.keys().next().value!)
65
+ }
66
+
67
+ /** Forget a session's images (a revert hides messages; their bytes must not be served from here). */
68
+ invalidate(session: string): void {
69
+ for (const [id, entry] of this.entries) if (entry.session === session) this.drop(id)
70
+ }
71
+
72
+ private drop(id: string): void {
73
+ const entry = this.entries.get(id)
74
+ if (!entry) return
75
+ this.entries.delete(id)
76
+ this.total -= entry.data.length
77
+ }
78
+ }
79
+
80
+ export interface TimelineMediaInput {
81
+ readonly sessionID: string
82
+ readonly messageID: string
83
+ readonly ref: string
84
+ readonly offset?: number
85
+ readonly cache: MediaCache
86
+ /** The session's messages (read only when the answer is not a later chunk still cached). */
87
+ readonly load: () => Promise<MediaMessages>
88
+ /** The session's directory (relative image paths, the path policy). */
89
+ readonly directory: string
90
+ readonly fetchMedia?: FetchMedia
91
+ }
92
+
93
+ /**
94
+ * The answer to timelineMedia: the chunk at `offset` of the image (with its metadata), `tooLarge`, `refused`, or
95
+ * nothing when there is no such image. The first chunk (offset 0) always reads the session again (so access is
96
+ * checked again); later chunks reuse the cached bytes. Throws for an offset at or past the end.
97
+ */
98
+ export async function timelineMedia(input: TimelineMediaInput): Promise<TimelineMediaResult> {
99
+ const ref = parseMediaRef(input.ref)
100
+ if (!ref) return {}
101
+ const offset = input.offset ?? 0
102
+ const key = `${input.messageID}\u0000${input.ref}`
103
+ const cached = offset > 0 ? input.cache.get(input.sessionID, key) : undefined
104
+ if (cached) return chunkOf(cached, offset)
105
+ const messages = await input.load()
106
+ const message = messages.all.map(record).find((m) => m.id === input.messageID)
107
+ if (!message) return input.messageID === messages.committed ? { refused: "hidden" } : {}
108
+ if (!messages.shown.some((m) => record(m).id === input.messageID)) return { refused: "hidden" }
109
+ const source = await sourceOf(message, ref, messages.shown, input)
110
+ if (!("bytes" in source)) return source
111
+ const check = checkImage(source.bytes, source.claim)
112
+ if ("refused" in check) return check
113
+ const media: CachedMedia = {
114
+ info: { ...check.ok, bytes: source.bytes.length, sha256: createHash("sha256").update(source.bytes).digest("hex") },
115
+ data: source.bytes,
116
+ }
117
+ input.cache.set(input.sessionID, key, media)
118
+ return chunkOf(media, offset)
119
+ }
120
+
121
+ function chunkOf(media: CachedMedia, offset: number): TimelineMediaResult {
122
+ if (offset >= media.data.length) throw new Error("Media offset is past the end of the image")
123
+ const end = Math.min(media.data.length, offset + MEDIA_LIMITS.chunkBytes)
124
+ const data = Buffer.from(media.data.buffer, media.data.byteOffset + offset, end - offset).toString("base64")
125
+ return { media: media.info, chunk: { offset, data, last: end >= media.data.length } }
126
+ }
127
+
128
+ type Source = { readonly bytes: Uint8Array; readonly claim: string } | TimelineMediaResult
129
+
130
+ const tooLarge = (bytes: number): TimelineMediaResult => ({ tooLarge: { bytes, max: MEDIA_LIMITS.maxBytes } })
131
+
132
+ /** Decoded base64 (bare or a `data:` URI), unless it is too large. */
133
+ function fromBase64(data: string, claim: string): Source {
134
+ const base64 = parseDataURI(data)?.base64 ?? data
135
+ const size = base64Bytes(base64)
136
+ if (size > MEDIA_LIMITS.maxBytes) return tooLarge(size)
137
+ return { bytes: Uint8Array.from(Buffer.from(base64, base64.includes("-") || base64.includes("_") ? "base64url" : "base64")), claim }
138
+ }
139
+
140
+ async function sourceOf(message: Record<string, any>, ref: MediaRef, shown: readonly unknown[], input: TimelineMediaInput): Promise<Source> {
141
+ switch (ref.kind) {
142
+ case "file": {
143
+ if (message.type !== "user" || !Array.isArray(message.files)) return {}
144
+ const file = record(message.files[ref.index])
145
+ if (typeof file.data !== "string" || !file.data || typeof file.mime !== "string") return {}
146
+ if (!mediaMime(file.mime)) return { refused: "type" }
147
+ return fromBase64(file.data, file.mime)
148
+ }
149
+ case "tool": {
150
+ if (message.type !== "assistant" || !Array.isArray(message.content)) return {}
151
+ const part = record(message.content[ref.part])
152
+ const content = record(part.state).content
153
+ if (part.type !== "tool" || !Array.isArray(content)) return {}
154
+ const item = record(content[ref.item])
155
+ if (item.type !== "file" || typeof item.mime !== "string") return {}
156
+ if (!mediaMime(item.mime)) return { refused: "type" }
157
+ // OpenCode's ToolFileContent: `uri` a data: or media: URI (raw base64 `data` from other harnesses' shapes).
158
+ if (typeof item.uri === "string") {
159
+ if (item.uri.startsWith("media:")) return fromStore(item.uri.slice("media:".length), item.mime, input.fetchMedia)
160
+ return item.uri.startsWith("data:") ? fromBase64(item.uri, item.mime) : {}
161
+ }
162
+ return typeof item.data === "string" && item.data ? fromBase64(item.data, item.mime) : {}
163
+ }
164
+ case "path": {
165
+ if (message.type !== "assistant" || !Array.isArray(message.content)) return {}
166
+ const text = message.content
167
+ .filter((p: any) => p?.type === "text" && typeof p.text === "string")
168
+ .map((p: any) => p.text as string)
169
+ .join("\n\n")
170
+ const read = await readMediaPath({ src: ref.path, text, directory: input.directory, touched: touchedPaths(shown, input.directory), max: MEDIA_LIMITS.maxBytes })
171
+ if ("refused" in read) return read
172
+ if ("tooLarge" in read) return tooLarge(read.tooLarge)
173
+ const claim = mimeOfPath(read.path)
174
+ return claim ? { bytes: read.bytes, claim } : { refused: "type" }
175
+ }
176
+ }
177
+ }
178
+
179
+ /** OpenCode media hashes: lowercase hex SHA-256 (MediaHash). */
180
+ const HASH = /^[0-9a-f]{64}$/
181
+
182
+ /** An item in OpenCode's media store; none when the store cannot be reached or does not have it. */
183
+ async function fromStore(hash: string, claim: string, fetchMedia: FetchMedia | undefined): Promise<Source> {
184
+ if (!fetchMedia || !HASH.test(hash)) return {}
185
+ const found = await fetchMedia(hash).catch(() => undefined)
186
+ if (!found) return {}
187
+ if (found.bytes.length > MEDIA_LIMITS.maxBytes) return tooLarge(found.bytes.length)
188
+ return { bytes: found.bytes, claim }
189
+ }
@@ -2,6 +2,7 @@
2
2
  // forms). The background service is found with Service.discover; a foreground `opencode serve` from its
3
3
  // command line and password variable. A standalone server started by a TUI (`serve --stdio --port 0`)
4
4
  // keeps its password to itself: its forms are relayed through the TUI instead (Spoke.formActions).
5
+ import { MEDIA_LIMITS } from "../protocol"
5
6
 
6
7
  /** URL and Authorization header of a foreground `opencode serve` this process is, when knowable. */
7
8
  export function foregroundEndpoint(
@@ -30,3 +31,32 @@ export function formOutcome(cause: unknown): "gone" | undefined {
30
31
  const message = cause instanceof Error ? cause.message : String(cause)
31
32
  return /form (not found|already settled)/i.test(message) ? "gone" : undefined
32
33
  }
34
+
35
+
36
+ /** OpenCode media hashes: lowercase hex SHA-256 (its MediaHash). */
37
+ const MEDIA_HASH = /^[0-9a-f]{64}$/
38
+ const MEDIA_TIMEOUT_MS = 10_000
39
+
40
+ /**
41
+ * (S4 M2) A blob of OpenCode's media store (`media:<sha256>` URIs in tool output) read from the own server
42
+ * (GET /api/experimental/media/:hash, experimental). Undefined when there is no endpoint, the server cannot be
43
+ * reached or does not have it, or it is larger than MEDIA_LIMITS.maxBytes.
44
+ */
45
+ export async function fetchOwnMedia(
46
+ endpoint: { url: string; headers: Record<string, string> } | undefined,
47
+ hash: string,
48
+ fetcher: typeof fetch = fetch,
49
+ ): Promise<{ bytes: Uint8Array; mime?: string } | undefined> {
50
+ if (!endpoint || !MEDIA_HASH.test(hash)) return undefined
51
+ try {
52
+ const res = await fetcher(`${endpoint.url}/api/experimental/media/${hash}`, { headers: endpoint.headers, signal: AbortSignal.timeout(MEDIA_TIMEOUT_MS) })
53
+ if (!res.ok) return undefined
54
+ if (Number(res.headers.get("content-length") ?? 0) > MEDIA_LIMITS.maxBytes) return undefined
55
+ const bytes = new Uint8Array(await res.arrayBuffer())
56
+ if (bytes.length > MEDIA_LIMITS.maxBytes) return undefined
57
+ const mime = res.headers.get("content-type")?.split(";")[0]?.trim()
58
+ return { bytes, ...(mime ? { mime } : {}) }
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }