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

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +47 -15
  3. package/README.pt-BR.md +43 -13
  4. package/package.json +13 -10
  5. package/plugin.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/scripts/code_mode_server.py +1 -1
  8. package/scripts/cost.mjs +244 -41
  9. package/scripts/install/agents-md.mjs +2 -2
  10. package/scripts/install/health-check.mjs +9 -2
  11. package/scripts/install/shared.mjs +5 -5
  12. package/scripts/mcp_persistence_server.py +1 -1
  13. package/scripts/mcp_resources_server.py +1 -1
  14. package/scripts/memory_mcp_server.py +1 -1
  15. package/scripts/probe-context-rehydrate.mjs +6 -0
  16. package/scripts/probe-pantheon-cost.mjs +71 -15
  17. package/scripts/validate-routing.mjs +3 -3
  18. package/src/mcp/code_mode_server.py +1 -1
  19. package/src/mcp/mcp_persistence_server.py +1 -1
  20. package/src/mcp/mcp_resources_server.py +1 -1
  21. package/src/mcp/memory_mcp_server.py +1 -1
  22. package/src/mcp/pantheon_vision_server.py +1 -1
  23. package/src/mcp/requirements-mcp.txt +6 -4
  24. package/src/mcp/requirements-vision.txt +2 -2
  25. package/src/pantheon/cost-command.ts +399 -101
  26. package/src/pantheon/delegation-enforce.ts +10 -1
  27. package/src/pantheon/presets.mjs +3 -3
  28. package/src/pantheon/v2-tools.ts +426 -0
  29. package/src/plugin-v2.ts +473 -190
  30. package/src/plugins/tui/dist/server.d.ts +1 -2
  31. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  32. package/src/plugins/tui/dist/tui.d.ts +74 -74
  33. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  34. package/src/plugins/tui/dist/tui.js +17 -20
  35. package/src/plugins/tui/package-lock.json +364 -317
  36. package/src/plugins/tui/package.json +8 -5
  37. package/src/plugins/tui/tsconfig.build.json +5 -0
  38. package/src/plugins/tui/tsdown.config.ts +3 -0
package/scripts/cost.mjs CHANGED
@@ -3,19 +3,42 @@
3
3
  * cost.mjs — read-only token aggregation from opencode.db.
4
4
  *
5
5
  * CLI fallback for the `pantheon_cost` tool when node:sqlite is unavailable
6
- * in the plugin process. Uses node:sqlite itself and prints a single JSON
7
- * object to stdout:
8
- * { ok: true, days, rows: [{agent, phase, tokensInput, tokensOutput, tokensTotal}] }
6
+ * in the plugin process. Prints a single JSON object to stdout:
7
+ * { ok: true, days, rows: [{agent, phase, tokensInput, tokensOutput, tokensTotal}], coverage }
9
8
  * { ok: false, error: "<message>" } (exit code 1)
10
9
  *
11
- * NEVER writes to the database — opened read-only. Malformed rows are
12
- * skipped; assistant messages carry the authoritative token usage.
10
+ * ## This file is a MIRROR of src/pantheon/cost-command.ts
11
+ *
12
+ * It cannot import that module — it exists precisely to run on a plain Node
13
+ * where the plugin runtime cannot — so the two implementations duplicate the
14
+ * same contract on purpose:
15
+ * - ledger families are detected by TABLE PRESENCE, never by filename and
16
+ * never by a pinned host generation;
17
+ * - PANTHEON_OPENCODE_VERSION only NARROWS an already-detected set;
18
+ * - V1 marks assistant turns with `data.role`, V2 with the `type` COLUMN
19
+ * (V2 payloads carry no `role` key at all);
20
+ * - the coverage counters mean the same thing in both, because the caller
21
+ * feeds them to the same diagnosis.
22
+ *
23
+ * Every one of those was a real divergence once: this script kept the V1
24
+ * predicate and dropped 100% of a V2 ledger, while the caller reported a
25
+ * successful, empty report. A change to one side that is not made on the other
26
+ * is a regression, not a refactor.
27
+ *
28
+ * NEVER writes to the database — opened read-only. Malformed rows are skipped;
29
+ * assistant messages carry the authoritative token usage.
13
30
  *
