pantheon-opencode 1.5.2 → 1.6.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -5,7 +5,7 @@ Key-Value store with FTS5 full-text search, TTL-based expiration,
5
5
  and namespace isolation. Uses SQLite with zero external dependencies.
6
6
 
7
7
  Usage:
8
- python scripts/mcp_persistence_server.py
8
+ python scripts/mcp_persistence.py
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -18,11 +18,15 @@ import sqlite3
18
18
  import sys
19
19
  import threading
20
20
  import time
21
+ from collections.abc import Callable
22
+ from contextlib import suppress
21
23
  from datetime import UTC, datetime, timedelta
22
24
  from pathlib import Path
25
+ from typing import Annotated
23
26
 
24
27
  from _pantheon_paths import pantheon_home, pantheon_project
25
- from mcp.server.fastmcp import FastMCP
28
+ from fastmcp import FastMCP
29
+ from pydantic import Field
26
30
 
27
31
  # ── Schema ──────────────────────────────────────────────────────────────────────
28
32
 
@@ -36,6 +40,7 @@ CREATE TABLE IF NOT EXISTS kv_store (
36
40
  deleted_at TEXT, -- NULL = active, set on TTL purge
37
41
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
38
42
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
43
+ revision INTEGER, -- context monotonic revision; NULL = legacy row
39
44
  UNIQUE(namespace, key)
40
45
  );
41
46
 
@@ -115,11 +120,33 @@ def _init_db(db_path: Path) -> sqlite3.Connection:
115
120
  conn.executescript(_no_fts)
116
121
  else:
117
122
  conn.executescript(CREATE_SQL)
123
+ _ensure_revision_column(conn)
118
124
  conn.commit()
119
125
 
120
126
  return conn
121
127
 
122
128
 
129
+ # `revision` was added after `kv_store` shipped, so an existing database has the
130
+ # column only if it is added here. ADD COLUMN is additive and non-destructive:
131
+ # pre-existing rows get NULL, which every reader treats as "no revision yet"
132
+ # and resolves through the legacy `updated_at` path. No backfill is performed
133
+ # on purpose — the live store holds both formats and rewriting them in place
134
+ # would be unrecoverable if the mapping were ever wrong.
135
+ _ADD_REVISION_SQL: str = "ALTER TABLE kv_store ADD COLUMN revision INTEGER"
136
+
137
+
138
+ def _ensure_revision_column(conn: sqlite3.Connection) -> None:
139
+ """Add the dedicated ``revision`` column if this database predates it."""
140
+ try:
141
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(kv_store)")}
142
+ except sqlite3.Error:
143
+ return
144
+ if "revision" in columns:
145
+ return
146
+ with suppress(sqlite3.OperationalError):
147
+ conn.execute(_ADD_REVISION_SQL)
148
+
149
+
123
150
  def _db(scope: str) -> sqlite3.Connection:
124
151
  """Resolve the connection for the given scope."""
125
152
  if scope == "global":
@@ -199,7 +226,9 @@ _project_db = _project_db_instance
199
226
  @mcp.tool(
200
227
  name="kv_store",
201
228
  description="Store a key-value pair in a namespace with optional TTL (seconds). "
202
- "INSERT OR REPLACE on duplicate (namespace, key).",
229
+ "INSERT OR REPLACE on duplicate (namespace, key). "
230
+ "ttl must be an integer between 1 and 31536000, or null/omitted. "
231
+ "All invalid arguments are reported together in one message.",
203
232
  )
