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.
- package/AGENTS.global.md +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +7 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1158 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +52 -35
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- 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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
132
|
+
.describe('Number of rolling 24-hour days of history to include (default 7).'),
|
|
78
133
|
} satisfies z.ZodRawShape
|
|
79
134
|
|
|
80
|
-
/**
|
|
81
|
-
function defaultDbPath(
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
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:
|
|
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
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
329
|
+
const sources = resolveSources(db, requestedVersion())
|
|
147
330
|
const since = Date.now() - days * 86_400_000
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
/**
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;.
|
|
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
|
|
390
|
-
* parsing the JSON the script
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
},
|