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.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +343 -0
- package/README.md +577 -79
- package/README.pt-BR.md +345 -60
- package/bin/pantheon-init.mjs +26 -3
- 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-optimize.md +2 -2
- package/commands/pantheon-vision.md +61 -12
- package/package.json +30 -11
- package/plugin.json +2 -2
- package/pyproject.toml +11 -16
- package/scripts/build-agents-md.mjs +376 -58
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/{code_mode_server.py → code_mode.py} +119 -27
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +236 -29
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +319 -6
- package/scripts/install/health-check.mjs +26 -37
- package/scripts/install/interactive.mjs +7 -7
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +36 -13
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +2 -2
- 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 +1913 -0
- package/scripts/validate-routing.mjs +43 -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 +15 -16
- 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 +1 -1
- package/src/agents/mnemosyne.md +23 -60
- 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 -130
- package/src/instructions/agent-return-format.instructions.md +18 -3
- 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 +11 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
- package/src/mcp/requirements-mcp.txt +10 -6
- package/src/mcp/requirements-vision.txt +4 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/todo-enforcer.ts +1 -1
- 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-tools.ts +429 -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 +1161 -116
- package/src/plugin-v2.ts +1244 -288
- 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 +5504 -1341
- package/src/plugins/tui/package.json +25 -15
- 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 -22
- package/src/skills/README.md +34 -39
- 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 +54 -95
- 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/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/opencode.json +0 -58
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- 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
|
-
*
|
|
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
|
|
@@ -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
|
-
/**
|
|
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,291 @@ 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
|
+
`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
|
|
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
|
-
|
|
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
|
-
|
|
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;.
|
|
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
|
|
390
|
-
* parsing the JSON the script
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────────
|