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.
Files changed (118) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/manifest.json +21 -0
  12. package/.pantheon/code-mode/session-end-save.py +186 -0
  13. package/.pantheon/code-mode/session-end-save.sh +18 -0
  14. package/.pantheon/code-mode/test-allowed.sh +5 -0
  15. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  16. package/.pantheon/code-mode/test-env.sh +7 -0
  17. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  18. package/.pantheon/code-mode/test-timeout.sh +6 -0
  19. package/CHANGELOG.md +1026 -0
  20. package/README.md +292 -1080
  21. package/README.pt-BR.md +308 -0
  22. package/bin/pantheon-init.mjs +207 -31
  23. package/opencode.json +1 -0
  24. package/package.json +34 -14
  25. package/plugin.json +15 -0
  26. package/pyproject.toml +65 -0
  27. package/scripts/_pantheon_paths.py +48 -7
  28. package/scripts/ci-validate-yaml.py +7 -2
  29. package/scripts/code_mode_server.py +451 -44
  30. package/scripts/cost.mjs +30 -14
  31. package/scripts/doctor.mjs +306 -129
  32. package/scripts/generate-preset-docs.mjs +127 -66
  33. package/scripts/generate-prompts.sh +2 -2
  34. package/scripts/install/cli-ui.mjs +23 -10
  35. package/scripts/install/config-migration.mjs +399 -0
  36. package/scripts/install/health-check.mjs +3 -3
  37. package/scripts/install/model-picker.mjs +72 -23
  38. package/scripts/install/opencode-version.mjs +47 -0
  39. package/scripts/install/opencode.mjs +396 -121
  40. package/scripts/install/plugin.mjs +89 -49
  41. package/scripts/install/shared.mjs +55 -159
  42. package/scripts/install/strings.mjs +129 -0
  43. package/scripts/install-mcp.mjs +45 -232
  44. package/scripts/manifest-inventory.mjs +410 -0
  45. package/scripts/mcp_persistence_server.py +798 -60
  46. package/scripts/mcp_resources_server.py +144 -40
  47. package/scripts/memory_mcp_server.py +2 -2
  48. package/scripts/package-evidence.mjs +242 -0
  49. package/scripts/probe-context-rehydrate.mjs +240 -0
  50. package/scripts/probe-pantheon-cost.mjs +313 -0
  51. package/scripts/recover-zenodo-deposition.mjs +146 -0
  52. package/scripts/release-beta-version.mjs +56 -60
  53. package/scripts/sync-artifacts.mjs +169 -0
  54. package/scripts/sync-tui.mjs +135 -42
  55. package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
  56. package/scripts/uninstall.mjs +298 -954
  57. package/scripts/validate-zenodo-release.mjs +154 -0
  58. package/scripts/validation-policy.mjs +7 -7
  59. package/scripts/version-check.mjs +113 -108
  60. package/scripts/versioning.mjs +230 -86
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/agents/mnemosyne.md +1 -1
  64. package/src/agents/zeus.md +27 -16
  65. package/src/mcp/_pantheon_paths.py +48 -7
  66. package/src/mcp/code_mode_server.py +451 -44
  67. package/src/mcp/eval_store.py +1 -1
  68. package/src/mcp/mcp_persistence_server.py +798 -60
  69. package/src/mcp/mcp_resources_server.py +144 -40
  70. package/src/mcp/pantheon_vision_server.py +1 -1
  71. package/src/mcp/toon_codec.py +491 -0
  72. package/src/pantheon/auto-wake.ts +1 -5
  73. package/src/pantheon/background-job-board.ts +110 -2
  74. package/src/pantheon/compaction-handoff.ts +304 -0
  75. package/src/pantheon/cost-command.ts +356 -69
  76. package/src/pantheon/delegate-manager.ts +766 -0
  77. package/src/pantheon/delegation-classifier.ts +307 -0
  78. package/src/pantheon/delegation-enforce.ts +13 -12
  79. package/src/pantheon/delegation-finalize.ts +406 -0
  80. package/src/pantheon/delegation-notify.ts +212 -0
  81. package/src/pantheon/delegation-toast.ts +73 -0
  82. package/src/pantheon/delegation.ts +1337 -0
  83. package/src/pantheon/file-persistence-salvage.ts +64 -0
  84. package/src/pantheon/file-persistence.ts +194 -45
  85. package/src/pantheon/goal-loop.ts +2 -2
  86. package/src/pantheon/goal-store.ts +1 -1
  87. package/src/pantheon/hashline/tool.ts +2 -2
  88. package/src/pantheon/model-command.ts +2 -1
  89. package/src/pantheon/native-probe.ts +234 -0
  90. package/src/pantheon/native-task-status.ts +139 -0
  91. package/src/pantheon/permission-eval.ts +284 -0
  92. package/src/pantheon/provider-capabilities.ts +221 -0
  93. package/src/pantheon/provider-stream-tracker.ts +182 -0
  94. package/src/pantheon/shared-board.ts +37 -0
  95. package/src/pantheon/step-cap.ts +110 -0
  96. package/src/pantheon/task-result-guard.ts +107 -0
  97. package/src/pantheon/token-opt.ts +689 -0
  98. package/src/pantheon/tool-ceiling.ts +159 -0
  99. package/src/pantheon/toon-codec.ts +481 -0
  100. package/src/pantheon/tui-version-check.ts +1 -3
  101. package/src/pantheon/v2-bridge.ts +129 -0
  102. package/src/pantheon/v2-events.ts +148 -0
  103. package/src/pantheon/v2-hooks.ts +287 -0
  104. package/src/pantheon/v2-tool-definitions.ts +331 -0
  105. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  106. package/src/plugin-v2/index.ts +21 -0
  107. package/src/plugin-v2.ts +824 -0
  108. package/src/plugin.ts +261 -51
  109. package/src/plugins/pantheon-hooks.ts +51 -0
  110. package/src/plugins/tui/dist/tui.d.ts +116 -24
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +416 -149
  113. package/src/plugins/tui/dist/tui.tsx +456 -87
  114. package/src/plugins/tui/package-lock.json +51 -51
  115. package/src/plugins/tui/package.json +7 -7
  116. package/src/plugins/tui/src/index.tsx +462 -95
  117. package/src/skills/README.md +0 -2
  118. 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 uuid