14
31
  * Usage: node scripts/cost.mjs --db-path <dbPath> --days <days>
15
32
  */
16
33
  import { existsSync } from 'node:fs'
17
34
  import { DatabaseSync } from 'node:sqlite'
18
35
 
36
+ /** Ledger families a host database can carry; a migrated one carries both. */
37
+ const COST_SOURCES = ['message', 'session_message']
38
+
39
+ /** Which family each host generation stores its ledger in. */
40
+ const VERSION_SOURCES = { v1: 'message', v2: 'session_message' }
41
+
19
42
  /**
20
43
  * Fail via exitCode + natural process exit (NEVER process.exit right after
21
44
  * stdout.write — the pipe is async and the write can be truncated).
@@ -26,22 +49,201 @@ function fail(message) {
26
49
  process.exitCode = 1
27
50
  }
28
51
 
29
- function assertCompatibleSchema(db) {
30
- const table = db
31
- .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'")
32
- .get()
33
- if (!table)
34
- throw new Error('incompatible opencode.db schema: required "message" table is missing')
35
- const columns = db.prepare('PRAGMA table_info(message)').all()
36
- const names = new Set(columns.map((column) => column.name))
37
- const missing = ['data', 'time_created'].filter((name) => !names.has(name))
52
+ /** Read and validate the optional host-generation selector. */
53
+ function requestedVersion() {
54
+ const configured = process.env.PANTHEON_OPENCODE_VERSION
55
+ if (configured === undefined || configured === '') return undefined
56
+ if (configured !== 'v1' && configured !== 'v2') {
57
+ throw new Error(`invalid PANTHEON_OPENCODE_VERSION "${configured}"; expected "v1" or "v2"`)
58
+ }
59
+ return configured
60
+ }
61
+
62
+ /**
63
+ * Detect the ledger families by TABLE PRESENCE, then narrow if asked.
64
+ *
65
+ * Presence is the only reliable discriminator: every real installation writes
66
+ * `opencode.db`, and a migrated database carries BOTH families at once.
67
+ */
68
+ function resolveSources(db) {
69
+ const present = new Set(
70
+ db
71
+ .prepare(
72
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('message', 'session_message')",
73
+ )
74
+ .all()
75
+ .map((row) => String(row.name)),
76
+ )
77
+ const detected = COST_SOURCES.filter((source) => present.has(source))
78
+ if (detected.length === 0) {
79
+ throw new Error(
80
+ 'incompatible opencode.db schema: neither the "message" nor the "session_message" table exists, so there is no token ledger to read',
81
+ )
82
+ }
83
+ const requested = requestedVersion()
84
+ if (requested === undefined) return detected
85
+ const narrowed = detected.filter((source) => source === VERSION_SOURCES[requested])
86
+ if (narrowed.length === 0) {
87
+ throw new Error(
88
+ `incompatible opencode.db schema: PANTHEON_OPENCODE_VERSION=${requested} selected no table family, but this database contains ${detected.join(' and ')}`,
89
+ )
90
+ }
91
+ return narrowed
92
+ }
93
+
94
+ /** The columns this reader needs from the family it is about to query. */
95
+ function assertColumns(db, source) {
96
+ const names = new Set(
97
+ db
98
+ .prepare(`PRAGMA table_info(${source})`)
99
+ .all()
100
+ .map((column) => String(column.name)),
101
+ )
102
+ const required =
103
+ source === 'session_message' ? ['data', 'time_created', 'type'] : ['data', 'time_created']
104
+ const missing = required.filter((name) => !names.has(name))
38
105
  if (missing.length > 0) {
39
106
  throw new Error(
40
- `incompatible opencode.db schema: message table is missing ${missing.join(', ')}; select the matching V1/V2 database`,
107
+ `incompatible opencode.db schema: ${source} table is missing ${missing.join(', ')}; select a database whose token layout this build understands`,
41
108
  )
42
109
  }
43
110
  }
44
111
 
