pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11

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 (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -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
@@ -74,35 +129,42 @@ const costArgs = {
74
129
  .min(1)
75
130
  .max(365)
76
131
  .optional()
77
- .describe('Number of days of delegation history to include (default 7).'),
132
+ .describe('Number of rolling 24-hour days of 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,294 @@ 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
+ 'Window: rolling 24-hour days from query time (not calendar days or session boundaries).',
463
+ 'Aggregation: per agent and phase; phase may be unknown on V2.',
464
+ 'Limits: not grouped by session, task, model, or provider; tokens only (no dollar costs), so this cannot establish model-specific savings or deltas.',
465
+ `Source: opencode.db (read-only) — table families: ${coverage.sources.join(' + ')} — ${coverage.assistantRows} assistant row(s) scanned`,
466
+ ].join('\n')
201
467
  if (rows.length === 0) {
202
- return `## Token usage by agent and phase (last ${days} days)\n\nNo token records in the last ${days} days.`
468
+ return `${header}\n\nNo token records in the last ${days} days.\n\n${footer}`
203
469
  }
204
470
  const total = rows.reduce(
205
471
  (acc, r) => {
@@ -211,7 +477,7 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
211
477
  { tokensInput: 0, tokensOutput: 0, tokensTotal: 0 },
212
478
  )
213
479
  const lines = [
214
- `## Token usage by agent and phase (last ${days} days)`,
480
+ header,
215
481
  '',
216
482
  '| Agent | Phase | Tokens In | Tokens Out | Tokens Total |',
217
483
  '|-------|-------|----------:|-----------:|-------------:|',
@@ -221,7 +487,7 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
221
487
  ),
222
488
  `| **Total** | — | **${total.tokensInput}** | **${total.tokensOutput}** | **${total.tokensTotal}** |`,
223
489
  '',
224
- `Source: opencode.db (read-only)`,
490
+ footer,
225
491
  ]
226
492
  return lines.join('\n')
227
493
  }
@@ -297,7 +563,7 @@ export function resolveNodeFromPath(
297
563
  if (pathValue === '') return undefined
298
564
  const isWindows = process.platform === 'win32'
299
565
  const extensions = isWindows
300
- ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter((ext) => ext !== '')
566
+ ? (env.PATHEXT ?? '.EXE;.BAT;.CMD;.COM').split(';').filter((ext) => ext !== '')
301
567
  : ['']
302
568
  for (const dir of pathValue.split(delimiter)) {
303
569
  if (dir === '') continue
@@ -386,8 +652,9 @@ function extractScriptFailure(err: unknown): string {
386
652
 
387
653
  /**
388
654
  * 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.
655
+ * unavailable in the plugin process (e.g. Bun runtime). Returns the same
656
+ * CostQuery the in-process reader produces by parsing the JSON the script
657
+ * writes to stdout.
391
658
  *
392
659
  * Resolves a REAL Node binary (PANTHEON_NODE → PATH): process.execPath under
393
660
  * Bun is the Bun binary, which cannot run node:sqlite. The resolved binary is
@@ -398,7 +665,7 @@ export async function queryWithCliFallback(
398
665
  dbPath: string,
399
666
  days: number,
400
667
  deps?: CliFallbackDeps,
401
- ): Promise<CostRow[]> {
668
+ ): Promise<CostQuery> {
402
669
  const env = deps?.env ?? process.env
403
670
  const execFileAsync = deps?.execFile ?? defaultExecFile
404
671
  const isExecutable = deps?.isExecutable ?? defaultIsExecutable
@@ -436,7 +703,7 @@ export async function queryWithCliFallback(
436
703
  if (parsed === null || typeof parsed !== 'object') {
437
704
  throw new Error(`CLI fallback returned non-JSON output: ${truncateForError(stdout)}`)
438
705
  }
439
- const record = parsed as { ok?: unknown; rows?: unknown; error?: unknown }
706
+ const record = parsed as { ok?: unknown; rows?: unknown; coverage?: unknown; error?: unknown }
440
707
  if (record.ok !== true) {
441
708
  const detail =
442
709
  typeof record.error === 'string' && record.error !== ''
@@ -447,7 +714,33 @@ export async function queryWithCliFallback(
447
714
  if (!Array.isArray(record.rows)) {
448
715
  throw new Error('CLI fallback failed: scripts/cost.mjs response is missing the rows array')
449
716
  }
450
- return record.rows as CostRow[]
717
+ const coverage = record.coverage
718
+ if (coverage === null || typeof coverage !== 'object') {
719
+ throw new Error(
720
+ 'CLI fallback failed: scripts/cost.mjs response is missing the coverage object; the mirror must report what it scanned',
721
+ )
722
+ }
723
+ const mirror = coverage as {
724
+ sources?: unknown
725
+ scanned?: unknown
726
+ assistantRows?: unknown
727
+ databaseStart?: unknown
728
+ windowStart?: unknown
729
+ }
730
+ return {
731
+ rows: record.rows as CostRow[],
732
+ coverage: {
733
+ sources: Array.isArray(mirror.sources) ? (mirror.sources as CostSource[]) : [],
734
+ scanned: numberOf(mirror.scanned) ?? 0,
735
+ assistantRows: numberOf(mirror.assistantRows) ?? 0,
736
+ ...(numberOf(mirror.databaseStart) === undefined
737
+ ? {}
738
+ : { databaseStart: numberOf(mirror.databaseStart) as number }),
739
+ ...(numberOf(mirror.windowStart) === undefined
740
+ ? {}
741
+ : { windowStart: numberOf(mirror.windowStart) as number }),
742
+ },
743
+ }
451
744
  }
452
745
 
453
746
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
@@ -473,7 +766,10 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
473
766
  detail.includes('not found') ||
474
767
  detail.includes('unknown builtin') ||
475
768
  detail.includes('no such built-in') ||
476
- detail.includes('cannot find module')
769
+ detail.includes('cannot find module') ||
770
+ // The mirror's own "found a ledger but could not read tokens" verdict.
771
+ // Must stay in step with scripts/cost.mjs, which emits this exact phrase.
772
+ detail.includes('no readable token source')
477
773
  ) {
478
774
  return 'UNSUPPORTED'
479
775
  }
@@ -488,6 +784,13 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
488
784
  return 'UNAVAILABLE'
489
785
  }
490
786
 
787
+ /** Run a query and turn its coverage into either a report or a diagnosis. */
788
+ const render = (query: CostQuery, days: number): string => {
789
+ const unreadable = diagnoseUnreadableLedger(query, days)
790
+ if (unreadable !== undefined) return failure('UNSUPPORTED', unreadable)
791
+ return `status: OK\n${renderMarkdown(query, days)}`
792
+ }
793
+
491
794
  const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
492
795
  const days = args?.days ?? 7
493
796
  try {
@@ -507,16 +810,14 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
507
810
  const status = statusForError(loadErr)
508
811
  if (status === 'UNSUPPORTED') {
509
812
  try {
510
- const rows = await queryWithCliFallback(dbPath, days, options?.cliFallback)
511
- return `status: OK\n${renderMarkdown(rows, days)}`
813
+ return render(await queryWithCliFallback(dbPath, days, options?.cliFallback), days)
512
814
  } catch (cliErr: unknown) {
513
815
  return failure(statusForError(cliErr), diagnostic(cliErr))
514
816
  }
515
817
  }
516
818
  throw loadErr
517
819
  }
518
- const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
519
- return `status: OK\n${renderMarkdown(rows, days)}`
820
+ return render(queryWithSqlite(dbPath, days, sqlite), days)
520
821
  } catch (err: unknown) {
521
822
  return failure(statusForError(err), diagnostic(err))
522
823
  }
@@ -525,7 +826,7 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
525
826
  return {
526
827
  pantheon_cost: {
527
828
  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).',
829
+ 'Report input, output, and total token usage by agent and phase in a rolling N × 24-hour window from query time (read-only, no monetary values). It is not grouped by session, task, model, or provider; phase may be unknown on V2, so it cannot establish model-specific savings or deltas. The report states the coverage window it actually covers.',
529
830
  args: costArgs,
530
831
  execute,
531
832
  },