18
- from datetime import datetime, timedelta, timezone
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(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
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 ttl is not None:
223
- expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl)).isoformat()
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("SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL").fetchone()[0]
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
- def _opportunistic_auto_purge(conn: sqlite3.Connection, namespace: str, threshold: int = 500) -> None:
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. All entries use namespace
566
- # "checkpoint:{slug}" with auto-TTL of 4 hours (session duration).
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 auto-TTL of 4h.",
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 | None = None,
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
- If session_id is provided, namespace becomes "checkpoint:{slug}:{session_id}"
587
- preventing cross-session reads. If omitted, a random UUID is auto-generated
588
- and returned so the caller can pass it to subsequent calls.
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: Unique session ID for namespace isolation (auto-generated if None).
595
- ttl: TTL in seconds (default 4h / 14400). None = session duration.
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
- actual_session = session_id or uuid.uuid4().hex[:12]
602
- ns = f"checkpoint:{slug}:{actual_session}"
603
- actual_ttl = ttl if ttl is not None else DEFAULT_CONTEXT_TTL
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(timezone.utc) + timedelta(seconds=actual_ttl)).isoformat()
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
- # Also always update "latest" pointer
620
- if key != "latest":
621
- # Use a larger TTL for "latest" to outlive individual phase entries
622
- conn.execute(
623
- "INSERT INTO kv_store (namespace, key, value, expires_at, "
624
- "created_at, updated_at) VALUES (?, 'latest', ?, ?, datetime('now'), datetime('now'))"
625
- "ON CONFLICT(namespace, key) DO UPDATE SET "
626
- " value = excluded.value, "
627
- " expires_at = excluded.expires_at, "
628
- " updated_at = datetime('now')",
629
- (ns, content, expires_at),
630
- )
631
- conn.commit()
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 {"status": "stored", "namespace": ns, "key": key, "session_id": actual_session, "ttl": actual_ttl}
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 isolation. If None, searches without session scope.
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
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
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: Optional. If provided, narrows to specific session.
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 = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
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: Optional. If provided, narrows to specific session.
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 = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
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 datetime(expires_at) < datetime('now') "
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(timezone.utc)
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