pantheon-opencode 1.4.3 → 1.5.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pantheon/code-mode/README.md +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/manifest.json +21 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +292 -1080
- package/README.pt-BR.md +308 -0
- package/bin/pantheon-init.mjs +207 -31
- package/opencode.json +1 -0
- package/package.json +34 -14
- package/plugin.json +15 -0
- package/pyproject.toml +65 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +451 -44
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +306 -129
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +23 -10
- package/scripts/install/config-migration.mjs +399 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +396 -121
- package/scripts/install/plugin.mjs +89 -49
- package/scripts/install/shared.mjs +55 -159
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +45 -232
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +798 -60
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +135 -42
- package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +27 -16
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +451 -44
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +798 -60
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +110 -2
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +356 -69
- package/src/pantheon/delegate-manager.ts +766 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +406 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1337 -0
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +689 -0
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +148 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +261 -51
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/tui.d.ts +116 -24
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +416 -149
- package/src/plugins/tui/dist/tui.tsx +456 -87
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +462 -95
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -11,11 +11,14 @@ Usage:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
14
16
|
import re
|
|
15
17
|
import sqlite3
|
|
16
18
|
import sys
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from datetime import UTC, datetime, timedelta
|
|
19
22
|
from pathlib import Path
|
|
20
23
|
|
|
21
24
|
from _pantheon_paths import pantheon_home, pantheon_project
|
|
@@ -159,7 +162,7 @@ def _write_deletelog(db_path: Path, count: int, keys: list[str]) -> None:
|
|
|
159
162
|
log_path = db_path.with_name(db_path.name + ".deletelog")
|
|
160
163
|
_rotate_deletelog(log_path)
|
|
161
164
|
|
|
162
|
-
timestamp = datetime.now(
|
|
165
|
+
timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
163
166
|
keys_str = ",".join(keys)
|
|
164
167
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
165
168
|
f.write(f"[{timestamp}] PURGED={count} KEYS=[{keys_str}]\n")
|
|
@@ -217,10 +220,11 @@ async def kv_store(
|
|
|
217
220
|
Returns:
|
|
218
221
|
Status dict with namespace and key.
|
|
219
222
|
"""
|
|
223
|
+
validated_ttl = _validate_ttl(ttl)
|
|
220
224
|
conn = _db(scope)
|
|
221
225
|
expires_at: str | None = None
|
|
222
|
-
if
|
|
223
|
-
expires_at = (datetime.now(
|
|
226
|
+
if validated_ttl is not None:
|
|
227
|
+
expires_at = (datetime.now(UTC) + timedelta(seconds=validated_ttl)).isoformat()
|
|
224
228
|
|
|
225
229
|
conn.execute(
|
|
226
230
|
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
@@ -287,7 +291,9 @@ async def kv_stats(
|
|
|
287
291
|
"""
|
|
288
292
|
conn = _db(scope)
|
|
289
293
|
|
|
290
|
-
total = conn.execute(
|
|
294
|
+
total = conn.execute(
|
|
295
|
+
"SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL"
|
|
296
|
+
).fetchone()[0]
|
|
291
297
|
expired = conn.execute(
|
|
292
298
|
"SELECT COUNT(*) FROM kv_store "
|
|
293
299
|
"WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') "
|
|
@@ -300,10 +306,7 @@ async def kv_stats(
|
|
|
300
306
|
"FROM kv_store WHERE deleted_at IS NULL GROUP BY namespace ORDER BY cnt DESC"
|
|
301
307
|
).fetchall()
|
|
302
308
|
|
|
303
|
-
namespaces = {
|
|
304
|
-
r[0]: {"count": r[1], "expired": r[2]}
|
|
305
|
-
for r in ns_rows
|
|
306
|
-
}
|
|
309
|
+
namespaces = {r[0]: {"count": r[1], "expired": r[2]} for r in ns_rows}
|
|
307
310
|
|
|
308
311
|
db_path = _resolve_db_path(scope)
|
|
309
312
|
db_size = db_path.stat().st_size if db_path and db_path.exists() else 0
|
|
@@ -316,6 +319,7 @@ async def kv_stats(
|
|
|
316
319
|
"db_size_bytes": db_size,
|
|
317
320
|
}
|
|
318
321
|
|
|
322
|
+
|
|
319
323
|
@mcp.tool(
|
|
320
324
|
name="kv_delete",
|
|
321
325
|
description="Delete a key-value pair by namespace and key. "
|
|
@@ -504,8 +508,9 @@ async def purge_expired(
|
|
|
504
508
|
return {"purged": count, "dry_run": False}
|
|
505
509
|
|
|
506
510
|
|
|
507
|
-
|
|
508
|
-
|
|
511
|
+
def _opportunistic_auto_purge(
|
|
512
|
+
conn: sqlite3.Connection, namespace: str, threshold: int = 500
|
|
513
|
+
) -> None:
|
|
509
514
|
"""Lightweight auto-purge: if namespace exceeds threshold, soft-delete expired entries."""
|
|
510
515
|
count = conn.execute(
|
|
511
516
|
"SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
|
|
@@ -562,75 +567,429 @@ async def kv_delete_namespace(
|
|
|
562
567
|
|
|
563
568
|
# ── Context Checkpoint Tools ────────────────────────────────────────────────────
|
|
564
569
|
# Session-scoped context storage for deepwork checkpoints, phase state,
|
|
565
|
-
# tool call history, and reasoning chains.
|
|
566
|
-
# "checkpoint:{slug}"
|
|
570
|
+
# tool call history, and reasoning chains. Session-qualified entries use the
|
|
571
|
+
# namespace "checkpoint:{slug}:{session}"; direct unqualified KV access is
|
|
572
|
+
# retained only for legacy context_get/list/stats callers.
|
|
567
573
|
|
|
568
574
|
DEFAULT_CONTEXT_TTL: int = 14400 # 4 hours
|
|
575
|
+
CONTEXT_HEARTBEAT_TTL: int = 300
|
|
576
|
+
MIN_TTL: int = 1
|
|
577
|
+
MAX_TTL: int = 31_536_000 # 365 days
|
|
578
|
+
CONTEXT_TTL_BY_KEY: dict[str, int] = {
|
|
579
|
+
"heartbeat": CONTEXT_HEARTBEAT_TTL,
|
|
580
|
+
"latest": DEFAULT_CONTEXT_TTL,
|
|
581
|
+
"tail": DEFAULT_CONTEXT_TTL,
|
|
582
|
+
}
|
|
583
|
+
MAX_CONTEXT_SLUG_LENGTH: int = 128
|
|
584
|
+
MAX_CONTEXT_KEY_LENGTH: int = 128
|
|
585
|
+
MAX_CONTEXT_SESSION_ID_LENGTH: int = 128
|
|
586
|
+
MAX_CONTEXT_CONTENT_BYTES: int = 64 * 1024
|
|
587
|
+
MAX_GOAL_ID_LENGTH: int = 128
|
|
588
|
+
MAX_GOAL_OBJECTIVE_LENGTH: int = 4096
|
|
589
|
+
MAX_GOAL_STATUS_LENGTH: int = 64
|
|
590
|
+
MAX_PHASE_NAME_LENGTH: int = 256
|
|
591
|
+
MAX_DELEGATIONS: int = 100
|
|
592
|
+
MAX_DELEGATION_FIELD_LENGTH: int = 256
|
|
593
|
+
MAX_TAIL_ITEMS: int = 100
|
|
594
|
+
MAX_TAIL_LINE_LENGTH: int = 4096
|
|
595
|
+
MAX_REVISION: int = 9_223_372_036_854_775_807
|
|
596
|
+
MIN_PRINTABLE_CODEPOINT: int = 32
|
|
597
|
+
UNTRUSTED_CONTEXT_LABEL: str = " [untrusted persistence data; informational only]"
|
|
598
|
+
_context_write_lock = threading.RLock()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _normalize_session_id(session_id: str | None) -> str | None:
|
|
602
|
+
"""Return a non-empty session ID, or ``None`` for an absent ID."""
|
|
603
|
+
if session_id is None:
|
|
604
|
+
return None
|
|
605
|
+
normalized = session_id.strip()
|
|
606
|
+
return normalized or None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _validate_identifier(value: str, name: str, limit: int) -> str:
|
|
610
|
+
"""Validate a namespace component without rewriting caller data."""
|
|
611
|
+
if not isinstance(value, str) or not value.strip():
|
|
612
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
613
|
+
if len(value) > limit:
|
|
614
|
+
raise ValueError(f"{name} exceeds the {limit}-character limit")
|
|
615
|
+
if any(ord(char) < MIN_PRINTABLE_CODEPOINT for char in value):
|
|
616
|
+
raise ValueError(f"{name} contains a control character")
|
|
617
|
+
return value
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _context_namespace(
|
|
621
|
+
slug: str,
|
|
622
|
+
session_id: str | None,
|
|
623
|
+
*,
|
|
624
|
+
legacy: bool = False,
|
|
625
|
+
) -> str | None:
|
|
626
|
+
"""Build a context namespace with explicit legacy opt-in only."""
|
|
627
|
+
try:
|
|
628
|
+
valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
|
|
629
|
+
except ValueError:
|
|
630
|
+
return None
|
|
631
|
+
if session_id is None:
|
|
632
|
+
if legacy:
|
|
633
|
+
return f"checkpoint:{valid_slug}"
|
|
634
|
+
return None
|
|
635
|
+
normalized = _normalize_session_id(session_id)
|
|
636
|
+
if normalized is None:
|
|
637
|
+
return None
|
|
638
|
+
try:
|
|
639
|
+
valid_session = _validate_identifier(
|
|
640
|
+
normalized, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
|
|
641
|
+
)
|
|
642
|
+
except ValueError:
|
|
643
|
+
return None
|
|
644
|
+
return f"checkpoint:{valid_slug}:{valid_session}"
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _validate_ttl(ttl: object) -> int | None:
|
|
648
|
+
"""Validate an optional TTL before it reaches ``datetime.timedelta``."""
|
|
649
|
+
if ttl is None:
|
|
650
|
+
return None
|
|
651
|
+
if isinstance(ttl, bool) or not isinstance(ttl, int):
|
|
652
|
+
raise ValueError("ttl must be an integer or null")
|
|
653
|
+
if ttl < MIN_TTL or ttl > MAX_TTL:
|
|
654
|
+
raise ValueError(f"ttl must be between {MIN_TTL} and {MAX_TTL} seconds")
|
|
655
|
+
return ttl
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _context_ttl(key: str, ttl: int | None) -> int:
|
|
659
|
+
"""Return a validated TTL or the explicit policy for a checkpoint key."""
|
|
660
|
+
validated_ttl = _validate_ttl(ttl)
|
|
661
|
+
return (
|
|
662
|
+
validated_ttl
|
|
663
|
+
if validated_ttl is not None
|
|
664
|
+
else CONTEXT_TTL_BY_KEY.get(key, DEFAULT_CONTEXT_TTL)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _validate_revision(revision: int | None) -> int | None:
|
|
669
|
+
"""Validate an optional caller revision used for stale-write rejection."""
|
|
670
|
+
if revision is None:
|
|
671
|
+
return None
|
|
672
|
+
if isinstance(revision, bool) or not isinstance(revision, int):
|
|
673
|
+
raise ValueError("revision must be an integer")
|
|
674
|
+
if revision < 1 or revision > MAX_REVISION:
|
|
675
|
+
raise ValueError(f"revision must be between 1 and {MAX_REVISION}")
|
|
676
|
+
return revision
|
|
677
|
+
|
|
678
|
+
|
|
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
|
+
def _should_update_latest(key: str) -> bool:
|
|
691
|
+
"""Keep operational metadata from clobbering the recovery checkpoint."""
|
|
692
|
+
return key not in {"heartbeat", "tail"}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _validate_bounded_text(value: object, field: str, limit: int) -> None:
|
|
696
|
+
"""Validate a string field without changing its contents."""
|
|
697
|
+
if not isinstance(value, str) or len(value.encode()) > limit:
|
|
698
|
+
raise ValueError(f"{field} exceeds its size limit")
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _validate_goal(goal: object) -> None:
|
|
702
|
+
"""Validate the bounded goal section of a checkpoint."""
|
|
703
|
+
if not isinstance(goal, dict):
|
|
704
|
+
raise ValueError("goal must be an object")
|
|
705
|
+
for field, limit in (
|
|
706
|
+
("id", MAX_GOAL_ID_LENGTH),
|
|
707
|
+
("objective", MAX_GOAL_OBJECTIVE_LENGTH),
|
|
708
|
+
("status", MAX_GOAL_STATUS_LENGTH),
|
|
709
|
+
):
|
|
710
|
+
value = goal.get(field)
|
|
711
|
+
if value is not None:
|
|
712
|
+
_validate_bounded_text(value, f"goal.{field}", limit)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _validate_phase(phase: object) -> None:
|
|
716
|
+
"""Validate the bounded phase section of a checkpoint."""
|
|
717
|
+
if not isinstance(phase, dict):
|
|
718
|
+
raise ValueError("phase must be an object")
|
|
719
|
+
for field in ("current", "total"):
|
|
720
|
+
value = phase.get(field)
|
|
721
|
+
if value is not None and (
|
|
722
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
723
|
+
):
|
|
724
|
+
raise ValueError(f"phase.{field} must be a non-negative integer")
|
|
725
|
+
name = phase.get("name")
|
|
726
|
+
if name is not None:
|
|
727
|
+
_validate_bounded_text(name, "phase.name", MAX_PHASE_NAME_LENGTH)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _validate_delegations(delegations: object) -> None:
|
|
731
|
+
"""Validate bounded in-flight delegation records."""
|
|
732
|
+
if not isinstance(delegations, dict):
|
|
733
|
+
raise ValueError("delegations must be an object")
|
|
734
|
+
in_flight = delegations.get("in_flight")
|
|
735
|
+
if in_flight is None:
|
|
736
|
+
return
|
|
737
|
+
if not isinstance(in_flight, list) or len(in_flight) > MAX_DELEGATIONS:
|
|
738
|
+
raise ValueError("delegations.in_flight exceeds its size limit")
|
|
739
|
+
for job in in_flight:
|
|
740
|
+
if not isinstance(job, dict):
|
|
741
|
+
raise ValueError("delegations.in_flight entries must be objects")
|
|
742
|
+
for field in ("alias", "agent", "task_id"):
|
|
743
|
+
value = job.get(field)
|
|
744
|
+
if value is not None:
|
|
745
|
+
_validate_bounded_text(
|
|
746
|
+
value, f"delegations.in_flight.{field}", MAX_DELEGATION_FIELD_LENGTH
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _validate_tail(tail: object, payload_bytes: int | None = None) -> list[str]:
|
|
751
|
+
"""Validate bounded serialized tail lines."""
|
|
752
|
+
if not isinstance(tail, list) or len(tail) > MAX_TAIL_ITEMS:
|
|
753
|
+
raise ValueError("tail exceeds its size limit")
|
|
754
|
+
for line in tail:
|
|
755
|
+
if not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH:
|
|
756
|
+
raise ValueError("tail entries exceed their size limit")
|
|
757
|
+
if payload_bytes is not None and payload_bytes > MAX_CONTEXT_CONTENT_BYTES:
|
|
758
|
+
raise ValueError("tail payload exceeds its size limit")
|
|
759
|
+
if sum(len(line.encode()) for line in tail) > MAX_CONTEXT_CONTENT_BYTES:
|
|
760
|
+
raise ValueError("tail payload exceeds its size limit")
|
|
761
|
+
return tail
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _validate_heartbeat(heartbeat: object) -> None:
|
|
765
|
+
"""Validate bounded heartbeat metadata."""
|
|
766
|
+
if not isinstance(heartbeat, dict):
|
|
767
|
+
raise ValueError("heartbeat must be an object")
|
|
768
|
+
status = heartbeat.get("status")
|
|
769
|
+
if status is not None:
|
|
770
|
+
_validate_bounded_text(status, "heartbeat.status", MAX_GOAL_STATUS_LENGTH)
|
|
771
|
+
turn_count = heartbeat.get("turn_count")
|
|
772
|
+
if turn_count is not None and (
|
|
773
|
+
isinstance(turn_count, bool)
|
|
774
|
+
or not isinstance(turn_count, int)
|
|
775
|
+
or turn_count < 0
|
|
776
|
+
):
|
|
777
|
+
raise ValueError("heartbeat.turn_count must be a non-negative integer")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _validate_version_metadata(version: object) -> None:
|
|
781
|
+
"""Validate optional checkpoint ``version`` metadata leniently.
|
|
782
|
+
|
|
783
|
+
``version`` is opaque application metadata (rehydrate does not consume it),
|
|
784
|
+
not a persistence requirement. It may be a positive integer, a JSON float
|
|
785
|
+
such as ``1.0``, or a version label like ``"1.5.0-beta.2"``. Absent/null
|
|
786
|
+
versions and any non-empty string remain valid, preserving existing integer
|
|
787
|
+
checkpoints while allowing forward-compatible labels. Only bools, empty
|
|
788
|
+
strings, non-finite/zero/negative numbers, and structured values are
|
|
789
|
+
rejected.
|
|
790
|
+
"""
|
|
791
|
+
if version is None:
|
|
792
|
+
return
|
|
793
|
+
if isinstance(version, bool):
|
|
794
|
+
raise ValueError("version must be a positive integer or version label")
|
|
795
|
+
if isinstance(version, (int, float)):
|
|
796
|
+
if not 1 <= version < float("inf"):
|
|
797
|
+
raise ValueError("version must be a positive integer or version label")
|
|
798
|
+
return
|
|
799
|
+
if not isinstance(version, str) or not version.strip():
|
|
800
|
+
raise ValueError("version must be a positive integer or version label")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _validate_checkpoint_shape(checkpoint: dict) -> None:
|
|
804
|
+
"""Validate bounded structured checkpoint fields without sanitizing them."""
|
|
805
|
+
_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:
|
|
814
|
+
value = checkpoint.get(field)
|
|
815
|
+
if value is not None:
|
|
816
|
+
validator(value)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _validate_context_content(content: str, key: str) -> None:
|
|
820
|
+
"""Bound context content and validate structured JSON when supplied."""
|
|
821
|
+
if not isinstance(content, str):
|
|
822
|
+
raise ValueError("content must be a string")
|
|
823
|
+
if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
|
|
824
|
+
raise ValueError(f"content exceeds the {MAX_CONTEXT_CONTENT_BYTES}-byte limit")
|
|
825
|
+
try:
|
|
826
|
+
parsed = json.loads(content)
|
|
827
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
828
|
+
# Raw text remains a compatibility format, but rehydrate never treats
|
|
829
|
+
# it as a checkpoint. It is still bounded above.
|
|
830
|
+
return
|
|
831
|
+
if isinstance(parsed, list) and key == "tail":
|
|
832
|
+
if len(parsed) > MAX_TAIL_ITEMS or any(
|
|
833
|
+
not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH
|
|
834
|
+
for line in parsed
|
|
835
|
+
):
|
|
836
|
+
raise ValueError("tail exceeds its size limit")
|
|
837
|
+
return
|
|
838
|
+
if not isinstance(parsed, dict):
|
|
839
|
+
raise ValueError("structured context content must be a JSON object")
|
|
840
|
+
_validate_checkpoint_shape(parsed)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _safe_untrusted_text(value: object) -> str:
|
|
844
|
+
"""Escape control/markup delimiters while preserving untrusted content."""
|
|
845
|
+
text = str(value)
|
|
846
|
+
return (
|
|
847
|
+
text.replace("<", r"\u003c")
|
|
848
|
+
.replace(">", r"\u003e")
|
|
849
|
+
.replace("\r", r"\r")
|
|
850
|
+
.replace("\n", r"\n")
|
|
851
|
+
.replace("\x00", r"\u0000")
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _stored_revision(raw: object) -> int:
|
|
856
|
+
"""Parse a context revision; legacy datetime values are revision zero."""
|
|
857
|
+
try:
|
|
858
|
+
revision = int(str(raw))
|
|
859
|
+
except (TypeError, ValueError):
|
|
860
|
+
return 0
|
|
861
|
+
return revision if 0 <= revision <= MAX_REVISION else 0
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _next_context_revision(
|
|
865
|
+
conn: sqlite3.Connection, namespace: str, key: str, latest_key: str | None = None
|
|
866
|
+
) -> int:
|
|
867
|
+
"""Choose a persisted monotonic revision while the write lock is held."""
|
|
868
|
+
if latest_key is None:
|
|
869
|
+
rows = conn.execute(
|
|
870
|
+
"SELECT updated_at FROM kv_store WHERE namespace = ? AND key = ?",
|
|
871
|
+
(namespace, key),
|
|
872
|
+
).fetchall()
|
|
873
|
+
else:
|
|
874
|
+
rows = conn.execute(
|
|
875
|
+
"SELECT updated_at FROM kv_store WHERE namespace = ? AND key IN (?, ?)",
|
|
876
|
+
(namespace, key, latest_key),
|
|
877
|
+
).fetchall()
|
|
878
|
+
current = max((_stored_revision(row[0]) for row in rows), default=0)
|
|
879
|
+
return min(MAX_REVISION, max(time.time_ns(), current + 1))
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _upsert_context_entry(
|
|
883
|
+
conn: sqlite3.Connection,
|
|
884
|
+
namespace: str,
|
|
885
|
+
key: str,
|
|
886
|
+
content: str,
|
|
887
|
+
expires_at: str,
|
|
888
|
+
revision: int,
|
|
889
|
+
) -> None:
|
|
890
|
+
"""Upsert one context row inside the caller's transaction."""
|
|
891
|
+
conn.execute(
|
|
892
|
+
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
893
|
+
"created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), ?)"
|
|
894
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
895
|
+
" value = excluded.value, "
|
|
896
|
+
" expires_at = excluded.expires_at, "
|
|
897
|
+
" updated_at = excluded.updated_at",
|
|
898
|
+
(namespace, key, content, expires_at, str(revision)),
|
|
899
|
+
)
|
|
569
900
|
|
|
570
901
|
|
|
571
902
|
@mcp.tool(
|
|
572
903
|
name="context_save",
|
|
573
904
|
description="Save a context checkpoint for a session/phase. "
|
|
574
|
-
"Stores structured JSON in persistence KV with
|
|
905
|
+
"Stores structured JSON in persistence KV with a key-specific TTL.",
|
|
575
906
|
)
|
|
576
907
|
async def context_save(
|
|
577
908
|
slug: str,
|
|
578
909
|
key: str,
|
|
579
910
|
content: str,
|
|
580
|
-
session_id: str
|
|
911
|
+
session_id: str,
|
|
581
912
|
ttl: int | None = None,
|
|
582
913
|
scope: str = "project",
|
|
914
|
+
revision: int | None = None,
|
|
583
915
|
) -> dict:
|
|
584
916
|
"""Save a context checkpoint with session-level isolation.
|
|
585
917
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
918
|
+
Namespace is always "checkpoint:{slug}:{session_id}", preventing
|
|
919
|
+
cross-session reads. The session_id must be supplied by the autonomy
|
|
920
|
+
caller; this function never generates one.
|
|
589
921
|
|
|
590
922
|
Args:
|
|
591
923
|
slug: Session identifier (e.g. "auth-refactor").
|
|
592
924
|
key: Checkpoint key (e.g. "phase:3", "latest", "heartbeat").
|
|
593
925
|
content: JSON-serializable string or any text content.
|
|
594
|
-
session_id:
|
|
595
|
-
ttl: TTL in seconds (
|
|
926
|
+
session_id: Required unique session ID for namespace isolation.
|
|
927
|
+
ttl: TTL in seconds (defaults to the key-specific context policy).
|
|
596
928
|
scope: 'project' (default) or 'global'.
|
|
929
|
+
revision: Optional monotonic revision. Older/equal revisions are no-ops.
|
|
597
930
|
|
|
598
931
|
Returns:
|
|
599
932
|
Status dict with namespace, key, and session_id for subsequent calls.
|
|
600
933
|
"""
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
934
|
+
valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
|
|
935
|
+
valid_key = _validate_identifier(key, "key", MAX_CONTEXT_KEY_LENGTH)
|
|
936
|
+
actual_session = _normalize_session_id(session_id)
|
|
937
|
+
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)
|
|
944
|
+
ns = f"checkpoint:{valid_slug}:{valid_session}"
|
|
945
|
+
actual_ttl = _context_ttl(valid_key, ttl)
|
|
604
946
|
|
|
605
947
|
conn = _db(scope)
|
|
606
|
-
expires_at = (datetime.now(
|
|
607
|
-
|
|
608
|
-
conn.execute(
|
|
609
|
-
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
610
|
-
"created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))"
|
|
611
|
-
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
612
|
-
" value = excluded.value, "
|
|
613
|
-
" expires_at = excluded.expires_at, "
|
|
614
|
-
" updated_at = datetime('now')",
|
|
615
|
-
(ns, key, content, expires_at),
|
|
616
|
-
)
|
|
617
|
-
conn.commit()
|
|
948
|
+
expires_at = (datetime.now(UTC) + timedelta(seconds=actual_ttl)).isoformat()
|
|
618
949
|
|
|
619
|
-
#
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
"
|
|
625
|
-
"
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
(
|
|
630
|
-
|
|
631
|
-
|
|
950
|
+
# BEGIN IMMEDIATE serializes writers in this process and across SQLite
|
|
951
|
+
# connections. The checkpoint and raw-content latest pointer commit as one
|
|
952
|
+
# unit; a crash or failed second write therefore cannot expose half a save.
|
|
953
|
+
with _context_write_lock:
|
|
954
|
+
try:
|
|
955
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
956
|
+
latest_key = "latest" if _should_update_latest(valid_key) else None
|
|
957
|
+
actual_revision = requested_revision or _next_context_revision(
|
|
958
|
+
conn, ns, valid_key, latest_key
|
|
959
|
+
)
|
|
960
|
+
current_revision = _current_context_revision(conn, ns, valid_key)
|
|
961
|
+
if actual_revision <= current_revision:
|
|
962
|
+
conn.rollback()
|
|
963
|
+
return {
|
|
964
|
+
"status": "stale",
|
|
965
|
+
"namespace": ns,
|
|
966
|
+
"key": valid_key,
|
|
967
|
+
"session_id": valid_session,
|
|
968
|
+
"ttl": actual_ttl,
|
|
969
|
+
"revision": current_revision,
|
|
970
|
+
}
|
|
971
|
+
_upsert_context_entry(
|
|
972
|
+
conn, ns, valid_key, content, expires_at, actual_revision
|
|
973
|
+
)
|
|
974
|
+
if latest_key is not None and actual_revision > _current_context_revision(
|
|
975
|
+
conn, ns, latest_key
|
|
976
|
+
):
|
|
977
|
+
_upsert_context_entry(
|
|
978
|
+
conn, ns, latest_key, content, expires_at, actual_revision
|
|
979
|
+
)
|
|
980
|
+
conn.commit()
|
|
981
|
+
except Exception:
|
|
982
|
+
conn.rollback()
|
|
983
|
+
raise
|
|
632
984
|
|
|
633
|
-
return {
|
|
985
|
+
return {
|
|
986
|
+
"status": "stored",
|
|
987
|
+
"namespace": ns,
|
|
988
|
+
"key": valid_key,
|
|
989
|
+
"session_id": valid_session,
|
|
990
|
+
"ttl": actual_ttl,
|
|
991
|
+
"revision": actual_revision,
|
|
992
|
+
}
|
|
634
993
|
|
|
635
994
|
|
|
636
995
|
@mcp.tool(
|
|
@@ -642,6 +1001,7 @@ async def context_get(
|
|
|
642
1001
|
slug: str,
|
|
643
1002
|
key: str = "latest",
|
|
644
1003
|
session_id: str | None = None,
|
|
1004
|
+
legacy: bool = False,
|
|
645
1005
|
scope: str = "project",
|
|
646
1006
|
) -> str | None:
|
|
647
1007
|
"""Get a context checkpoint with session isolation.
|
|
@@ -649,14 +1009,22 @@ async def context_get(
|
|
|
649
1009
|
Args:
|
|
650
1010
|
slug: Session identifier.
|
|
651
1011
|
key: Checkpoint key (default "latest").
|
|
652
|
-
session_id: Required for
|
|
1012
|
+
session_id: Required for session-scoped reads.
|
|
1013
|
+
legacy: Explicitly opt into the unqualified legacy namespace when no
|
|
1014
|
+
session_id is available.
|
|
653
1015
|
scope: 'project' (default) or 'global'.
|
|
654
1016
|
|
|
655
1017
|
Returns:
|
|
656
1018
|
Content string or None if not found/expired.
|
|
657
1019
|
"""
|
|
658
1020
|
conn = _db(scope)
|
|
659
|
-
|
|
1021
|
+
try:
|
|
1022
|
+
_validate_identifier(key, "key", MAX_CONTEXT_KEY_LENGTH)
|
|
1023
|
+
except ValueError:
|
|
1024
|
+
return None
|
|
1025
|
+
ns = _context_namespace(slug, session_id, legacy=legacy)
|
|
1026
|
+
if ns is None:
|
|
1027
|
+
return None
|
|
660
1028
|
row = conn.execute(
|
|
661
1029
|
"SELECT value FROM kv_store "
|
|
662
1030
|
"WHERE namespace = ? AND key = ? "
|
|
@@ -675,20 +1043,25 @@ async def context_get(
|
|
|
675
1043
|
async def context_list(
|
|
676
1044
|
slug: str,
|
|
677
1045
|
session_id: str | None = None,
|
|
1046
|
+
legacy: bool = False,
|
|
678
1047
|
scope: str = "project",
|
|
679
1048
|
) -> list[dict]:
|
|
680
1049
|
"""List all checkpoints for a session.
|
|
681
1050
|
|
|
682
1051
|
Args:
|
|
683
1052
|
slug: Session identifier.
|
|
684
|
-
session_id:
|
|
1053
|
+
session_id: Required for session-scoped reads.
|
|
1054
|
+
legacy: Explicitly opt into the unqualified legacy namespace when no
|
|
1055
|
+
session_id is available.
|
|
685
1056
|
scope: 'project' (default) or 'global'.
|
|
686
1057
|
|
|
687
1058
|
Returns:
|
|
688
1059
|
List of dicts with key, created_at, expires_at.
|
|
689
1060
|
"""
|
|
690
1061
|
conn = _db(scope)
|
|
691
|
-
ns =
|
|
1062
|
+
ns = _context_namespace(slug, session_id, legacy=legacy)
|
|
1063
|
+
if ns is None:
|
|
1064
|
+
return []
|
|
692
1065
|
rows = conn.execute(
|
|
693
1066
|
"SELECT key, created_at, expires_at FROM kv_store "
|
|
694
1067
|
"WHERE namespace = ? "
|
|
@@ -715,37 +1088,52 @@ async def context_list(
|
|
|
715
1088
|
async def context_stats(
|
|
716
1089
|
slug: str,
|
|
717
1090
|
session_id: str | None = None,
|
|
1091
|
+
legacy: bool = False,
|
|
718
1092
|
scope: str = "project",
|
|
719
1093
|
) -> dict:
|
|
720
1094
|
"""Return storage statistics for a session's context.
|
|
721
1095
|
|
|
722
1096
|
Args:
|
|
723
1097
|
slug: Session identifier.
|
|
724
|
-
session_id:
|
|
1098
|
+
session_id: Required for session-scoped reads.
|
|
1099
|
+
legacy: Explicitly opt into the unqualified legacy namespace when no
|
|
1100
|
+
session_id is available.
|
|
725
1101
|
scope: 'project' (default) or 'global'.
|
|
726
1102
|
|
|
727
1103
|
Returns:
|
|
728
1104
|
Dict with entry_count, total_bytes, ttl_remaining.
|
|
729
1105
|
"""
|
|
730
1106
|
conn = _db(scope)
|
|
731
|
-
ns =
|
|
1107
|
+
ns = _context_namespace(slug, session_id, legacy=legacy)
|
|
1108
|
+
if ns is None:
|
|
1109
|
+
return {
|
|
1110
|
+
"slug": slug,
|
|
1111
|
+
"namespace": None,
|
|
1112
|
+
"entry_count": 0,
|
|
1113
|
+
"expired_entries": 0,
|
|
1114
|
+
"total_bytes": 0,
|
|
1115
|
+
"ttl_remaining_seconds": None,
|
|
1116
|
+
}
|
|
732
1117
|
|
|
733
1118
|
count = conn.execute(
|
|
734
1119
|
"SELECT COUNT(*) FROM kv_store "
|
|
735
|
-
"WHERE namespace = ? AND deleted_at IS NULL"
|
|
1120
|
+
"WHERE namespace = ? AND deleted_at IS NULL "
|
|
1121
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
|
|
736
1122
|
(ns,),
|
|
737
1123
|
).fetchone()[0]
|
|
738
1124
|
|
|
739
1125
|
expired = conn.execute(
|
|
740
1126
|
"SELECT COUNT(*) FROM kv_store "
|
|
741
|
-
"WHERE namespace = ? AND
|
|
1127
|
+
"WHERE namespace = ? AND expires_at IS NOT NULL "
|
|
1128
|
+
"AND datetime(expires_at) <= datetime('now') "
|
|
742
1129
|
"AND deleted_at IS NULL",
|
|
743
1130
|
(ns,),
|
|
744
1131
|
).fetchone()[0]
|
|
745
1132
|
|
|
746
1133
|
size = conn.execute(
|
|
747
1134
|
"SELECT COALESCE(SUM(LENGTH(value)), 0) FROM kv_store "
|
|
748
|
-
"WHERE namespace = ? AND deleted_at IS NULL"
|
|
1135
|
+
"WHERE namespace = ? AND deleted_at IS NULL "
|
|
1136
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
|
|
749
1137
|
(ns,),
|
|
750
1138
|
).fetchone()[0]
|
|
751
1139
|
|
|
@@ -759,7 +1147,7 @@ async def context_stats(
|
|
|
759
1147
|
if row and row[0]:
|
|
760
1148
|
try:
|
|
761
1149
|
exp = datetime.fromisoformat(row[0])
|
|
762
|
-
now = datetime.now(
|
|
1150
|
+
now = datetime.now(UTC)
|
|
763
1151
|
remaining = max(0, int((exp - now).total_seconds()))
|
|
764
1152
|
except (ValueError, TypeError):
|
|
765
1153
|
pass
|
|
@@ -773,6 +1161,356 @@ async def context_stats(
|
|
|
773
1161
|
"ttl_remaining_seconds": remaining,
|
|
774
1162
|
}
|
|
775
1163
|
|
|
1164
|
+
|
|
1165
|
+
# ── Post-Compaction Injector (WS2 PR #94) ─────────────────────────────────────
|
|
1166
|
+
# Deterministic rehydration after a native compaction event: a long session
|
|
1167
|
+
# reads ``latest`` + serialized ``tail`` from
|
|
1168
|
+
# ``checkpoint:<slug>:<session_id>`` and
|
|
1169
|
+
# rebuilds its critical context (active goal, in-flight delegations,
|
|
1170
|
+
# current phase) WITHOUT any LLM call or generative embedding.
|
|
1171
|
+
#
|
|
1172
|
+
# Checkpoint JSON convention (written by Zeus via context_save):
|
|
1173
|
+
# {"version": 1,
|
|
1174
|
+
# "goal": {"id": ..., "objective": ..., "status": ...},
|
|
1175
|
+
# "phase": {"current": ..., "total": ..., "name": ...},
|
|
1176
|
+
# "delegations": {"in_flight": [{"alias": ..., "agent": ..., "task_id": ...}]},
|
|
1177
|
+
# "tail": ["phase:1 ...", ...], # serialized last-N phase digests
|
|
1178
|
+
# "heartbeat": {...}}
|
|
1179
|
+
# ``tail`` may also live under a separate ``tail`` key (JSON array).
|
|
1180
|
+
#
|
|
1181
|
+
# Guarantees: required session_id, kill-switch PANTHEON_COMPACTION=off,
|
|
1182
|
+
# TTL-respecting reads, no sibling fallback, conditional heartbeat TTL refresh,
|
|
1183
|
+
# and idempotent recovery (no checkpoint rows are added or duplicated).
|
|
1184
|
+
|
|
1185
|
+
COMPACTION_KILL_SWITCH_ENV: str = "PANTHEON_COMPACTION"
|
|
1186
|
+
SESSION_END_SUMMARY_ENV: str = "PANTHEON_SESSION_END_SUMMARY"
|
|
1187
|
+
REHYDRATE_TAIL_CAP: int = 10
|
|
1188
|
+
SUMMARY_TAIL_CAP: int = 5
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _compaction_disabled() -> bool:
|
|
1192
|
+
"""True when the PANTHEON_COMPACTION kill-switch is off."""
|
|
1193
|
+
return os.environ.get(COMPACTION_KILL_SWITCH_ENV, "").lower() == "off"
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _read_checkpoint_value(
|
|
1197
|
+
conn: sqlite3.Connection, namespace: str, key: str
|
|
1198
|
+
) -> str | None:
|
|
1199
|
+
"""Read one unexpired, non-deleted checkpoint value (None when absent)."""
|
|
1200
|
+
row = conn.execute(
|
|
1201
|
+
"SELECT value FROM kv_store "
|
|
1202
|
+
"WHERE namespace = ? AND key = ? "
|
|
1203
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
|
|
1204
|
+
"AND deleted_at IS NULL",
|
|
1205
|
+
(namespace, key),
|
|
1206
|
+
).fetchone()
|
|
1207
|
+
return row[0] if row else None
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _parse_checkpoint(content: str) -> dict | None:
|
|
1211
|
+
"""Parse and validate one checkpoint; invalid JSON fails closed."""
|
|
1212
|
+
if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
|
|
1213
|
+
return None
|
|
1214
|
+
try:
|
|
1215
|
+
data = json.loads(content)
|
|
1216
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
1217
|
+
return None
|
|
1218
|
+
if not isinstance(data, dict):
|
|
1219
|
+
return None
|
|
1220
|
+
try:
|
|
1221
|
+
_validate_checkpoint_shape(data)
|
|
1222
|
+
except ValueError:
|
|
1223
|
+
return None
|
|
1224
|
+
return data
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def _checkpoint_tail(checkpoint: dict, tail_raw: str | None) -> list[str]:
|
|
1228
|
+
"""Tail lines from embedded ``tail`` with fallback to the ``tail`` key."""
|
|
1229
|
+
tail = checkpoint.get("tail")
|
|
1230
|
+
if isinstance(tail, list):
|
|
1231
|
+
try:
|
|
1232
|
+
lines = _validate_tail(tail)
|
|
1233
|
+
except ValueError:
|
|
1234
|
+
return []
|
|
1235
|
+
elif isinstance(tail_raw, str):
|
|
1236
|
+
if len(tail_raw.encode()) > MAX_CONTEXT_CONTENT_BYTES:
|
|
1237
|
+
return []
|
|
1238
|
+
try:
|
|
1239
|
+
parsed = json.loads(tail_raw)
|
|
1240
|
+
lines = _validate_tail(parsed, payload_bytes=len(tail_raw.encode()))
|
|
1241
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
1242
|
+
return []
|
|
1243
|
+
else:
|
|
1244
|
+
lines = []
|
|
1245
|
+
return [line for line in lines if line.strip()][:REHYDRATE_TAIL_CAP]
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _mission_block(checkpoint: dict) -> str | None:
|
|
1249
|
+
"""<mission_context> for a non-done goal (done goals stay buried)."""
|
|
1250
|
+
goal = checkpoint.get("goal")
|
|
1251
|
+
if not isinstance(goal, dict):
|
|
1252
|
+
return None
|
|
1253
|
+
status = str(goal.get("status", ""))
|
|
1254
|
+
objective = str(goal.get("objective", "")).strip()
|
|
1255
|
+
if status == "done" or not objective:
|
|
1256
|
+
return None
|
|
1257
|
+
gid = str(goal.get("id", "goal")).strip() or "goal"
|
|
1258
|
+
return (
|
|
1259
|
+
f"<mission_context>\n{UNTRUSTED_CONTEXT_LABEL}\n"
|
|
1260
|
+
f" [{_safe_untrusted_text(gid)}] {_safe_untrusted_text(objective)}"
|
|
1261
|
+
f" — {_safe_untrusted_text(status)}"
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _phase_block(checkpoint: dict) -> str | None:
|
|
1266
|
+
"""<phase_context> for the current phase (omitted when unknown)."""
|
|
1267
|
+
phase = checkpoint.get("phase")
|
|
1268
|
+
if not isinstance(phase, dict) or phase.get("current") is None:
|
|
1269
|
+
return None
|
|
1270
|
+
total = phase.get("total")
|
|
1271
|
+
current = phase.get("current")
|
|
1272
|
+
span = f"{current}/{total}" if total is not None else str(current)
|
|
1273
|
+
name = str(phase.get("name", "")).strip()
|
|
1274
|
+
line = f" phase {_safe_untrusted_text(span)}"
|
|
1275
|
+
if name:
|
|
1276
|
+
line += f" — {_safe_untrusted_text(name)}"
|
|
1277
|
+
return f"<phase_context>\n{UNTRUSTED_CONTEXT_LABEL}\n{line}"
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _delegation_block(checkpoint: dict) -> str | None:
|
|
1281
|
+
"""<delegation_context> for in-flight delegations (never capped)."""
|
|
1282
|
+
delegations = checkpoint.get("delegations")
|
|
1283
|
+
if not isinstance(delegations, dict):
|
|
1284
|
+
return None
|
|
1285
|
+
in_flight = delegations.get("in_flight")
|
|
1286
|
+
if not isinstance(in_flight, list):
|
|
1287
|
+
return None
|
|
1288
|
+
lines = []
|
|
1289
|
+
for job in in_flight:
|
|
1290
|
+
if not isinstance(job, dict):
|
|
1291
|
+
continue
|
|
1292
|
+
alias = str(job.get("alias") or job.get("task_id") or "?").strip()
|
|
1293
|
+
agent = str(job.get("agent", "?")).strip()
|
|
1294
|
+
task_id = str(job.get("task_id", "")).strip()
|
|
1295
|
+
detail = (
|
|
1296
|
+
f" — {_safe_untrusted_text(task_id)}"
|
|
1297
|
+
if task_id and task_id != alias
|
|
1298
|
+
else ""
|
|
1299
|
+
)
|
|
1300
|
+
lines.append(
|
|
1301
|
+
f" [{_safe_untrusted_text(alias)}] {_safe_untrusted_text(agent)}"
|
|
1302
|
+
f"{detail} [in-flight]"
|
|
1303
|
+
)
|
|
1304
|
+
if not lines:
|
|
1305
|
+
return None
|
|
1306
|
+
return "<delegation_context>\n" + UNTRUSTED_CONTEXT_LABEL + "\n" + "\n".join(lines)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _tail_block(checkpoint: dict) -> str | None:
|
|
1310
|
+
"""<tail_context> for the serialized tail (already capped on load)."""
|
|
1311
|
+
tail_lines = checkpoint.get("_tail_raw")
|
|
1312
|
+
try:
|
|
1313
|
+
validated_lines = _validate_tail(tail_lines)
|
|
1314
|
+
except ValueError:
|
|
1315
|
+
return None
|
|
1316
|
+
if not validated_lines:
|
|
1317
|
+
return None
|
|
1318
|
+
quoted = "\n".join(f" - {_safe_untrusted_text(line)}" for line in validated_lines)
|
|
1319
|
+
return f"<tail_context>\n{UNTRUSTED_CONTEXT_LABEL}\n{quoted}"
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def build_rehydration_blocks(checkpoint: dict) -> list[str] | None:
|
|
1323
|
+
"""Build deterministic rehydration blocks from a checkpoint dict.
|
|
1324
|
+
|
|
1325
|
+
Pure string templating — no LLM, no embeddings. Mirrors the
|
|
1326
|
+
``<mission_context>`` convention of delegation-compaction.ts: only a
|
|
1327
|
+
non-done goal is re-emitted. Returns None when nothing critical remains.
|
|
1328
|
+
"""
|
|
1329
|
+
if not isinstance(checkpoint, dict) or not checkpoint:
|
|
1330
|
+
return None
|
|
1331
|
+
blocks = [
|
|
1332
|
+
block
|
|
1333
|
+
for block in (
|
|
1334
|
+
_mission_block(checkpoint),
|
|
1335
|
+
_phase_block(checkpoint),
|
|
1336
|
+
_delegation_block(checkpoint),
|
|
1337
|
+
_tail_block(checkpoint),
|
|
1338
|
+
)
|
|
1339
|
+
if block is not None
|
|
1340
|
+
]
|
|
1341
|
+
return blocks if blocks else None
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def build_session_end_summary(checkpoint: dict) -> str | None:
|
|
1345
|
+
"""Build the Tier 2 session-end summary deterministically (no LLM).
|
|
1346
|
+
|
|
1347
|
+
Fires automatically at session end — no Themis approval gate. Returns
|
|
1348
|
+
None when the checkpoint carries nothing worth compressing.
|
|
1349
|
+
"""
|
|
1350
|
+
if not isinstance(checkpoint, dict) or not checkpoint:
|
|
1351
|
+
return None
|
|
1352
|
+
parts: list[str] = []
|
|
1353
|
+
|
|
1354
|
+
goal = checkpoint.get("goal")
|
|
1355
|
+
if isinstance(goal, dict):
|
|
1356
|
+
objective = str(goal.get("objective", "")).strip()
|
|
1357
|
+
if objective:
|
|
1358
|
+
parts.append(
|
|
1359
|
+
f"Goal: {_safe_untrusted_text(objective)} "
|
|
1360
|
+
f"({_safe_untrusted_text(goal.get('status', '?'))})"
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
phase = checkpoint.get("phase")
|
|
1364
|
+
if isinstance(phase, dict) and phase.get("current") is not None:
|
|
1365
|
+
total = phase.get("total")
|
|
1366
|
+
span = (
|
|
1367
|
+
f"{phase.get('current')}/{total}"
|
|
1368
|
+
if total is not None
|
|
1369
|
+
else phase.get("current")
|
|
1370
|
+
)
|
|
1371
|
+
name = str(phase.get("name", "")).strip()
|
|
1372
|
+
parts.append(
|
|
1373
|
+
f"Phase: {_safe_untrusted_text(span)}"
|
|
1374
|
+
+ (f" {_safe_untrusted_text(name)}" if name else "")
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
delegations = checkpoint.get("delegations")
|
|
1378
|
+
if isinstance(delegations, dict):
|
|
1379
|
+
in_flight = delegations.get("in_flight")
|
|
1380
|
+
if isinstance(in_flight, list):
|
|
1381
|
+
aliases = [
|
|
1382
|
+
_safe_untrusted_text(str(job.get("alias") or job.get("task_id") or "?"))
|
|
1383
|
+
for job in in_flight
|
|
1384
|
+
if isinstance(job, dict)
|
|
1385
|
+
]
|
|
1386
|
+
if aliases:
|
|
1387
|
+
parts.append(f"In-flight: {', '.join(aliases)}")
|
|
1388
|
+
|
|
1389
|
+
tail_raw = checkpoint.get("_tail_raw")
|
|
1390
|
+
if isinstance(tail_raw, list) and tail_raw:
|
|
1391
|
+
kept = [_safe_untrusted_text(str(line)) for line in tail_raw[:SUMMARY_TAIL_CAP]]
|
|
1392
|
+
parts.append("Tail: " + " | ".join(kept))
|
|
1393
|
+
|
|
1394
|
+
if not parts:
|
|
1395
|
+
return None
|
|
1396
|
+
return "# Session summary (Tier 2 auto — no approval gate)\n" + "\n".join(
|
|
1397
|
+
f"- {part}" for part in parts
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _load_checkpoint_for_rehydrate(
|
|
1402
|
+
conn: sqlite3.Connection, namespace: str
|
|
1403
|
+
) -> dict | None:
|
|
1404
|
+
"""Load exactly ``latest``; expired, malformed, or empty data fails closed."""
|
|
1405
|
+
tail_raw = _read_checkpoint_value(conn, namespace, "tail")
|
|
1406
|
+
raw = _read_checkpoint_value(conn, namespace, "latest")
|
|
1407
|
+
if raw is None:
|
|
1408
|
+
return None
|
|
1409
|
+
checkpoint = _parse_checkpoint(raw)
|
|
1410
|
+
if checkpoint is None:
|
|
1411
|
+
return None
|
|
1412
|
+
checkpoint["_tail_raw"] = _checkpoint_tail(checkpoint, tail_raw)
|
|
1413
|
+
return checkpoint if build_rehydration_blocks(checkpoint) is not None else None
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
|
|
1417
|
+
"""Extend an unexpired heartbeat TTL; never shorten, never resurrect."""
|
|
1418
|
+
row = conn.execute(
|
|
1419
|
+
"SELECT expires_at FROM kv_store "
|
|
1420
|
+
"WHERE namespace = ? AND key = 'heartbeat' "
|
|
1421
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
|
|
1422
|
+
"AND deleted_at IS NULL",
|
|
1423
|
+
(namespace,),
|
|
1424
|
+
).fetchone()
|
|
1425
|
+
if not row or not row[0]:
|
|
1426
|
+
return
|
|
1427
|
+
try:
|
|
1428
|
+
current_exp = datetime.fromisoformat(row[0])
|
|
1429
|
+
except (ValueError, TypeError):
|
|
1430
|
+
return
|
|
1431
|
+
now = datetime.now(UTC)
|
|
1432
|
+
if current_exp.tzinfo is None:
|
|
1433
|
+
current_exp = current_exp.replace(tzinfo=UTC)
|
|
1434
|
+
fresh_exp = now + timedelta(seconds=CONTEXT_HEARTBEAT_TTL)
|
|
1435
|
+
if fresh_exp <= current_exp:
|
|
1436
|
+
return
|
|
1437
|
+
revision = _next_context_revision(conn, namespace, "heartbeat")
|
|
1438
|
+
cursor = conn.execute(
|
|
1439
|
+
"UPDATE kv_store SET expires_at = ?, updated_at = ? "
|
|
1440
|
+
"WHERE namespace = ? AND key = 'heartbeat' AND expires_at = ? "
|
|
1441
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
|
|
1442
|
+
"AND deleted_at IS NULL",
|
|
1443
|
+
(fresh_exp.isoformat(), str(revision), namespace, row[0]),
|
|
1444
|
+
)
|
|
1445
|
+
if cursor.rowcount:
|
|
1446
|
+
conn.commit()
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
@mcp.tool(
|
|
1450
|
+
name="context_rehydrate",
|
|
1451
|
+
description="Rehydrate critical context after native compaction. "
|
|
1452
|
+
"Reads 'latest' + serialized 'tail' from a session-qualified checkpoint "
|
|
1453
|
+
"namespace and rebuilds "
|
|
1454
|
+
"goal/phase/delegation blocks deterministically (no LLM). Refreshes the "
|
|
1455
|
+
"heartbeat TTL. Requires session_id and honors PANTHEON_COMPACTION=off.",
|
|
1456
|
+
)
|
|
1457
|
+
async def context_rehydrate(
|
|
1458
|
+
slug: str,
|
|
1459
|
+
session_id: str,
|
|
1460
|
+
scope: str = "project",
|
|
1461
|
+
) -> list[str] | None:
|
|
1462
|
+
"""Rehydrate one known session; missing IDs fail closed."""
|
|
1463
|
+
if _compaction_disabled():
|
|
1464
|
+
return None
|
|
1465
|
+
normalized_session = _normalize_session_id(session_id)
|
|
1466
|
+
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
|
+
)
|
|
1472
|
+
conn = _db(scope)
|
|
1473
|
+
namespace = f"checkpoint:{valid_slug}:{valid_session}"
|
|
1474
|
+
checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
|
|
1475
|
+
if checkpoint is None:
|
|
1476
|
+
return None
|
|
1477
|
+
blocks = build_rehydration_blocks(checkpoint)
|
|
1478
|
+
if blocks is None:
|
|
1479
|
+
return None
|
|
1480
|
+
_refresh_heartbeat_ttl(conn, namespace)
|
|
1481
|
+
return blocks
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
@mcp.tool(
|
|
1485
|
+
name="context_session_summary",
|
|
1486
|
+
description="Tier 2 session-end summary without Themis approval. "
|
|
1487
|
+
"Compresses the checkpoint (goal, phase, in-flight, tail) into a "
|
|
1488
|
+
"memory-bank-ready summary deterministically (no LLM). "
|
|
1489
|
+
"Requires session_id. Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
|
|
1490
|
+
)
|
|
1491
|
+
async def context_session_summary(
|
|
1492
|
+
slug: str,
|
|
1493
|
+
session_id: str,
|
|
1494
|
+
scope: str = "project",
|
|
1495
|
+
) -> str | None:
|
|
1496
|
+
"""Summarize one known session; missing IDs fail closed."""
|
|
1497
|
+
if os.environ.get(SESSION_END_SUMMARY_ENV, "").lower() == "off":
|
|
1498
|
+
return None
|
|
1499
|
+
normalized_session = _normalize_session_id(session_id)
|
|
1500
|
+
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
|
+
)
|
|
1506
|
+
conn = _db(scope)
|
|
1507
|
+
namespace = f"checkpoint:{valid_slug}:{valid_session}"
|
|
1508
|
+
checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
|
|
1509
|
+
if checkpoint is None:
|
|
1510
|
+
return None
|
|
1511
|
+
return build_session_end_summary(checkpoint)
|
|
1512
|
+
|
|
1513
|
+
|
|
776
1514
|
def _resolve_db_path(scope: str) -> Path | None:
|
|
777
1515
|
"""Resolve the database file path for a given scope.
|
|
778
1516
|
|