pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.2

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.
Files changed (30) hide show
  1. package/README.md +3 -0
  2. package/package.json +1 -1
  3. package/plugin.json +1 -1
  4. package/scripts/cost.mjs +30 -14
  5. package/scripts/mcp_persistence_server.py +44 -7
  6. package/scripts/probe-context-rehydrate.mjs +240 -0
  7. package/scripts/probe-pantheon-cost.mjs +304 -0
  8. package/scripts/test-opencode-v1-v2-sandbox.sh +394 -41
  9. package/src/mcp/mcp_persistence_server.py +44 -7
  10. package/src/pantheon/cost-command.ts +49 -32
  11. package/src/pantheon/delegate-manager.ts +285 -0
  12. package/src/pantheon/token-opt.ts +237 -11
  13. package/src/plugin-v2.ts +2 -14
  14. package/src/plugin.ts +3 -8
  15. package/src/plugins/tui/package.json +1 -1
  16. package/.pantheon/code-mode/.gitkeep +0 -0
  17. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  18. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  19. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  20. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  21. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  22. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  23. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  24. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  25. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  26. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  27. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  28. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  29. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  30. package/src/pantheon/delegate-task-adapter.ts +0 -444
package/README.md CHANGED
@@ -90,6 +90,9 @@ and [changelog](CHANGELOG.md) for the latest changes.
90
90
  - Sandbox validator for global installs (`scripts/test-opencode-v1-v2-sandbox.sh`)
91
91
  covering OpenCode V1/V2 side by side — see
