pantheon-opencode 1.6.0-beta.2 → 1.6.0-beta.3

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,426 @@
1
+ /**
2
+ * V2 tool surface — the Pantheon tools that are self-sufficient on the
3
+ * OpenCode V2 host, declared for `ctx.tool.transform`'s `draft.add`.
4
+ *
5
+ * ## Why only three tools
6
+ *
7
+ * A V2 tool can only be served here when it needs no V1 infrastructure. The
8
+ * three goal tools (`pantheon_goal_create`, `pantheon_goal_get`,
9
+ * `pantheon_goal_update`) are NOT: `GoalLoopDeps` requires a `GoalStore`, a
10
+ * `GoalLoopClient` and a `BackgroundJobBoard`, none of which exist on the V2
11
+ * `PluginContext`, and `getV2BridgeFromContext` resolves to `null` outside V1.
12
+ * They are therefore absent from this surface rather than registered as
13
+ * placeholders that resolve to a message — a placeholder still fails the host's
14
+ * output contract on every call, so it buys nothing. See
15
+ * `V2_UNSUPPORTED_FEATURES` in `../plugin-v2.ts` (marker `goal-tools`).
16
+ *
17
+ * `hashline_edit` (pure filesystem), `pantheon_cost` (reads `opencode.db`) and
18
+ * `pantheon_model` (reads/writes `active-preset.json`) need none of that, so
19
+ * they are wired to their real V1 implementations.
20
+ *
21
+ * ## The output declaration is mandatory
22
+ *
23
+ * The host enforces a biconditional between a tool's declared `output` and the
24
+ * value its `execute` resolves to (opencode 2.0.22):
25
+ *
26
+ * `def.output === undefined` and `'output' in result` -> throws
27
+ * "Tool result declared output without an output schema"
28
+ * `def.output !== undefined` and `!('output' in result)` -> throws
29
+ * "Tool did not return its declared output"
30
+ *
31
+ * Declaring `output` and returning it are therefore one decision, not two. The
32
+ * first branch is the historical Pantheon defect; the second is why the two
33
+ * must move together. `V2ToolDef` makes the field required so TypeScript
34
+ * rejects a tool that forgets it.
35
+ *
36
+ * @module pantheon/v2-tools
37
+ */
38
+
39
+ import { createCostCommand } from './cost-command.ts'
40
+ import { createHashlineEditTool, type HashlineToolResult } from './hashline/tool.ts'
41
+ import { createModelCommand } from './model-command.ts'
42
+ import type { ToolContextLike } from './tool-context.ts'
43
+
44
+ // ─── Contract types ─────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * V2 tool result shape.
48
+ *
49
+ * The V1 SDK types `ToolResult` as `string | { output: string, ... }`, but the
50
+ * V2 beta host reads a field off the resolved value and throws when a tool
51
+ * resolves to a bare string. Always resolve to the object shape — it satisfies
52
+ * both the SDK union and the beta host.
53
+ *
54
+ * `title` and `metadata` are part of the shape because `hashline_edit` returns
55
+ * them on success (see `HashlineToolResult`); the host spreads `metadata`
56
+ * through when present, so they are carried rather than dropped.
57
+ */
58
+ export interface V2ToolResult {
59
+ output: string
60
+ title?: string
61
+ metadata?: Record<string, unknown>
62
+ }
63
+
64
+ /**
65
+ * A V2 tool definition ready for registration.
66
+ *
67
+ * `output` is REQUIRED, not optional: an omitted declaration is precisely the
68
+ * condition the host rejects at runtime, so making it optional would let the
69
+ * defect return silently.
70
+ */
71
+ export interface V2ToolDef {
72
+ name: string
73
+ description: string
74
+ input: Record<string, unknown>
75
+ output: Record<string, unknown>
76
+ execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
77
+ }
78
+
79
+ // ─── Context adapter ────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Project the host's V2 tool context onto the structural `ToolContextLike`.
83
+ *
84
+ * The host may omit `directory`/`worktree`; `hashline_edit` then falls back to
85
+ * `process.cwd()` through its own `resolveFile` containment guard, so an absent
86
+ * field degrades safely rather than throwing.
87
+ */
88
+ function toolContext(context: unknown): ToolContextLike {
89
+ const ctx = (context ?? {}) as Partial<ToolContextLike>
90
+ return {
91
+ sessionID: typeof ctx.sessionID === 'string' ? ctx.sessionID : 'v2-unknown',
92
+ // Spread rather than assign `undefined`: the project compiles with
93
+ // exactOptionalPropertyTypes, so an explicit undefined is not assignable.
94
+ ...(typeof ctx.directory === 'string' ? { directory: ctx.directory } : {}),
95
+ ...(typeof ctx.worktree === 'string' ? { worktree: ctx.worktree } : {}),
96
+ ...(typeof ctx.agent === 'string' ? { agent: ctx.agent } : {}),
97
+ }
98
+ }
99
+
100
+ /** Error text for an unexpected throw, without leaking a stack trace. */
101
+ function errorText(prefix: string, err: unknown): string {
102
+ return `${prefix}: ${err instanceof Error ? err.message : String(err)}`
103
+ }
104
+
105
+ // ─── hashline_edit ──────────────────────────────────────────────────────
106
+
107
+ /** JSON Schema for `hashline_edit`, mirroring the V1 zod args exactly. */
108
+ const HASHLINE_EDIT_INPUT: Record<string, unknown> = {
109
+ type: 'object',
110
+ additionalProperties: false,
111
+ required: ['file', 'edits'],
112
+ properties: {
113
+ file: {
114
+ type: 'string',
115
+ minLength: 1,
116
+ description: 'File to edit. Absolute, or relative to the worktree.',
117
+ },
118
+ edits: {
119
+ type: 'array',
120
+ minItems: 1,
121
+ description:
122
+ 'Edits to apply. All refs are validated against the ORIGINAL file before anything is written.',
123
+ items: {
124
+ type: 'object',
125
+ additionalProperties: false,
126
+ required: ['op', 'ref'],
127
+ properties: {
128
+ op: { type: 'string', enum: ['replace', 'append', 'prepend', 'delete'] },
129
+ ref: {
130
+ type: 'string',
131
+ minLength: 1,
132
+ description: 'Anchor ref "LINE#TAG" against the ORIGINAL snapshot.',
133
+ },
134
+ endRef: {
135
+ type: 'string',
136
+ minLength: 1,
137
+ description: 'Optional range end for replace/delete, as "LINE#TAG".',
138
+ },
139
+ lines: {
140
+ type: 'array',
141
+ items: { type: 'string' },
142
+ description: 'Replacement lines (replace only).',
143
+ },
144
+ content: {
145
+ type: 'string',
146
+ description: 'Text to insert (append/prepend); split on newlines.',
147
+ },
148
+ },
149
+ },
150
+ },
151
+ },
152
+ }
153
+
154
+ /** Coerced `hashline_edit` args, structurally equal to the V1 zod inference. */
155
+ interface HashlineEditArgs {
156
+ file: string
157
+ edits: Array<{
158
+ op: 'replace' | 'append' | 'prepend' | 'delete'
159
+ ref: string
160
+ endRef?: string
161
+ lines?: string[]
162
+ content?: string
163
+ }>
164
+ }
165
+
166
+ const HASHLINE_OPS = new Set(['replace', 'append', 'prepend', 'delete'])
167
+
168
+ function isRecord(value: unknown): value is Record<string, unknown> {
169
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
170
+ }
171
+
172
+ /**
173
+ * Re-validate the host-provided input against the declared JSON Schema.
174
+ *
175
+ * The host validates `input` before `execute`, but this wrapper is also the
176
+ * direct programmatic entry point (tests, embedded callers), so it must not
177
+ * assume a pre-validated shape. Failures are returned as error-as-text, the
178
+ * same contract the V1 tool uses for user errors.
179
+ */
180
+ function coerceHashlineEditArgs(input: Record<string, unknown>):
181
+ | {
182
+ ok: true
183
+ args: HashlineEditArgs
184
+ }
185
+ | { ok: false; error: string } {
186
+ const file = input.file
187
+ if (typeof file !== 'string' || file.length === 0) {
188
+ return { ok: false, error: 'hashline_edit: "file" must be a non-empty string' }
189
+ }
190
+ const rawEdits = input.edits
191
+ if (!Array.isArray(rawEdits) || rawEdits.length === 0) {
192
+ return { ok: false, error: 'hashline_edit: "edits" must be a non-empty array' }
193
+ }
194
+ const edits: HashlineEditArgs['edits'] = []
195
+ for (const [i, raw] of rawEdits.entries()) {
196
+ if (!isRecord(raw)) {
197
+ return { ok: false, error: `hashline_edit: edit (index ${i}) must be an object` }
198
+ }
199
+ const op = raw.op
200
+ if (typeof op !== 'string' || !HASHLINE_OPS.has(op)) {
201
+ return {
202
+ ok: false,
203
+ error: `hashline_edit: edit (index ${i}) "op" must be one of replace, append, prepend, delete`,
204
+ }
205
+ }
206
+ const ref = raw.ref
207
+ if (typeof ref !== 'string' || ref.length === 0) {
208
+ return {
209
+ ok: false,
210
+ error: `hashline_edit: edit (index ${i}) "ref" must be a non-empty string`,
211
+ }
212
+ }
213
+ let lines: string[] | undefined
214
+ if (raw.lines !== undefined) {
215
+ if (!Array.isArray(raw.lines) || raw.lines.some((l) => typeof l !== 'string')) {
216
+ return {
217
+ ok: false,
218
+ error: `hashline_edit: edit (index ${i}) "lines" must be a string array`,
219
+ }
220
+ }
221
+ lines = raw.lines as string[]
222
+ }
223
+ if (raw.content !== undefined && typeof raw.content !== 'string') {
224
+ return { ok: false, error: `hashline_edit: edit (index ${i}) "content" must be a string` }
225
+ }
226
+ if (raw.endRef !== undefined && typeof raw.endRef !== 'string') {
227
+ return { ok: false, error: `hashline_edit: edit (index ${i}) "endRef" must be a string` }
228
+ }
229
+ edits.push({
230
+ op: op as HashlineEditArgs['edits'][number]['op'],
231
+ ref,
232
+ // Conditional spread: exactOptionalPropertyTypes forbids `prop: undefined`.
233
+ ...(typeof raw.endRef === 'string' ? { endRef: raw.endRef } : {}),
234
+ ...(lines === undefined ? {} : { lines }),
235
+ ...(typeof raw.content === 'string' ? { content: raw.content } : {}),
236
+ })
237
+ }
238
+ return { ok: true, args: { file, edits } }
239
+ }
240
+
241
+ const hashlineEdit = createHashlineEditTool()
242
+
243
+ /**
244
+ * Wrap the V1 `hashline_edit` tool for the V2 surface.
245
+ *
246
+ * The V1 tool returns error-as-text for user errors and the success object
247
+ * `{ title, output, metadata }`. Both are carried: the `output` string always
248
+ * satisfies the host's declaration, and `title`/`metadata` survive because the
249
+ * host spreads metadata through.
250
+ */
251
+ async function executeHashlineEdit(
252
+ input: Record<string, unknown>,
253
+ context: unknown,
254
+ ): Promise<V2ToolResult> {
255
+ const coerced = coerceHashlineEditArgs(input)
256
+ if (!coerced.ok) return { output: coerced.error }
257
+ let result: HashlineToolResult
258
+ try {
259
+ result = await hashlineEdit.execute(coerced.args, toolContext(context))
260
+ } catch (err: unknown) {
261
+ // The V1 tool does not throw for user errors; this is defence in depth so
262
+ // an unexpected throw surfaces as text instead of an absent tool result.
263
+ return { output: errorText('hashline_edit failed', err) }
264
+ }
265
+ if (typeof result === 'string') return { output: result }
266
+ return {
267
+ output: result.output,
268
+ ...(result.title === undefined ? {} : { title: result.title }),
269
+ ...(result.metadata === undefined ? {} : { metadata: result.metadata }),
270
+ }
271
+ }
272
+
273
+ // ─── pantheon_cost ──────────────────────────────────────────────────────
274
+
275
+ /** JSON Schema for `pantheon_cost`, mirroring the V1 zod args. */
276
+ const COST_INPUT: Record<string, unknown> = {
277
+ type: 'object',
278
+ additionalProperties: false,
279
+ properties: {
280
+ days: {
281
+ type: 'integer',
282
+ minimum: 1,
283
+ maximum: 365,
284
+ description: 'Reporting window in days (default 7).',
285
+ },
286
+ },
287
+ }
288
+
289
+ /** Validate `days` exactly as the V1 zod schema does; absent means default. */
290
+ function coerceDays(value: unknown): { ok: true; days?: number } | { ok: false; error: string } {
291
+ if (value === undefined) return { ok: true }
292
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 365) {
293
+ return { ok: false, error: 'pantheon_cost: "days" must be an integer between 1 and 365' }
294
+ }
295
+ return { ok: true, days: value }
296
+ }
297
+
298
+ const costCommand = createCostCommand()
299
+
300
+ async function executeCost(
301
+ input: Record<string, unknown>,
302
+ context: unknown,
303
+ ): Promise<V2ToolResult> {
304
+ const days = coerceDays(input.days)
305
+ if (!days.ok) return { output: days.error }
306
+ // The V1 tool already wraps its whole body and reports failures as text.
307
+ const output = await costCommand.pantheon_cost.execute({ days: days.days }, toolContext(context))
308
+ return { output }
309
+ }
310
+
311
+ // ─── pantheon_model ─────────────────────────────────────────────────────
312
+
313
+ /** JSON Schema for `pantheon_model`, mirroring the V1 zod args. */
314
+ const MODEL_INPUT: Record<string, unknown> = {
315
+ type: 'object',
316
+ additionalProperties: false,
317
+ properties: {
318
+ action: {
319
+ type: 'string',
320
+ enum: ['status', 'show', 'set', 'reset'],
321
+ description: 'Operation to perform. Defaults to status when agent/model are absent.',
322
+ },
323
+ agent: { type: 'string', description: 'Agent name (one of the 14 canonical agents).' },
324
+ model: { type: 'string', description: 'Model in provider/model-id format; used by set.' },
325
+ effort: {
326
+ type: 'string',
327
+ enum: ['low', 'medium', 'high'],
328
+ description: 'Reasoning effort (variant), clamped to model capability.',
329
+ },
330
+ scope: {
331
+ type: 'string',
332
+ enum: ['project', 'global'],
333
+ description: 'Scope for active-preset.json (default project).',
334
+ },
335
+ confirm: {
336
+ type: 'boolean',
337
+ description: 'Explicit confirmation for a mutating operation; required for global changes.',
338
+ },
339
+ authorize_global: {
340
+ type: 'boolean',
341
+ description: 'Separate authorization for global configuration; not implied by confirm.',
342
+ },
343
+ },
344
+ }
345
+
346
+ const WIZARD_UNAVAILABLE =
347
+ 'the interactive wizard has no user in the V2 tool surface — pass action="status" to read ' +
348
+ 'the current overrides, or pass agent and model explicitly for action="set"'
349
+
350
+ /**
351
+ * The V1 command falls back to an interactive readline prompt when invoked
352
+ * with no arguments. A tool call has no user at the terminal, so that fallback
353
+ * would block the call until it timed out. `ask` is injected to refuse
354
+ * deterministically instead, which routes through the command's own
355
+ * `wizard canceled` error path rather than forking its logic.
356
+ */
357
+ const modelCommand = createModelCommand({
358
+ ask: () => Promise.reject(new Error(WIZARD_UNAVAILABLE)),
359
+ })
360
+
361
+ async function executeModel(
362
+ input: Record<string, unknown>,
363
+ context: unknown,
364
+ ): Promise<V2ToolResult> {
365
+ // The V1 tool validates its own args (zod) and reports failures as text.
366
+ const output = await modelCommand.pantheon_model.execute(
367
+ input as Parameters<typeof modelCommand.pantheon_model.execute>[0],
368
+ toolContext(context),
369
+ )
370
+ return { output }
371
+ }
372
+
373
+ // ─── Tool set ───────────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Build the V2 tool definitions.
377
+ *
378
+ * Every entry declares `output`. An empty object is the host's own marker for
379
+ * "declared, unconstrained" (its MCP bridge uses `output: g.outputSchema ??
380
+ * {}`), so it satisfies the declaration without adding a second way for a tool
381
+ * to fail validation. The protection against forgetting the field is the
382
+ * required `output` on `V2ToolDef`, not the emptiness of this schema.
383
+ *
384
+ * The three tool instances (`hashlineEdit`, `costCommand`, `modelCommand`) are
385
+ * module-level consts created once at import, not per call. What matters for
386
+ * this function is only that it allocates a fresh descriptor array each call,
387
+ * so a caller cannot mutate another's definitions. None of the three factories
388
+ * performs filesystem or database work at construction, so importing this
389
+ * module stays side-effect free.
390
+ */
391
+ export function createV2ToolDefinitions(): V2ToolDef[] {
392
+ return [
393
+ {
394
+ name: 'hashline_edit',
395
+ description:
396
+ 'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
397
+ 'Ops: replace (ref..endRef or single ref → lines), append/prepend (anchor ref → content), ' +
398
+ 'delete (ref..endRef). ALL refs must be validated against the ORIGINAL file before ' +
399
+ 'anything is written; on mismatch the tool returns an error with a re-tagged excerpt ' +
400
+ 'and a Did-you-mean suggestion, and nothing is modified.',
401
+ input: HASHLINE_EDIT_INPUT,
402
+ output: {},
403
+ execute: executeHashlineEdit,
404
+ },
405
+ {
406
+ name: 'pantheon_cost',
407
+ description:
408
+ 'Report input, output, and total token usage by agent and phase over the last N days, ' +
409
+ 'read from opencode.db (read-only, no monetary values).',
410
+ input: COST_INPUT,
411
+ output: {},
412
+ execute: executeCost,
413
+ },
414
+ {
415
+ name: 'pantheon_model',
416
+ description:
417
+ 'Show, set, or reset per-agent model overrides in active-preset.json (project or global). ' +
418
+ 'Overrides win over preset; env preset wins over file. Never writes .env or top-level ' +
419
+ 'model/small_model. Pass action="status" to read the current overrides; unlike the V1 ' +
420
+ 'command there is no interactive wizard on this surface.',
421
+ input: MODEL_INPUT,
422
+ output: {},
423
+ execute: executeModel,
424
+ },
425
+ ]
426
+ }