pantheon-opencode 1.5.1 → 1.6.0-beta.1
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 +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +22 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/state.mjs +27 -2
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/sync-tui.mjs +11 -5
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -21
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -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
28
|
from mcp.server.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
|
-
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
|
820
|
-
"""
|
|
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
|
-
|
|
1021
|
+
errors.shape(prefix, "a string", content)
|
|
1022
|
+
return
|
|
823
1023
|
if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
|
|
824
|
-
|
|
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
|
-
|
|
1037
|
+
errors.add(f"{prefix}.tail", "exceeds its size limit")
|
|
837
1038
|
return
|
|
838
1039
|
if not isinstance(parsed, dict):
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
"
|
|
1129
|
+
f"{_REVISION_SELECT} AND key = ?",
|
|
871
1130
|
(namespace, key),
|
|
872
1131
|
).fetchall()
|
|
873
1132
|
else:
|
|
874
1133
|
rows = conn.execute(
|
|
875
|
-
"
|
|
1134
|
+
f"{_REVISION_SELECT} AND key IN (?, ?)",
|
|
876
1135
|
(namespace, key, latest_key),
|
|
877
1136
|
).fetchall()
|
|
878
|
-
current = max((
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
935
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1743
|
+
"AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) "
|
|
1442
1744
|
"AND deleted_at IS NULL",
|
|
1443
|
-
(fresh_exp.isoformat(),
|
|
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
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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)
|