pantheon-opencode 1.4.3 → 1.5.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 +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/manifest.json +21 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +292 -1080
- package/README.pt-BR.md +308 -0
- package/bin/pantheon-init.mjs +207 -31
- package/opencode.json +1 -0
- package/package.json +34 -14
- package/plugin.json +15 -0
- package/pyproject.toml +65 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +451 -44
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +306 -129
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +23 -10
- package/scripts/install/config-migration.mjs +399 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +396 -121
- package/scripts/install/plugin.mjs +89 -49
- package/scripts/install/shared.mjs +55 -159
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +45 -232
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +798 -60
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +135 -42
- package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +27 -16
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +451 -44
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +798 -60
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +110 -2
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +356 -69
- package/src/pantheon/delegate-manager.ts +766 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +406 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1337 -0
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +689 -0
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +148 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +261 -51
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/tui.d.ts +116 -24
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +416 -149
- package/src/plugins/tui/dist/tui.tsx +456 -87
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +462 -95
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
/**
|
|
4
|
+
* Offline/ambiental probe for context_rehydrate and context_session_summary.
|
|
5
|
+
*
|
|
6
|
+
* The Python child imports the installed persistence server and exercises the
|
|
7
|
+
* real FastMCP tools against a temporary SQLite database. No OpenCode command,
|
|
8
|
+
* network call, LLM, or user database is involved. Missing package/runtime is
|
|
9
|
+
* NOT_TESTED/AMBIENTAL rather than a false PASS.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const requestedVersion = valueFor('--version')
|
|
17
|
+
const jsonOutput = process.argv.includes('--json')
|
|
18
|
+
const runtime = fileURLToPath(new URL('./mcp_persistence_server.py', import.meta.url))
|
|
19
|
+
|
|
20
|
+
function valueFor(name) {
|
|
21
|
+
const index = process.argv.indexOf(name)
|
|
22
|
+
return index >= 0 ? process.argv[index + 1] : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function emit(status, detail, checks = []) {
|
|
26
|
+
const result = { status, version: requestedVersion, detail, checks }
|
|
27
|
+
if (jsonOutput) process.stdout.write(JSON.stringify(result))
|
|
28
|
+
else process.stdout.write(`${status}: ${detail}\n`)
|
|
29
|
+
if (status === 'FAIL') process.exitCode = 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unavailable(detail) {
|
|
33
|
+
emit('NOT_TESTED', detail)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pythonCandidates() {
|
|
37
|
+
const venvPython = process.env.HOME
|
|
38
|
+
? join(process.env.HOME, '.config', 'opencode', '.venv', 'bin', 'python')
|
|
39
|
+
: undefined
|
|
40
|
+
return [process.env.PANTHEON_PYTHON, venvPython, 'python3'].filter(Boolean)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PYTHON_HARNESS = `
|
|
44
|
+
import asyncio
|
|
45
|
+
import importlib.util
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
runtime_path = Path(sys.argv[1]).resolve()
|
|
52
|
+
db_root = Path(sys.argv[2]).resolve()
|
|
53
|
+
sys.path.insert(0, str(runtime_path.parent))
|
|
54
|
+
sys.argv = [str(runtime_path), "--global-db", str(db_root / "global.db"),
|
|
55
|
+
"--project-db", str(db_root / "project.db")]
|
|
56
|
+
|
|
57
|
+
spec = importlib.util.spec_from_file_location("probe_mcp_persistence", runtime_path)
|
|
58
|
+
if spec is None or spec.loader is None:
|
|
59
|
+
raise RuntimeError("could not load persistence runtime")
|
|
60
|
+
module = importlib.util.module_from_spec(spec)
|
|
61
|
+
spec.loader.exec_module(module)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def call(name, arguments):
|
|
65
|
+
result = await module.mcp.call_tool(name, arguments)
|
|
66
|
+
if isinstance(result, tuple):
|
|
67
|
+
structured = result[1]
|
|
68
|
+
if structured is not None:
|
|
69
|
+
if isinstance(structured, dict) and "result" in structured:
|
|
70
|
+
return structured["result"]
|
|
71
|
+
return structured
|
|
72
|
+
result = result[0]
|
|
73
|
+
if isinstance(result, dict):
|
|
74
|
+
return result
|
|
75
|
+
if not result:
|
|
76
|
+
return None
|
|
77
|
+
text = getattr(result[0], "text", str(result[0]))
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(text)
|
|
80
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
81
|
+
return text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def main():
|
|
85
|
+
checks = []
|
|
86
|
+
slug = "context-rehydrate-probe"
|
|
87
|
+
session_id = "synthetic-session"
|
|
88
|
+
checkpoint = {
|
|
89
|
+
"version": 1,
|
|
90
|
+
"goal": {"id": "probe-goal", "objective": "verify context recovery",
|
|
91
|
+
"status": "in_progress"},
|
|
92
|
+
"phase": {"current": 2, "total": 3, "name": "offline validation"},
|
|
93
|
+
"delegations": {"in_flight": [{"alias": "apo-probe", "agent": "apollo",
|
|
94
|
+
"task_id": "probe-task"}]},
|
|
95
|
+
}
|
|
96
|
+
tail = ["phase 1 complete", "phase 2 payload validated"]
|
|
97
|
+
|
|
98
|
+
# context_save creates the real session-qualified latest pointer. Keeping
|
|
99
|
+
# tail under its own key also tests the runtime's latest/tail fallback.
|
|
100
|
+
await call("context_save", {"slug": slug, "key": "tail",
|
|
101
|
+
"content": json.dumps(tail),
|
|
102
|
+
"session_id": session_id})
|
|
103
|
+
await call("context_save", {"slug": slug, "key": "phase:2",
|
|
104
|
+
"content": json.dumps(checkpoint),
|
|
105
|
+
"session_id": session_id})
|
|
106
|
+
latest = await call("context_get", {"slug": slug, "key": "latest",
|
|
107
|
+
"session_id": session_id})
|
|
108
|
+
stored_tail = await call("context_get", {"slug": slug, "key": "tail",
|
|
109
|
+
"session_id": session_id})
|
|
110
|
+
if latest is None or stored_tail is None:
|
|
111
|
+
raise AssertionError("synthetic latest/tail checkpoint payload is null")
|
|
112
|
+
checks.append("synthetic latest/tail/goal/phase/delegation checkpoint: PASS")
|
|
113
|
+
|
|
114
|
+
os.environ.pop("PANTHEON_COMPACTION", None)
|
|
115
|
+
os.environ.pop("PANTHEON_SESSION_END_SUMMARY", None)
|
|
116
|
+
blocks = await call("context_rehydrate", {"slug": slug})
|
|
117
|
+
summary = await call("context_session_summary", {"slug": slug})
|
|
118
|
+
if not isinstance(blocks, list) or not blocks:
|
|
119
|
+
raise AssertionError("context_rehydrate returned a null/empty payload")
|
|
120
|
+
if not isinstance(summary, str) or not summary:
|
|
121
|
+
raise AssertionError("context_session_summary returned a null/empty payload")
|
|
122
|
+
for fragment in ("verify context recovery", "phase 2/3", "apo-probe",
|
|
123
|
+
"phase 2 payload validated"):
|
|
124
|
+
if not any(fragment in block for block in blocks) and fragment not in summary:
|
|
125
|
+
raise AssertionError(f"payload is missing {fragment!r}")
|
|
126
|
+
checks.append("context_rehydrate non-null payload: PASS")
|
|
127
|
+
checks.append("context_session_summary non-null payload: PASS")
|
|
128
|
+
|
|
129
|
+
missing_blocks = await call("context_rehydrate", {"slug": "probe-absent"})
|
|
130
|
+
missing_summary = await call("context_session_summary", {"slug": "probe-absent"})
|
|
131
|
+
if missing_blocks is not None or missing_summary is not None:
|
|
132
|
+
raise AssertionError("absent checkpoint must return null payloads")
|
|
133
|
+
checks.append("absence returns null: PASS")
|
|
134
|
+
|
|
135
|
+
expired_slug = "context-expired-probe"
|
|
136
|
+
await call("context_save", {"slug": expired_slug, "key": "phase:1",
|
|
137
|
+
"content": json.dumps(checkpoint), "ttl": -1,
|
|
138
|
+
"session_id": session_id})
|
|
139
|
+
expired_blocks = await call("context_rehydrate", {"slug": expired_slug})
|
|
140
|
+
expired_summary = await call("context_session_summary", {"slug": expired_slug})
|
|
141
|
+
if expired_blocks is not None or expired_summary is not None:
|
|
142
|
+
raise AssertionError("expired checkpoint must return null payloads")
|
|
143
|
+
checks.append("expiration returns null: PASS")
|
|
144
|
+
|
|
145
|
+
invalid_slug = "context-invalid-probe"
|
|
146
|
+
await call("context_save", {"slug": invalid_slug, "key": "phase:1",
|
|
147
|
+
"content": "not-json", "session_id": session_id})
|
|
148
|
+
invalid_blocks = await call("context_rehydrate", {"slug": invalid_slug})
|
|
149
|
+
invalid_summary = await call("context_session_summary", {"slug": invalid_slug})
|
|
150
|
+
if invalid_blocks is not None or invalid_summary is not None:
|
|
151
|
+
raise AssertionError("invalid checkpoint must return null payloads")
|
|
152
|
+
checks.append("invalid payload returns null: PASS")
|
|
153
|
+
|
|
154
|
+
os.environ["PANTHEON_COMPACTION"] = "off"
|
|
155
|
+
switched_blocks = await call("context_rehydrate", {"slug": slug})
|
|
156
|
+
if switched_blocks is not None:
|
|
157
|
+
raise AssertionError("PANTHEON_COMPACTION=off must return null")
|
|
158
|
+
os.environ.pop("PANTHEON_COMPACTION", None)
|
|
159
|
+
os.environ["PANTHEON_SESSION_END_SUMMARY"] = "off"
|
|
160
|
+
switched_summary = await call("context_session_summary", {"slug": slug})
|
|
161
|
+
if switched_summary is not None:
|
|
162
|
+
raise AssertionError("PANTHEON_SESSION_END_SUMMARY=off must return null")
|
|
163
|
+
checks.append("kill-switches return null: PASS")
|
|
164
|
+
print(json.dumps({"checks": checks}))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
asyncio.run(main())
|
|
168
|
+
`
|
|
169
|
+
|
|
170
|
+
async function main() {
|
|
171
|
+
if (requestedVersion !== 'v1' && requestedVersion !== 'v2') {
|
|
172
|
+
emit('FAIL', 'usage: --version v1|v2 [--json]')
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (!existsSync(runtime)) {
|
|
176
|
+
unavailable('installed package does not include mcp_persistence_server.py')
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const root = mkdtempSync(join(tmpdir(), 'pantheon-context-probe-'))
|
|
181
|
+
const dbRoot = join(root, 'db')
|
|
182
|
+
const python = pythonCandidates().find((candidate) =>
|
|
183
|
+
candidate.includes('/') ? existsSync(candidate) : true,
|
|
184
|
+
)
|
|
185
|
+
if (!python) {
|
|
186
|
+
rmSync(root, { recursive: true, force: true })
|
|
187
|
+
unavailable('Python persistence runtime is not available')
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const child = spawnSync(python, ['-c', PYTHON_HARNESS, runtime, dbRoot], {
|
|
193
|
+
encoding: 'utf8',
|
|
194
|
+
timeout: 30000,
|
|
195
|
+
env: process.env,
|
|
196
|
+
})
|
|
197
|
+
if (child.error) {
|
|
198
|
+
const detail = child.error instanceof Error ? child.error.message : String(child.error)
|
|
199
|
+
if (/EACCES|permission denied|not found/i.test(detail)) {
|
|
200
|
+
emit('AMBIENTAL', `persistence runtime unavailable: ${detail}`)
|
|
201
|
+
} else {
|
|
202
|
+
unavailable(`persistence runtime unavailable: ${detail}`)
|
|
203
|
+
}
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (child.signal === 'SIGTERM') {
|
|
207
|
+
emit('AMBIENTAL', 'persistence probe timed out after 30s')
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (child.status !== 0) {
|
|
211
|
+
const detail = `${child.stderr || child.stdout || 'persistence probe failed'}`.trim()
|
|
212
|
+
if (
|
|
213
|
+
/No module named|ModuleNotFoundError|can't open file|No such file|importerror/i.test(detail)
|
|
214
|
+
) {
|
|
215
|
+
unavailable(`persistence runtime unavailable: ${detail.slice(0, 320)}`)
|
|
216
|
+
} else {
|
|
217
|
+
emit('FAIL', `offline persistence checks failed: ${detail.slice(0, 320)}`)
|
|
218
|
+
}
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lines = child.stdout.trim().split('\n').filter(Boolean)
|
|
223
|
+
const payload = JSON.parse(lines.at(-1) || '')
|
|
224
|
+
if (!Array.isArray(payload.checks) || payload.checks.length < 6) {
|
|
225
|
+
emit('FAIL', 'offline persistence probe returned incomplete checks')
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
emit(
|
|
229
|
+
'PASS',
|
|
230
|
+
'offline synthetic persistence checks passed; no LLM or user DB was used',
|
|
231
|
+
payload.checks,
|
|
232
|
+
)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
emit('FAIL', error instanceof Error ? error.message : String(error))
|
|
235
|
+
} finally {
|
|
236
|
+
rmSync(root, { recursive: true, force: true })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await main()
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline/ambiental probe for pantheon_cost.
|
|
4
|
+
*
|
|
5
|
+
* The probe never reads or writes a user's real database. Without --fixture it
|
|
6
|
+
* reports AMBIENTAL because a real dependency/database is intentionally not
|
|
7
|
+
* exercised. With --fixture it imports the installed command and creates a
|
|
8
|
+
* temporary synthetic opencode database. A missing fixture runtime is also
|
|
9
|
+
* AMBIENTAL rather than a false PASS.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const requestedVersion = valueFor('--version')
|
|
16
|
+
const jsonOutput = process.argv.includes('--json')
|
|
17
|
+
const fixtureMode = process.argv.includes('--fixture')
|
|
18
|
+
|
|
19
|
+
function valueFor(name) {
|
|
20
|
+
const index = process.argv.indexOf(name)
|
|
21
|
+
return index >= 0 ? process.argv[index + 1] : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function emit(status, detail, checks = []) {
|
|
25
|
+
const result = { status, version: requestedVersion, detail, checks }
|
|
26
|
+
if (jsonOutput) process.stdout.write(JSON.stringify(result))
|
|
27
|
+
else process.stdout.write(`${status}: ${detail}\n`)
|
|
28
|
+
if (status === 'FAIL') process.exitCode = 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function diagnostic(error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
33
|
+
const stderr =
|
|
34
|
+
error && typeof error === 'object' && typeof error.stderr === 'string'
|
|
35
|
+
? error.stderr.trim()
|
|
36
|
+
: ''
|
|
37
|
+
return stderr === '' ? message : `${message}; stderr: ${stderr}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ambiental(detail) {
|
|
41
|
+
emit('AMBIENTAL', detail)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setEnvironment(values) {
|
|
45
|
+
const previous = new Map()
|
|
46
|
+
for (const [key, value] of Object.entries(values)) {
|
|
47
|
+
previous.set(key, process.env[key])
|
|
48
|
+
if (value === undefined) delete process.env[key]
|
|
49
|
+
else process.env[key] = value
|
|
50
|
+
}
|
|
51
|
+
return () => {
|
|
52
|
+
for (const [key, value] of previous) {
|
|
53
|
+
if (value === undefined) delete process.env[key]
|
|
54
|
+
else process.env[key] = value
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createSyntheticDb(DatabaseSync, path, rows) {
|
|
60
|
+
const db = new DatabaseSync(path)
|
|
61
|
+
db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
|
|
62
|
+
const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
|
|
63
|
+
for (const data of rows) insert.run(JSON.stringify(data), Date.now())
|
|
64
|
+
db.close()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createIncompatibleDb(DatabaseSync, path) {
|
|
68
|
+
const db = new DatabaseSync(path)
|
|
69
|
+
db.exec('CREATE TABLE not_message (value TEXT NOT NULL)')
|
|
70
|
+
db.close()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireOutput(output, fragment, label) {
|
|
74
|
+
if (!output.includes(fragment)) throw new Error(`${label}: missing ${fragment}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertTokensOnly(output, expectedRows, total) {
|
|
78
|
+
requireOutput(output, 'Tokens In | Tokens Out | Tokens Total', 'tokens-only header')
|
|
79
|
+
for (const row of expectedRows) {
|
|
80
|
+
requireOutput(
|
|
81
|
+
output,
|
|
82
|
+
`| ${row.agent} | ${row.phase} | ${row.input} | ${row.output} | ${row.total} |`,
|
|
83
|
+
'token row',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
requireOutput(
|
|
87
|
+
output,
|
|
88
|
+
`| **Total** | — | **${total.input}** | **${total.output}** | **${total.total}** |`,
|
|
89
|
+
'token total',
|
|
90
|
+
)
|
|
91
|
+
if (/Cost \(USD\)|costUsd|\$\d|\bUSD\b/i.test(output)) {
|
|
92
|
+
throw new Error('tokens-only output contains a monetary value')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
if (requestedVersion !== 'v1' && requestedVersion !== 'v2') {
|
|
98
|
+
emit('FAIL', 'usage: --version v1|v2 [--fixture] [--json]')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!fixtureMode) {
|
|
103
|
+
ambiental(
|
|
104
|
+
'real pantheon_cost dependency/database not exercised; use --fixture for synthetic checks',
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const source = new URL('../src/pantheon/cost-command.ts', import.meta.url)
|
|
110
|
+
if (!existsSync(source)) {
|
|
111
|
+
ambiental(
|
|
112
|
+
'pantheon_cost dependency is unavailable: installed package lacks src/pantheon/cost-command.ts',
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let DatabaseSync
|
|
118
|
+
let createCostCommand
|
|
119
|
+
try {
|
|
120
|
+
;({ DatabaseSync } = await import('node:sqlite'))
|
|
121
|
+
;({ createCostCommand } = await import(source.href))
|
|
122
|
+
} catch (error) {
|
|
123
|
+
ambiental(
|
|
124
|
+
`pantheon_cost fixture runtime unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
|
|
130
|
+
const dataHome = join(root, 'data')
|
|
131
|
+
const opencodeDir = join(dataHome, 'opencode')
|
|
132
|
+
const selectedDb = join(opencodeDir, requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db')
|
|
133
|
+
const otherDb = join(root, 'other.db')
|
|
134
|
+
const envDb = join(root, 'env.db')
|
|
135
|
+
const explicitDb = join(root, 'explicit.db')
|
|
136
|
+
const incompatibleDb = join(root, 'incompatible.db')
|
|
137
|
+
const checks = []
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
mkdirSync(opencodeDir, { recursive: true })
|
|
141
|
+
// These rows intentionally include a monetary-looking field to prove it is
|
|
142
|
+
// ignored; only input/output/total tokens may appear in the report.
|
|
143
|
+
const selectedRows =
|
|
144
|
+
requestedVersion === 'v2'
|
|
145
|
+
? [
|
|
146
|
+
{
|
|
147
|
+
role: 'assistant',
|
|
148
|
+
agent: 'aphrodite',
|
|
149
|
+
phase: 'plan',
|
|
150
|
+
cost: 999.99,
|
|
151
|
+
tokens: { input: 7, output: 8 },
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
role: 'assistant',
|
|
155
|
+
agent: 'aphrodite',
|
|
156
|
+
phase: 'plan',
|
|
157
|
+
cost: 999.99,
|
|
158
|
+
tokens: { input: 1, output: 2 },
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
role: 'assistant',
|
|
162
|
+
agent: 'zeus',
|
|
163
|
+
phase: 'audit',
|
|
164
|
+
cost: 999.99,
|
|
165
|
+
tokens: { input: 4, output: 6 },
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
: [
|
|
169
|
+
{
|
|
170
|
+
role: 'assistant',
|
|
171
|
+
agent: 'hermes',
|
|
172
|
+
phase: 'plan',
|
|
173
|
+
cost: 999.99,
|
|
174
|
+
tokens: { input: 10, output: 20 },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
role: 'assistant',
|
|
178
|
+
agent: 'hermes',
|
|
179
|
+
phase: 'plan',
|
|
180
|
+
cost: 999.99,
|
|
181
|
+
tokens: { input: 3, output: 4 },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
role: 'assistant',
|
|
185
|
+
agent: 'athena',
|
|
186
|
+
phase: 'review',
|
|
187
|
+
cost: 999.99,
|
|
188
|
+
tokens: { input: 2, output: 5 },
|
|
189
|
+
},
|
|
190
|
+
]
|
|
191
|
+
createSyntheticDb(DatabaseSync, selectedDb, selectedRows)
|
|
192
|
+
createSyntheticDb(DatabaseSync, otherDb, [
|
|
193
|
+
{
|
|
194
|
+
role: 'assistant',
|
|
195
|
+
agent: 'wrong-version',
|
|
196
|
+
phase: 'other',
|
|
197
|
+
tokens: { input: 90, output: 90 },
|
|
198
|
+
},
|
|
199
|
+
])
|
|
200
|
+
createSyntheticDb(DatabaseSync, envDb, [
|
|
201
|
+
{ role: 'assistant', agent: 'env-agent', phase: 'override', tokens: { input: 1, output: 2 } },
|
|
202
|
+
])
|
|
203
|
+
createSyntheticDb(DatabaseSync, explicitDb, [
|
|
204
|
+
{
|
|
205
|
+
role: 'assistant',
|
|
206
|
+
agent: 'explicit-agent',
|
|
207
|
+
phase: 'explicit',
|
|
208
|
+
tokens: { input: 9, output: 1 },
|
|
209
|
+
},
|
|
210
|
+
])
|
|
211
|
+
createIncompatibleDb(DatabaseSync, incompatibleDb)
|
|
212
|
+
|
|
213
|
+
const restore = setEnvironment({
|
|
214
|
+
XDG_DATA_HOME: dataHome,
|
|
215
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
216
|
+
PANTHEON_COST_DB: undefined,
|
|
217
|
+
})
|
|
218
|
+
try {
|
|
219
|
+
const selectedOutput = await createCostCommand().pantheon_cost.execute(
|
|
220
|
+
{},
|
|
221
|
+
{ sessionID: 'cost-probe' },
|
|
222
|
+
)
|
|
223
|
+
assertTokensOnly(
|
|
224
|
+
selectedOutput,
|
|
225
|
+
requestedVersion === 'v2'
|
|
226
|
+
? [
|
|
227
|
+
{ agent: 'aphrodite', phase: 'plan', input: 8, output: 10, total: 18 },
|
|
228
|
+
{ agent: 'zeus', phase: 'audit', input: 4, output: 6, total: 10 },
|
|
229
|
+
]
|
|
230
|
+
: [
|
|
231
|
+
{ agent: 'hermes', phase: 'plan', input: 13, output: 24, total: 37 },
|
|
232
|
+
{ agent: 'athena', phase: 'review', input: 2, output: 5, total: 7 },
|
|
233
|
+
],
|
|
234
|
+
requestedVersion === 'v2'
|
|
235
|
+
? { input: 12, output: 16, total: 28 }
|
|
236
|
+
: { input: 15, output: 29, total: 44 },
|
|
237
|
+
)
|
|
238
|
+
checks.push('version-selected DB: PASS')
|
|
239
|
+
} finally {
|
|
240
|
+
restore()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const envRestore = setEnvironment({
|
|
244
|
+
XDG_DATA_HOME: dataHome,
|
|
245
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
246
|
+
PANTHEON_COST_DB: envDb,
|
|
247
|
+
})
|
|
248
|
+
try {
|
|
249
|
+
const output = await createCostCommand().pantheon_cost.execute(
|
|
250
|
+
{},
|
|
251
|
+
{ sessionID: 'cost-probe' },
|
|
252
|
+
)
|
|
253
|
+
requireOutput(output, '| env-agent | override | 1 | 2 | 3 |', 'PANTHEON_COST_DB')
|
|
254
|
+
if (output.includes('wrong-version') || output.includes('env-agent') === false) {
|
|
255
|
+
throw new Error('PANTHEON_COST_DB did not override the version-selected database')
|
|
256
|
+
}
|
|
257
|
+
checks.push('PANTHEON_COST_DB override: PASS')
|
|
258
|
+
} finally {
|
|
259
|
+
envRestore()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const explicitRestore = setEnvironment({
|
|
263
|
+
XDG_DATA_HOME: dataHome,
|
|
264
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
265
|
+
PANTHEON_COST_DB: envDb,
|
|
266
|
+
})
|
|
267
|
+
try {
|
|
268
|
+
const output = await createCostCommand({ dbPath: explicitDb }).pantheon_cost.execute(
|
|
269
|
+
{},
|
|
270
|
+
{ sessionID: 'cost-probe' },
|
|
271
|
+
)
|
|
272
|
+
requireOutput(output, '| explicit-agent | explicit | 9 | 1 | 10 |', 'explicit dbPath')
|
|
273
|
+
if (output.includes('env-agent')) throw new Error('explicit dbPath did not take precedence')
|
|
274
|
+
checks.push('explicit dbPath precedence: PASS')
|
|
275
|
+
} finally {
|
|
276
|
+
explicitRestore()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const missingRestore = setEnvironment({
|
|
280
|
+
XDG_DATA_HOME: join(root, 'missing-data'),
|
|
281
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
282
|
+
PANTHEON_COST_DB: undefined,
|
|
283
|
+
})
|
|
284
|
+
try {
|
|
285
|
+
const output = await createCostCommand().pantheon_cost.execute(
|
|
286
|
+
{},
|
|
287
|
+
{ sessionID: 'cost-probe' },
|
|
288
|
+
)
|
|
289
|
+
requireOutput(output, 'not found', 'missing DB')
|
|
290
|
+
requireOutput(
|
|
291
|
+
output,
|
|
292
|
+
requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db',
|
|
293
|
+
'selected missing DB path',
|
|
294
|
+
)
|
|
295
|
+
checks.push('missing DB: PASS')
|
|
296
|
+
} finally {
|
|
297
|
+
missingRestore()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const incompatibleOutput = await createCostCommand({
|
|
301
|
+
dbPath: incompatibleDb,
|
|
302
|
+
}).pantheon_cost.execute({}, { sessionID: 'cost-probe' })
|
|
303
|
+
requireOutput(incompatibleOutput, 'incompatible opencode.db schema', 'incompatible schema')
|
|
304
|
+
checks.push('incompatible schema: PASS')
|
|
305
|
+
emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
|
|
306
|
+
} catch (error) {
|
|
307
|
+
emit('FAIL', diagnostic(error), checks)
|
|
308
|
+
} finally {
|
|
309
|
+
rmSync(root, { recursive: true, force: true })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { validateZenodoResponse } from './zenodo-release-state.mjs'
|
|
3
|
+
|
|
4
|
+
export function zenodoReleaseMarker(tag) {
|
|
5
|
+
if (typeof tag !== 'string' || !/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag))
|
|
6
|
+
throw new Error('Release tag is invalid.')
|
|
7
|
+
return `pantheon-opencode-release:${tag}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function depositionDescription(deposition) {
|
|
11
|
+
return [deposition?.metadata?.description, deposition?.description]
|
|
12
|
+
.filter((value) => typeof value === 'string')
|
|
13
|
+
.join('\n')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function exactTotal(value) {
|
|
17
|
+
if (Number.isSafeInteger(value) && value >= 0) return value
|
|
18
|
+
if (
|
|
19
|
+
value &&
|
|
20
|
+
typeof value === 'object' &&
|
|
21
|
+
Number.isSafeInteger(value.value) &&
|
|
22
|
+
value.value >= 0 &&
|
|
23
|
+
(value.relation === undefined || value.relation === 'eq')
|
|
24
|
+
)
|
|
25
|
+
return value.value
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function findUniqueZenodoDeposition(
|
|
30
|
+
depositionsUrl,
|
|
31
|
+
token,
|
|
32
|
+
releaseMarker,
|
|
33
|
+
fetchImpl = fetch,
|
|
34
|
+
) {
|
|
35
|
+
let url
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(depositionsUrl)
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error('Zenodo deposition URL must be a valid absolute HTTPS URL.')
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
url.protocol !== 'https:' ||
|
|
43
|
+
!url.hostname ||
|
|
44
|
+
url.username ||
|
|
45
|
+
url.password ||
|
|
46
|
+
url.hash ||
|
|
47
|
+
/%(?![0-9A-Fa-f]{2})/.test(depositionsUrl) ||
|
|
48
|
+
/[\s{}\\|^`]/.test(depositionsUrl)
|
|
49
|
+
)
|
|
50
|
+
throw new Error('Zenodo deposition URL must be a valid HTTPS URL without credentials.')
|
|
51
|
+
if (typeof releaseMarker !== 'string' || !releaseMarker)
|
|
52
|
+
throw new Error('Zenodo release marker is required.')
|
|
53
|
+
if (typeof token !== 'string' || !token) throw new Error('Zenodo token is required.')
|
|
54
|
+
url.searchParams.set('q', releaseMarker)
|
|
55
|
+
// Zenodo documents page/size for this endpoint. The response's links are
|
|
56
|
+
// optional, so keep a deterministic fallback for the real hits/total shape.
|
|
57
|
+
url.searchParams.set('page', '1')
|
|
58
|
+
url.searchParams.set('size', '100')
|
|
59
|
+
const candidates = []
|
|
60
|
+
const visitedUrls = new Set()
|
|
61
|
+
let nextUrl = url
|
|
62
|
+
let pageNumber = 1
|
|
63
|
+
let expectedTotal = null
|
|
64
|
+
while (nextUrl) {
|
|
65
|
+
const currentUrl = String(nextUrl)
|
|
66
|
+
if (visitedUrls.has(currentUrl)) throw new Error('Zenodo search returned a pagination cycle.')
|
|
67
|
+
visitedUrls.add(currentUrl)
|
|
68
|
+
const response = await fetchImpl(nextUrl, {
|
|
69
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
70
|
+
})
|
|
71
|
+
if (!response.ok) throw new Error(`Zenodo deposition search failed (${response.status}).`)
|
|
72
|
+
const payload = await response.json()
|
|
73
|
+
if (Array.isArray(payload)) {
|
|
74
|
+
throw new Error('Zenodo search returned ambiguous pagination.')
|
|
75
|
+
}
|
|
76
|
+
const page = payload?.hits?.hits
|
|
77
|
+
const total = payload?.hits?.total
|
|
78
|
+
const normalizedTotal = exactTotal(total)
|
|
79
|
+
if (!Array.isArray(page) || normalizedTotal === null)
|
|
80
|
+
throw new Error('Zenodo search returned ambiguous pagination.')
|
|
81
|
+
if (expectedTotal !== null && expectedTotal !== normalizedTotal)
|
|
82
|
+
throw new Error('Zenodo search returned inconsistent pagination totals.')
|
|
83
|
+
expectedTotal = normalizedTotal
|
|
84
|
+
candidates.push(...page)
|
|
85
|
+
const rawNext = payload?.links?.next
|
|
86
|
+
if (candidates.length >= expectedTotal) {
|
|
87
|
+
nextUrl = null
|
|
88
|
+
} else if (rawNext === null || rawNext === undefined || rawNext === '') {
|
|
89
|
+
// Some Zenodo Cloud responses omit links entirely. Continue with the
|
|
90
|
+
// documented page parameter instead of treating the first page as the
|
|
91
|
+
// complete search result.
|
|
92
|
+
if (page.length === 0)
|
|
93
|
+
throw new Error('Zenodo search ended before all results were examined.')
|
|
94
|
+
pageNumber += 1
|
|
95
|
+
const pagedNext = new URL(url)
|
|
96
|
+
pagedNext.searchParams.set('page', String(pageNumber))
|
|
97
|
+
nextUrl = pagedNext
|
|
98
|
+
} else {
|
|
99
|
+
if (typeof rawNext !== 'string')
|
|
100
|
+
throw new Error('Zenodo search returned an invalid pagination link.')
|
|
101
|
+
let parsedNext
|
|
102
|
+
try {
|
|
103
|
+
parsedNext = new URL(rawNext, url)
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error('Zenodo search returned an invalid pagination link.')
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
parsedNext.protocol !== 'https:' ||
|
|
109
|
+
parsedNext.origin !== url.origin ||
|
|
110
|
+
parsedNext.pathname !== url.pathname
|
|
111
|
+
)
|
|
112
|
+
throw new Error('Zenodo search returned an unsafe pagination link.')
|
|
113
|
+
const linkedPage = Number(parsedNext.searchParams.get('page'))
|
|
114
|
+
if (Number.isSafeInteger(linkedPage) && linkedPage > pageNumber) {
|
|
115
|
+
pageNumber = linkedPage
|
|
116
|
+
}
|
|
117
|
+
nextUrl = parsedNext
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const escapedMarker = releaseMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
121
|
+
const markerPattern = new RegExp(`(?:^|[^A-Za-z0-9._:-])${escapedMarker}(?![A-Za-z0-9.-])`)
|
|
122
|
+
const matches = candidates.filter((deposition) =>
|
|
123
|
+
markerPattern.test(depositionDescription(deposition)),
|
|
124
|
+
)
|
|
125
|
+
if (matches.length === 0) return null
|
|
126
|
+
if (matches.length !== 1) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Zenodo recovery requires exactly one matching deposition; found ${matches.length}.`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return validateZenodoResponse(matches[0])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
135
|
+
try {
|
|
136
|
+
const result = await findUniqueZenodoDeposition(
|
|
137
|
+
process.env.ZENODO_DEPOSITIONS_URL,
|
|
138
|
+
process.env.ZENODO_TOKEN,
|
|
139
|
+
process.env.RELEASE_MARKER,
|
|
140
|
+
)
|
|
141
|
+
process.stdout.write(JSON.stringify(result))
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error(`zenodo recovery: ${error.message}`)
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
}
|