@mirrowel/opencode-openshare 0.1.0 → 0.2.0-dev.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/README.md +36 -30
- package/ci/openshare.config.json +7 -2
- package/ci/openshare.js +625 -197
- package/dist/config.d.ts +5 -1
- package/dist/config.js +9 -2
- package/dist/contract.d.ts +156 -0
- package/dist/contract.js +263 -0
- package/dist/core.js +411 -55
- package/dist/dialogs.js +38 -0
- package/dist/share-flow.js +2 -2
- package/dist/sidebar.d.ts +3 -1
- package/dist/sidebar.js +3 -17
- package/dist/tui.js +73 -121
- package/dist/v2-server.d.ts +0 -27
- package/dist/v2-server.js +6 -121
- package/openshare.config.json +1 -1
- package/package.json +4 -2
- package/src/banner.txt +7 -4
- package/src/config.ts +8 -2
- package/src/contract.ts +320 -0
- package/src/core.ts +403 -56
- package/src/dialogs.tsx +34 -0
- package/src/share-flow.ts +2 -2
- package/src/sidebar.tsx +4 -21
- package/src/tui.tsx +1 -1
- package/src/v2-server.ts +7 -110
package/dist/v2-server.js
CHANGED
|
@@ -29,125 +29,10 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { METADATA_KEY, moduleDir, packageRoot } from "./config.js";
|
|
31
31
|
import { createEngine } from "./core.js";
|
|
32
|
-
// --- v2 message -> v1 items ---------------------------------------------------
|
|
33
|
-
function toolContentText(content) {
|
|
34
|
-
if (typeof content === "string")
|
|
35
|
-
return content;
|
|
36
|
-
if (Array.isArray(content)) {
|
|
37
|
-
return content
|
|
38
|
-
.map((item) => {
|
|
39
|
-
if (typeof item === "string")
|
|
40
|
-
return item;
|
|
41
|
-
if (item && typeof item === "object") {
|
|
42
|
-
const record = item;
|
|
43
|
-
if (typeof record.text === "string")
|
|
44
|
-
return record.text;
|
|
45
|
-
}
|
|
46
|
-
return "";
|
|
47
|
-
})
|
|
48
|
-
.join("");
|
|
49
|
-
}
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
function convertToolState(state) {
|
|
53
|
-
if (!state || typeof state !== "object")
|
|
54
|
-
return { status: "pending" };
|
|
55
|
-
const record = state;
|
|
56
|
-
const status = String(record.status ?? "pending");
|
|
57
|
-
const mapped = {};
|
|
58
|
-
if (status === "streaming" || status === "running")
|
|
59
|
-
mapped.status = "running";
|
|
60
|
-
else if (status === "completed")
|
|
61
|
-
mapped.status = "completed";
|
|
62
|
-
else if (status === "failed" || status === "error")
|
|
63
|
-
mapped.status = "error";
|
|
64
|
-
else
|
|
65
|
-
mapped.status = "pending";
|
|
66
|
-
if (record.input !== undefined)
|
|
67
|
-
mapped.input = record.input;
|
|
68
|
-
if (record.output !== undefined)
|
|
69
|
-
mapped.output = toolContentText(record.output);
|
|
70
|
-
if (record.error !== undefined)
|
|
71
|
-
mapped.error = record.error;
|
|
72
|
-
if (record.time !== undefined)
|
|
73
|
-
mapped.time = record.time;
|
|
74
|
-
return mapped;
|
|
75
|
-
}
|
|
76
|
-
/** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
|
|
77
|
-
export function convertV2Message(message, sessionID) {
|
|
78
|
-
if (!message || typeof message.id !== "string")
|
|
79
|
-
return undefined;
|
|
80
|
-
const base = {
|
|
81
|
-
id: message.id,
|
|
82
|
-
sessionID,
|
|
83
|
-
};
|
|
84
|
-
if (message.type === "user") {
|
|
85
|
-
return {
|
|
86
|
-
info: {
|
|
87
|
-
...base,
|
|
88
|
-
role: "user",
|
|
89
|
-
time: { created: message.time?.created ?? 0 },
|
|
90
|
-
text: message.text ?? "",
|
|
91
|
-
},
|
|
92
|
-
parts: [],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
if (message.type === "assistant") {
|
|
96
|
-
const parts = [];
|
|
97
|
-
const content = Array.isArray(message.content) ? message.content : [];
|
|
98
|
-
content.forEach((block, index) => {
|
|
99
|
-
const record = block;
|
|
100
|
-
const kind = String(record.type ?? "");
|
|
101
|
-
if (kind === "text") {
|
|
102
|
-
parts.push({
|
|
103
|
-
id: `${message.id}_t${index}`,
|
|
104
|
-
messageID: message.id,
|
|
105
|
-
sessionID,
|
|
106
|
-
type: "text",
|
|
107
|
-
text: String(record.text ?? ""),
|
|
108
|
-
synthetic: false,
|
|
109
|
-
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
else if (kind === "reasoning") {
|
|
113
|
-
parts.push({
|
|
114
|
-
id: `${message.id}_r${index}`,
|
|
115
|
-
messageID: message.id,
|
|
116
|
-
sessionID,
|
|
117
|
-
type: "reasoning",
|
|
118
|
-
text: String(record.text ?? ""),
|
|
119
|
-
synthetic: false,
|
|
120
|
-
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
else if (kind === "tool") {
|
|
124
|
-
parts.push({
|
|
125
|
-
id: String(record.id ?? `${message.id}_tool${index}`),
|
|
126
|
-
messageID: message.id,
|
|
127
|
-
sessionID,
|
|
128
|
-
type: "tool",
|
|
129
|
-
tool: String(record.name ?? "unknown"),
|
|
130
|
-
state: convertToolState(record.state),
|
|
131
|
-
synthetic: false,
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
return {
|
|
136
|
-
info: {
|
|
137
|
-
...base,
|
|
138
|
-
role: "assistant",
|
|
139
|
-
agent: message.agent,
|
|
140
|
-
model: message.model
|
|
141
|
-
? { providerID: message.model.providerID, modelID: message.model.modelID ?? message.model.id }
|
|
142
|
-
: undefined,
|
|
143
|
-
time: { created: message.time?.created ?? 0, completed: message.time?.completed ?? 0 },
|
|
144
|
-
},
|
|
145
|
-
parts,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
return undefined;
|
|
149
|
-
}
|
|
150
32
|
// --- v2 client -> engine ClientApi ---------------------------------------------
|
|
33
|
+
// Contract v2: messages pass through VERBATIM as v2 session messages (the
|
|
34
|
+
// engine's ingestion detects v2-native shapes and enqueues them directly —
|
|
35
|
+
// no v1 conversion, no dropped compaction, no dropped user text).
|
|
151
36
|
function adaptSessionClient(session, message, directory) {
|
|
152
37
|
if (!session)
|
|
153
38
|
return undefined;
|
|
@@ -178,9 +63,9 @@ function adaptSessionClient(session, message, directory) {
|
|
|
178
63
|
if (!Array.isArray(data))
|
|
179
64
|
break;
|
|
180
65
|
for (const raw of data) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
66
|
+
if (raw && typeof raw === "object" && typeof raw.id === "string") {
|
|
67
|
+
out.push({ info: raw, parts: [] });
|
|
68
|
+
}
|
|
184
69
|
}
|
|
185
70
|
const next = res?.cursor?.next ?? undefined;
|
|
186
71
|
if (!next || data.length === 0)
|
package/openshare.config.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), openTasksInNewTab (task rows open subagent pages in a new tab), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
|
|
2
|
+
"$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), openTasksInNewTab (task rows open subagent pages in a new tab), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
|
|
3
3
|
"workerUrl": "",
|
|
4
4
|
"createToken": "",
|
|
5
5
|
"servers": {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirrowel/opencode-openshare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-dev.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Open(code)Share — re:share: self-hosted session sharing for opencode. Server plugin (single file or package), optional TUI for interactive opt-in sharing.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -76,6 +76,8 @@
|
|
|
76
76
|
"test:v2": "node scripts/v2-mock-tests.mjs",
|
|
77
77
|
"test:auth": "node scripts/auth-tests.mjs",
|
|
78
78
|
"test:server-auth": "node scripts/auth-tests.mjs --backend bun",
|
|
79
|
+
"test:big": "node scripts/big-tests.mjs",
|
|
80
|
+
"test:server-big": "node scripts/big-tests.mjs --backend bun",
|
|
79
81
|
"test:server-enroll": "node scripts/enroll-tests.mjs --backend bun",
|
|
80
82
|
"test:enroll": "node scripts/enroll-tests.mjs",
|
|
81
83
|
"test:fanout": "node scripts/fanout-tests.mjs",
|
|
@@ -92,7 +94,7 @@
|
|
|
92
94
|
"@opentui/solid": "^0.4.1"
|
|
93
95
|
},
|
|
94
96
|
"peerDependencies": {
|
|
95
|
-
"@opencode-ai/plugin": ">=1.
|
|
97
|
+
"@opencode-ai/plugin": ">=1.18.0"
|
|
96
98
|
},
|
|
97
99
|
"engines": {
|
|
98
100
|
"node": ">=20"
|
package/src/banner.txt
CHANGED
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
// lands in the log and optionally in `urlFile`).
|
|
14
14
|
// - Interactive opt-in: install the package's TUI part (tui.json) too —
|
|
15
15
|
// the server detects its presence and switches to manual mode
|
|
16
|
-
// (`/share
|
|
16
|
+
// (`/share` + `/openshare` in the TUI).
|
|
17
17
|
//
|
|
18
18
|
// Config: env (OPENSHARE_*) > openshare.config.json (next to this file)
|
|
19
|
-
// > code defaults. Keys:
|
|
20
|
-
//
|
|
21
|
-
// shareMode ("all" | "manual"; default = TUI presence)
|
|
19
|
+
// > code defaults. Keys: servers (multi-server: url -> {token, code,
|
|
20
|
+
// label, active, adminToken}), workerUrl, createToken (legacy pair),
|
|
21
|
+
// shareMode ("all" | "manual"; default = TUI presence), debounceMs,
|
|
22
|
+
// completedDelayMs, modelLabel, ttlDays, openTasksInNewTab,
|
|
23
|
+
// authLogin, authPassword, spoolDir, urlFile ("none" = off),
|
|
24
|
+
// announceStderr.
|
|
22
25
|
//
|
package/src/config.ts
CHANGED
|
@@ -413,10 +413,16 @@ function specPath(spec: string): string | undefined {
|
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
/** Does this plugin spec (tui.json entry) point at the openshare package?
|
|
416
|
+
/** Does this plugin spec (tui.json entry) point at the openshare package?
|
|
417
|
+
* Accepts the bare npm name, name@version, and the /tui (or any) subpath
|
|
418
|
+
* form — older docs shipped the subpath spec and presence detection must
|
|
419
|
+
* never silently miss it (all-mode would keep auto-sharing while the user
|
|
420
|
+
* believes they installed manual mode — live-caught in review). */
|
|
417
421
|
export function isOpenShareSpec(spec: unknown): spec is string {
|
|
418
422
|
if (typeof spec !== "string" || spec.length === 0) return false
|
|
419
|
-
if (spec === NPM_NAME
|
|
423
|
+
if (spec === NPM_NAME) return true
|
|
424
|
+
// name@version, name/tui, name@version/tui
|
|
425
|
+
if (spec.startsWith(`${NPM_NAME}@`) || spec.startsWith(`${NPM_NAME}/`)) return true
|
|
420
426
|
const path = specPath(spec)
|
|
421
427
|
if (path) {
|
|
422
428
|
if (path.endsWith("/opencode-openshare") || path.endsWith("/share-plugin")) return true
|
package/src/contract.ts
ADDED
|
@@ -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
|
+
}
|