pantheon-opencode 1.6.0-beta.3 → 1.6.0-beta.4
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/CHANGELOG.md +22 -0
- package/README.md +19 -7
- package/README.pt-BR.md +17 -7
- package/package.json +12 -10
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/code_mode_server.py +1 -1
- package/scripts/cost.mjs +244 -41
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/health-check.mjs +9 -2
- package/scripts/install/shared.mjs +5 -5
- package/scripts/mcp_persistence_server.py +1 -1
- package/scripts/mcp_resources_server.py +1 -1
- package/scripts/memory_mcp_server.py +1 -1
- package/scripts/probe-context-rehydrate.mjs +6 -0
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/validate-routing.mjs +3 -3
- package/src/mcp/code_mode_server.py +1 -1
- package/src/mcp/mcp_persistence_server.py +1 -1
- package/src/mcp/mcp_resources_server.py +1 -1
- package/src/mcp/memory_mcp_server.py +1 -1
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/presets.mjs +3 -3
- package/src/plugin-v2.ts +131 -58
- 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.d.ts +74 -74
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +17 -20
- package/src/plugins/tui/package-lock.json +364 -317
- package/src/plugins/tui/package.json +8 -5
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
18
18
|
|
|
19
19
|
## ✅ Closed Issues
|
|
20
20
|
|
|
21
|
+
## [v1.6.0-beta.4] - 2026-10-03
|
|
22
|
+
|
|
23
|
+
<!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
|
|
24
|
+
move this section to a versioned entry and reset the template below. -->
|
|
25
|
+
|
|
26
|
+
## 🆕 What's New
|
|
27
|
+
|
|
28
|
+
- **Canário host-backed que dirige `hashline_edit` end-to-end** (`tests/canary/plugin-v2-tool-canary.test.mjs`, 12 casos): carrega `src/plugin-v2.ts` pelo loader real do host `opencode 2.0.22` e registra `Object.keys(ctx)` de dentro do host — medidos, `tool`/`event`/`permission`/`session` existem e `catalog` não. É o primeiro gate que dirige uma ferramenta do Pantheon por um host real. O canário existente só dirige o `read` embutido, e foi por isso que `draft.add()` sem `output` sobreviveu 26 dias com todos os testes verdes.
|
|
29
|
+
- **Os quatro domínios que o SDK `@opencode-ai/plugin` não tipa** (`tool`, `event`, `permission`, `session`) passaram a uma declaração estrutural local (`HostPluginContext`) e **um** cast documentado (`hostDomains()`), no lugar de seis `as unknown as Record<string, unknown>` que não verificavam tipo nenhum. Todo membro continua opcional e todo chamador reconferir antes de usar. A linha estável do SDK é `1.18.x` e não carrega o contrato do host — `1.18.34` (`latest`) é idêntica a `1.18.33`, então seguir o `dev` diário resolveria pior, não melhor.
|
|
30
|
+
|
|
31
|
+
## 🐞 Fixed
|
|
32
|
+
|
|
33
|
+
- `pantheon_cost` respondia `CORRUPT_DATA` em **toda** chamada contra o host 2.0.22: exigia a tabela `message`, que não existe nesse banco. Os tokens de assistant **estão** legíveis em `session_message`. **Não é renomeação:** `session_message` tem coluna `data`, mas não tem a chave `role` dentro dela — `json_type(data,'$.role')` é nulo em **0 de 40 307** linhas válidas. O V1 marca turnos de assistant com `data.role`; o V2 marca pela **coluna** `type`. Trocar apenas o nome da tabela casaria com nada e devolveria `status: OK` em silêncio. A detecção passa a ser por **presença de tabela**, porque as duas famílias coexistem em bancos migrados.
|
|
34
|
+
- `PANTHEON_OPENCODE_VERSION` nunca selecionou o schema: só mudava o **nome do arquivo**, e `opencode-v2.db` é o isolamento do sandbox via `OPENCODE_DB`, não um banco de host 2.x. A distinção v1/v2 é o **schema detectado**; a variável agora apenas restringe um conjunto já detectado e falha rápido quando pede uma família ausente.
|
|
35
|
+
- `scripts/cost.mjs` duplicava o mesmo bug literal, com o mesmo assert — o fallback continuava morto. `probe-pantheon-cost.mjs` acompanha.
|
|
36
|
+
- O report agora **declara a cobertura temporal** que ele mediu (janela varrida, linha mais antiga), em vez de apresentar totais como se cobrissem meses.
|
|
37
|
+
|
|
38
|
+
## ⚠️ Known Issues
|
|
39
|
+
|
|
40
|
+
- O canário **host-backed pula no CI**: o runner não tem binário `opencode`. O skip é **visível** no log (`::notice`), e os dois casos sem host rodam incondicionalmente, com `PANTHEON_TOOL_CANARY_BIN` apontado para um caminho inexistente. Rodar de verdade exige um modo no script de sandbox — **não feito**.
|
|
41
|
+
- A cobertura de custo do banco v2 é de **~8,3 dias** nesta máquina (primeiro registro em 2026-09-25). O histórico v1 (~96 dias) fica num diretório irmão com nome de epoch, que **não** é contrato e por isso não é descoberto automaticamente. Quem quiser aponta `PANTHEON_COST_DB`.
|
|
42
|
+
- `metadata.phase` nunca existiu em v1 nem em v2 — medido: 0 linhas de `session_message` têm `$.metadata.phase` nem `$.phase`. A coluna Phase sempre foi `unknown`. Não é regressão, é feature que nunca funcionou.
|
|
21
43
|
## [v1.6.0-beta.3] - 2026-10-03
|
|
22
44
|
|
|
23
45
|
<!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
|
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ opencode
|
|
|
50
50
|
The installer guides you through the available setup. For optional MCP servers,
|
|
51
51
|
project-local installation, or non-interactive setup, see the
|
|
52
52
|
[installation guide](docs/INSTALLATION.md).
|
|
53
|
+
The bundled Python MCP servers use the pinned MCP SDK 2.2.0 and standalone
|
|
54
|
+
FastMCP 4.0.10 runtime; see the [MCP guide](docs/MCP.md).
|
|
53
55
|
|
|
54
56
|
## A simple example
|
|
55
57
|
|
|
@@ -242,13 +244,23 @@ Pantheon references from both config shapes before writing only the selected
|
|
|
242
244
|
Pantheon registration. Third-party entries are not converted or claimed by
|
|
243
245
|
this rule.
|
|
244
246
|
|
|
245
|
-
The
|
|
246
|
-
`
|
|
247
|
-
`PANTHEON_COST_DB=/absolute/path/to/opencode.db
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
247
|
+
The `pantheon_cost` report resolves its database by PATH, in this order: an
|
|
248
|
+
explicit `dbPath` supplied by the tool caller, then
|
|
249
|
+
`PANTHEON_COST_DB=/absolute/path/to/opencode.db`, then `OPENCODE_DB`, then the
|
|
250
|
+
XDG default `opencode.db`. There is no per-version filename in that chain:
|
|
251
|
+
`opencode-v2.db` is not a host 2.x database — it is the name a sandbox gives
|
|
252
|
+
its own state database through `OPENCODE_DB`.
|
|
253
|
+
|
|
254
|
+
The v1/v2 distinction is the DETECTED SCHEMA, not the file name. The report
|
|
255
|
+
looks for the `message` and `session_message` tables and reads whichever the
|
|
256
|
+
open database actually holds, because a migrated database carries both
|
|
257
|
+
families at once. `PANTHEON_OPENCODE_VERSION=v1|v2` selects no file: it only
|
|
258
|
+
narrows an already-detected set to one table family, and fails fast when the
|
|
259
|
+
value is neither `v1` nor `v2` or when the requested family is not there. Left
|
|
260
|
+
unset, every detected family is read. A database carrying neither table comes
|
|
261
|
+
back as an actionable error (`CORRUPT_DATA`), and a ledger that exists but
|
|
262
|
+
yields no readable tokens as `UNSUPPORTED` — never as a successful empty
|
|
263
|
+
report.
|
|
252
264
|
|
|
253
265
|
The installer still writes the compatibility settings required by the selected
|
|
254
266
|
OpenCode host, such as `experimental.subagent_depth`; this does not convert a
|
package/README.pt-BR.md
CHANGED
|
@@ -179,13 +179,23 @@ instalador remove referências Pantheon das duas formas de config antes de
|
|
|
179
179
|
gravar apenas o registro Pantheon selecionado. Entradas de terceiros não são
|
|
180
180
|
convertidas nem reivindicadas por essa regra.
|
|
181
181
|
|
|
182
|
-
O relatório `pantheon_cost`, disponível nos plugins V1 e V2,
|
|
183
|
-
|
|
184
|
-
`PANTHEON_COST_DB=/caminho/absoluto/para/opencode.db
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
182
|
+
O relatório `pantheon_cost`, disponível nos plugins V1 e V2, resolve o banco por
|
|
183
|
+
CAMINHO, nesta ordem: um `dbPath` explícito fornecido pelo chamador da
|
|
184
|
+
ferramenta, depois `PANTHEON_COST_DB=/caminho/absoluto/para/opencode.db`,
|
|
185
|
+
depois `OPENCODE_DB`, e por fim o padrão do XDG `opencode.db`. Não há nome de
|
|
186
|
+
arquivo por versão nessa cadeia: `opencode-v2.db` não é um banco de um host 2.x
|
|
187
|
+
— é o nome que um sandbox dá ao próprio banco de estado via `OPENCODE_DB`.
|
|
188
|
+
|
|
189
|
+
A distinção v1/v2 é o SCHEMA DETECTADO, não o nome do arquivo. O relatório
|
|
190
|
+
procura as tabelas `message` e `session_message` e lê aquele que o banco aberto
|
|
191
|
+
realmente tiver, porque um banco migrado carrega as duas famílias ao mesmo
|
|
192
|
+
tempo. `PANTHEON_OPENCODE_VERSION=v1|v2` não seleciona arquivo nenhum: apenas
|
|
193
|
+
estreita um conjunto já detectado para uma família de tabelas, e falha rápido
|
|
194
|
+
quando o valor não é `v1` nem `v2` ou quando a família pedida não existe lá.
|
|
195
|
+
Sem a variável, todas as famílias detectadas são lidas. Um banco sem nenhuma
|
|
196
|
+
das duas tabelas volta como erro acionável (`CORRUPT_DATA`), e um ledger que
|
|
197
|
+
existe mas não produz tokens legíveis como `UNSUPPORTED` — nunca como um
|
|
198
|
+
relatório vazio com sucesso.
|
|
189
199
|
|
|
190
200
|
O instalador continua gravando as configurações de compatibilidade exigidas
|
|
191
201
|
pelo host OpenCode selecionado, como `experimental.subagent_depth`; isso não
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-opencode",
|
|
3
|
-
"version": "1.6.0-beta.
|
|
3
|
+
"version": "1.6.0-beta.4",
|
|
4
4
|
"description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"coverage:plugin-v2": "node scripts/check-plugin-v2-coverage.mjs",
|
|
47
47
|
"test:ts": "for f in tests/pantheon/*.test.ts; do if [ \"$f\" = tests/pantheon/plugin-v2-contract.test.ts ]; then NODE_OPTIONS=--conditions=import tsx \"$f\"; else tsx \"$f\"; fi || exit 1; done",
|
|
48
48
|
"test:hooks": "node --test tests/pantheon/plugin-v2-hook-canary.test.mjs",
|
|
49
|
+
"test:tool-canary": "node --test tests/canary/plugin-v2-tool-canary.test.mjs",
|
|
49
50
|
"test:all": "npm run test:ci && npm run test:node && npm run test:ts",
|
|
50
51
|
"test": "npm run test:all",
|
|
51
52
|
"test:ci": "python3 -m pytest -q --tb=short 2>&1"
|
|
@@ -97,21 +98,22 @@
|
|
|
97
98
|
"!**/*.pyc"
|
|
98
99
|
],
|
|
99
100
|
"dependencies": {
|
|
100
|
-
"@opencode-ai/plugin": "1.18.
|
|
101
|
-
"js-yaml": "^4.
|
|
102
|
-
"zod": "4.1.8"
|
|
101
|
+
"@opencode-ai/plugin": "1.18.33",
|
|
102
|
+
"js-yaml": "^5.4.2",
|
|
103
|
+
"zod": "4.1.8",
|
|
104
|
+
"@opencode-ai/sdk": "1.18.33"
|
|
103
105
|
},
|
|
104
106
|
"devDependencies": {
|
|
105
|
-
"@biomejs/biome": "2.5.
|
|
106
|
-
"@commitlint/cli": "21.2.
|
|
107
|
-
"@commitlint/config-conventional": "21.2.
|
|
107
|
+
"@biomejs/biome": "2.5.14",
|
|
108
|
+
"@commitlint/cli": "21.2.3",
|
|
109
|
+
"@commitlint/config-conventional": "21.2.3",
|
|
108
110
|
"ajv": "8.20.0",
|
|
109
111
|
"husky": "^9.1.7",
|
|
110
|
-
"tsx": "4.23.
|
|
111
|
-
"typescript": "
|
|
112
|
+
"tsx": "4.23.15",
|
|
113
|
+
"typescript": "7.0.2"
|
|
112
114
|
},
|
|
113
115
|
"overrides": {
|
|
114
|
-
"rolldown": "1.2.
|
|
116
|
+
"rolldown": "1.2.7"
|
|
115
117
|
},
|
|
116
118
|
"repository": {
|
|
117
119
|
"type": "git",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon",
|
|
3
3
|
"publisher": "ils15",
|
|
4
|
-
"version": "1.6.0-beta.
|
|
4
|
+
"version": "1.6.0-beta.4",
|
|
5
5
|
"displayName": "Pantheon",
|
|
6
6
|
"description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
|
|
7
7
|
"agents": "./src/agents",
|
package/pyproject.toml
CHANGED
|
@@ -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
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
22
22
|
import { dirname, join } from 'node:path'
|
|
23
23
|
import { fileURLToPath } from 'node:url'
|
|
24
|
-
import
|
|
24
|
+
import { load } from 'js-yaml'
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Paths
|
|
@@ -47,7 +47,7 @@ function parseAgentFrontmatter(content) {
|
|
|
47
47
|
if (!match) return null
|
|
48
48
|
|
|
49
49
|
try {
|
|
50
|
-
const fm =
|
|
50
|
+
const fm = load(match[1])
|
|
51
51
|
if (!fm || typeof fm !== 'object' || !fm.name) return null
|
|
52
52
|
return {
|
|
53
53
|
name: String(fm.name),
|
|
@@ -173,13 +173,20 @@ export function healthCheck(target, { dryRun = false, pythonTarget = target } =
|
|
|
173
173
|
if (python) {
|
|
174
174
|
const mcpResult = spawnSync(
|
|
175
175
|
python,
|
|
176
|
-
[
|
|
176
|
+
[
|
|
177
|
+
'-c',
|
|
178
|
+
'import importlib.metadata as metadata; from fastmcp import FastMCP; ' +
|
|
179
|
+
'print(f\'mcp={metadata.version("mcp")} fastmcp={metadata.version("fastmcp")} {FastMCP.__module__}\')',
|
|
180
|
+
],
|
|
177
181
|
{ stdio: 'pipe' },
|
|
178
182
|
)
|
|
179
183
|
if (mcpResult.status === 0) {
|
|
180
184
|
results.passed.push({ check: 'mcp-sdk', detail: mcpResult.stdout.toString().trim() })
|
|
181
185
|
} else {
|
|
182
|
-
results.failed.push({
|
|
186
|
+
results.failed.push({
|
|
187
|
+
check: 'mcp-sdk',
|
|
188
|
+
detail: 'MCP SDK or standalone FastMCP is unavailable — all MCP servers will fail',
|
|
189
|
+
})
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
192
|
// Check 4: the memory server's only search backend is the stdlib's own
|
|
@@ -24,7 +24,7 @@ if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
|
|
|
24
24
|
process.exit(1)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
import
|
|
27
|
+
import { dump, load } from 'js-yaml'
|
|
28
28
|
|
|
29
29
|
export const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
30
30
|
export const ROOT = join(__dirname, '..', '..')
|
|
@@ -326,10 +326,10 @@ export function collectSkillNames() {
|
|
|
326
326
|
* Returns { fm: object, body: string } or null if no frontmatter.
|
|
327
327
|
*/
|
|
328
328
|
export function parseFrontmatter(content) {
|
|
329
|
-
const match = content.match(/^---\r?\n([\s\S]*?)
|
|
329
|
+
const match = content.match(/^---\r?\n([\s\S]*?)(?:\r?\n)?---\r?\n?([\s\S]*)$/)
|
|
330
330
|
if (!match) return null
|
|
331
331
|
return {
|
|
332
|
-
fm:
|
|
332
|
+
fm: match[1].trim() ? (load(match[1]) ?? {}) : {},
|
|
333
333
|
body: match[2],
|
|
334
334
|
}
|
|
335
335
|
}
|
|
@@ -339,9 +339,9 @@ export function parseFrontmatter(content) {
|
|
|
339
339
|
* Uses long-line mode and avoids unnecessary quoting.
|
|
340
340
|
*/
|
|
341
341
|
export function serializeFm(fm) {
|
|
342
|
-
return
|
|
342
|
+
return dump(fm, {
|
|
343
343
|
lineWidth: -1,
|
|
344
|
-
|
|
344
|
+
quoteStyle: 'double',
|
|
345
345
|
forceQuotes: false,
|
|
346
346
|
noRefs: true,
|
|
347
347
|
})
|
|
@@ -25,7 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
from typing import Annotated
|
|
26
26
|
|
|
27
27
|
from _pantheon_paths import pantheon_home, pantheon_project
|
|
28
|
-
from
|
|
28
|
+
from fastmcp import FastMCP
|
|
29
29
|
from pydantic import Field
|
|
30
30
|
|
|
31
31
|
# ── Schema ──────────────────────────────────────────────────────────────────────
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
import yaml
|
|
20
20
|
from _pantheon_paths import has_symlink_component, pantheon_home, pantheon_project
|
|
21
21
|
from eval_store import get_latest_eval, list_evals
|
|
22
|
-
from
|
|
22
|
+
from fastmcp import FastMCP
|
|
23
23
|
|
|
24
24
|
# ── Path Resolution ──────────────────────────────────────────────────────────
|
|
25
25
|
_PANTHEON_HOME: Path = pantheon_home()
|
|
@@ -33,7 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Annotated, Any
|
|
34
34
|
|
|
35
35
|
from _pantheon_paths import pantheon_home
|
|
36
|
-
from
|
|
36
|
+
from fastmcp import FastMCP
|
|
37
37
|
from pydantic import BeforeValidator, Field
|
|
38
38
|
|
|
39
39
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
@@ -63,6 +63,12 @@ spec.loader.exec_module(module)
|
|
|
63
63
|
|
|
64
64
|
async def call(name, arguments):
|
|
65
65
|
result = await module.mcp.call_tool(name, arguments)
|
|
66
|
+
structured = getattr(result, "structured_content", None)
|
|
67
|
+
if structured is not None:
|
|
68
|
+
metadata = getattr(result, "meta", None) or {}
|
|
69
|
+
if metadata.get("fastmcp", {}).get("wrap_result"):
|
|
70
|
+
return structured.get("result") if isinstance(structured, dict) else structured
|
|
71
|
+
return structured
|
|
66
72
|
if isinstance(result, tuple):
|
|
67
73
|
structured = result[1]
|
|
68
74
|
if structured is not None:
|