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.
@@ -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
+ }