@mirrowel/opencode-openshare 0.1.1 → 0.2.0-dev.2

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.
@@ -0,0 +1,320 @@
1
+ // OpenShare transcript contract v2.
2
+ //
3
+ // A share is a set of keyed items (session / message / session_diff /
4
+ // model / prefs) with tombstone deletes. The CONTENT model is the opencode
5
+ // v2 session-message shapes, promoted into OUR envelope: other clients
6
+ // (pi, anything) can emit these shapes without running opencode, unknown
7
+ // message types degrade as notices in the viewer, and the share carries
8
+ // `v: 2` so consumers can dispatch on it. v1 hosts normalize at ingestion
9
+ // (below); v2 hosts pass messages through verbatim.
10
+ //
11
+ // Item set (envelope v2):
12
+ // session data = session info {id, title?, time, parentID?, ...}
13
+ // message data = v2 session message (see V2Message) — content is
14
+ // embedded; there are no separate part items
15
+ // session_diff data = FileDiffInfo[] (v2 review shapes)
16
+ // model data = [{id, providerID, name?}]
17
+ // prefs data = viewer preferences
18
+ // Tombstones: {type, del: true, data: {id}} — a message tombstone removes
19
+ // the whole message (parts are embedded; no cascade needed).
20
+
21
+ export const CONTRACT_VERSION = 2
22
+
23
+ /** Message tombstone item. sessionID MUST ride along: viewers key
24
+ * messages as message/<sessionID>/<id> — an unstamped del misses the
25
+ * key and undone turns resurrect on the live page (live-caught
26
+ * 2026-10-21, refresh-correct because the worker keys differently). */
27
+ export function messageTombstone(sessionID: string, messageID: string) {
28
+ return { type: "message", del: true, sessionID, data: { id: messageID } } as const
29
+ }
30
+
31
+ // --- v2 content shapes (loose on purpose: the viewer's vendored v2
32
+ // session-ui validates what it renders; we only guarantee field names) ---
33
+
34
+ export type V2ToolState =
35
+ | { status: "streaming"; input: string }
36
+ | { status: "running"; input: Record<string, unknown>; metadata: Record<string, unknown> }
37
+ | { status: "completed"; input: Record<string, unknown>; content: Array<{ type: "text"; text: string }>; metadata?: Record<string, unknown> }
38
+ | { status: "error"; input: Record<string, unknown>; error: { type: string; message: string }; content?: Array<{ type: "text"; text: string }>; metadata?: Record<string, unknown> }
39
+
40
+ export type V2Content =
41
+ | { type: "text"; text: string; state?: string }
42
+ | { type: "reasoning"; text: string; state?: string; time?: { created?: number; completed?: number } }
43
+ | { type: "tool"; id: string; name: string; executed?: boolean; state: V2ToolState; time: { created?: number; ran?: number; completed?: number } }
44
+
45
+ export type V2FileAttachment = { data: string; mime: string; source: { type: "inline" } | { type: "uri"; uri: string }; name?: string }
46
+
47
+ export interface V2UserMessage {
48
+ id: string
49
+ type: "user"
50
+ time: { created: number }
51
+ text: string
52
+ files?: V2FileAttachment[]
53
+ agents?: Array<{ name: string }>
54
+ metadata?: Record<string, unknown>
55
+ }
56
+
57
+ export interface V2AssistantMessage {
58
+ id: string
59
+ type: "assistant"
60
+ agent: string
61
+ model: { id: string; providerID: string; variant?: string }
62
+ time: { created: number; streamed?: number; completed?: number }
63
+ content: V2Content[]
64
+ cost?: number
65
+ tokens?: Record<string, unknown>
66
+ finish?: string
67
+ error?: { type: string; message: string; status?: number }
68
+ retry?: { attempt: number; at: number; error?: { type: string; message: string } }
69
+ }
70
+
71
+ export interface V2CompactionMessage {
72
+ id: string
73
+ type: "compaction"
74
+ status: "completed"
75
+ reason: "auto" | "manual"
76
+ summary: string
77
+ recent: string
78
+ time: { created: number }
79
+ }
80
+
81
+ export type V2Message = V2UserMessage | V2AssistantMessage | V2CompactionMessage | Record<string, unknown>
82
+
83
+ // --- v1 → v2 normalization (the ingestion edge for v1 hosts) ---------------
84
+
85
+ /** True when the object is already a v2 session message (v2 hosts pass
86
+ * messages through verbatim; v1 infos carry `role`). */
87
+ export function isV2NativeMessage(x: unknown): boolean {
88
+ if (!x || typeof x !== "object") return false
89
+ const r = x as Record<string, unknown>
90
+ return typeof r.type === "string" && r.type !== "" && r.role === undefined && typeof r.id === "string"
91
+ }
92
+
93
+ const TOOL_NAME_MAP: Record<string, string> = {
94
+ bash: "shell",
95
+ task: "subagent",
96
+ }
97
+
98
+ // v1 read/edit/write carry `filePath`; v2 renderers read `path`
99
+ const INPUT_KEY_MAP: Record<string, string> = {
100
+ filePath: "path",
101
+ // the v2 kit's subagent row reads input.agent for the label (the real
102
+ // agent name, e.g. "explore"); v1's task tool calls it subagent_type
103
+ subagent_type: "agent",
104
+ }
105
+
106
+ function normalizeToolInput(input: Record<string, unknown>, title?: string): Record<string, unknown> {
107
+ const out: Record<string, unknown> = {}
108
+ for (const [k, v] of Object.entries(input ?? {})) out[INPUT_KEY_MAP[k] ?? k] = v
109
+ // v1 completed tools carry a human title ("Read src/app.tsx"); v2
110
+ // derives titles from inputs — surface ours through `description`
111
+ if (title && out.description === undefined) out.description = title
112
+ return out
113
+ }
114
+
115
+ function normalizeToolMetadata(meta: Record<string, unknown> | undefined): Record<string, unknown> {
116
+ const out: Record<string, unknown> = {}
117
+ for (const [k, v] of Object.entries(meta ?? {})) {
118
+ if (k === "filediff") out.files = [v]
119
+ else if (k === "sessionId") out.sessionID = v
120
+ else out[k] = v
121
+ }
122
+ return out
123
+ }
124
+
125
+ function toolContent(output: unknown): Array<{ type: "text"; text: string }> {
126
+ const text = typeof output === "string" ? output : ""
127
+ return [{ type: "text", text: text || " " }] // v2 content is NonEmpty
128
+ }
129
+
130
+ function normalizeToolState(state: Record<string, unknown>): V2ToolState {
131
+ const status = String(state?.status ?? "pending")
132
+ if (status === "pending") {
133
+ return { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(state.input ?? {}) }
134
+ }
135
+ if (status === "running") {
136
+ return { status: "running", input: normalizeToolInput((state.input ?? {}) as Record<string, unknown>, state.title as string | undefined), metadata: normalizeToolMetadata(state.metadata as Record<string, unknown> | undefined) }
137
+ }
138
+ if (status === "completed") {
139
+ return { status: "completed", input: normalizeToolInput((state.input ?? {}) as Record<string, unknown>, state.title as string | undefined), content: toolContent(state.output), metadata: normalizeToolMetadata(state.metadata as Record<string, unknown> | undefined) }
140
+ }
141
+ return {
142
+ status: "error",
143
+ input: normalizeToolInput((state.input ?? {}) as Record<string, unknown>),
144
+ error: { type: "unknown", message: typeof state.error === "string" ? state.error : "tool failed" },
145
+ metadata: normalizeToolMetadata(state.metadata as Record<string, unknown> | undefined),
146
+ }
147
+ }
148
+
149
+ function normalizeFilePart(p: Record<string, unknown>): V2FileAttachment | undefined {
150
+ const url = typeof p.url === "string" ? p.url : ""
151
+ if (!url) return undefined
152
+ const inline = url.startsWith("data:")
153
+ const comma = url.indexOf(",")
154
+ return {
155
+ data: inline ? url.slice(comma + 1) : url,
156
+ mime: typeof p.mime === "string" ? p.mime : "application/octet-stream",
157
+ source: inline ? { type: "inline" } : { type: "uri", uri: url },
158
+ ...(typeof p.filename === "string" ? { name: p.filename } : {}),
159
+ }
160
+ }
161
+
162
+ /** v1 message + its parts → v2 message. Returns:
163
+ * - { message } — emit as a message item
164
+ * - { refill, summary } — v1 compaction summary assistant: do not emit;
165
+ * rewrite the parent compaction message instead
166
+ * - undefined — nothing to emit (should not happen in practice) */
167
+ export function normalizeV1Message(
168
+ info: Record<string, unknown>,
169
+ parts: Array<Record<string, unknown>>,
170
+ ): { message: V2Message } | { refill: string; summary: string } | undefined {
171
+ if (!info || typeof info.id !== "string") return undefined
172
+ const id = info.id
173
+ const created = ((info.time as Record<string, unknown> | undefined)?.created as number) ?? 0
174
+
175
+ if (info.role === "user") {
176
+ // compaction trigger user message → v2 compaction message (summary
177
+ // refilled when the summary assistant arrives)
178
+ const compaction = parts.find((p) => p?.type === "compaction") as Record<string, unknown> | undefined
179
+ if (compaction) {
180
+ const message: V2CompactionMessage = {
181
+ id,
182
+ type: "compaction",
183
+ status: "completed",
184
+ reason: compaction.auto === false ? "manual" : "auto",
185
+ summary: "",
186
+ recent: "",
187
+ time: { created },
188
+ }
189
+ return { message }
190
+ }
191
+ const text = parts
192
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
193
+ .map((p) => p.text as string)
194
+ .join("\n")
195
+ const files = (parts.filter((p) => p?.type === "file") as Array<Record<string, unknown>>)
196
+ .map(normalizeFilePart)
197
+ .filter((f): f is V2FileAttachment => f !== undefined)
198
+ const agents = (parts.filter((p) => p?.type === "agent") as Array<Record<string, unknown>>)
199
+ .map((p) => ({ name: String(p.name ?? "") }))
200
+ .filter((a) => a.name)
201
+ const model = info.model as Record<string, unknown> | undefined
202
+ const message: V2UserMessage = {
203
+ id,
204
+ type: "user",
205
+ time: { created },
206
+ text,
207
+ ...(files.length ? { files } : {}),
208
+ ...(agents.length ? { agents } : {}),
209
+ metadata: {
210
+ ...(info.agent !== undefined ? { agent: String(info.agent) } : {}),
211
+ ...(model ? { model: { id: String(model.modelID ?? ""), providerID: String(model.providerID ?? "") } } : {}),
212
+ },
213
+ }
214
+ return { message }
215
+ }
216
+
217
+ if (info.role === "assistant") {
218
+ const time = info.time as Record<string, unknown> | undefined
219
+ // the v1 compaction summary assistant folds INTO the compaction
220
+ // message that precedes it (its parentID)
221
+ if (info.summary === true && info.mode === "compaction") {
222
+ const summary = parts
223
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
224
+ .map((p) => p.text as string)
225
+ .join("\n")
226
+ return { refill: String(info.parentID ?? ""), summary }
227
+ }
228
+
229
+ const content: V2Content[] = []
230
+ let cost = typeof info.cost === "number" ? info.cost : 0
231
+ let tokens: Record<string, unknown> | undefined
232
+ let retry: V2AssistantMessage["retry"]
233
+ for (const p of parts) {
234
+ switch (p?.type) {
235
+ case "text":
236
+ content.push({ type: "text", text: String(p.text ?? "") })
237
+ break
238
+ case "reasoning": {
239
+ const rt = p.time as Record<string, unknown> | undefined
240
+ content.push({ type: "reasoning", text: String(p.text ?? ""), time: { created: (rt?.start as number) ?? undefined, completed: (rt?.end as number) ?? undefined } })
241
+ break
242
+ }
243
+ case "tool": {
244
+ const st = p.state as Record<string, unknown> | undefined
245
+ const tt = (st?.time ?? {}) as Record<string, unknown>
246
+ content.push({
247
+ type: "tool",
248
+ id: String(p.id ?? ""),
249
+ name: TOOL_NAME_MAP[String(p.tool ?? "unknown")] ?? String(p.tool ?? "unknown"),
250
+ state: normalizeToolState(st ?? {}),
251
+ time: { created: (tt.start as number) ?? undefined, completed: (tt.end as number) ?? undefined },
252
+ })
253
+ break
254
+ }
255
+ case "retry": {
256
+ const err = p.error as Record<string, unknown> | undefined
257
+ retry = {
258
+ attempt: Number(p.attempt ?? 0),
259
+ at: ((p.time as Record<string, unknown> | undefined)?.created as number) ?? 0,
260
+ error: { type: "unknown", message: String(err?.message ?? err?.name ?? "retry") },
261
+ }
262
+ break
263
+ }
264
+ case "step-finish": {
265
+ // fold step aggregates into the message
266
+ if (typeof p.cost === "number") cost += p.cost
267
+ if (p.tokens && typeof p.tokens === "object") tokens = p.tokens as Record<string, unknown>
268
+ break
269
+ }
270
+ // step-start / snapshot / patch / subtask / agent / assistant
271
+ // file parts have no v2 content equivalent — dropped by design
272
+ }
273
+ }
274
+ const finish = typeof info.finish === "string" ? info.finish : undefined
275
+ const errObj = info.error as Record<string, unknown> | undefined
276
+ const message: V2AssistantMessage = {
277
+ id,
278
+ type: "assistant",
279
+ agent: String(info.agent ?? "build"),
280
+ model: { id: String(info.modelID ?? ""), providerID: String(info.providerID ?? "") },
281
+ time: { created, streamed: (time?.started as number) ?? undefined, completed: (time?.completed as number) ?? undefined },
282
+ content,
283
+ cost,
284
+ ...(tokens ? { tokens } : {}),
285
+ ...(finish ? { finish } : {}),
286
+ ...(errObj ? { error: { type: String(errObj.name ?? "UnknownError"), message: String(errObj.message ?? "turn failed") } } : {}),
287
+ ...(retry ? { retry } : {}),
288
+ }
289
+ return { message }
290
+ }
291
+ return undefined
292
+ }
293
+
294
+ /** v1 session diff (FileDiff.Info[]) → v2 FileDiffInfo[]: the v2 shape
295
+ * REQUIRES numeric additions/deletions (renderers read them unguarded) —
296
+ * compute from the patch when the host didn't provide counts. */
297
+ export function normalizeV1Diff(diff: unknown): unknown {
298
+ if (!Array.isArray(diff)) return diff
299
+ return diff.map((d) => {
300
+ if (!d || typeof d !== "object") return d
301
+ const r = d as Record<string, unknown>
302
+ if (typeof r.additions === "number" && typeof r.deletions === "number") return r
303
+ const patch = typeof r.patch === "string" ? r.patch : ""
304
+ let additions = 0
305
+ let deletions = 0
306
+ for (const line of patch.split("\n")) {
307
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++
308
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions++
309
+ }
310
+ return { ...r, additions: typeof r.additions === "number" ? r.additions : additions, deletions: typeof r.deletions === "number" ? r.deletions : deletions }
311
+ })
312
+ }
313
+
314
+ /** v1 hosts paginate messages with an opaque cursor: base64url of
315
+ * {id, time} where time = the page tail's time_created (message-v2.ts
316
+ * cursor codec). A bare message id is rejected with 400 — that silently
317
+ * truncated every v1 backfill to one page (live-caught). */
318
+ export function cursorFor(id: string, time: number): string {
319
+ return Buffer.from(JSON.stringify({ id, time })).toString("base64url")
320
+ }