112
+ /**
113
+ * Parse one payload. Returns undefined for anything unparseable, which is how
114
+ * both readers treat it: the V1 counter requires json_valid(data) and the V2
115
+ * aggregate does too, so a malformed row contributes to nothing.
116
+ *
117
+ * Deliberately NOT unwrapping a nested `data` string: the in-process reader
118
+ * extracts `$.role`/`$.agent` from the TOP level, so a wrapped payload yields
119
+ * NULL there and is dropped. Unwrapping here would resurrect rows the primary
120
+ * path cannot see, and the two readers would disagree on the same file.
121
+ */
122
+ function readPayload(data) {
123
+ try {
124
+ const parsed = JSON.parse(String(data))
125
+ return typeof parsed === 'object' && parsed !== null ? parsed : undefined
126
+ } catch {
127
+ return undefined
128
+ }
129
+ }
130
+
131
+ /** CAST(json_extract(...) AS INTEGER) semantics: absent or non-numeric → 0. */
132
+ function toToken(value) {
133
+ const parsed = Number(value)
134
+ return Number.isFinite(parsed) ? parsed : 0
135
+ }
136
+
137
+ /**
138
+ * Map key for one aggregated row.
139
+ *
140
+ * NUL-separated: agent names cannot contain it, so an agent whose name ends
141
+ * in the phase cannot collide with a phase-only row.
142
+ */
143
+ function rowKey(agent, phase) {
144
+ return `${agent}\u0000${phase}`
145
+ }
146
+
147
+ /** Read one family.
148
+ *
149
+ * `assistantRows` follows the per-generation discriminator exactly, including
150
+ * the asymmetry that matters: V2 counts an assistant row by its COLUMN whether
151
+ * or not the payload parses, because that counter SQL has no json_valid gate.
152
+ */
153
+ function readSource(db, source, since) {
154
+ const windowRow = db
155
+ .prepare(
156
+ `SELECT COUNT(*) AS scanned, MIN(time_created) AS windowStart FROM ${source} WHERE time_created >= ?`,
157
+ )
158
+ .get(since)
159
+ const databaseRow = db.prepare(`SELECT MIN(time_created) AS databaseStart FROM ${source}`).get()
160
+ const select = source === 'session_message' ? 'SELECT type, data' : 'SELECT data'
161
+ const rows = db.prepare(`${select} FROM ${source} WHERE time_created >= ?`).all(since)
162
+
163
+ const groups = new Map()
164
+ let assistantRows = 0
165
+ for (const row of rows) {
166
+ const info = readPayload(row.data)
167
+ if (source === 'session_message') {
168
+ if (row.type !== 'assistant') continue
169
+ assistantRows += 1
170
+ if (info === undefined) continue
171
+ } else {
172
+ if (info === undefined || info.role !== 'assistant') continue
173
+ assistantRows += 1
174
+ }
175
+ const rawAgent = info.agent
176
+ const agent = rawAgent == null ? '' : typeof rawAgent === 'string' ? rawAgent : String(rawAgent)
177
+ if (agent === '') continue
178
+ // V2 has no phase anywhere: its SQL emits the literal, so the mirror does
179
+ // too rather than inventing a column the reader never had.
180
+ const metadata = info.metadata
181
+ const phase =
182
+ source === 'session_message'
183
+ ? 'unknown'
184
+ : (typeof info.phase === 'string' && info.phase) ||
185
+ (typeof metadata?.phase === 'string' && metadata.phase) ||
186
+ 'unknown'
187
+ const tokensInput = toToken(info.tokens?.input)
188
+ const tokensOutput = toToken(info.tokens?.output)
189
+ const key = rowKey(agent, phase)
190
+ const acc = groups.get(key) ?? {
191
+ agent,
192
+ phase,
193
+ tokensInput: 0,
194
+ tokensOutput: 0,
195
+ tokensTotal: 0,
196
+ }
197
+ acc.tokensInput += tokensInput
198
+ acc.tokensOutput += tokensOutput
199
+ acc.tokensTotal += tokensInput + tokensOutput
200
+ groups.set(key, acc)
201
+ }
202
+
203
+ return {
204
+ groups,
205
+ scanned: Number(windowRow?.scanned ?? 0) || 0,
206
+ windowStart: numberOrUndefined(windowRow?.windowStart),
207
+ databaseStart: numberOrUndefined(databaseRow?.databaseStart),
208
+ assistantRows,
209
+ }
210
+ }
211
+
212
+ function numberOrUndefined(value) {
213
+ if (value === null || value === undefined) return undefined
214
+ const parsed = Number(value)
215
+ return Number.isFinite(parsed) ? parsed : undefined
216
+ }
217
+
218
+ /**
219
+ * The same verdict the in-process reader reaches, with the same phrase: the
220
+ * caller maps "no readable token source" to UNSUPPORTED, so emitting anything
221
+ * softer here would resurrect the silent-empty-report failure this replaced.
222
+ *
223
+ * `scanned === 0` is NOT unreadable — an empty window is a real, reportable
224
+ * answer, and only a ledger that was found and could not be read is a fault.
225
+ */
226
+ function diagnoseUnreadableLedger(coverage, rows, days) {
227
+ if (coverage.scanned === 0) return undefined
228
+ const tables = coverage.sources.join(' and ')
229
+ if (coverage.assistantRows === 0) {
230
+ return (
231
+ `no readable token source: ${coverage.scanned} row(s) were scanned in the last ${days} day(s) ` +
232
+ `but none matched the assistant discriminator of ${tables}, so no usage can be attributed. ` +
233
+ 'This is an unrecognised ledger layout, not an empty one; point PANTHEON_COST_DB at a ' +
234
+ 'database whose token layout this build understands.'
235
+ )
236
+ }
237
+ if (!rows.some((row) => row.tokensInput !== 0 || row.tokensOutput !== 0)) {
238
+ return (
239
+ `no readable token source: ${coverage.assistantRows} assistant row(s) were found in ${tables} ` +
240
+ 'but none carried both an attributable agent and a token payload, so no usage can be ' +
241
+ 'attributed. This is an unrecognised ledger layout, not an empty one.'
242
+ )
243
+ }
244
+ return undefined
245
+ }
246
+
45
247
  function main() {
46
248
  const argv = process.argv.slice(2)
47
249
  const valueFor = (name) => {
@@ -56,44 +258,45 @@ function main() {
56
258
 
57
259
  try {
58
260
  const db = new DatabaseSync(dbPath, { readOnly: true })
59
- assertCompatibleSchema(db)
261
+ const sources = resolveSources(db)
262
+ for (const source of sources) assertColumns(db, source)
60
263
  const since = Date.now() - days * 86_400_000
61
- const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
62
- db.close()
63
264
 
64
265
  const byAgent = new Map()
65
- for (const row of rows) {
66
- try {
67
- const outer = JSON.parse(String(row.data))
68
- const info = typeof outer.data === 'string' ? JSON.parse(outer.data) : outer
69
- if (info.role !== 'assistant') continue
70
- const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
71
- if (!agent) continue
72
- const metadata = info.metadata
73
- const phase =
74
- (typeof info.phase === 'string' && info.phase) ||
75
- (typeof metadata?.phase === 'string' && metadata.phase) ||
76
- 'unknown'
77
- const tokensInput = Number(info.tokens?.input) || 0
78
- const tokensOutput = Number(info.tokens?.output) || 0
79
- const key = `${agent}\u0000${phase}`
266
+ const coverage = { sources: [], scanned: 0, assistantRows: 0 }
267
+ const note = (key, value) => {
268
+ if (value === undefined) return
269
+ const current = coverage[key]
270
+ coverage[key] = current === undefined ? value : Math.min(current, value)
271
+ }
272
+ for (const source of sources) {
273
+ const read = readSource(db, source, since)
274
+ for (const row of read.groups.values()) {
275
+ const key = rowKey(row.agent, row.phase)
80
276
  const acc = byAgent.get(key) ?? {
81
- agent,
82
- phase,
277
+ agent: row.agent,
278
+ phase: row.phase,
83
279
  tokensInput: 0,
84
280
  tokensOutput: 0,
85
281
  tokensTotal: 0,
86
282
  }
87
- acc.tokensInput += tokensInput
88
- acc.tokensOutput += tokensOutput
89
- acc.tokensTotal += tokensInput + tokensOutput
283
+ acc.tokensInput += row.tokensInput
284
+ acc.tokensOutput += row.tokensOutput
285
+ acc.tokensTotal += row.tokensInput + row.tokensOutput
90
286
  byAgent.set(key, acc)
91
- } catch {
92
- // Skip malformed rows — a partial ledger never breaks the report.
93
287
  }
288
+ coverage.sources.push(source)
289
+ coverage.scanned += read.scanned
290
+ coverage.assistantRows += read.assistantRows
291
+ note('windowStart', read.windowStart)
292
+ note('databaseStart', read.databaseStart)
94
293
  }
294
+ db.close()
295
+
95
296
  const result = [...byAgent.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
96
- process.stdout.write(JSON.stringify({ ok: true, days, rows: result }))
297
+ const unreadable = diagnoseUnreadableLedger(coverage, result, days)
298
+ if (unreadable !== undefined) return fail(unreadable)
299
+ process.stdout.write(JSON.stringify({ ok: true, days, rows: result, coverage }))
97
300
  } catch (err) {
98
301
  fail(err instanceof Error ? err.message : String(err))
99
302
  }
@@ -21,7 +21,7 @@
21
21
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
22
22
  import { dirname, join } from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
24
- import yaml from 'js-yaml'
24
+ import { load } from 'js-yaml'
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Paths
@@ -47,7 +47,7 @@ function parseAgentFrontmatter(content) {
47
47
  if (!match) return null
48
48
 
49
49
  try {
50
- const fm = yaml.load(match[1])
50
+ const fm = load(match[1])
51
51
  if (!fm || typeof fm !== 'object' || !fm.name) return null
52
52
  return {
53
53
  name: String(fm.name),
@@ -173,13 +173,20 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
173
173
  if (python) {
174
174
  const mcpResult = spawnSync(
175
175
  python,
176
- ['-c', 'from mcp.server.fastmcp import FastMCP; print(FastMCP.__module__)'],
176
+ [
177
+ '-c',
178
+ 'import importlib.metadata as metadata; from fastmcp import FastMCP; ' +
179
+ 'print(f\'mcp={metadata.version("mcp")} fastmcp={metadata.version("fastmcp")} {FastMCP.__module__}\')',
180
+ ],
177
181
  { stdio: 'pipe' },
178
182
  )
179
183
  if (mcpResult.status === 0) {
180
184
  results.passed.push({ check: 'mcp-sdk', detail: mcpResult.stdout.toString().trim() })
181
185
  } else {
182
- results.failed.push({ check: 'mcp-sdk', detail: 'NOT INSTALLED — all MCP servers will fail' })
186
+ results.failed.push({
187
+ check: 'mcp-sdk',
188
+ detail: 'MCP SDK or standalone FastMCP is unavailable — all MCP servers will fail',
189
+ })
183
190
  }
184
191
  }
185
192
  // Check 4: the memory server's only search backend is the stdlib's own
@@ -24,7 +24,7 @@ if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
24
24
  process.exit(1)
25
25
  }
26
26
 
27
- import yaml from 'js-yaml'
27
+ import { dump, load } from 'js-yaml'
28
28
 
29
29
  export const __dirname = dirname(fileURLToPath(import.meta.url))
30
30
  export const ROOT = join(__dirname, '..', '..')
@@ -326,10 +326,10 @@ export function collectSkillNames() {
326
326
  * Returns { fm: object, body: string } or null if no frontmatter.
327
327
  */
328
328
  export function parseFrontmatter(content) {
329
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
329
+ const match = content.match(/^---\r?\n([\s\S]*?)(?:\r?\n)?---\r?\n?([\s\S]*)$/)
330
330
  if (!match) return null
331
331
  return {
332
- fm: yaml.load(match[1]) ?? {},
332
+ fm: match[1].trim() ? (load(match[1]) ?? {}) : {},
333
333
  body: match[2],
334
334
  }
335
335
  }
@@ -339,9 +339,9 @@ export function parseFrontmatter(content) {
339
339
  * Uses long-line mode and avoids unnecessary quoting.
340
340
  */
341
341
  export function serializeFm(fm) {
342
- return yaml.dump(fm, {
342
+ return dump(fm, {
343
343
  lineWidth: -1,
344
- quotingType: '"',
344
+ quoteStyle: 'double',
345
345
  forceQuotes: false,
346
346
  noRefs: true,
347
347
  })
@@ -25,7 +25,7 @@ from pathlib import Path
25
25
  from typing import Annotated
26
26
 
27
27
  from _pantheon_paths import pantheon_home, pantheon_project
28
- from mcp.server.fastmcp import FastMCP
28
+ from fastmcp import FastMCP
29
29
  from pydantic import Field
30
30
 
31
31
  # ── Schema ──────────────────────────────────────────────────────────────────────
@@ -19,7 +19,7 @@ from typing import Any
19
19
  import yaml
20
20
  from _pantheon_paths import has_symlink_component, pantheon_home, pantheon_project
21
21
  from eval_store import get_latest_eval, list_evals
22
- from mcp.server.fastmcp import FastMCP
22
+ from fastmcp import FastMCP
23
23
 
24
24
  # ── Path Resolution ──────────────────────────────────────────────────────────
25
25
  _PANTHEON_HOME: Path = pantheon_home()
@@ -33,7 +33,7 @@ from pathlib import Path
33
33
  from typing import Annotated, Any
34
34
 
35
35
  from _pantheon_paths import pantheon_home
36
- from mcp.server.fastmcp import FastMCP
36
+ from fastmcp import FastMCP
37
37
  from pydantic import BeforeValidator, Field
38
38
 
39
39
  # ── Paths ─────────────────────────────────────────────────────────────────────
@@ -63,6 +63,12 @@ spec.loader.exec_module(module)
63
63
 
64
64
  async def call(name, arguments):
65
65
  result = await module.mcp.call_tool(name, arguments)
66
+ structured = getattr(result, "structured_content", None)
67
+ if structured is not None:
68
+ metadata = getattr(result, "meta", None) or {}
69
+ if metadata.get("fastmcp", {}).get("wrap_result"):
70
+ return structured.get("result") if isinstance(structured, dict) else structured
71
+ return structured
66
72
  if isinstance(result, tuple):
67
73
  structured = result[1]
68
74
  if structured is not None:
@@ -56,11 +56,48 @@ function setEnvironment(values) {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Write the rows in the family the requested host generation actually uses.
61
+ *
62
+ * Call sites describe an assistant turn the way they always have
63
+ * (`role: 'assistant'`, plus a `phase`). This function translates that
64
+ * descriptor into what is on disk: for V2, `role` becomes the `type` COLUMN and
65
+ * `phase` is dropped, because a real V2 payload carries neither — measured, the
66
+ * `role` key is absent from 40052 of 40052 rows on a host 2.0.22 database.
67
+ *
68
+ * Keeping that translation in ONE place is the point: a fixture that wrote
69
+ * `role` into a session_message row asserts the V1 format under a V2 name, which
70
+ * is exactly how the stale V1 predicate in the CLI mirror stayed green — the
71
+ * synthetic database agreed with the bug instead of contradicting it.
72
+ */
59
73
  function createSyntheticDb(DatabaseSync, path, rows) {
60
74
  const db = new DatabaseSync(path)
61
- db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
62
- const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
63
- for (const data of rows) insert.run(JSON.stringify(data), Date.now())
75
+ if (requestedVersion === 'v2') {
76
+ db.exec(
77
+ 'CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, time_created INTEGER, time_updated INTEGER, data TEXT)',
78
+ )
79
+ const insert = db.prepare(
80
+ 'INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?,?,?,?,?,?,?)',
81
+ )
82
+ rows.forEach((row, index) => {
83
+ const payload = { ...row }
84
+ delete payload.role
85
+ delete payload.phase
86
+ insert.run(
87
+ `msg_${index + 1}`,
88
+ 'ses_probe',
89
+ row.role,
90
+ index + 1,
91
+ Date.now(),
92
+ Date.now(),
93
+ JSON.stringify(payload),
94
+ )
95
+ })
96
+ } else {
97
+ db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
98
+ const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
99
+ for (const data of rows) insert.run(JSON.stringify(data), Date.now())
100
+ }
64
101
  db.close()
65
102
  }
66
103
 
@@ -129,7 +166,10 @@ async function main() {
129
166
  const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
130
167
  const dataHome = join(root, 'data')
131
168
  const opencodeDir = join(dataHome, 'opencode')
132
- const selectedDb = join(opencodeDir, requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db')
169
+ // Every real installation writes `opencode.db`; the v1/v2 distinction is the
170
+ // SCHEMA of that file, never its name. Deriving `opencode-v2.db` here made the
171
+ // probe assert a path that exists on no real machine.
172
+ const selectedDb = join(opencodeDir, 'opencode.db')
133
173
  const otherDb = join(root, 'other.db')
134
174
  const envDb = join(root, 'env.db')
135
175
  const explicitDb = join(root, 'explicit.db')
@@ -139,7 +179,9 @@ async function main() {
139
179
  try {
140
180
  mkdirSync(opencodeDir, { recursive: true })
141
181
  // These rows intentionally include a monetary-looking field to prove it is
142
- // ignored; only input/output/total tokens may appear in the report.
182
+ // ignored; only input/output/total tokens may appear in the report. Every
183
+ // V2 phase reads back as `unknown` because the V2 reader emits that literal:
184
+ // a V2 payload carries no phase field for a reader to recover.
143
185
  const selectedRows =
144
186
  requestedVersion === 'v2'
145
187
  ? [
@@ -224,8 +266,8 @@ async function main() {
224
266
  selectedOutput,
225
267
  requestedVersion === 'v2'
226
268
  ? [
227
- { agent: 'aphrodite', phase: 'plan', input: 8, output: 10, total: 18 },
228
- { agent: 'zeus', phase: 'audit', input: 4, output: 6, total: 10 },
269
+ { agent: 'aphrodite', phase: 'unknown', input: 8, output: 10, total: 18 },
270
+ { agent: 'zeus', phase: 'unknown', input: 4, output: 6, total: 10 },
229
271
  ]
230
272
  : [
231
273
  { agent: 'hermes', phase: 'plan', input: 13, output: 24, total: 37 },
@@ -250,7 +292,12 @@ async function main() {
250
292
  {},
251
293
  { sessionID: 'cost-probe' },
252
294
  )
253
- requireOutput(output, '| env-agent | override | 1 | 2 | 3 |', 'PANTHEON_COST_DB')
295
+ // V2 has no phase to report, so the same row reads back as 'unknown'.
296
+ requireOutput(
297
+ output,
298
+ `| env-agent | ${requestedVersion === 'v2' ? 'unknown' : 'override'} | 1 | 2 | 3 |`,
299
+ 'PANTHEON_COST_DB',
300
+ )
254
301
  if (output.includes('wrong-version') || output.includes('env-agent') === false) {
255
302
  throw new Error('PANTHEON_COST_DB did not override the version-selected database')
256
303
  }
@@ -269,7 +316,11 @@ async function main() {
269
316
  {},
270
317
  { sessionID: 'cost-probe' },
271
318
  )
272
- requireOutput(output, '| explicit-agent | explicit | 9 | 1 | 10 |', 'explicit dbPath')
319
+ requireOutput(
320
+ output,
321
+ `| explicit-agent | ${requestedVersion === 'v2' ? 'unknown' : 'explicit'} | 9 | 1 | 10 |`,
322
+ 'explicit dbPath',
323
+ )
273
324
  if (output.includes('env-agent')) throw new Error('explicit dbPath did not take precedence')
274
325
  checks.push('explicit dbPath precedence: PASS')
275
326
  } finally {
@@ -287,11 +338,7 @@ async function main() {
287
338
  { sessionID: 'cost-probe' },
288
339
  )
289
340
  requireOutput(output, 'not found', 'missing DB')
290
- requireOutput(
291
- output,
292
- requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db',
293
- 'selected missing DB path',
294
- )
341
+ requireOutput(output, 'opencode.db', 'selected missing DB path')
295
342
  checks.push('missing DB: PASS')
296
343
  } finally {
297
344
  missingRestore()
@@ -302,7 +349,16 @@ async function main() {
302
349
  }).pantheon_cost.execute({}, { sessionID: 'cost-probe' })
303
350
  requireOutput(incompatibleOutput, 'incompatible opencode.db schema', 'incompatible schema')
304
351
  checks.push('incompatible schema: PASS')
305
- emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
352
+ // Names the synthetic fixture that was built, because the previous wording
353
+ // ("no real DB was read") was true of every run and therefore true of the run
354
+ // in which the V2 ledger was read as V1 and silently dropped. A verdict that
355
+ // cannot fail is not a verdict: it stayed green across the exact defect it
356
+ // was meant to cover.
357
+ emit(
358
+ 'PASS',
359
+ `${checks.length} synthetic ${requestedVersion} checks passed against a temporary opencode.db (no user database was read)`,
360
+ checks,
361
+ )
306
362
  } catch (error) {
307
363
  emit('FAIL', diagnostic(error), checks)
308
364
  } finally {
@@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'
19
19
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
20
20
  import { dirname, join } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
- import yaml from 'js-yaml'
22
+ import { load } from 'js-yaml'
23
23
  import { validatePresetDefs } from '../src/pantheon/presets.mjs'
24
24
 
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -52,7 +52,7 @@ if (!existsSync(routingPath)) {
52
52
  console.error('❌ routing.yml not found')
53
53
  process.exit(1)
54
54
  }
55
- const routing = yaml.load(readFileSync(routingPath, 'utf8'))
55
+ const routing = load(readFileSync(routingPath, 'utf8'))
56
56
 
57
57
  console.log('🔍 Validating routing.yml...\n')
58
58
 
@@ -253,7 +253,7 @@ function parseFrontmatter(filePath) {
253
253
  const match = content.match(FRONTMATTER_RE)
254
254
  if (!match) return null
255
255
  try {
256
- return yaml.load(match[1])
256
+ return load(match[1])
257
257
  } catch {
258
258
  return null
259
259
  }
@@ -36,7 +36,7 @@ from typing import Any
36
36
 
37
37
  import yaml
38
38
  from _pantheon_paths import pantheon_home, pantheon_project
39
- from mcp.server.fastmcp import FastMCP
39
+ from fastmcp import FastMCP
40
40
 
41
41
  # ── Constants ─────────────────────────────────────────────────────────────────
42
42
  ALLOWED_EXTENSIONS: frozenset[str] = frozenset({".sh", ".py"})
@@ -25,7 +25,7 @@ from pathlib import Path
25
25
  from typing import Annotated
26
26
 
27
27
  from _pantheon_paths import pantheon_home, pantheon_project
28
- from mcp.server.fastmcp import FastMCP
28
+ from fastmcp import FastMCP
29
29
  from pydantic import Field
30
30
 
31
31
  # ── Schema ──────────────────────────────────────────────────────────────────────
@@ -19,7 +19,7 @@ from typing import Any
19
19
  import yaml
20
20
  from _pantheon_paths import has_symlink_component, pantheon_home, pantheon_project
21
21
  from eval_store import get_latest_eval, list_evals
22
- from mcp.server.fastmcp import FastMCP
22
+ from fastmcp import FastMCP
23
23
 
24
24
  # ── Path Resolution ──────────────────────────────────────────────────────────
25
25
  _PANTHEON_HOME: Path = pantheon_home()
@@ -34,7 +34,7 @@ from pathlib import Path
34
34
  from typing import Annotated, Any
35
35
 
36
36
  from _pantheon_paths import pantheon_home
37
- from mcp.server.fastmcp import FastMCP
37
+ from fastmcp import FastMCP
38
38
  from pydantic import BeforeValidator, Field
39
39
 
40
40
  try: