pantheon-opencode 1.5.2 → 1.6.0-beta.10

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 (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -7,11 +7,27 @@
7
7
  * - only path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
8
  * - missing/unreadable db → a diagnostic contract status as TEXT.
9
9
  *
10
- * dbPath resolution: explicit option > env PANTHEON_COST_DB > the version-aware
11
- * default selected by PANTHEON_OPENCODE_VERSION (v1 or v2). An unset version
12
- * deliberately preserves V1 behavior. The tool is wired in
13
- * plugin.ts alongside the delegation toolset (usable by zeus and any agent
14
- * with tool access — no routing change needed).
10
+ * ## Host generations
11
+ *
12
+ * The V1/V2 distinction is the SCHEMA of opencode.db, never its filename: every
13
+ * real installation writes `opencode.db`, and a migrated database carries BOTH
14
+ * `message` (V1) and `session_message` (V2) tables at once. The two families are
15
+ * detected by table presence and their `id` spaces are disjoint, so both are
16
+ * read and merged rather than one being guessed at.
17
+ *
18
+ * The two discriminators are NOT interchangeable. V1 marks assistant turns with
19
+ * `data.role = 'assistant'`; V2 has no `role` key at all (measured: absent from
20
+ * 40052 of 40052 rows on the host 2.0.22 database) and marks them with the
21
+ * `type = 'assistant'` COLUMN. Reusing the V1 predicate against V2 discards
22
+ * every row, which is why an unreadable ledger is reported as UNSUPPORTED
23
+ * instead of as a successful, empty report.
24
+ *
25
+ * dbPath resolution: explicit option > PANTHEON_COST_DB > OPENCODE_DB > the XDG
26
+ * default `opencode.db`. OPENCODE_DB is what a SANDBOX uses to name its state
27
+ * database `opencode-v2.db`, so it — not the host generation — is what selects
28
+ * a non-default file. The tool is wired in plugin.ts alongside the delegation
29
+ * toolset (usable by zeus and any agent with tool access — no routing change
30
+ * needed).
15
31
  *
16
32
  * @module cost-command
17
33
  */
@@ -36,6 +52,45 @@ export interface CostRow {
36
52
  tokensTotal: number
37
53
  }
38
54
 
55
+ /**
56
+ * Host table families that can carry a token ledger. A migrated database
57
+ * carries both at once.
58
+ */
59
+ export type CostSource = 'message' | 'session_message'
60
+
61
+ const COST_SOURCES: readonly CostSource[] = ['message', 'session_message']
62
+
63
+ /**
64
+ * Which table family each host generation stores its ledger in.
65
+ *
66
+ * Spelled out rather than comparing the literals: `'message' === 'v1'` is
67
+ * false, so a filter written as `source === requested` narrows to nothing and
68
+ * every version-pinned read fails.
69
+ */
70
+ const VERSION_SOURCES: Record<OpenCodeVersion, CostSource> = {
71
+ v1: 'message',
72
+ v2: 'session_message',
73
+ }
74
+
75
+ /** What the query actually found, so the report can describe its own coverage. */
76
+ export interface CostCoverage {
77
+ /** Table families that contributed rows. */
78
+ sources: CostSource[]
79
+ /** Rows of ANY type inside the window. */
80
+ scanned: number
81
+ /** Rows that passed the schema-appropriate assistant discriminator. */
82
+ assistantRows: number
83
+ /** Earliest row in the WHOLE database (epoch ms) — the history the file holds. */
84
+ databaseStart?: number
85
+ /** Earliest row inside the requested window (epoch ms). */
86
+ windowStart?: number
87
+ }
88
+
89
+ export interface CostQuery {
90
+ rows: CostRow[]
91
+ coverage: CostCoverage
92
+ }
93
+
39
94
  export interface CostCommandOptions {
40
95
  /** Explicit db path (testability / unusual installs). Default: resolved candidates. */
41
96
  dbPath?: string
@@ -77,32 +132,39 @@ const costArgs = {
77
132
  .describe('Number of days of delegation history to include (default 7).'),
78
133
  } satisfies z.ZodRawShape
79
134
 
80
- /** Return the safe, isolated state DB name for an OpenCode version. */
81
- function defaultDbPath(version: OpenCodeVersion | undefined): string {
135
+ /** The XDG state directory every real host installation writes its ledger to. */
136
+ function defaultDbPath(): string {
82
137
  const xdg = process.env.XDG_DATA_HOME
83
138
  const dataHome = xdg !== undefined && xdg !== '' ? xdg : join(homedir(), '.local', 'share')
84
- const filename = version === 'v2' ? 'opencode-v2.db' : 'opencode.db'
85
- return join(dataHome, 'opencode', filename)
139
+ return join(dataHome, 'opencode', 'opencode.db')
140
+ }
141
+
142
+ /** Read and validate the optional host-generation selector. */
143
+ function requestedVersion(): OpenCodeVersion | undefined {
144
+ const configured = process.env.PANTHEON_OPENCODE_VERSION
145
+ if (configured === undefined) return undefined
146
+ if (configured !== 'v1' && configured !== 'v2') {
147
+ throw new Error(`invalid PANTHEON_OPENCODE_VERSION "${configured}"; expected "v1" or "v2"`)
148
+ }
149
+ return configured
86
150
  }
87
151
 
88
152
  /**
89
- * Resolve the database without probing another version's database.
153
+ * Resolve the database.
154
+ *
155
+ * Precedence: explicit option → PANTHEON_COST_DB → OPENCODE_DB → the XDG
156
+ * default `opencode.db`. An invalid PANTHEON_OPENCODE_VERSION fails fast rather
157
+ * than being silently ignored.
90
158
  *
91
- * Explicit paths always win. A version selector changes exactly one default
92
- * filename; it never falls back to V1, which prevents a V2 report from
93
- * silently reading V1 history (or vice versa).
159
+ * There is deliberately NO per-version filename. Deriving one from the host
160
+ * generation pointed the V2 report at `opencode-v2.db`, which exists only
161
+ * because a sandbox sets OPENCODE_DB — hence OPENCODE_DB's place in this chain.
94
162
  */
95
163
  export function resolveCostDbPath(options?: CostCommandOptions): string {
96
- const explicit = options?.dbPath || process.env.PANTHEON_COST_DB
164
+ const explicit = options?.dbPath || process.env.PANTHEON_COST_DB || process.env.OPENCODE_DB
97
165
  if (explicit) return explicit
98
-
99
- const configuredVersion = process.env.PANTHEON_OPENCODE_VERSION
100
- if (configuredVersion !== undefined && configuredVersion !== 'v1' && configuredVersion !== 'v2') {
101
- throw new Error(
102
- `invalid PANTHEON_OPENCODE_VERSION "${configuredVersion}"; expected "v1" or "v2"`,
103
- )
104
- }
105
- return defaultDbPath(configuredVersion as OpenCodeVersion | undefined)
166
+ requestedVersion()
167
+ return defaultDbPath()
106
168
  }
107
169
 
108
170
  /**
@@ -116,90 +178,291 @@ function findExistingDb(override?: string): string | undefined {
116
178
  return existsSync(candidate) ? candidate : undefined
117
179
  }
118
180
 
119
- function assertCompatibleSchema(db: {
120
- prepare(sql: string): { get(): unknown; all(...args: unknown[]): unknown[] }
121
- }): void {
122
- const row = db
123
- .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'")
124
- .get()
125
- if (row === undefined) {
126
- throw new Error('incompatible opencode.db schema: required "message" table is missing')
181
+ /**
182
+ * Detect the ledger families by TABLE PRESENCE.
183
+ *
184
+ * Presence is the only reliable discriminator: a migrated database carries both
185
+ * families, so pinning a schema-version constant would break whichever
186
+ * population it excluded, and no manifest declares a supported host range.
187
+ * PANTHEON_OPENCODE_VERSION only NARROWS an already-detected set — it never
188
+ * chooses a file and never invents a family that is not there.
189
+ */
190
+ function resolveSources(db: SqliteDatabase, requested: OpenCodeVersion | undefined): CostSource[] {
191
+ const present = new Set(
192
+ (
193
+ db
194
+ .prepare(
195
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('message', 'session_message')",
196
+ )
197
+ .all() as Array<{ name?: unknown }>
198
+ ).map((row) => String(row.name)),
199
+ )
200
+ const detected = COST_SOURCES.filter((source) => present.has(source))
201
+ if (detected.length === 0) {
202
+ throw new Error(
203
+ 'incompatible opencode.db schema: neither the "message" nor the "session_message" table exists, so there is no token ledger to read',
204
+ )
127
205
  }
128
- const columns = db.prepare('PRAGMA table_info(message)').all() as Array<{ name?: unknown }>
129
- const names = new Set(columns.map((column) => column.name))
130
- const missing = ['data', 'time_created'].filter((name) => !names.has(name))
131
- if (missing.length > 0) {
206
+ if (requested === undefined) return detected
207
+ const wanted = VERSION_SOURCES[requested]
208
+ const narrowed = detected.filter((source) => source === wanted)
209
+ if (narrowed.length === 0) {
132
210
  throw new Error(
133
- `incompatible opencode.db schema: message table is missing ${missing.join(', ')}; select the matching V1/V2 database`,
211
+ `incompatible opencode.db schema: PANTHEON_OPENCODE_VERSION=${requested} selected no table family, but this database contains ${detected.join(' and ')}`,
134
212
  )
135
213
  }
214
+ return narrowed
136
215
  }
137
216
 
138
- /** Aggregate tokens by agent and phase via node:sqlite (read-only). */
139
- async function queryWithNodeSqlite(
140
- dbPath: string,
141
- days: number,
142
- sqlite: SqliteModule,
143
- ): Promise<CostRow[]> {
217
+ /**
218
+ * Per-family aggregation.
219
+ *
220
+ * Aggregating in SQL instead of pulling `data` blobs into JS is not a
221
+ * micro-optimisation: the host's own 7-day window is 271 MB across 26410
222
+ * assistant rows, and the equivalent SQL finishes in 0.9 s over the
223
+ * time_created index.
224
+ *
225
+ * Two correctness details are load-bearing:
226
+ * - `json_valid(data)` gates every extraction, because `json_extract` and
227
+ * `->>` both RAISE on malformed JSON in node:sqlite's bundled SQLite. The
228
+ * inner CTE applies it before extracting anything, so the outer query can
229
+ * never see an unparseable payload whatever order SQLite evaluates terms in.
230
+ * - `COALESCE(SUM(...), 0)` — a group whose tokens are all NULL sums to NULL,
231
+ * and adding NULL into an accumulator would print NaN into the report.
232
+ *
233
+ * V1 marks assistant turns with `data.role`; V2 marks them with the `type`
234
+ * COLUMN. V2 carries no `role` key, so the V1 predicate would match nothing.
235
+ */
236
+ const AGGREGATE_SQL: Record<CostSource, string> = {
237
+ message: `
238
+ WITH valid AS (
239
+ SELECT json_extract(data, '$.role') AS role,
240
+ json_extract(data, '$.agent') AS agent,
241
+ json_extract(data, '$.phase') AS phase,
242
+ json_extract(data, '$.metadata.phase') AS metadataPhase,
243
+ CAST(json_extract(data, '$.tokens.input') AS INTEGER) AS inTok,
244
+ CAST(json_extract(data, '$.tokens.output') AS INTEGER) AS outTok
245
+ FROM message
246
+ WHERE time_created >= ? AND json_valid(data)
247
+ )
248
+ SELECT agent AS agent,
249
+ COALESCE(NULLIF(phase, ''), NULLIF(metadataPhase, ''), 'unknown') AS phase,
250
+ COALESCE(SUM(inTok), 0) AS tokensInput,
251
+ COALESCE(SUM(outTok), 0) AS tokensOutput,
252
+ COUNT(*) AS rows
253
+ FROM valid
254
+ WHERE role = 'assistant'
255
+ GROUP BY 1, 2
256
+ HAVING agent IS NOT NULL AND agent != ''`,
257
+ session_message: `
258
+ WITH valid AS (
259
+ SELECT json_extract(data, '$.agent') AS agent,
260
+ CAST(json_extract(data, '$.tokens.input') AS INTEGER) AS inTok,
261
+ CAST(json_extract(data, '$.tokens.output') AS INTEGER) AS outTok
262
+ FROM session_message
263
+ WHERE time_created >= ? AND type = 'assistant' AND json_valid(data)
264
+ )
265
+ SELECT agent AS agent,
266
+ 'unknown' AS phase,
267
+ COALESCE(SUM(inTok), 0) AS tokensInput,
268
+ COALESCE(SUM(outTok), 0) AS tokensOutput,
269
+ COUNT(*) AS rows
270
+ FROM valid
271
+ GROUP BY 1
272
+ HAVING agent IS NOT NULL AND agent != ''`,
273
+ }
274
+
275
+ /**
276
+ * Window counters — the evidence a diagnosis is built from. A report that
277
+ * scanned rows but matched no assistant row must say so rather than render an
278
+ * empty table under a success status.
279
+ */
280
+ const COUNTER_SQL: Record<CostSource, string> = {
281
+ message: `
282
+ SELECT
283
+ (SELECT COUNT(*) FROM message WHERE time_created >= ?) AS scanned,
284
+ (SELECT MIN(time_created) FROM message WHERE time_created >= ?) AS windowStart,
285
+ (SELECT COUNT(*) FROM (
286
+ SELECT json_extract(data, '$.role') AS role
287
+ FROM message WHERE time_created >= ? AND json_valid(data)
288
+ ) WHERE role = 'assistant') AS assistantRows`,
289
+ session_message: `
290
+ SELECT
291
+ (SELECT COUNT(*) FROM session_message WHERE time_created >= ?) AS scanned,
292
+ (SELECT MIN(time_created) FROM session_message WHERE time_created >= ?) AS windowStart,
293
+ (SELECT COUNT(*) FROM session_message WHERE time_created >= ? AND type = 'assistant') AS assistantRows`,
294
+ }
295
+
296
+ const DATABASE_START_SQL: Record<CostSource, string> = {
297
+ message: 'SELECT MIN(time_created) AS databaseStart FROM message',
298
+ session_message: 'SELECT MIN(time_created) AS databaseStart FROM session_message',
299
+ }
300
+
301
+ type Counters = { scanned?: unknown; windowStart?: unknown; assistantRows?: unknown }
302
+
303
+ function numberOf(value: unknown): number | undefined {
304
+ if (value === null || value === undefined) return undefined
305
+ const parsed = Number(value)
306
+ return Number.isFinite(parsed) ? parsed : undefined
307
+ }
308
+
309
+ function emptyCoverage(sources: CostSource[]): CostCoverage {
310
+ return { sources, scanned: 0, assistantRows: 0 }
311
+ }
312
+
313
+ /**
314
+ * Read the token ledger of one database, read-only.
315
+ *
316
+ * Exported so the mirrored implementation in scripts/cost.mjs can be held to
317
+ * the same answer in tests. The script cannot import this module: it runs on a
318
+ * plain Node precisely because node:sqlite is missing from the plugin runtime.
319
+ */
320
+ export async function queryCostRows(dbPath: string, days: number): Promise<CostQuery> {
321
+ const sqlite = (await import('node:sqlite')) as SqliteModule
322
+ return queryWithSqlite(dbPath, days, sqlite)
323
+ }
324
+
325
+ /** Shared aggregation path for the in-process reader and queryCostRows. */
326
+ function queryWithSqlite(dbPath: string, days: number, sqlite: SqliteModule): CostQuery {
144
327
  const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
145
328
  try {
146
- assertCompatibleSchema(db)
329
+ const sources = resolveSources(db, requestedVersion())
147
330
  const since = Date.now() - days * 86_400_000
148
- const rows = db
149
- .prepare('SELECT data FROM message WHERE time_created >= ?')
150
- .all(since) as Array<{ data?: unknown }>
151
- // Rows without a data payload carry no cost info — skip them instead of
152
- // coercing null/undefined into "null"/"undefined" strings.
153
- return aggregateMessages(rows.flatMap((row) => (row?.data == null ? [] : [String(row.data)])))
331
+ const byAgentAndPhase = new Map<string, CostRow>()
332
+ const coverage = emptyCoverage([])
333
+
334
+ const note = (key: 'windowStart' | 'databaseStart', value: number | undefined): void => {
335
+ if (value === undefined) return
336
+ const current = coverage[key]
337
+ coverage[key] = current === undefined ? value : Math.min(current, value)
338
+ }
339
+
340
+ for (const source of sources) {
341
+ const countersRow = (
342
+ db.prepare(COUNTER_SQL[source]).all(since, since, since) as Array<Counters>
343
+ )[0]
344
+ coverage.sources.push(source)
345
+ coverage.scanned += numberOf(countersRow?.scanned) ?? 0
346
+ coverage.assistantRows += numberOf(countersRow?.assistantRows) ?? 0
347
+ note('windowStart', numberOf(countersRow?.windowStart))
348
+ note(
349
+ 'databaseStart',
350
+ numberOf(
351
+ (db.prepare(DATABASE_START_SQL[source]).get() as { databaseStart?: unknown })
352
+ ?.databaseStart,
353
+ ),
354
+ )
355
+
356
+ const groups = db.prepare(AGGREGATE_SQL[source]).all(since) as Array<{
357
+ agent?: unknown
358
+ phase?: unknown
359
+ tokensInput?: unknown
360
+ tokensOutput?: unknown
361
+ }>
362
+ for (const group of groups) {
363
+ const agent = String(group.agent ?? '')
364
+ if (agent === '') continue
365
+ const phase =
366
+ group.phase === undefined || group.phase === null ? 'unknown' : String(group.phase)
367
+ const tokensInput = numberOf(group.tokensInput) ?? 0
368
+ const tokensOutput = numberOf(group.tokensOutput) ?? 0
369
+ // Keyed on a NUL separator: agent names cannot contain it, so an agent
370
+ // whose name ends in the phase cannot collide with a phase-only row.
371
+ const key = `${agent}\u0000${phase}`
372
+ const acc = byAgentAndPhase.get(key) ?? {
373
+ agent,
374
+ phase,
375
+ tokensInput: 0,
376
+ tokensOutput: 0,
377
+ tokensTotal: 0,
378
+ }
379
+ acc.tokensInput += tokensInput
380
+ acc.tokensOutput += tokensOutput
381
+ acc.tokensTotal += tokensInput + tokensOutput
382
+ byAgentAndPhase.set(key, acc)
383
+ }
384
+ }
385
+
386
+ const rows = [...byAgentAndPhase.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
387
+ return { rows, coverage }
154
388
  } finally {
155
389
  db.close()
156
390
  }
157
391
  }
158
392
 
159
- /** Parse message.data JSON rows and aggregate tokens by assistant agent/phase. */
160
- function aggregateMessages(dataValues: readonly string[]): CostRow[] {
161
- const byAgentAndPhase = new Map<string, CostRow>()
162
- for (const value of dataValues) {
163
- try {
164
- const outer = JSON.parse(value) as Record<string, unknown>
165
- // The message row wraps the payload in a nested JSON string; when it
166
- // is not a string, the row IS the payload (either way: flat info).
167
- const info =
168
- typeof outer.data === 'string' ? (JSON.parse(outer.data) as Record<string, unknown>) : outer
169
- if (info.role !== 'assistant') continue
170
- const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
171
- if (agent === null) continue
172
- const metadata = info.metadata as Record<string, unknown> | undefined
173
- const phase =
174
- (typeof info.phase === 'string' && info.phase) ||
175
- (typeof metadata?.phase === 'string' && metadata.phase) ||
176
- 'unknown'
177
- const tokens = (info.tokens ?? {}) as Record<string, unknown>
178
- const tokensInput = Number(tokens.input) || 0
179
- const tokensOutput = Number(tokens.output) || 0
180
- const key = `${agent}\u0000${phase}`
181
- const acc = byAgentAndPhase.get(key) ?? {
182
- agent,
183
- phase,
184
- tokensInput: 0,
185
- tokensOutput: 0,
186
- tokensTotal: 0,
187
- }
188
- acc.tokensInput += tokensInput
189
- acc.tokensOutput += tokensOutput
190
- acc.tokensTotal += tokensInput + tokensOutput
191
- byAgentAndPhase.set(key, acc)
192
- } catch {
193
- // Skip malformed rows — a partial ledger never breaks the report.
194
- }
393
+ /** ISO day (UTC) — the granularity a coverage claim can honestly carry. */
394
+ function isoDay(epochMs: number): string {
395
+ return new Date(epochMs).toISOString().slice(0, 10)
396
+ }
397
+
398
+ /**
399
+ * Describe the window the numbers actually cover.
400
+ *
401
+ * A database created by a V2 host holds only the days since it was created, so
402
+ * a 30-day request over 8 days of history must not read as a month of usage.
403
+ */
404
+ function formatCoverage(coverage: CostCoverage, days: number): string {
405
+ const databaseStart = coverage.databaseStart
406
+ if (databaseStart === undefined) {
407
+ return `Coverage: this database holds no message rows at all, so the ${days}-day window cannot be covered.`
408
+ }
409
+ const start = isoDay(databaseStart)
410
+ const heldDays = Math.floor((Date.now() - databaseStart) / 86_400_000) + 1
411
+ const head = `Coverage: this database holds ${heldDays} day${heldDays === 1 ? '' : 's'} of history, from ${start}`
412
+ if (coverage.windowStart === undefined) {
413
+ return `${head}, and no rows fall inside the requested ${days}-day window.`
414
+ }
415
+ if (heldDays < days) {
416
+ return `${head}, so the requested ${days}-day window is only covered from ${isoDay(coverage.windowStart)}.`
417
+ }
418
+ return `${head} (requested window: ${days} days).`
419
+ }
420
+
421
+ /**
422
+ * Diagnose a ledger that WAS found but could not be read.
423
+ *
424
+ * An empty table under a success status is a worse failure than the error it
425
+ * replaces: it is indistinguishable from a quiet week. Every branch here is a
426
+ * case where rows exist but no total could be attributed, so the tool must
427
+ * diagnose it instead of printing an empty ledger.
428
+ *
429
+ * The last branch asks whether ANY row carries a token, not whether any row
430
+ * exists: an aggregate can carry an agent and still sum to zero (no `tokens`
431
+ * key), and rendering `| agent | unknown | 0 | 0 | 0 |` under `status: OK`
432
+ * asserts a measurement that was never made.
433
+ */
434
+ function diagnoseUnreadableLedger(query: CostQuery, days: number): string | undefined {
435
+ const { scanned, assistantRows, sources } = query.coverage
436
+ if (scanned === 0) return undefined
437
+ const tables = sources.join(' and ')
438
+ if (assistantRows === 0) {
439
+ return (
440
+ `no readable token source: ${scanned} row(s) were scanned in the last ${days} day(s) ` +
441
+ `but none matched the assistant discriminator of ${tables}, so no usage can be attributed. ` +
442
+ 'This is an unrecognised ledger layout, not an empty one; point PANTHEON_COST_DB at a ' +
443
+ 'database whose token layout this build understands.'
444
+ )
195
445
  }
196
- return [...byAgentAndPhase.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
446
+ if (!query.rows.some((row) => row.tokensInput !== 0 || row.tokensOutput !== 0)) {
447
+ return (
448
+ `no readable token source: ${assistantRows} assistant row(s) were found in ${tables} ` +
449
+ 'but none carried both an attributable agent and a token payload, so no usage can be ' +
450
+ 'attributed. This is an unrecognised ledger layout, not an empty one.'
451
+ )
452
+ }
453
+ return undefined
197
454
  }
198
455
 
199
456
  /** Render the aggregate as a markdown table with a total row. */
200
- function renderMarkdown(rows: readonly CostRow[], days: number): string {
457
+ function renderMarkdown(query: CostQuery, days: number): string {
458
+ const { rows, coverage } = query
459
+ const header = `## Token usage by agent and phase (last ${days} days)`
460
+ const footer = [
461
+ formatCoverage(coverage, days),
462
+ `Source: opencode.db (read-only) — table families: ${coverage.sources.join(' + ')} — ${coverage.assistantRows} assistant row(s) scanned`,
463
+ ].join('\n')
201
464
  if (rows.length === 0) {
202
- return `## Token usage by agent and phase (last ${days} days)\n\nNo token records in the last ${days} days.`
465
+ return `${header}\n\nNo token records in the last ${days} days.\n\n${footer}`
203
466
  }
204
467
  const total = rows.reduce(
205
468
  (acc, r) => {
@@ -211,7 +474,7 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
211
474
  { tokensInput: 0, tokensOutput: 0, tokensTotal: 0 },
212
475
  )
213
476
  const lines = [
214
- `## Token usage by agent and phase (last ${days} days)`,
477
+ header,
215
478
  '',
216
479
  '| Agent | Phase | Tokens In | Tokens Out | Tokens Total |',
217
480
  '|-------|-------|----------:|-----------:|-------------:|',
@@ -221,7 +484,7 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
221
484
  ),
222
485
  `| **Total** | — | **${total.tokensInput}** | **${total.tokensOutput}** | **${total.tokensTotal}** |`,
223
486
  '',
224
- `Source: opencode.db (read-only)`,
487
+ footer,
225
488
  ]
226
489
  return lines.join('\n')
227
490
  }
@@ -297,7 +560,7 @@ export function resolveNodeFromPath(
297
560
  if (pathValue === '') return undefined
298
561
  const isWindows = process.platform === 'win32'
299
562
  const extensions = isWindows
300
- ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter((ext) => ext !== '')
563
+ ? (env.PATHEXT ?? '.EXE;.BAT;.CMD;.COM').split(';').filter((ext) => ext !== '')
301
564
  : ['']
302
565
  for (const dir of pathValue.split(delimiter)) {
303
566
  if (dir === '') continue
@@ -386,8 +649,9 @@ function extractScriptFailure(err: unknown): string {
386
649
 
387
650
  /**
388
651
  * Spawn `node scripts/cost.mjs` as a CLI fallback when node:sqlite is
389
- * unavailable in the plugin process (e.g. Bun runtime). Returns CostRow[] by
390
- * parsing the JSON the script writes to stdout.
652
+ * unavailable in the plugin process (e.g. Bun runtime). Returns the same
653
+ * CostQuery the in-process reader produces by parsing the JSON the script
654
+ * writes to stdout.
391
655
  *
392
656
  * Resolves a REAL Node binary (PANTHEON_NODE → PATH): process.execPath under
393
657
  * Bun is the Bun binary, which cannot run node:sqlite. The resolved binary is
@@ -398,7 +662,7 @@ export async function queryWithCliFallback(
398
662
  dbPath: string,
399
663
  days: number,
400
664
  deps?: CliFallbackDeps,
401
- ): Promise<CostRow[]> {
665
+ ): Promise<CostQuery> {
402
666
  const env = deps?.env ?? process.env
403
667
  const execFileAsync = deps?.execFile ?? defaultExecFile
404
668
  const isExecutable = deps?.isExecutable ?? defaultIsExecutable
@@ -436,7 +700,7 @@ export async function queryWithCliFallback(
436
700
  if (parsed === null || typeof parsed !== 'object') {
437
701
  throw new Error(`CLI fallback returned non-JSON output: ${truncateForError(stdout)}`)
438
702
  }
439
- const record = parsed as { ok?: unknown; rows?: unknown; error?: unknown }
703
+ const record = parsed as { ok?: unknown; rows?: unknown; coverage?: unknown; error?: unknown }
440
704
  if (record.ok !== true) {
441
705
  const detail =
442
706
  typeof record.error === 'string' && record.error !== ''
@@ -447,7 +711,33 @@ export async function queryWithCliFallback(
447
711
  if (!Array.isArray(record.rows)) {
448
712
  throw new Error('CLI fallback failed: scripts/cost.mjs response is missing the rows array')
449
713
  }
450
- return record.rows as CostRow[]
714
+ const coverage = record.coverage
715
+ if (coverage === null || typeof coverage !== 'object') {
716
+ throw new Error(
717
+ 'CLI fallback failed: scripts/cost.mjs response is missing the coverage object; the mirror must report what it scanned',
718
+ )
719
+ }
720
+ const mirror = coverage as {
721
+ sources?: unknown
722
+ scanned?: unknown
723
+ assistantRows?: unknown
724
+ databaseStart?: unknown
725
+ windowStart?: unknown
726
+ }
727
+ return {
728
+ rows: record.rows as CostRow[],
729
+ coverage: {
730
+ sources: Array.isArray(mirror.sources) ? (mirror.sources as CostSource[]) : [],
731
+ scanned: numberOf(mirror.scanned) ?? 0,
732
+ assistantRows: numberOf(mirror.assistantRows) ?? 0,
733
+ ...(numberOf(mirror.databaseStart) === undefined
734
+ ? {}
735
+ : { databaseStart: numberOf(mirror.databaseStart) as number }),
736
+ ...(numberOf(mirror.windowStart) === undefined
737
+ ? {}
738
+ : { windowStart: numberOf(mirror.windowStart) as number }),
739
+ },
740
+ }
451
741
  }
452
742
 
453
743
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
@@ -473,7 +763,10 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
473
763
  detail.includes('not found') ||
474
764
  detail.includes('unknown builtin') ||
475
765
  detail.includes('no such built-in') ||
476
- detail.includes('cannot find module')
766
+ detail.includes('cannot find module') ||
767
+ // The mirror's own "found a ledger but could not read tokens" verdict.
768
+ // Must stay in step with scripts/cost.mjs, which emits this exact phrase.
769
+ detail.includes('no readable token source')
477
770
  ) {
478
771
  return 'UNSUPPORTED'
479
772
  }
@@ -488,6 +781,13 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
488
781
  return 'UNAVAILABLE'
489
782
  }
490
783
 
784
+ /** Run a query and turn its coverage into either a report or a diagnosis. */
785
+ const render = (query: CostQuery, days: number): string => {
786
+ const unreadable = diagnoseUnreadableLedger(query, days)
787
+ if (unreadable !== undefined) return failure('UNSUPPORTED', unreadable)
788
+ return `status: OK\n${renderMarkdown(query, days)}`
789
+ }
790
+
491
791
  const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
492
792
  const days = args?.days ?? 7
493
793
  try {
@@ -507,16 +807,14 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
507
807
  const status = statusForError(loadErr)
508
808
  if (status === 'UNSUPPORTED') {
509
809
  try {
510
- const rows = await queryWithCliFallback(dbPath, days, options?.cliFallback)
511
- return `status: OK\n${renderMarkdown(rows, days)}`
810
+ return render(await queryWithCliFallback(dbPath, days, options?.cliFallback), days)
512
811
  } catch (cliErr: unknown) {
513
812
  return failure(statusForError(cliErr), diagnostic(cliErr))
514
813
  }
515
814
  }
516
815
  throw loadErr
517
816
  }
518
- const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
519
- return `status: OK\n${renderMarkdown(rows, days)}`
817
+ return render(queryWithSqlite(dbPath, days, sqlite), days)
520
818
  } catch (err: unknown) {
521
819
  return failure(statusForError(err), diagnostic(err))
522
820
  }
@@ -525,7 +823,7 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
525
823
  return {
526
824
  pantheon_cost: {
527
825
  description:
528
- 'Report input, output, and total token usage by agent and phase over the last N days, read from opencode.db (read-only, no monetary values).',
826
+ 'Report input, output, and total token usage by agent and phase over the last N days, read from opencode.db (read-only, no monetary values). The report states the coverage window it actually covers.',
529
827
  args: costArgs,
530
828
  execute,
531
829
  },
@@ -9,12 +9,14 @@
9
9
  * module never needs the opencode SDK — the handler is a structural closure
10
10
  * wired from plugin.ts.
11
11
  *
12
- * Blocked tools (edit, write, bash, task, hashline_edit):
12
+ * Blocked tools (edit, write, bash, task, hashline_edit, pantheon_model):
13
13
  * - edit/write/bash are the mutating surface — read-only agents (apollo,
14
14
  * gaia) must stay investigation-only.
15
15
  * - `hashline_edit` is the Wave 2 (PR #46) tag-anchored edit tool — it is a
16
16
  * write surface too, so read-only agents must not bypass enforcement via
17
17
  * it.
18
+ * - `pantheon_model` writes active-preset.json in project and global scope —
19
+ * a config write a read-only session must not perform.
18
20
  * - `task` is blocked too, which hard-enforces depth-2: a read-only agent
19
21
  * cannot spawn its own subagents.
20
22
  *
@@ -182,6 +184,13 @@ export const DEFAULT_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
182
184
  'bash',
183
185
  'task',
184
186
  'hashline_edit',
187
+ // `pantheon_model` writes active-preset.json in BOTH the project and the
188
+ // global scope, so it is a write surface with the same blast radius as
189
+ // `write`. Read-only agents have no reason to change agent models, and a
190
+ // global override from an investigation-only session is unrecoverable from
191
+ // inside that session. `pantheon_cost` is deliberately absent: it only reads
192
+ // opencode.db.
193
+ 'pantheon_model',
185
194
  ])
186
195
 
187
196
  // ─── Zeus Read Guard ──────────────────────────────────────────────────