@huynle/hub 0.8.7 → 0.9.1
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 +152 -0
- package/README.md +75 -16
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1413 -523
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- 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/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- 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 +59 -6
- package/packages/protocol/src/launch.ts +46 -6
- 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 +22 -3
- 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/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- 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-BKISN-Qj.js +1 -0
- package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.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-azw2B_QH.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-CcjZXsfF.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-DdzbzmeK.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-CDyjrK-I.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-DYwoUWME.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-LNMZCqgE.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-ycZ_aQZO.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-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.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,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
|
+
}
|
package/src/spoke/own-server.ts
CHANGED
|
@@ -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
|
+
}
|