92
92
  [Sandbox validation](#sandbox-validation-v1v2).
93
+ - Beta2 agent-economy policy: direct native delegation, bounded compaction
94
+ carry-forward, compact context encoding, and quality floors — see
95
+ [Beta2 agent economy policy](docs/ws3-token-opt-measurements.md#beta2-agent-economy-policy).
93
96
  - A `--prompts` installer flag is planned for a future release.
94
97
 
95
98
  ## OpenCode V1/V2 — Dual Version (1.5.0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.5.0-beta.1",
3
+ "version": "1.5.0-beta.2",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon",
3
3
  "publisher": "ils15",
4
- "version": "1.5.0-beta.1",
4
+ "version": "1.5.0",
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/scripts/cost.mjs CHANGED
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * cost.mjs — read-only cost aggregation from opencode.db (Wave 4, PR #46).
3
+ * cost.mjs — read-only token aggregation from opencode.db.
4
4
  *
5
- * Fallback for the `pantheon_cost` tool when node:sqlite is unavailable in
6
- * the plugin process (node < 22.5). Uses node:sqlite itself and prints a
7
- * single JSON object to stdout:
8
- * { ok: true, days, rows: [{agent, costUsd, tokensInput, tokensOutput}] }
5
+ * CLI fallback for the `pantheon_cost` tool when node:sqlite is unavailable
6
+ * in the plugin process. Uses node:sqlite itself and prints a single JSON
7
+ * object to stdout:
8
+ * { ok: true, days, rows: [{agent, phase, tokensInput, tokensOutput, tokensTotal}] }
9
9
  * { ok: false, error: "<message>" } (exit code 1)
10
10
  *
11
11
  * NEVER writes to the database — opened read-only. Malformed rows are
12
- * skipped; assistant messages carry the authoritative cost + tokens.
12
+ * skipped; assistant messages carry the authoritative token usage.
13
13
  *
14
- * Usage: node scripts/cost.mjs <dbPath> <days>
14
+ * Usage: node scripts/cost.mjs --db-path <dbPath> --days <days>
15
15
  */
16
16
  import { existsSync } from 'node:fs'
17
17
  import { DatabaseSync } from 'node:sqlite'
@@ -43,8 +43,13 @@ function assertCompatibleSchema(db) {
43
43
  }
44
44
 
45
45
  function main() {
46
- const [, , dbPath, daysArg] = process.argv
47
- const days = Number(daysArg ?? '7')
46
+ const argv = process.argv.slice(2)
47
+ const valueFor = (name) => {
48
+ const index = argv.indexOf(name)
49
+ return index >= 0 ? argv[index + 1] : undefined
50
+ }
51
+ const dbPath = valueFor('--db-path')
52
+ const days = Number(valueFor('--days') ?? '7')
48
53
 
49
54
  if (!dbPath) return fail('missing <dbPath> argument')
50
55
  if (!existsSync(dbPath)) return fail(`database not found: ${dbPath}`)
@@ -64,19 +69,30 @@ function main() {
64
69
  if (info.role !== 'assistant') continue
65
70
  const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
66
71
  if (!agent) continue
67
- const costUsd = Number(info.cost) || 0
72
+ const metadata = info.metadata
73
+ const phase =
74
+ (typeof info.phase === 'string' && info.phase) ||
75
+ (typeof metadata?.phase === 'string' && metadata.phase) ||
76
+ 'unknown'
68
77
  const tokensInput = Number(info.tokens?.input) || 0
69
78
  const tokensOutput = Number(info.tokens?.output) || 0
70
- const acc = byAgent.get(agent) ?? { agent, costUsd: 0, tokensInput: 0, tokensOutput: 0 }
71
- acc.costUsd += costUsd
79
+ const key = `${agent}\u0000${phase}`
80
+ const acc = byAgent.get(key) ?? {
81
+ agent,
82
+ phase,
83
+ tokensInput: 0,
84
+ tokensOutput: 0,
85
+ tokensTotal: 0,
86
+ }
72
87
  acc.tokensInput += tokensInput
73
88
  acc.tokensOutput += tokensOutput
74
- byAgent.set(agent, acc)
89
+ acc.tokensTotal += tokensInput + tokensOutput
90
+ byAgent.set(key, acc)
75
91
  } catch {
76
92
  // Skip malformed rows — a partial ledger never breaks the report.
77
93
  }
78
94
  }
79
- const result = [...byAgent.values()].sort((a, b) => b.costUsd - a.costUsd)
95
+ const result = [...byAgent.values()].sort((a, b) => b.tokensTotal - a.tokensTotal)
80
96
  process.stdout.write(JSON.stringify({ ok: true, days, rows: result }))
81
97
  } catch (err) {
82
98
  fail(err instanceof Error ? err.message : String(err))
@@ -566,7 +566,7 @@ async def kv_delete_namespace(
566
566
  # ── Context Checkpoint Tools ────────────────────────────────────────────────────
567
567
  # Session-scoped context storage for deepwork checkpoints, phase state,
568
568
  # tool call history, and reasoning chains. All entries use namespace
569
- # "checkpoint:{slug}" with auto-TTL of 4 hours (session duration).
569
+ # "checkpoint:{slug}:{session}" with auto-TTL of 4 hours (session duration).
570
570
 
571
571
  DEFAULT_CONTEXT_TTL: int = 14400 # 4 hours
572
572
 
@@ -1023,6 +1023,31 @@ def _load_checkpoint_for_rehydrate(
1023
1023
  return None
1024
1024
 
1025
1025
 
1026
+ def _checkpoint_namespaces(
1027
+ conn: sqlite3.Connection, slug: str, session_id: str | None
1028
+ ) -> list[str]:
1029
+ """Resolve checkpoint namespaces while preserving session isolation.
1030
+
1031
+ ``context_save`` always creates a session-qualified namespace, including
1032
+ when the caller omits ``session_id`` (it generates one and returns it).
1033
+ Runtime callers may not retain that return value after compaction, so an
1034
+ unqualified rehydrate/summary must discover the newest matching session.
1035
+ The prefix comparison is parameterized and avoids LIKE wildcard leakage.
1036
+ """
1037
+ if session_id:
1038
+ return [f"checkpoint:{slug}:{session_id}"]
1039
+ prefix = f"checkpoint:{slug}:"
1040
+ rows = conn.execute(
1041
+ "SELECT namespace FROM kv_store "
1042
+ "WHERE substr(namespace, 1, ?) = ? "
1043
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1044
+ "AND deleted_at IS NULL "
1045
+ "GROUP BY namespace ORDER BY MAX(updated_at) DESC LIMIT 20",
1046
+ (len(prefix), prefix),
1047
+ ).fetchall()
1048
+ return [str(row[0]) for row in rows if row and row[0]]
1049
+
1050
+
1026
1051
  def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1027
1052
  """Extend an unexpired heartbeat TTL; never shorten, never resurrect."""
1028
1053
  row = conn.execute(
@@ -1055,7 +1080,8 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1055
1080
  @mcp.tool(
1056
1081
  name="context_rehydrate",
1057
1082
  description="Rehydrate critical context after native compaction. "
1058
- "Reads 'latest' + serialized 'tail' from checkpoint:<slug> and rebuilds "
1083
+ "Reads 'latest' + serialized 'tail' from a session-qualified checkpoint "
1084
+ "namespace and rebuilds "
1059
1085
  "goal/phase/delegation blocks deterministically (no LLM). Refreshes the "
1060
1086
  "heartbeat TTL. Idempotent. Honors PANTHEON_COMPACTION=off.",
1061
1087
  )
@@ -1068,14 +1094,21 @@ async def context_rehydrate(
1068
1094
  if _compaction_disabled():
1069
1095
  return None
1070
1096
  conn = _db(scope)
1071
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
1072
- checkpoint = _load_checkpoint_for_rehydrate(conn, ns)
1097
+ namespaces = _checkpoint_namespaces(conn, slug, session_id)
1098
+ checkpoint = None
1099
+ selected_namespace = None
1100
+ for namespace in namespaces:
1101
+ checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1102
+ if checkpoint is not None:
1103
+ selected_namespace = namespace
1104
+ break
1073
1105
  if checkpoint is None:
1074
1106
  return None
1075
1107
  blocks = build_rehydration_blocks(checkpoint)
1076
1108
  if blocks is None:
1077
1109
  return None
1078
- _refresh_heartbeat_ttl(conn, ns)
1110
+ if selected_namespace is not None:
1111
+ _refresh_heartbeat_ttl(conn, selected_namespace)
1079
1112
  return blocks
1080
1113
 
1081
1114
 
@@ -1095,8 +1128,12 @@ async def context_session_summary(
1095
1128
  if os.environ.get(SESSION_END_SUMMARY_ENV, "").lower() == "off":
1096
1129
  return None
1097
1130
  conn = _db(scope)
1098
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
1099
- checkpoint = _load_checkpoint_for_rehydrate(conn, ns)
1131
+ namespaces = _checkpoint_namespaces(conn, slug, session_id)
1132
+ checkpoint = None
1133
+ for namespace in namespaces:
1134
+ checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1135
+ if checkpoint is not None:
1136
+ break
1100
1137
  if checkpoint is None:
1101
1138
  return None
1102
1139
  return build_session_end_summary(checkpoint)
@@ -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()