204
233
  async def kv_store(
205
234
  namespace: str,
@@ -210,6 +239,9 @@ async def kv_store(
210
239
  ) -> dict:
211
240
  """Store a value under namespace+key with optional TTL.
212
241
 
242
+ Validates every argument before writing so a caller with both a bad ttl
243
+ and a bad scope sees both violations in one message.
244
+
213
245
  Args:
214
246
  namespace: Logical grouping for keys.
215
247
  key: Unique key within the namespace.
@@ -220,7 +252,16 @@ async def kv_store(
220
252
  Returns:
221
253
  Status dict with namespace and key.
222
254
  """
223
- validated_ttl = _validate_ttl(ttl)
255
+ errors = _ArgErrors("kv_store")
256
+ try:
257
+ validated_ttl = _validate_ttl(ttl)
258
+ except ValueError as exc:
259
+ errors.report(str(exc), repr(ttl))
260
+ errors.example(TTL_EXAMPLE)
261
+ validated_ttl = None
262
+ _collect_scope_error(errors, scope)
263
+ errors.raise_if_any()
264
+
224
265
  conn = _db(scope)
225
266
  expires_at: str | None = None
226
267
  if validated_ttl is not None:
@@ -267,7 +308,7 @@ async def kv_get(
267
308
  row = conn.execute(
268
309
  "SELECT value FROM kv_store "
269
310
  "WHERE namespace = ? AND key = ? "
270
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
311
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
271
312
  "AND deleted_at IS NULL",
272
313
  (namespace, key),
273
314
  ).fetchone()
@@ -276,7 +317,10 @@ async def kv_get(
276
317
 
277
318
  @mcp.tool(
278
319
  name="kv_stats",
279
- description="Return storage statistics: total entries, expired count, per-namespace breakdown, DB file size.",
320
+ description=(
321
+ "Return storage statistics: total entries, expired count, per-namespace "
322
+ "breakdown, DB file size."
323
+ ),
280
324
  )
281
325
  async def kv_stats(
282
326
  scope: str = "project",
@@ -296,13 +340,14 @@ async def kv_stats(
296
340
  ).fetchone()[0]
297
341
  expired = conn.execute(
298
342
  "SELECT COUNT(*) FROM kv_store "
299
- "WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') "
343
+ "WHERE expires_at IS NOT NULL AND julianday(expires_at) < julianday('now') "
300
344
  "AND deleted_at IS NULL"
301
345
  ).fetchone()[0]
302
346
 
303
347
  ns_rows = conn.execute(
304
348
  "SELECT namespace, COUNT(*) as cnt, "
305
- "SUM(CASE WHEN expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') THEN 1 ELSE 0 END) as expired_count "
349
+ "SUM(CASE WHEN expires_at IS NOT NULL AND julianday(expires_at) "
350
+ "< julianday('now') THEN 1 ELSE 0 END) as expired_count "
306
351
  "FROM kv_store WHERE deleted_at IS NULL GROUP BY namespace ORDER BY cnt DESC"
307
352
  ).fetchall()
308
353
 
@@ -377,7 +422,7 @@ async def kv_list(
377
422
  rows = conn.execute(
378
423
  "SELECT key, value, created_at, expires_at FROM kv_store "
379
424
  "WHERE namespace = ? AND key LIKE ? "
380
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
425
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
381
426
  "AND deleted_at IS NULL "
382
427
  "ORDER BY key LIMIT ?",
383
428
  (namespace, f"{prefix}%", limit),
@@ -431,7 +476,8 @@ async def kv_search(
431
476
  "JOIN kv_store ON kv_store_fts.rowid = kv_store.id "
432
477
  "WHERE kv_store_fts MATCH ? "
433
478
  "AND kv_store.deleted_at IS NULL "
434
- "AND (kv_store.expires_at IS NULL OR datetime(kv_store.expires_at) > datetime('now'))"
479
+ "AND (kv_store.expires_at IS NULL OR "
480
+ "julianday(kv_store.expires_at) > julianday('now'))"
435
481
  )
436
482
  params: list[str | int] = [fts_query]
437
483
 
@@ -479,7 +525,7 @@ async def purge_expired(
479
525
  expired = conn.execute(
480
526
  "SELECT key FROM kv_store "
481
527
  "WHERE expires_at IS NOT NULL "
482
- "AND datetime(expires_at) < datetime('now') "
528
+ "AND julianday(expires_at) < julianday('now') "
483
529
  "AND deleted_at IS NULL",
484
530
  ).fetchall()
485
531
  expired_keys = [r[0] for r in expired]
@@ -495,7 +541,7 @@ async def purge_expired(
495
541
  conn.execute(
496
542
  "UPDATE kv_store SET deleted_at = datetime('now') "
497
543
  "WHERE expires_at IS NOT NULL "
498
- "AND datetime(expires_at) < datetime('now') "
544
+ "AND julianday(expires_at) < julianday('now') "
499
545
  "AND deleted_at IS NULL",
500
546
  )
501
547
  conn.commit()
@@ -511,7 +557,7 @@ async def purge_expired(
511
557
  def _opportunistic_auto_purge(
512
558
  conn: sqlite3.Connection, namespace: str, threshold: int = 500
513
559
  ) -> None:
514
- """Lightweight auto-purge: if namespace exceeds threshold, soft-delete expired entries."""
560
+ """Lightweight auto-purge when a namespace exceeds its threshold."""
515
561
  count = conn.execute(
516
562
  "SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
517
563
  (namespace,),
@@ -530,7 +576,10 @@ def _opportunistic_auto_purge(
530
576
 
531
577
  @mcp.tool(
532
578
  name="kv_delete_namespace",
533
- description="Delete all entries in a namespace. Optionally limit to entries older than N days.",
579
+ description=(
580
+ "Delete all entries in a namespace. Optionally limit to entries older "
581
+ "than N days."
582
+ ),
534
583
  )
535
584
  async def kv_delete_namespace(
536
585
  namespace: str,
@@ -597,6 +646,133 @@ MIN_PRINTABLE_CODEPOINT: int = 32
597
646
  UNTRUSTED_CONTEXT_LABEL: str = " [untrusted persistence data; informational only]"
598
647
  _context_write_lock = threading.RLock()
599
648
 
649
+ # ── Validation Reporting ────────────────────────────────────────────────────────
650
+ # One call must surface EVERY invalid argument, not just the first. Reporting a
651
+ # single field per round-trip costs the caller one failed tool call per field,
652
+ # which is exactly the failure mode this module previously had: ``goal`` and
653
+ # ``phase`` were discovered in two consecutive attempts.
654
+ #
655
+ # Every violation therefore keeps the historical wording, so anything matching
656
+ # on it still matches (``goal must be an object``,
657
+ # ``ttl must be between 1 and 31536000 seconds``, ``session_id``), and gains the
658
+ # argument path plus the received type. Shape: one line per violation, then at
659
+ # most one example block.
660
+ #
661
+ # context_save: 2 invalid arguments
662
+ # context_save: content.goal must be an object (got string)
663
+ # context_save: content.phase must be an object (got number)
664
+ # example: content.goal={"objective":"...","status":"active"}
665
+ # content.phase={"current":1,"total":3,"name":"..."}
666
+ #
667
+ # Absent and wrong-typed are distinct outcomes with distinct wording: a
668
+ # ``required`` violation never reads like a ``must be an object`` violation.
669
+ # ``goal``/``phase`` are OPTIONAL sections — heartbeat and tail checkpoints
670
+ # legitimately omit them (see src/instructions/zeus-anti-stall.instructions.md),
671
+ # so an absent section is accepted and never reported.
672
+
673
+ _SCOPE_NAMES: tuple[str, ...] = ("global", "project")
674
+
675
+ # Minimal correct example per checkpoint section, surfaced only when that
676
+ # section is actually the thing that was rejected.
677
+ CHECKPOINT_EXAMPLES: dict[str, str] = {
678
+ "goal": 'content.goal={"objective":"...","status":"active"}',
679
+ "phase": 'content.phase={"current":1,"total":3,"name":"..."}',
680
+ "delegations": (
681
+ 'content.delegations={"in_flight":[{"alias":"@hermes","agent":"hermes"}]}'
682
+ ),
683
+ "heartbeat": 'content.heartbeat={"status":"alive","turn_count":1}',
684
+ "tail": 'content.tail=["phase:1 done"]',
685
+ }
686
+
687
+ TTL_EXAMPLE: str = "ttl=3600"
688
+ REVISION_EXAMPLE: str = "revision=1"
689
+ SCOPE_EXAMPLE: str = 'scope="project"'
690
+ SESSION_ID_EXAMPLE: str = 'session_id="ses_abc123"'
691
+
692
+
693
+ def _json_type_name(value: object) -> str:
694
+ """Name a value's JSON type for error messages (ints read as ``number``)."""
695
+ if value is None:
696
+ type_name = "null"
697
+ elif isinstance(value, bool):
698
+ type_name = "boolean"
699
+ elif isinstance(value, str):
700
+ type_name = "string"
701
+ elif isinstance(value, int | float):
702
+ type_name = "number"
703
+ elif isinstance(value, list):
704
+ type_name = "array"
705
+ elif isinstance(value, dict):
706
+ type_name = "object"
707
+ else:
708
+ type_name = type(value).__name__
709
+ return type_name
710
+
711
+
712
+ class _ArgErrors:
713
+ """Accumulate argument violations so a single call reports all of them."""
714
+
715
+ def __init__(self, tool: str) -> None:
716
+ self._tool = tool
717
+ self._violations: list[str] = []
718
+ self._examples: list[str] = []
719
+
720
+ def __bool__(self) -> bool:
721
+ return bool(self._violations)
722
+
723
+ def add(self, path: str, message: str, got: str | None = None) -> None:
724
+ """Record one violation, optionally suffixing the received type/value."""
725
+ suffix = f" (got {got})" if got is not None else ""
726
+ self._violations.append(f"{path} {message}{suffix}")
727
+
728
+ def report(self, message: str, got: str | None = None) -> None:
729
+ """Record a validator message that already names its own argument.
730
+
731
+ ``_validate_ttl`` raises ``ttl must be between 1 and 31536000 seconds``,
732
+ so the field name is part of the message and must not be prepended
733
+ again.
734
+ """
735
+ suffix = f" (got {got})" if got is not None else ""
736
+ self._violations.append(f"{message}{suffix}")
737
+
738
+ def reroot(self, prefix: str, message: str) -> None:
739
+ """Re-root a self-qualified message under a container path.
740
+
741
+ ``goal.objective exceeds its size limit`` becomes
742
+ ``content.goal.objective exceeds its size limit``.
743
+ """
744
+ field, _, rest = message.partition(" ")
745
+ self._violations.append(f"{prefix}.{field} {rest}".rstrip())
746
+
747
+ def shape(
748
+ self, path: str, expected: str, value: object, example: str | None = None
749
+ ) -> None:
750
+ """Record a type violation: ``<path> must be <expected> (got <type>)``."""
751
+ self.add(path, f"must be {expected}", _json_type_name(value))
752
+ self.example(example)
753
+
754
+ def example(self, example: str | None) -> None:
755
+ """Attach a minimal correct example for the field just recorded."""
756
+ if example:
757
+ self._examples.append(example)
758
+
759
+ def message(self) -> str:
760
+ """Render one line per violation plus at most one example block."""
761
+ lines = [f"{self._tool}: {violation}" for violation in self._violations]
762
+ examples = list(dict.fromkeys(self._examples))
763
+ if examples:
764
+ lines.append("example: " + " ".join(examples))
765
+ if len(self._violations) == 1:
766
+ return "\n".join(lines)
767
+ return f"{self._tool}: {len(self._violations)} invalid arguments\n" + "\n".join(
768
+ lines
769
+ )
770
+
771
+ def raise_if_any(self) -> None:
772
+ """Raise once, with every collected violation, if anything was invalid."""
773
+ if self._violations:
774
+ raise ValueError(self.message())
775
+
600
776
 
601
777
  def _normalize_session_id(session_id: str | None) -> str | None:
602
778
  """Return a non-empty session ID, or ``None`` for an absent ID."""
@@ -676,17 +852,6 @@ def _validate_revision(revision: int | None) -> int | None:
676
852
  return revision
677
853
 
678
854
 
679
- def _current_context_revision(
680
- conn: sqlite3.Connection, namespace: str, key: str
681
- ) -> int:
682
- """Read the persisted revision for one context entry."""
683
- row = conn.execute(
684
- "SELECT updated_at FROM kv_store WHERE namespace = ? AND key = ?",
685
- (namespace, key),
686
- ).fetchone()
687
- return _stored_revision(row[0]) if row else 0
688
-
689
-
690
855
  def _should_update_latest(key: str) -> bool:
691
856
  """Keep operational metadata from clobbering the recovery checkpoint."""
692
857
  return key not in {"heartbeat", "tail"}
@@ -800,28 +965,64 @@ def _validate_version_metadata(version: object) -> None:
800
965
  raise ValueError("version must be a positive integer or version label")
801
966
 
802
967
 
968
+ # (field, validator, python type, wording) in report order. ``tail`` is the one
969
+ # array section; the rest are objects. ``None`` sections are OPTIONAL and are
970
+ # never reported — heartbeat checkpoints omit goal/phase by design.
971
+ CHECKPOINT_SECTIONS: tuple[tuple[str, Callable[[object], object], type, str], ...] = (
972
+ ("goal", _validate_goal, dict, "an object"),
973
+ ("phase", _validate_phase, dict, "an object"),
974
+ ("delegations", _validate_delegations, dict, "an object"),
975
+ ("tail", _validate_tail, list, "an array"),
976
+ ("heartbeat", _validate_heartbeat, dict, "an object"),
977
+ )
978
+
979
+
803
980
  def _validate_checkpoint_shape(checkpoint: dict) -> None:
804
- """Validate bounded structured checkpoint fields without sanitizing them."""
981
+ """Validate bounded structured checkpoint fields without sanitizing them.
982
+
983
+ Fail-fast on purpose: the read path needs only a yes/no verdict.
984
+ ``context_save`` uses ``_collect_checkpoint_errors`` so a single call
985
+ reports every invalid section instead of only the first.
986
+ """
805
987
  _validate_version_metadata(checkpoint.get("version"))
806
- validators = (
807
- ("goal", _validate_goal),
808
- ("phase", _validate_phase),
809
- ("delegations", _validate_delegations),
810
- ("tail", _validate_tail),
811
- ("heartbeat", _validate_heartbeat),
812
- )
813
- for field, validator in validators:
988
+ for field, validator, _expected_type, _wording in CHECKPOINT_SECTIONS:
814
989
  value = checkpoint.get(field)
815
990
  if value is not None:
816
991
  validator(value)
817
992
 
818
993
 
819
- def _validate_context_content(content: str, key: str) -> None:
820
- """Bound context content and validate structured JSON when supplied."""
994
+ def _collect_checkpoint_errors(errors: _ArgErrors, parsed: dict, prefix: str) -> None:
995
+ """Accumulate every invalid section of one parsed checkpoint object."""
996
+ try:
997
+ _validate_version_metadata(parsed.get("version"))
998
+ except ValueError as exc:
999
+ errors.reroot(prefix, str(exc))
1000
+
1001
+ for field, validator, expected_type, wording in CHECKPOINT_SECTIONS:
1002
+ value = parsed.get(field)
1003
+ if value is None:
1004
+ continue
1005
+ if not isinstance(value, expected_type):
1006
+ errors.shape(
1007
+ f"{prefix}.{field}", wording, value, CHECKPOINT_EXAMPLES.get(field)
1008
+ )
1009
+ continue
1010
+ try:
1011
+ validator(value)
1012
+ except ValueError as exc:
1013
+ errors.reroot(prefix, str(exc))
1014
+
1015
+
1016
+ def _collect_context_content_errors(
1017
+ errors: _ArgErrors, content: str, key: str, prefix: str = "content"
1018
+ ) -> None:
1019
+ """Bound context content and accumulate every structured-field violation."""
821
1020
  if not isinstance(content, str):
822
- raise ValueError("content must be a string")
1021
+ errors.shape(prefix, "a string", content)
1022
+ return
823
1023
  if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
824
- raise ValueError(f"content exceeds the {MAX_CONTEXT_CONTENT_BYTES}-byte limit")
1024
+ errors.add(prefix, f"exceeds the {MAX_CONTEXT_CONTENT_BYTES}-byte limit")
1025
+ return
825
1026
  try:
826
1027
  parsed = json.loads(content)
827
1028
  except (json.JSONDecodeError, TypeError, ValueError):
@@ -833,11 +1034,38 @@ def _validate_context_content(content: str, key: str) -> None:
833
1034
  not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH
834
1035
  for line in parsed
835
1036
  ):
836
- raise ValueError("tail exceeds its size limit")
1037
+ errors.add(f"{prefix}.tail", "exceeds its size limit")
837
1038
  return
838
1039
  if not isinstance(parsed, dict):
839
- raise ValueError("structured context content must be a JSON object")
840
- _validate_checkpoint_shape(parsed)
1040
+ # The message already names 'content': do not prepend the path again.
1041
+ errors.report(
1042
+ "structured context content must be a JSON object",
1043
+ _json_type_name(parsed),
1044
+ )
1045
+ return
1046
+ _collect_checkpoint_errors(errors, parsed, prefix)
1047
+
1048
+
1049
+ def _collect_identifier(
1050
+ errors: _ArgErrors, value: str, name: str, limit: int
1051
+ ) -> str | None:
1052
+ """Validate one namespace component, recording the violation and continuing."""
1053
+ try:
1054
+ return _validate_identifier(value, name, limit)
1055
+ except ValueError as exc:
1056
+ errors.report(str(exc))
1057
+ return None
1058
+
1059
+
1060
+ def _collect_scope_error(errors: _ArgErrors, scope: str) -> None:
1061
+ """Record an unknown scope name; DB resolution stays ``_db``'s job."""
1062
+ if scope not in _SCOPE_NAMES:
1063
+ errors.add(
1064
+ "scope",
1065
+ "must be 'global' or 'project'",
1066
+ repr(scope),
1067
+ )
1068
+ errors.example(SCOPE_EXAMPLE)
841
1069
 
842
1070
 
843
1071
  def _safe_untrusted_text(value: object) -> str:
@@ -861,21 +1089,52 @@ def _stored_revision(raw: object) -> int:
861
1089
  return revision if 0 <= revision <= MAX_REVISION else 0
862
1090
 
863
1091
 
1092
+ def _row_revision(row: object) -> int:
1093
+ """Resolve a row's context revision, tolerating the legacy layout.
1094
+
1095
+ Rows written before the dedicated ``revision`` column existed carry the
1096
+ revision in ``updated_at`` and have ``revision IS NULL``. Reading the new
1097
+ column first and falling back keeps monotonicity intact across the
1098
+ transition: a writer that only looked at the new column would restart the
1099
+ counter at 1 and could hand out a revision an older row already used.
1100
+ """
1101
+ if row is None:
1102
+ return 0
1103
+ try:
1104
+ dedicated, legacy = row[0], row[1]
1105
+ except (IndexError, TypeError):
1106
+ return 0
1107
+ if dedicated is not None:
1108
+ return _stored_revision(dedicated)
1109
+ return _stored_revision(legacy)
1110
+
1111
+
1112
+ _REVISION_SELECT = "SELECT revision, updated_at FROM kv_store WHERE namespace = ?"
1113
+
1114
+
1115
+ def _current_context_revision(
1116
+ conn: sqlite3.Connection, namespace: str, key: str
1117
+ ) -> int:
1118
+ """Read the persisted revision for one context entry."""
1119
+ row = conn.execute(f"{_REVISION_SELECT} AND key = ?", (namespace, key)).fetchone()
1120
+ return _row_revision(row) if row else 0
1121
+
1122
+
864
1123
  def _next_context_revision(
865
1124
  conn: sqlite3.Connection, namespace: str, key: str, latest_key: str | None = None
866
1125
  ) -> int:
867
1126
  """Choose a persisted monotonic revision while the write lock is held."""
868
1127
  if latest_key is None:
869
1128
  rows = conn.execute(
870
- "SELECT updated_at FROM kv_store WHERE namespace = ? AND key = ?",
1129
+ f"{_REVISION_SELECT} AND key = ?",
871
1130
  (namespace, key),
872
1131
  ).fetchall()
873
1132
  else:
874
1133
  rows = conn.execute(
875
- "SELECT updated_at FROM kv_store WHERE namespace = ? AND key IN (?, ?)",
1134
+ f"{_REVISION_SELECT} AND key IN (?, ?)",
876
1135
  (namespace, key, latest_key),
877
1136
  ).fetchall()
878
- current = max((_stored_revision(row[0]) for row in rows), default=0)
1137
+ current = max((_row_revision(row) for row in rows), default=0)
879
1138
  return min(MAX_REVISION, max(time.time_ns(), current + 1))
880
1139
 
881
1140
 
@@ -890,24 +1149,45 @@ def _upsert_context_entry(
890
1149
  """Upsert one context row inside the caller's transaction."""
891
1150
  conn.execute(
892
1151
  "INSERT INTO kv_store (namespace, key, value, expires_at, "
893
- "created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), ?)"
1152
+ "created_at, updated_at, revision) "
1153
+ "VALUES (?, ?, ?, ?, datetime('now'), datetime('now'), ?)"
894
1154
  "ON CONFLICT(namespace, key) DO UPDATE SET "
895
1155
  " value = excluded.value, "
896
1156
  " expires_at = excluded.expires_at, "
897
- " updated_at = excluded.updated_at",
898
- (namespace, key, content, expires_at, str(revision)),
1157
+ " updated_at = excluded.updated_at, "
1158
+ " revision = excluded.revision",
1159
+ (namespace, key, content, expires_at, revision),
899
1160
  )
900
1161
 
901
1162
 
902
1163
  @mcp.tool(
903
1164
  name="context_save",
904
1165
  description="Save a context checkpoint for a session/phase. "
905
- "Stores structured JSON in persistence KV with a key-specific TTL.",
1166
+ "Stores structured JSON in persistence KV with a key-specific TTL. "
1167
+ "content is a JSON STRING; when it parses to an object, 'goal', 'phase', "
1168
+ "'delegations' and 'heartbeat' must each be JSON OBJECTS and 'tail' must "
1169
+ "be a JSON ARRAY. 'phase' is an object {current,total,name} — NOT a phase "
1170
+ "counter (pass phase.number for that). All sections are optional, so a "
1171
+ "heartbeat checkpoint may omit goal/phase. "
1172
+ 'Example: content=\'{"goal": {"objective": "..."}, "phase": '
1173
+ '{"current": 1, "total": 3, "name": "..."}}\'. '
1174
+ "All invalid arguments are reported together in one message.",
906
1175
  )
907
1176
  async def context_save(
908
1177
  slug: str,
909
1178
  key: str,
910
- content: str,
1179
+ content: Annotated[
1180
+ str,
1181
+ Field(
1182
+ description=(
1183
+ "JSON checkpoint string. When it parses to an object, 'goal', "
1184
+ "'phase', 'delegations' and 'heartbeat' must be objects and "
1185
+ "'tail' must be an array; all are optional. 'phase' is an "
1186
+ "object {current,total,name}, not a counter. Example: "
1187
+ '\'{"goal": {"objective": "..."}, "phase": {"current": 1}}\''
1188
+ )
1189
+ ),
1190
+ ],
911
1191
  session_id: str,
912
1192
  ttl: int | None = None,
913
1193
  scope: str = "project",
@@ -919,6 +1199,9 @@ async def context_save(
919
1199
  cross-session reads. The session_id must be supplied by the autonomy
920
1200
  caller; this function never generates one.
921
1201
 
1202
+ Every argument is validated before any write, and all violations are
1203
+ reported in a single error so the caller fixes them in one round-trip.
1204
+
922
1205
  Args:
923
1206
  slug: Session identifier (e.g. "auth-refactor").
924
1207
  key: Checkpoint key (e.g. "phase:3", "latest", "heartbeat").
@@ -931,18 +1214,36 @@ async def context_save(
931
1214
  Returns:
932
1215
  Status dict with namespace, key, and session_id for subsequent calls.
933
1216
  """
934
- valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
935
- valid_key = _validate_identifier(key, "key", MAX_CONTEXT_KEY_LENGTH)
1217
+ errors = _ArgErrors("context_save")
1218
+ valid_slug = _collect_identifier(errors, slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
1219
+ valid_key = _collect_identifier(errors, key, "key", MAX_CONTEXT_KEY_LENGTH)
936
1220
  actual_session = _normalize_session_id(session_id)
937
1221
  if actual_session is None:
938
- raise ValueError("session_id is required and must be non-empty")
939
- valid_session = _validate_identifier(
940
- actual_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
941
- )
942
- _validate_context_content(content, valid_key)
943
- requested_revision = _validate_revision(revision)
1222
+ errors.add("session_id", "is required and must be non-empty")
1223
+ errors.example(SESSION_ID_EXAMPLE)
1224
+ valid_session = None
1225
+ else:
1226
+ valid_session = _collect_identifier(
1227
+ errors, actual_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
1228
+ )
1229
+ _collect_context_content_errors(errors, content, valid_key or key)
1230
+ try:
1231
+ requested_revision = _validate_revision(revision)
1232
+ except ValueError as exc:
1233
+ errors.report(str(exc), repr(revision))
1234
+ errors.example(REVISION_EXAMPLE)
1235
+ requested_revision = None
1236
+ _collect_scope_error(errors, scope)
1237
+ try:
1238
+ requested_ttl = _validate_ttl(ttl)
1239
+ except ValueError as exc:
1240
+ errors.report(str(exc), repr(ttl))
1241
+ errors.example(TTL_EXAMPLE)
1242
+ requested_ttl = None
1243
+ errors.raise_if_any()
1244
+
944
1245
  ns = f"checkpoint:{valid_slug}:{valid_session}"
945
- actual_ttl = _context_ttl(valid_key, ttl)
1246
+ actual_ttl = _context_ttl(valid_key, requested_ttl)
946
1247
 
947
1248
  conn = _db(scope)
948
1249
  expires_at = (datetime.now(UTC) + timedelta(seconds=actual_ttl)).isoformat()
@@ -1028,7 +1329,7 @@ async def context_get(
1028
1329
  row = conn.execute(
1029
1330
  "SELECT value FROM kv_store "
1030
1331
  "WHERE namespace = ? AND key = ? "
1031
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1332
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
1032
1333
  "AND deleted_at IS NULL",
1033
1334
  (ns, key),
1034
1335
  ).fetchone()
@@ -1065,7 +1366,7 @@ async def context_list(
1065
1366
  rows = conn.execute(
1066
1367
  "SELECT key, created_at, expires_at FROM kv_store "
1067
1368
  "WHERE namespace = ? "
1068
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1369
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
1069
1370
  "AND deleted_at IS NULL "
1070
1371
  "ORDER BY created_at DESC LIMIT 50",
1071
1372
  (ns,),
@@ -1118,14 +1419,14 @@ async def context_stats(
1118
1419
  count = conn.execute(
1119
1420
  "SELECT COUNT(*) FROM kv_store "
1120
1421
  "WHERE namespace = ? AND deleted_at IS NULL "
1121
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
1422
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now'))",
1122
1423
  (ns,),
1123
1424
  ).fetchone()[0]
1124
1425
 
1125
1426
  expired = conn.execute(
1126
1427
  "SELECT COUNT(*) FROM kv_store "
1127
1428
  "WHERE namespace = ? AND expires_at IS NOT NULL "
1128
- "AND datetime(expires_at) <= datetime('now') "
1429
+ "AND julianday(expires_at) <= julianday('now') "
1129
1430
  "AND deleted_at IS NULL",
1130
1431
  (ns,),
1131
1432
  ).fetchone()[0]
@@ -1133,7 +1434,7 @@ async def context_stats(
1133
1434
  size = conn.execute(
1134
1435
  "SELECT COALESCE(SUM(LENGTH(value)), 0) FROM kv_store "
1135
1436
  "WHERE namespace = ? AND deleted_at IS NULL "
1136
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
1437
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now'))",
1137
1438
  (ns,),
1138
1439
  ).fetchone()[0]
1139
1440
 
@@ -1200,7 +1501,7 @@ def _read_checkpoint_value(
1200
1501
  row = conn.execute(
1201
1502
  "SELECT value FROM kv_store "
1202
1503
  "WHERE namespace = ? AND key = ? "
1203
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1504
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
1204
1505
  "AND deleted_at IS NULL",
1205
1506
  (namespace, key),
1206
1507
  ).fetchone()
@@ -1418,7 +1719,7 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1418
1719
  row = conn.execute(
1419
1720
  "SELECT expires_at FROM kv_store "
1420
1721
  "WHERE namespace = ? AND key = 'heartbeat' "
1421
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1722
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
1422
1723
  "AND deleted_at IS NULL",
1423
1724
  (namespace,),
1424
1725
  ).fetchone()
@@ -1436,11 +1737,12 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1436
1737
  return
1437
1738
  revision = _next_context_revision(conn, namespace, "heartbeat")
1438
1739
  cursor = conn.execute(
1439
- "UPDATE kv_store SET expires_at = ?, updated_at = ? "
1740
+ "UPDATE kv_store SET expires_at = ?, updated_at = datetime('now'), "
1741
+ "revision = ? "
1440
1742
  "WHERE namespace = ? AND key = 'heartbeat' AND expires_at = ? "
1441
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1743
+ "AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
1442
1744
  "AND deleted_at IS NULL",
1443
- (fresh_exp.isoformat(), str(revision), namespace, row[0]),
1745
+ (fresh_exp.isoformat(), revision, namespace, row[0]),
1444
1746
  )
1445
1747
  if cursor.rowcount:
1446
1748
  conn.commit()
@@ -1463,12 +1765,18 @@ async def context_rehydrate(
1463
1765
  if _compaction_disabled():
1464
1766
  return None
1465
1767
  normalized_session = _normalize_session_id(session_id)
1768
+ errors = _ArgErrors("context_rehydrate")
1769
+ valid_slug = _collect_identifier(errors, slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
1466
1770
  if normalized_session is None:
1467
- raise ValueError("session_id is required and must be non-empty")
1468
- valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
1469
- valid_session = _validate_identifier(
1470
- normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
1471
- )
1771
+ errors.add("session_id", "is required and must be non-empty")
1772
+ errors.example(SESSION_ID_EXAMPLE)
1773
+ valid_session = None
1774
+ else:
1775
+ valid_session = _collect_identifier(
1776
+ errors, normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
1777
+ )
1778
+ _collect_scope_error(errors, scope)
1779
+ errors.raise_if_any()
1472
1780
  conn = _db(scope)
1473
1781
  namespace = f"checkpoint:{valid_slug}:{valid_session}"
1474
1782
  checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
@@ -1497,12 +1805,18 @@ async def context_session_summary(
1497
1805
  if os.environ.get(SESSION_END_SUMMARY_ENV, "").lower() == "off":
1498
1806
  return None
1499
1807
  normalized_session = _normalize_session_id(session_id)
1808
+ errors = _ArgErrors("context_session_summary")
1809
+ valid_slug = _collect_identifier(errors, slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
1500
1810
  if normalized_session is None:
1501
- raise ValueError("session_id is required and must be non-empty")
1502
- valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
1503
- valid_session = _validate_identifier(
1504
- normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
1505
- )
1811
+ errors.add("session_id", "is required and must be non-empty")
1812
+ errors.example(SESSION_ID_EXAMPLE)
1813
+ valid_session = None
1814
+ else:
1815
+ valid_session = _collect_identifier(
1816
+ errors, normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
1817
+ )
1818
+ _collect_scope_error(errors, scope)
1819
+ errors.raise_if_any()
1506
1820
  conn = _db(scope)
1507
1821
  namespace = f"checkpoint:{valid_slug}:{valid_session}"
1508
1822
  checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)