opencode-plugboard 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +229 -2
- package/index.ts +101 -0
- package/package.json +33 -4
- package/src/config.ts +164 -0
- package/src/contract.ts +155 -0
- package/src/http.ts +324 -0
- package/src/log.ts +28 -0
- package/src/models.ts +100 -0
- package/src/openai/request.ts +334 -0
- package/src/openai/response.ts +286 -0
- package/src/openai/route.ts +216 -0
- package/src/responses/request.ts +377 -0
- package/src/responses/response.ts +225 -0
- package/src/responses/route.ts +72 -0
- package/src/runtime.ts +535 -0
- package/src/session/caller.ts +192 -0
- package/src/session/context.ts +175 -0
- package/src/session/events.ts +344 -0
- package/src/session/history.ts +89 -0
- package/src/session/host.ts +215 -0
- package/src/session/media.ts +89 -0
- package/src/session/prune.ts +175 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +144 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session event translation.
|
|
3
|
+
*
|
|
4
|
+
* `RequestCollector` receives the OpenCode bus events of ONE plugboard request session
|
|
5
|
+
* (already filtered by the process-wide session store) and turns them into `PlugEvent`s that the
|
|
6
|
+
* ModelCaller streams out through `events()` (not buffered beyond the queue).
|
|
7
|
+
*
|
|
8
|
+
* The turn is stopped from inside the model loop (see `session/seeds.ts`): once the step has ended
|
|
9
|
+
* (`session.step.ended`) with at least one tool call, the owner is asked to interrupt the
|
|
10
|
+
* session before a second model step can start, and the request finishes as `tool-calls`.
|
|
11
|
+
* The collector never calls the host itself; the owner passes `stopTurn`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
|
|
15
|
+
import type { BusEvent } from "./host.js"
|
|
16
|
+
import { FAILURE_EVENT } from "./state.js"
|
|
17
|
+
|
|
18
|
+
/** At most this many events may wait for a slow consumer, and at most this many (estimated) bytes of them. */
|
|
19
|
+
const MAX_QUEUED_EVENTS = 10_000
|
|
20
|
+
const MAX_QUEUED_BYTES = 8 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
/** The error a request fails with when its consumer cannot keep up. */
|
|
23
|
+
const RESPONSE_OVERFLOW: PlugError = {
|
|
24
|
+
status: 502,
|
|
25
|
+
kind: "server",
|
|
26
|
+
code: "response_overflow",
|
|
27
|
+
message: "The response was not read fast enough; too much output was waiting",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Rough size of an event's payload, for the queue's byte bound. */
|
|
31
|
+
function sizeOf(event: PlugEvent): number {
|
|
32
|
+
switch (event.type) {
|
|
33
|
+
case "text-delta":
|
|
34
|
+
case "reasoning-delta":
|
|
35
|
+
return event.text.length
|
|
36
|
+
case "tool-call":
|
|
37
|
+
return 64 + event.name.length + (JSON.stringify(event.input) ?? "").length
|
|
38
|
+
default:
|
|
39
|
+
return 256
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimal async queue: producers `push`, one consumer iterates; `close` ends the iteration.
|
|
45
|
+
* Bounded: `push` refuses an item that would exceed the item or byte limit (the caller decides
|
|
46
|
+
* what to do; it never waits), so a slow consumer cannot make one request grow without limit.
|
|
47
|
+
*/
|
|
48
|
+
class EventQueue<T> implements AsyncIterable<T> {
|
|
49
|
+
private items: Array<{ item: T; size: number }> = []
|
|
50
|
+
private bytes = 0
|
|
51
|
+
private waiter?: () => void
|
|
52
|
+
private closed = false
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly maxItems: number,
|
|
56
|
+
private readonly maxBytes: number,
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
/** False when the item was refused for size (nothing was queued). Unbounded when `force`. */
|
|
60
|
+
push(item: T, size: number, force = false): boolean {
|
|
61
|
+
if (this.closed) return true
|
|
62
|
+
if (!force && (this.items.length >= this.maxItems || this.bytes + size > this.maxBytes)) return false
|
|
63
|
+
this.items.push({ item, size })
|
|
64
|
+
this.bytes += size
|
|
65
|
+
this.wake()
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drop everything that waits (the consumer is gone or too slow). */
|
|
70
|
+
clear(): void {
|
|
71
|
+
this.items = []
|
|
72
|
+
this.bytes = 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(): void {
|
|
76
|
+
this.closed = true
|
|
77
|
+
this.wake()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private wake() {
|
|
81
|
+
const waiter = this.waiter
|
|
82
|
+
this.waiter = undefined
|
|
83
|
+
waiter?.()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
|
|
87
|
+
while (true) {
|
|
88
|
+
const next = this.items.shift()
|
|
89
|
+
if (next !== undefined) {
|
|
90
|
+
this.bytes -= next.size
|
|
91
|
+
yield next.item
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (this.closed) return
|
|
95
|
+
await new Promise<void>((resolve) => {
|
|
96
|
+
this.waiter = resolve
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The host's session token record (`TokenUsage.Info`, `session.step.ended` / `session.usage.updated`
|
|
104
|
+
* `tokens`). It is a NON-overlapping breakdown (`packages/core/src/session/usage.ts`): `input` is
|
|
105
|
+
* the non-cached prompt, `output` the visible (non-reasoning) completion, `reasoning` the hidden
|
|
106
|
+
* reasoning, and `cache.read` / `cache.write` the cached prompt parts.
|
|
107
|
+
*/
|
|
108
|
+
interface RawUsage {
|
|
109
|
+
readonly input?: number
|
|
110
|
+
readonly output?: number
|
|
111
|
+
readonly reasoning?: number
|
|
112
|
+
readonly cache?: { readonly read?: number; readonly write?: number }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface RawError {
|
|
116
|
+
readonly type?: string
|
|
117
|
+
readonly message?: string
|
|
118
|
+
readonly status?: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const total = (...parts: Array<number | undefined>): number | undefined =>
|
|
122
|
+
parts.some((part) => typeof part === "number") ? parts.reduce<number>((sum, part) => sum + (typeof part === "number" ? part : 0), 0) : undefined
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Host breakdown to `PlugUsage`, whose totals are inclusive (see `PlugUsage`):
|
|
126
|
+
* inputTokens = input + cache.read + cache.write
|
|
127
|
+
* outputTokens = output + reasoning
|
|
128
|
+
* reasoning and cache fields pass through as the subsets they are.
|
|
129
|
+
*/
|
|
130
|
+
const toUsage = (tokens?: RawUsage): PlugUsage | undefined =>
|
|
131
|
+
tokens
|
|
132
|
+
? {
|
|
133
|
+
inputTokens: total(tokens.input, tokens.cache?.read, tokens.cache?.write),
|
|
134
|
+
outputTokens: total(tokens.output, tokens.reasoning),
|
|
135
|
+
reasoningTokens: tokens.reasoning,
|
|
136
|
+
cacheReadTokens: tokens.cache?.read,
|
|
137
|
+
cacheWriteTokens: tokens.cache?.write,
|
|
138
|
+
}
|
|
139
|
+
: undefined
|
|
140
|
+
|
|
141
|
+
/** Host `SessionError` (`{ type, message, status?, response? }`) to a `PlugError`. */
|
|
142
|
+
function toPlugError(error: RawError | undefined, fallback: string): PlugError {
|
|
143
|
+
const status = typeof error?.status === "number" && error.status >= 400 ? error.status : undefined
|
|
144
|
+
const message = error?.message || fallback
|
|
145
|
+
const base = { message, ...(error?.type ? { code: error.type } : {}) }
|
|
146
|
+
if (status === 401) return { status, kind: "authentication", ...base }
|
|
147
|
+
if (status === 403) return { status, kind: "permission", ...base }
|
|
148
|
+
if (status === 404) return { status, kind: "not_found", ...base }
|
|
149
|
+
if (status === 429) return { status, kind: "rate_limit", ...base }
|
|
150
|
+
if (status === 408 || status === 504) return { status, kind: "timeout", ...base }
|
|
151
|
+
if (status !== undefined && status < 500) return { status, kind: "invalid_request", ...base }
|
|
152
|
+
return { status: status ?? 502, kind: "server", ...base }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface CollectorHooks {
|
|
156
|
+
/** The step ended with tool calls: stop the turn before the runner starts another model step. */
|
|
157
|
+
readonly stopTurn: () => void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class RequestCollector {
|
|
161
|
+
private readonly queue = new EventQueue<PlugEvent>(MAX_QUEUED_EVENTS, MAX_QUEUED_BYTES)
|
|
162
|
+
private readonly emittedText = new Map<number, number>()
|
|
163
|
+
private readonly toolNames = new Map<string, string>()
|
|
164
|
+
private readonly calls = new Set<string>()
|
|
165
|
+
private usage?: RawUsage
|
|
166
|
+
private finished = false
|
|
167
|
+
private executionDone = false
|
|
168
|
+
private wasCancelled = false
|
|
169
|
+
private settled = false
|
|
170
|
+
|
|
171
|
+
constructor(private readonly hooks: CollectorHooks) {}
|
|
172
|
+
|
|
173
|
+
/** True once a `finish` or `error` event was emitted (or the stream was ended); later events are ignored. */
|
|
174
|
+
get terminal(): boolean {
|
|
175
|
+
return this.finished
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** True once the OpenCode execution ended (succeeded, failed or interrupted). */
|
|
179
|
+
get executionEnded(): boolean {
|
|
180
|
+
return this.executionDone
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** True once a step ended with a plain `stop` and no tool calls: the runner finishes by itself, nothing needs interrupting. */
|
|
184
|
+
get turnSettled(): boolean {
|
|
185
|
+
return this.settled
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True once the owner cancelled the request (client gone or timeout). */
|
|
189
|
+
get cancelled(): boolean {
|
|
190
|
+
return this.wasCancelled
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The request's events, in order. Ends when the collector is terminal. */
|
|
194
|
+
events(): AsyncIterable<PlugEvent> {
|
|
195
|
+
return this.queue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Mark the request cancelled by its owner (client gone or timed out) and close the stream without a terminal event. */
|
|
199
|
+
cancel(): void {
|
|
200
|
+
this.wasCancelled = true
|
|
201
|
+
this.end()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `item` is the flattened bus event: `{ type, ...data }`. Field types are not verified here:
|
|
206
|
+
* the host's event payloads are read defensively (`String(...)`, `Number(...)`), and the few
|
|
207
|
+
* structured fields (`tokens`, `error`) are cast to the shapes in `RawUsage` / `RawError`.
|
|
208
|
+
*/
|
|
209
|
+
push(item: BusEvent): void {
|
|
210
|
+
switch (item.type) {
|
|
211
|
+
case "session.text.delta": {
|
|
212
|
+
if (this.finished) return
|
|
213
|
+
const ordinal = Number(item.ordinal ?? 0)
|
|
214
|
+
const delta = String(item.delta ?? "")
|
|
215
|
+
this.emittedText.set(ordinal, (this.emittedText.get(ordinal) ?? 0) + delta.length)
|
|
216
|
+
if (delta.length > 0) this.emit({ type: "text-delta", text: delta })
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
case "session.text.ended": {
|
|
220
|
+
if (this.finished) return
|
|
221
|
+
// Deltas are live-only; if any were missed, the ended event carries the full text.
|
|
222
|
+
const ordinal = Number(item.ordinal ?? 0)
|
|
223
|
+
const full = String(item.text ?? "")
|
|
224
|
+
const seen = this.emittedText.get(ordinal) ?? 0
|
|
225
|
+
if (full.length > seen) this.emit({ type: "text-delta", text: full.slice(seen) })
|
|
226
|
+
this.emittedText.set(ordinal, full.length)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
case "session.reasoning.delta": {
|
|
230
|
+
if (this.finished) return
|
|
231
|
+
const delta = String(item.delta ?? "")
|
|
232
|
+
if (delta.length > 0) this.emit({ type: "reasoning-delta", text: delta })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
case "session.tool.input.started":
|
|
236
|
+
this.toolNames.set(String(item.id), String(item.name ?? ""))
|
|
237
|
+
return
|
|
238
|
+
case "session.tool.called": {
|
|
239
|
+
if (this.finished) return
|
|
240
|
+
const id = String(item.id)
|
|
241
|
+
if (this.calls.has(id)) return
|
|
242
|
+
this.calls.add(id)
|
|
243
|
+
this.emit({ type: "tool-call", id, name: String(item.name ?? this.toolNames.get(id) ?? ""), input: item.input })
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
case "session.usage.updated":
|
|
247
|
+
// Assumed invariant: `tokens` is the host's token-usage record (RawUsage shape).
|
|
248
|
+
this.usage = (item.tokens as RawUsage | undefined) ?? this.usage
|
|
249
|
+
return
|
|
250
|
+
case "session.step.ended":
|
|
251
|
+
this.usage = (item.tokens as RawUsage | undefined) ?? this.usage
|
|
252
|
+
// Also after the response finished: a later step that ended with calls means the first
|
|
253
|
+
// interrupt lost the race or failed; ask again (the guard stays until the execution ends).
|
|
254
|
+
if (this.calls.size > 0 && !this.executionDone) this.hooks.stopTurn()
|
|
255
|
+
if (this.calls.size === 0 && toFinish(item.finish) === "stop") this.settled = true
|
|
256
|
+
this.finishWith(withCalls(toFinish(item.finish), this.calls.size > 0))
|
|
257
|
+
return
|
|
258
|
+
case FAILURE_EVENT:
|
|
259
|
+
// Synthetic, from inside the process (lost event stream, preparation mismatch).
|
|
260
|
+
if (isPlugError(item.error)) this.abort(item.error)
|
|
261
|
+
return
|
|
262
|
+
case "session.step.failed": {
|
|
263
|
+
this.usage = (item.tokens as RawUsage | undefined) ?? this.usage
|
|
264
|
+
// Assumed invariant: `error` is the host's `SessionError` (RawError shape).
|
|
265
|
+
const error = item.error as RawError | undefined
|
|
266
|
+
// The host can run another step after a failed one; the response is over, the turn must be too.
|
|
267
|
+
if (!this.executionDone && error?.type !== "aborted") this.hooks.stopTurn()
|
|
268
|
+
if (this.calls.size > 0 && error?.type === "aborted") return this.finishWith("tool-calls")
|
|
269
|
+
if (item.finish === "content-filter") return this.finishWith("content-filter")
|
|
270
|
+
if (error?.type === "aborted" && this.wasCancelled) return this.end()
|
|
271
|
+
return this.fail(toPlugError(error, "Model step failed"))
|
|
272
|
+
}
|
|
273
|
+
case "session.execution.failed":
|
|
274
|
+
this.executionDone = true
|
|
275
|
+
if (!this.finished) this.fail(toPlugError(item.error as RawError | undefined, "Session execution failed"))
|
|
276
|
+
this.end()
|
|
277
|
+
return
|
|
278
|
+
case "session.execution.interrupted":
|
|
279
|
+
case "session.execution.succeeded":
|
|
280
|
+
this.executionDone = true
|
|
281
|
+
if (!this.finished) {
|
|
282
|
+
if (this.calls.size > 0) this.finishWith("tool-calls")
|
|
283
|
+
else if (this.wasCancelled) this.end()
|
|
284
|
+
else if (item.type === "session.execution.succeeded") this.finishWith("stop")
|
|
285
|
+
else this.fail({ status: 500, kind: "server", message: "Request was interrupted before the model finished" })
|
|
286
|
+
}
|
|
287
|
+
this.end()
|
|
288
|
+
return
|
|
289
|
+
default:
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Fail the request with `error` and stop the turn. No-op once the request is terminal. */
|
|
295
|
+
abort(error: PlugError): void {
|
|
296
|
+
if (this.finished) return
|
|
297
|
+
this.fail(error)
|
|
298
|
+
this.hooks.stopTurn()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Close the stream without a terminal event (client gone). */
|
|
302
|
+
end(): void {
|
|
303
|
+
this.finished = true
|
|
304
|
+
this.queue.close()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private finishWith(reason: PlugFinish): void {
|
|
308
|
+
if (this.finished) return
|
|
309
|
+
this.emit({ type: "finish", reason, usage: toUsage(this.usage) })
|
|
310
|
+
this.end()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private fail(error: PlugError): void {
|
|
314
|
+
if (this.finished) return
|
|
315
|
+
this.emit({ type: "error", error })
|
|
316
|
+
this.end()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private emit(event: PlugEvent): void {
|
|
320
|
+
// Errors are always queued: they are how an overflow is reported.
|
|
321
|
+
if (this.queue.push(event, sizeOf(event), event.type === "error")) return
|
|
322
|
+
// The consumer is not keeping up: drop what waits, fail this request only, and stop the turn.
|
|
323
|
+
this.queue.clear()
|
|
324
|
+
this.queue.push({ type: "error", error: RESPONSE_OVERFLOW }, 0, true)
|
|
325
|
+
this.end()
|
|
326
|
+
if (!this.executionDone) this.hooks.stopTurn()
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A tool call turns a normal stop into `tool-calls`; a truncated, filtered or failed step keeps its reason. */
|
|
331
|
+
const withCalls = (reason: PlugFinish, hasCalls: boolean): PlugFinish => (hasCalls && (reason === "stop" || reason === "tool-calls") ? "tool-calls" : reason)
|
|
332
|
+
|
|
333
|
+
const toFinish = (finish: unknown): PlugFinish => {
|
|
334
|
+
switch (finish) {
|
|
335
|
+
case "length":
|
|
336
|
+
case "content-filter":
|
|
337
|
+
case "error":
|
|
338
|
+
return finish
|
|
339
|
+
case "tool-calls":
|
|
340
|
+
return "tool-calls"
|
|
341
|
+
default:
|
|
342
|
+
return "stop"
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where request sessions live and what they store: the data root, the app directory, and the
|
|
3
|
+
* session title and prompt transcript.
|
|
4
|
+
*
|
|
5
|
+
* One request = one kept OpenCode session in the caller app's directory under the data root.
|
|
6
|
+
* The stored prompt is the full caller transcript rendered as readable text (images ride along
|
|
7
|
+
* as prompt files, listed here by attachment number); the context hook substitutes the real
|
|
8
|
+
* structured system/messages/tools for the model call.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir, realpath } from "node:fs/promises"
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join, sep } from "node:path"
|
|
14
|
+
import type { PlugError, PlugRequest } from "../contract.js"
|
|
15
|
+
|
|
16
|
+
/** `$XDG_DATA_HOME/opencode-plugboard`, else `~/.local/share/opencode-plugboard`. */
|
|
17
|
+
function dataRoot(): string {
|
|
18
|
+
const base = process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME.length > 0 ? process.env.XDG_DATA_HOME : join(homedir(), ".local", "share")
|
|
19
|
+
return join(base, "opencode-plugboard")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create (if needed) and return the real path of `<dataRoot>/<app>`. `app` must already be validated.
|
|
24
|
+
* Fails closed with a `PlugError` when the resolved path is not strictly inside the canonical data
|
|
25
|
+
* root (a symlinked app directory): a session there would run in another project, where plugboard
|
|
26
|
+
* registers no context hook and no seeds, so it must never be created.
|
|
27
|
+
*
|
|
28
|
+
* This is the only place ownership of a directory is decided, and it is asynchronous: plugin
|
|
29
|
+
* setup never touches the filesystem. A directory that comes back here is what the runtime treats
|
|
30
|
+
* as a plugboard app directory (`ensureBackend` in src/runtime.ts).
|
|
31
|
+
*/
|
|
32
|
+
export async function ensureAppDir(app: string): Promise<string | PlugError> {
|
|
33
|
+
const root = dataRoot()
|
|
34
|
+
const appDir = join(root, app)
|
|
35
|
+
await mkdir(appDir, { recursive: true })
|
|
36
|
+
const [realRoot, realApp] = await Promise.all([realpath(root), realpath(appDir)])
|
|
37
|
+
if (!realApp.startsWith(realRoot + sep)) {
|
|
38
|
+
return { status: 500, kind: "server", code: "app_dir_outside_root", message: "The app directory resolves outside the plugboard data root" }
|
|
39
|
+
}
|
|
40
|
+
return realApp
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TITLE_LENGTH = 60
|
|
44
|
+
|
|
45
|
+
const firstUserText = (request: PlugRequest): string => {
|
|
46
|
+
for (const message of request.messages) {
|
|
47
|
+
if (message.role !== "user") continue
|
|
48
|
+
for (const part of message.content) if (part.type === "text" && part.text.trim().length > 0) return part.text
|
|
49
|
+
}
|
|
50
|
+
return request.model
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Session title: the first user text, whitespace-collapsed and cut to 60 characters. */
|
|
54
|
+
export const makeTitle = (request: PlugRequest): string => {
|
|
55
|
+
const flat = firstUserText(request).replace(/\s+/g, " ").trim()
|
|
56
|
+
return flat.length > TITLE_LENGTH ? flat.slice(0, TITLE_LENGTH - 1) + "…" : flat
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Readable transcript stored as the session's prompt; images are listed by attachment number. */
|
|
60
|
+
export function renderTranscript(request: PlugRequest): string {
|
|
61
|
+
const lines: string[] = []
|
|
62
|
+
let image = 0
|
|
63
|
+
if (request.system.length > 0) {
|
|
64
|
+
lines.push("## system", ...request.system, "")
|
|
65
|
+
}
|
|
66
|
+
for (const message of request.messages) {
|
|
67
|
+
lines.push(`## ${message.role}`)
|
|
68
|
+
for (const part of message.content) {
|
|
69
|
+
switch (part.type) {
|
|
70
|
+
case "text":
|
|
71
|
+
lines.push(part.text)
|
|
72
|
+
break
|
|
73
|
+
case "media":
|
|
74
|
+
lines.push(`[image ${++image}]`)
|
|
75
|
+
break
|
|
76
|
+
case "tool-call":
|
|
77
|
+
lines.push(`[tool call ${part.name} (${part.id})] ${typeof part.input === "string" ? part.input : JSON.stringify(part.input)}`)
|
|
78
|
+
break
|
|
79
|
+
case "tool-result":
|
|
80
|
+
lines.push(`[tool result ${part.name} (${part.id})${part.isError ? " (error)" : ""}] ${part.result}`)
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
lines.push("")
|
|
85
|
+
}
|
|
86
|
+
if (request.tools.length > 0) lines.push("## tools", request.tools.map((tool) => tool.name).join(", "), "")
|
|
87
|
+
const text = lines.join("\n").trim()
|
|
88
|
+
return text.length > 0 ? text : "(empty request)"
|
|
89
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host adapter: the one place that touches OpenCode's Effect plugin API on behalf of the
|
|
3
|
+
* session code. It turns `ctx.session` into the Promise-shaped `SessionPort`, `ctx.model.list`
|
|
4
|
+
* into a neutral catalog, and defines the narrow shapes the session code reads from the host (the
|
|
5
|
+
* `context` hook event, the flat bus-event record).
|
|
6
|
+
*
|
|
7
|
+
* Pinned to the OpenCode version in docs/architecture.md. Beyond `Effect.runPromise` this module
|
|
8
|
+
* only imports host types; no module under `session/` imports `@opencode/ai` or core internals at
|
|
9
|
+
* runtime.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Plugin } from "@opencode/plugin/effect"
|
|
13
|
+
import type { SessionContext } from "@opencode/plugin/effect/session"
|
|
14
|
+
import { Effect } from "effect"
|
|
15
|
+
import type { CatalogModel } from "../contract.js"
|
|
16
|
+
|
|
17
|
+
export type HostContext = Plugin.Context
|
|
18
|
+
|
|
19
|
+
/** JSON value, the shape of session metadata (the host's `Json`). */
|
|
20
|
+
export type Json = string | number | boolean | null | ReadonlyArray<Json> | { readonly [key: string]: Json }
|
|
21
|
+
|
|
22
|
+
/** Promise-shaped view of the host calls the session caller needs (the entry wires them to `ctx.session`). */
|
|
23
|
+
export interface SessionPort {
|
|
24
|
+
create(input: {
|
|
25
|
+
readonly directory: string
|
|
26
|
+
readonly model: { readonly providerID: string; readonly id: string; readonly variant?: string }
|
|
27
|
+
readonly title: string
|
|
28
|
+
readonly metadata: { readonly [key: string]: Json }
|
|
29
|
+
readonly permissions: ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }>
|
|
30
|
+
}): Promise<{ id: string }>
|
|
31
|
+
prompt(input: { readonly sessionID: string; readonly text: string; readonly files: ReadonlyArray<{ uri: string; name: string }> }): Promise<unknown>
|
|
32
|
+
interrupt(sessionID: string): Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type HostSession = HostContext["session"]
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The host's branded session ID. It is a string with a type-level brand and no runtime form, so
|
|
39
|
+
* the one cast below is the only unchecked step: the ID always comes from the host's own
|
|
40
|
+
* `session.create` result (or an event of that session).
|
|
41
|
+
*/
|
|
42
|
+
type HostSessionID = Parameters<HostSession["interrupt"]>[0]["sessionID"]
|
|
43
|
+
const hostSessionID = (id: string): HostSessionID => id as HostSessionID
|
|
44
|
+
|
|
45
|
+
type HostCreate = NonNullable<Parameters<HostSession["create"]>[0]>
|
|
46
|
+
type HostModelRef = NonNullable<HostCreate["model"]>
|
|
47
|
+
type HostDirectory = NonNullable<HostCreate["location"]>["directory"]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Branded model ref and directory. Same kind of cast as the session ID: the values are the
|
|
51
|
+
* resolved catalog ref (a model the host listed) and the app directory `ensureAppDir` returned
|
|
52
|
+
* (absolute, inside the data root); the host validates both again at runtime.
|
|
53
|
+
*/
|
|
54
|
+
const hostModel = (model: { readonly providerID: string; readonly id: string; readonly variant?: string }): HostModelRef => ({
|
|
55
|
+
providerID: model.providerID as HostModelRef["providerID"],
|
|
56
|
+
id: model.id as HostModelRef["id"],
|
|
57
|
+
...(model.variant ? { variant: model.variant as NonNullable<HostModelRef["variant"]> } : {}),
|
|
58
|
+
})
|
|
59
|
+
const hostDirectory = (directory: string): HostDirectory => directory as HostDirectory
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `ctx.session` as a Promise port. Inputs are built as object literals that must `satisfy` the
|
|
63
|
+
* host's own parameter types, so a host upgrade that changes them fails `bunx tsc --noEmit`;
|
|
64
|
+
* the only casts are the branded IDs (session, model, directory), one helper each. The host's effects need no services (it provides them).
|
|
65
|
+
*/
|
|
66
|
+
export function createSessionPort(ctx: HostContext): SessionPort {
|
|
67
|
+
const run = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => Effect.runPromise(effect)
|
|
68
|
+
return {
|
|
69
|
+
create: (input) =>
|
|
70
|
+
run(
|
|
71
|
+
ctx.session.create({
|
|
72
|
+
model: hostModel(input.model),
|
|
73
|
+
title: input.title,
|
|
74
|
+
metadata: input.metadata,
|
|
75
|
+
permissions: input.permissions,
|
|
76
|
+
location: { directory: hostDirectory(input.directory) },
|
|
77
|
+
} satisfies HostCreate),
|
|
78
|
+
).then((created) => ({ id: String(created.id) })),
|
|
79
|
+
prompt: (input) =>
|
|
80
|
+
run(
|
|
81
|
+
ctx.session.prompt({
|
|
82
|
+
sessionID: hostSessionID(input.sessionID),
|
|
83
|
+
text: input.text,
|
|
84
|
+
files: input.files,
|
|
85
|
+
} satisfies Parameters<HostSession["prompt"]>[0]),
|
|
86
|
+
),
|
|
87
|
+
interrupt: (sessionID) =>
|
|
88
|
+
run(ctx.session.interrupt({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["interrupt"]>[0])).then(() => undefined),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What the history index needs from the host: the plugin's own key-value storage (`ctx.storage`,
|
|
94
|
+
* namespaced by plugin id and shared by every Location of the server) and `session.remove`. The
|
|
95
|
+
* plugin session API has no `list`, so plugboard indexes its own request sessions in that storage
|
|
96
|
+
* and prunes from the index (src/session/prune.ts).
|
|
97
|
+
*/
|
|
98
|
+
export interface HistoryPort {
|
|
99
|
+
put(key: string, value: string): Promise<void>
|
|
100
|
+
drop(key: string): Promise<void>
|
|
101
|
+
/** Keys under `prefix` in ascending order, after `after` (exclusive); `next` is set when more remain. */
|
|
102
|
+
scan(input: { readonly prefix: string; readonly after?: string; readonly limit: number }): Promise<{ readonly keys: ReadonlyArray<string>; readonly next?: string }>
|
|
103
|
+
/** `missing` when the session no longer exists (the host's `Session.NotFoundError`); any other failure rejects. */
|
|
104
|
+
removeSession(sessionID: string): Promise<"removed" | "missing">
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const failureTag = (error: unknown): unknown => (typeof error === "object" && error !== null ? (error as { _tag?: unknown })._tag : undefined)
|
|
108
|
+
|
|
109
|
+
/** `ctx.storage` and `ctx.session.remove` as a Promise port. The branded session ID is the same host-issued string as above. */
|
|
110
|
+
export function createHistoryPort(ctx: HostContext): HistoryPort {
|
|
111
|
+
return {
|
|
112
|
+
put: (key, value) => Effect.runPromise(ctx.storage.set(key, value)),
|
|
113
|
+
drop: (key) => Effect.runPromise(ctx.storage.remove(key)),
|
|
114
|
+
scan: (input) =>
|
|
115
|
+
Effect.runPromise(ctx.storage.scan(input)).then((result) => ({
|
|
116
|
+
keys: result.entries.map((entry) => entry.key),
|
|
117
|
+
...(result.next === undefined ? {} : { next: result.next }),
|
|
118
|
+
})),
|
|
119
|
+
removeSession: (sessionID) =>
|
|
120
|
+
Effect.runPromise(
|
|
121
|
+
ctx.session.remove({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["remove"]>[0]).pipe(
|
|
122
|
+
Effect.match({ onSuccess: () => undefined, onFailure: (error) => ({ error }) }),
|
|
123
|
+
),
|
|
124
|
+
).then((failed) => {
|
|
125
|
+
if (failed === undefined) return "removed" as const
|
|
126
|
+
if (failureTag(failed.error) === "Session.NotFoundError") return "missing" as const
|
|
127
|
+
throw failed.error
|
|
128
|
+
}),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
133
|
+
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reduces `ctx.model.list` data (V2 `Model.Info`, tolerant of missing fields) to the catalog.
|
|
137
|
+
* Models with `enabled: false` are dropped; `modelID` wins over `id`; variants may be `{ id }` or strings.
|
|
138
|
+
*/
|
|
139
|
+
function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
|
|
140
|
+
const catalog: CatalogModel[] = []
|
|
141
|
+
for (const item of raw) {
|
|
142
|
+
const model = record(item)
|
|
143
|
+
if (!model || model.enabled === false) continue
|
|
144
|
+
const providerID = model.providerID
|
|
145
|
+
const id = model.modelID ?? model.id
|
|
146
|
+
if (typeof providerID !== "string" || typeof id !== "string" || !providerID || !id) continue
|
|
147
|
+
const variants = (Array.isArray(model.variants) ? model.variants : [])
|
|
148
|
+
.map((v) => (typeof v === "string" ? v : record(v)?.id))
|
|
149
|
+
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
|
150
|
+
const caps = record(model.capabilities)
|
|
151
|
+
const input = caps && Array.isArray(caps.input) ? caps.input : undefined
|
|
152
|
+
const context = record(model.limit)?.context
|
|
153
|
+
catalog.push({
|
|
154
|
+
providerID,
|
|
155
|
+
id,
|
|
156
|
+
...(typeof model.name === "string" ? { name: model.name } : {}),
|
|
157
|
+
variants: [...new Set(variants)],
|
|
158
|
+
...(caps
|
|
159
|
+
? {
|
|
160
|
+
capabilities: {
|
|
161
|
+
...(typeof caps.tools === "boolean" ? { tools: caps.tools } : {}),
|
|
162
|
+
...(input ? { image: input.includes("image") } : {}),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
: {}),
|
|
166
|
+
...(typeof context === "number" ? { contextLimit: context } : {}),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
return catalog
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The serving Location's enabled models, as the neutral catalog. */
|
|
173
|
+
export async function loadCatalog(ctx: HostContext): Promise<ReadonlyArray<CatalogModel>> {
|
|
174
|
+
const listed = await Effect.runPromise(ctx.model.list({}))
|
|
175
|
+
return toCatalog(listed.data ?? [])
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One part of a host message. Media parts carry host-built Assets that plugboard passes through untouched. */
|
|
179
|
+
export interface HostPart {
|
|
180
|
+
readonly type: string
|
|
181
|
+
readonly [key: string]: unknown
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A tool descriptor as the `context` hook exposes it. Plugboard rewrites it in place (see `context.ts`). */
|
|
185
|
+
export interface HostToolDescriptor {
|
|
186
|
+
description: string
|
|
187
|
+
input: unknown
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The parts of the `session.hook("context")` event plugboard reads and writes. The host passes
|
|
192
|
+
* its mutable `SessionContext`; assigning to these fields is how the hook replaces the model
|
|
193
|
+
* request. Fields plugboard treats defensively are optional here.
|
|
194
|
+
*/
|
|
195
|
+
export interface ContextHookEvent {
|
|
196
|
+
readonly sessionID: string
|
|
197
|
+
system: unknown[]
|
|
198
|
+
messages?: Array<{ content?: unknown }>
|
|
199
|
+
options?: Record<string, unknown>
|
|
200
|
+
tools?: Record<string, HostToolDescriptor>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Compile-time gate: fails to build when the pinned host's `SessionContext` stops being usable as a `ContextHookEvent`.
|
|
204
|
+
type Assert<T extends true> = T
|
|
205
|
+
export type HostContextMatches = Assert<SessionContext extends ContextHookEvent ? true : false>
|
|
206
|
+
|
|
207
|
+
/** A flattened bus event: `{ type, ...data }` as routed by the process-wide session store. */
|
|
208
|
+
export type BusEvent = Record<string, unknown>
|
|
209
|
+
|
|
210
|
+
/** Short readable text for an unknown thrown value. */
|
|
211
|
+
export const describeError = (error: unknown): string => {
|
|
212
|
+
if (error instanceof Error && error.message) return error.message
|
|
213
|
+
const text = String(error)
|
|
214
|
+
return text.length > 400 ? text.slice(0, 400) + "…" : text
|
|
215
|
+
}
|