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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge pending [Unreleased] notes into an already-promoted version section.
|
|
3
|
+
* This supports release candidates whose versioned changelog section exists
|
|
4
|
+
* before the final beta changes are ready, without creating a duplicate entry
|
|
5
|
+
* or advancing the package version.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const UNRELEASED_TEMPLATE = `
|
|
9
|
+
|
|
10
|
+
<!-- Add new changes here. Running \`node scripts/versioning.mjs apply\` will
|
|
11
|
+
move this section to a versioned entry and reset the template below. -->
|
|
12
|
+
|
|
13
|
+
## 🆕 What's New
|
|
14
|
+
|
|
15
|
+
## 🐞 Fixed
|
|
16
|
+
|
|
17
|
+
## ⚠️ Known Issues
|
|
18
|
+
|
|
19
|
+
## ✅ Closed Issues
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
const TEMPLATE_COMMENT = /<!-- Add new changes here\.[\s\S]*?reset the template below\. -->/g
|
|
23
|
+
const ESCAPED_TEMPLATE_COMMENT =
|
|
24
|
+
/<!-- Add new changes here\.[\s\S]*?reset the template below\. -->/g
|
|
25
|
+
|
|
26
|
+
function escapeRegExp(value) {
|
|
27
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findSection(lines, heading) {
|
|
31
|
+
const matches = []
|
|
32
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
33
|
+
if (lines[index] === heading) matches.push(index)
|
|
34
|
+
}
|
|
35
|
+
if (matches.length > 1) throw new Error(`CHANGELOG.md has duplicate section: ${heading}`)
|
|
36
|
+
if (matches.length === 0) return null
|
|
37
|
+
const start = matches[0]
|
|
38
|
+
let end = start + 1
|
|
39
|
+
while (end < lines.length && !/^## \[/.test(lines[end])) end += 1
|
|
40
|
+
return { start, end }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseBody(body) {
|
|
44
|
+
const withoutComments = body.replace(TEMPLATE_COMMENT, '').replace(ESCAPED_TEMPLATE_COMMENT, '')
|
|
45
|
+
const lines = withoutComments.split('\n')
|
|
46
|
+
const preamble = []
|
|
47
|
+
const sections = new Map()
|
|
48
|
+
let current = null
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const heading = /^## (.+)$/.exec(line)
|
|
52
|
+
if (heading) {
|
|
53
|
+
current = heading[1]
|
|
54
|
+
if (!sections.has(current)) sections.set(current, [])
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (current === null) preamble.push(line)
|
|
58
|
+
else sections.get(current).push(line)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const meaningful = (items) => items.join('\n').trim().length > 0
|
|
62
|
+
for (const [heading, content] of sections) {
|
|
63
|
+
if (!meaningful(content)) sections.delete(heading)
|
|
64
|
+
else {
|
|
65
|
+
sections.set(
|
|
66
|
+
heading,
|
|
67
|
+
content
|
|
68
|
+
.join('\n')
|
|
69
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
70
|
+
.trim()
|
|
71
|
+
.split('\n'),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
preamble: preamble.join('\n').trim(),
|
|
77
|
+
sections,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mergeBodies(existingBody, pendingBody) {
|
|
82
|
+
const existing = parseBody(existingBody)
|
|
83
|
+
const pending = parseBody(pendingBody)
|
|
84
|
+
const preamble = [existing.preamble, pending.preamble].filter(Boolean).join('\n\n')
|
|
85
|
+
const sections = new Map(existing.sections)
|
|
86
|
+
|
|
87
|
+
for (const [heading, content] of pending.sections) {
|
|
88
|
+
if (!sections.has(heading)) {
|
|
89
|
+
sections.set(heading, content)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
const previous = sections.get(heading)
|
|
93
|
+
const previousText = previous.join('\n').trim()
|
|
94
|
+
const pendingText = content.join('\n').trim()
|
|
95
|
+
sections.set(heading, [`${previousText}\n\n${pendingText}`])
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rendered = []
|
|
99
|
+
if (preamble) rendered.push(preamble)
|
|
100
|
+
for (const [heading, content] of sections) {
|
|
101
|
+
if (rendered.length > 0) rendered.push('')
|
|
102
|
+
rendered.push(`## ${heading}`, '', content.join('\n').trim())
|
|
103
|
+
}
|
|
104
|
+
return rendered.join('\n').trim()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Merge [Unreleased] into an existing exact version heading, preserving its
|
|
109
|
+
* date and existing notes. Returns null when the version section does not yet
|
|
110
|
+
* exist so the caller can use the normal promotion path.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} changelogText complete changelog
|
|
113
|
+
* @param {string} version semantic version, with or without a leading v
|
|
114
|
+
* @returns {{changed: boolean, content: string}|null}
|
|
115
|
+
*/
|
|
116
|
+
export function mergeUnreleasedIntoVersion(changelogText, version) {
|
|
117
|
+
const normalized = String(version).trim().replace(/^v/i, '')
|
|
118
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalized)) {
|
|
119
|
+
throw new Error(`invalid changelog version: ${version}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lines = changelogText.split('\n')
|
|
123
|
+
const unreleased = findSection(lines, '## [Unreleased]')
|
|
124
|
+
if (!unreleased) throw new Error('CHANGELOG.md is missing the [Unreleased] section')
|
|
125
|
+
|
|
126
|
+
const escaped = escapeRegExp(normalized)
|
|
127
|
+
const targetPattern = new RegExp(`^## \\[v?${escaped}\\](?: - \\d{4}-\\d{2}-\\d{2})?$`)
|
|
128
|
+
const targetMatches = []
|
|
129
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
130
|
+
if (targetPattern.test(lines[index])) targetMatches.push(index)
|
|
131
|
+
}
|
|
132
|
+
if (targetMatches.length > 1) {
|
|
133
|
+
throw new Error(`CHANGELOG.md has duplicate version sections: ${normalized}`)
|
|
134
|
+
}
|
|
135
|
+
if (targetMatches.length === 0) return null
|
|
136
|
+
|
|
137
|
+
const target = findSection(lines, lines[targetMatches[0]])
|
|
138
|
+
if (!target) throw new Error(`CHANGELOG.md is missing version section: ${normalized}`)
|
|
139
|
+
|
|
140
|
+
const pendingBody = lines.slice(unreleased.start + 1, unreleased.end).join('\n')
|
|
141
|
+
const existingBody = lines.slice(target.start + 1, target.end).join('\n')
|
|
142
|
+
const mergedBody = mergeBodies(existingBody, pendingBody)
|
|
143
|
+
const afterTarget = [lines[target.start], '', mergedBody].join('\n').trimEnd()
|
|
144
|
+
const updatedLines = [
|
|
145
|
+
...lines.slice(0, target.start),
|
|
146
|
+
afterTarget,
|
|
147
|
+
...(target.end < lines.length ? [''] : []),
|
|
148
|
+
...lines.slice(target.end),
|
|
149
|
+
]
|
|
150
|
+
.join('\n')
|
|
151
|
+
.split('\n')
|
|
152
|
+
const updatedUnreleased = findSection(updatedLines, '## [Unreleased]')
|
|
153
|
+
if (!updatedUnreleased) throw new Error('merged CHANGELOG.md lost the [Unreleased] section')
|
|
154
|
+
const content = [
|
|
155
|
+
...updatedLines.slice(0, updatedUnreleased.start),
|
|
156
|
+
`## [Unreleased]${UNRELEASED_TEMPLATE}`,
|
|
157
|
+
...updatedLines.slice(updatedUnreleased.end),
|
|
158
|
+
].join('\n')
|
|
159
|
+
|
|
160
|
+
return { changed: content !== changelogText, content }
|
|
161
|
+
}
|
|
@@ -5,12 +5,12 @@ Provides a confined execution environment for orchestration scripts
|
|
|
5
5
|
via MCP tools and resources.
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
|
-
python scripts/
|
|
8
|
+
python scripts/code_mode.py
|
|
9
9
|
|
|
10
10
|
Or via MCP client (stdio transport):
|
|
11
11
|
pantheon-code-mode:
|
|
12
12
|
command: python
|
|
13
|
-
args: ["scripts/
|
|
13
|
+
args: ["scripts/code_mode.py"]
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
@@ -36,7 +36,7 @@ from typing import Any
|
|
|
36
36
|
|
|
37
37
|
import yaml
|
|
38
38
|
from _pantheon_paths import pantheon_home, pantheon_project
|
|
39
|
-
from
|
|
39
|
+
from fastmcp import FastMCP
|
|
40
40
|
|
|
41
41
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
42
42
|
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({".sh", ".py"})
|
package/scripts/cost.mjs
CHANGED
|
@@ -3,19 +3,42 @@
|
|
|
3
3
|
* cost.mjs — read-only token aggregation from opencode.db.
|
|
4
4
|
*
|
|
5
5
|
* CLI fallback for the `pantheon_cost` tool when node:sqlite is unavailable
|
|
6
|
-
* in the plugin process.
|
|
7
|
-
*
|
|
8
|
-
* { ok: true, days, rows: [{agent, phase, tokensInput, tokensOutput, tokensTotal}] }
|
|
6
|
+
* in the plugin process. Prints a single JSON object to stdout:
|
|
7
|
+
* { ok: true, days, rows: [{agent, phase, tokensInput, tokensOutput, tokensTotal}], coverage }
|
|
9
8
|
* { ok: false, error: "<message>" } (exit code 1)
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* ## This file is a MIRROR of src/pantheon/cost-command.ts
|
|
11
|
+
*
|
|
12
|
+
* It cannot import that module — it exists precisely to run on a plain Node
|
|
13
|
+
* where the plugin runtime cannot — so the two implementations duplicate the
|
|
14
|
+
* same contract on purpose:
|
|
15
|
+
* - ledger families are detected by TABLE PRESENCE, never by filename and
|
|
16
|
+
* never by a pinned host generation;
|
|
17
|
+
* - PANTHEON_OPENCODE_VERSION only NARROWS an already-detected set;
|
|
18
|
+
* - V1 marks assistant turns with `data.role`, V2 with the `type` COLUMN
|
|
19
|
+
* (V2 payloads carry no `role` key at all);
|
|
20
|
+
* - the coverage counters mean the same thing in both, because the caller
|
|
21
|
+
* feeds them to the same diagnosis.
|
|
22
|
+
*
|
|
23
|
+
* Every one of those was a real divergence once: this script kept the V1
|
|
24
|
+
* predicate and dropped 100% of a V2 ledger, while the caller reported a
|
|
25
|
+
* successful, empty report. A change to one side that is not made on the other
|
|
26
|
+
* is a regression, not a refactor.
|
|
27
|
+
*
|
|
28
|
+
* NEVER writes to the database — opened read-only. Malformed rows are skipped;
|
|
29
|
+
* assistant messages carry the authoritative token usage.
|
|
13
30
|
*
|
|
14
31
|
* Usage: node scripts/cost.mjs --db-path <dbPath> --days <days>
|
|
15
32
|
*/
|
|
16
33
|
import { existsSync } from 'node:fs'
|
|
17
34
|
import { DatabaseSync } from 'node:sqlite'
|
|
18
35
|
|
|
36
|
+
/** Ledger families a host database can carry; a migrated one carries both. */
|
|
37
|
+
const COST_SOURCES = ['message', 'session_message']
|
|
38
|
+
|
|
39
|
+
/** Which family each host generation stores its ledger in. */
|
|
40
|
+
const VERSION_SOURCES = { v1: 'message', v2: 'session_message' }
|
|
41
|
+
|
|
19
42
|
/**
|
|
20
43
|
* Fail via exitCode + natural process exit (NEVER process.exit right after
|
|
21
44
|
* stdout.write — the pipe is async and the write can be truncated).
|
|
@@ -26,22 +49,201 @@ function fail(message) {
|
|
|
26
49
|
process.exitCode = 1
|
|
27
50
|
}
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
throw new Error(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
/** Read and validate the optional host-generation selector. */
|
|
53
|
+
function requestedVersion() {
|
|
54
|
+
const configured = process.env.PANTHEON_OPENCODE_VERSION
|
|
55
|
+
if (configured === undefined || configured === '') return undefined
|
|
56
|
+
if (configured !== 'v1' && configured !== 'v2') {
|
|
57
|
+
throw new Error(`invalid PANTHEON_OPENCODE_VERSION "${configured}"; expected "v1" or "v2"`)
|
|
58
|
+
}
|
|
59
|
+
return configured
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect the ledger families by TABLE PRESENCE, then narrow if asked.
|
|
64
|
+
*
|
|
65
|
+
* Presence is the only reliable discriminator: every real installation writes
|
|
66
|
+
* `opencode.db`, and a migrated database carries BOTH families at once.
|
|
67
|
+
*/
|
|
68
|
+
function resolveSources(db) {
|
|
69
|
+
const present = new Set(
|
|
70
|
+
db
|
|
71
|
+
.prepare(
|
|
72
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('message', 'session_message')",
|
|
73
|
+
)
|
|
74
|
+
.all()
|
|
75
|
+
.map((row) => String(row.name)),
|
|
76
|
+
)
|
|
77
|
+
const detected = COST_SOURCES.filter((source) => present.has(source))
|
|
78
|
+
if (detected.length === 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'incompatible opencode.db schema: neither the "message" nor the "session_message" table exists, so there is no token ledger to read',
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
const requested = requestedVersion()
|
|
84
|
+
if (requested === undefined) return detected
|
|
85
|
+
const narrowed = detected.filter((source) => source === VERSION_SOURCES[requested])
|
|
86
|
+
if (narrowed.length === 0) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`incompatible opencode.db schema: PANTHEON_OPENCODE_VERSION=${requested} selected no table family, but this database contains ${detected.join(' and ')}`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
return narrowed
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The columns this reader needs from the family it is about to query. */
|
|
95
|
+
function assertColumns(db, source) {
|
|
96
|
+
const names = new Set(
|
|
97
|
+
db
|
|
98
|
+
.prepare(`PRAGMA table_info(${source})`)
|
|
99
|
+
.all()
|
|
100
|
+
.map((column) => String(column.name)),
|
|
101
|
+
)
|
|
102
|
+
const required =
|
|
103
|
+
source === 'session_message' ? ['data', 'time_created', 'type'] : ['data', 'time_created']
|
|
104
|
+
const missing = required.filter((name) => !names.has(name))
|
|
38
105
|
if (missing.length > 0) {
|
|
39
106
|
throw new Error(
|
|
40
|
-
`incompatible opencode.db schema:
|
|
107
|
+
`incompatible opencode.db schema: ${source} table is missing ${missing.join(', ')}; select a database whose token layout this build understands`,
|
|
41
108
|
)
|
|
42
109
|
}
|
|
43
110
|
}
|
|
44
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Parse one payload. Returns undefined for anything unparseable, which is how
|
|
114
|
+
* both readers treat it: the V1 counter requires json_valid(data) and the V2
|
|
115
|
+
* aggregate does too, so a malformed row contributes to nothing.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT unwrapping a nested `data` string: the in-process reader
|
|
118
|
+
* extracts `$.role`/`$.agent` from the TOP level, so a wrapped payload yields
|
|
119
|
+
* NULL there and is dropped. Unwrapping here would resurrect rows the primary
|
|
120
|
+
* path cannot see, and the two readers would disagree on the same file.
|
|
121
|
+
*/
|
|
122
|
+
function readPayload(data) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(String(data))
|
|
125
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : undefined
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** CAST(json_extract(...) AS INTEGER) semantics: absent or non-numeric → 0. */
|
|
132
|
+
function toToken(value) {
|
|
133
|
+
const parsed = Number(value)
|
|
134
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Map key for one aggregated row.
|
|
139
|
+
*
|
|
140
|
+
* NUL-separated: agent names cannot contain it, so an agent whose name ends
|
|
141
|
+
* in the phase cannot collide with a phase-only row.
|
|
142
|
+
*/
|
|
143
|
+
function rowKey(agent, phase) {
|
|
144
|
+
return `${agent}\u0000${phase}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read one family.
|
|
148
|
+
*
|
|
149
|
+
* `assistantRows` follows the per-generation discriminator exactly, including
|
|
150
|
+
* the asymmetry that matters: V2 counts an assistant row by its COLUMN whether
|
|
151
|
+
* or not the payload parses, because that counter SQL has no json_valid gate.
|
|
152
|
+
*/
|
|
153
|
+
function readSource(db, source, since) {
|
|
154
|
+
const windowRow = db
|
|
155
|
+
.prepare(
|
|
156
|
+
`SELECT COUNT(*) AS scanned, MIN(time_created) AS windowStart FROM ${source} WHERE time_created >= ?`,
|
|
157
|
+
)
|
|
158
|
+
.get(since)
|
|
159
|
+
const databaseRow = db.prepare(`SELECT MIN(time_created) AS databaseStart FROM ${source}`).get()
|
|
160
|
+
const select = source === 'session_message' ? 'SELECT type, data' : 'SELECT data'
|
|
161
|
+
const rows = db.prepare(`${select} FROM ${source} WHERE time_created >= ?`).all(since)
|
|
162
|
+
|
|
163
|
+
const groups = new Map()
|
|
164
|
+
let assistantRows = 0
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
const info = readPayload(row.data)
|
|
167
|
+
if (source === 'session_message') {
|
|
168
|
+
if (row.type !== 'assistant') continue
|
|
169
|
+
assistantRows += 1
|
|
170
|
+
if (info === undefined) continue
|
|
171
|
+
} else {
|
|
172
|
+
if (info === undefined || info.role !== 'assistant') continue
|
|
173
|
+
assistantRows += 1
|
|
174
|
+
}
|
|
175
|
+
const rawAgent = info.agent
|
|
176
|
+
const agent = rawAgent == null ? '' : typeof rawAgent === 'string' ? rawAgent : String(rawAgent)
|
|
177
|
+
if (agent === '') continue
|
|
178
|
+
// V2 has no phase anywhere: its SQL emits the literal, so the mirror does
|
|
179
|
+
// too rather than inventing a column the reader never had.
|
|
180
|
+
const metadata = info.metadata
|
|
181
|
+
const phase =
|
|
182
|
+
source === 'session_message'
|
|
183
|
+
? 'unknown'
|
|
184
|
+
: (typeof info.phase === 'string' && info.phase) ||
|
|
185
|
+
(typeof metadata?.phase === 'string' && metadata.phase) ||
|
|
186
|
+
'unknown'
|
|
187
|
+
const tokensInput = toToken(info.tokens?.input)
|
|
188
|
+
const tokensOutput = toToken(info.tokens?.output)
|
|
189
|
+
const key = rowKey(agent, phase)
|
|
190
|
+
const acc = groups.get(key) ?? {
|
|
191
|
+
agent,
|
|
192
|
+
phase,
|
|
193
|
+
tokensInput: 0,
|
|
194
|
+
tokensOutput: 0,
|
|
195
|
+
tokensTotal: 0,
|
|
196
|
+
}
|
|
197
|
+
acc.tokensInput += tokensInput
|
|
198
|
+
acc.tokensOutput += tokensOutput
|
|
199
|
+
acc.tokensTotal += tokensInput + tokensOutput
|
|
200
|
+
groups.set(key, acc)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
groups,
|
|
205
|
+
scanned: Number(windowRow?.scanned ?? 0) || 0,
|
|
206
|
+
windowStart: numberOrUndefined(windowRow?.windowStart),
|
|
207
|
+
databaseStart: numberOrUndefined(databaseRow?.databaseStart),
|
|
208
|
+
assistantRows,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function numberOrUndefined(value) {
|
|
213
|
+
if (value === null || value === undefined) return undefined
|
|
214
|
+
const parsed = Number(value)
|
|
215
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The same verdict the in-process reader reaches, with the same phrase: the
|
|
220
|
+
* caller maps "no readable token source" to UNSUPPORTED, so emitting anything
|
|
221
|
+
* softer here would resurrect the silent-empty-report failure this replaced.
|
|
222
|
+
*
|
|
223
|
+
* `scanned === 0` is NOT unreadable — an empty window is a real, reportable
|
|
224
|
+
* answer, and only a ledger that was found and could not be read is a fault.
|
|
225
|
+
*/
|
|
226
|
+
function diagnoseUnreadableLedger(coverage, rows, days) {
|
|
227
|
+
if (coverage.scanned === 0) return undefined
|
|
228
|
+
const tables = coverage.sources.join(' and ')
|
|
229
|
+
if (coverage.assistantRows === 0) {
|
|
230
|
+
return (
|
|
231
|
+
`no readable token source: ${coverage.scanned} row(s) were scanned in the last ${days} day(s) ` +
|
|
232
|
+
`but none matched the assistant discriminator of ${tables}, so no usage can be attributed. ` +
|
|
233
|
+
'This is an unrecognised ledger layout, not an empty one; point PANTHEON_COST_DB at a ' +
|
|
234
|
+
'database whose token layout this build understands.'
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
if (!rows.some((row) => row.tokensInput !== 0 || row.tokensOutput !== 0)) {
|
|
238
|
+
return (
|
|
239
|
+
`no readable token source: ${coverage.assistantRows} assistant row(s) were found in ${tables} ` +
|
|
240
|
+
'but none carried both an attributable agent and a token payload, so no usage can be ' +
|
|
241
|
+
'attributed. This is an unrecognised ledger layout, not an empty one.'
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
45
247
|
function main() {
|
|
46
248
|
const argv = process.argv.slice(2)
|
|
47
249
|
const valueFor = (name) => {
|
|
@@ -56,44 +258,45 @@ function main() {
|
|
|
56
258
|
|
|
57
259
|
try {
|
|
58
260
|
const db = new DatabaseSync(dbPath, { readOnly: true })
|
|
59
|
-
|
|
261
|
+
const sources = resolveSources(db)
|
|
262
|
+
for (const source of sources) assertColumns(db, source)
|
|
60
263
|
const since = Date.now() - days * 86_400_000
|
|
61
|
-
const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
|
|
62
|
-
db.close()
|
|
63
264
|
|
|
64
265
|
const byAgent = new Map()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(typeof metadata?.phase === 'string' && metadata.phase) ||
|
|
76
|
-
'unknown'
|
|
77
|
-
const tokensInput = Number(info.tokens?.input) || 0
|
|
78
|
-
const tokensOutput = Number(info.tokens?.output) || 0
|
|
79
|
-
const key = `${agent}\u0000${phase}`
|
|
266
|
+
const coverage = { sources: [], scanned: 0, assistantRows: 0 }
|
|
267
|
+
const note = (key, value) => {
|
|
268
|
+
if (value === undefined) return
|
|
269
|
+
const current = coverage[key]
|
|
270
|
+
coverage[key] = current === undefined ? value : Math.min(current, value)
|
|
271
|
+
}
|
|
272
|
+
for (const source of sources) {
|
|
273
|
+
const read = readSource(db, source, since)
|
|
274
|
+
for (const row of read.groups.values()) {
|
|
275
|
+
const key = rowKey(row.agent, row.phase)
|
|
80
276
|
const acc = byAgent.get(key) ?? {
|
|
81
|
-
agent,
|
|
82
|
-
phase,
|
|
277
|
+
agent: row.agent,
|
|
278
|
+
phase: row.phase,
|
|
83
279
|
tokensInput: 0,
|
|
84
280
|
tokensOutput: 0,
|
|
85
281
|
tokensTotal: 0,
|
|
86
282
|
}
|
|
87
|
-
acc.tokensInput += tokensInput
|
|
88
|
-
acc.tokensOutput += tokensOutput
|
|
89
|
-
acc.tokensTotal += tokensInput + tokensOutput
|
|
283
|
+
acc.tokensInput += row.tokensInput
|
|
284
|
+
acc.tokensOutput += row.tokensOutput
|
|
285
|
+
acc.tokensTotal += row.tokensInput + row.tokensOutput
|
|
90
286
|
byAgent.set(key, acc)
|
|
91
|
-
} catch {
|
|
92
|
-
// Skip malformed rows — a partial ledger never breaks the report.
|
|
93
287
|
}
|
|
288
|
+
coverage.sources.push(source)
|
|
289
|
+
coverage.scanned += read.scanned
|
|
290
|
+
coverage.assistantRows += read.assistantRows
|
|
291
|
+
note('windowStart', read.windowStart)
|
|
292
|
+
note('databaseStart', read.databaseStart)
|
|
94
293
|
}
|
|
294
|
+
db.close()
|
|
295
|
+
|
|
95
296
|
const result = [...byAgent.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
|
|
96
|
-
|
|
297
|
+
const unreadable = diagnoseUnreadableLedger(coverage, result, days)
|
|
298
|
+
if (unreadable !== undefined) return fail(unreadable)
|
|
299
|
+
process.stdout.write(JSON.stringify({ ok: true, days, rows: result, coverage }))
|
|
97
300
|
} catch (err) {
|
|
98
301
|
fail(err instanceof Error ? err.message : String(err))
|
|
99
302
|
}
|