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

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 (83) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +1054 -0
  3. package/README.md +171 -16
  4. package/README.pt-BR.md +99 -13
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +23 -15
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +65 -0
  9. package/scripts/code_mode_server.py +431 -55
  10. package/scripts/cost.mjs +30 -14
  11. package/scripts/dev-tui.sh +48 -0
  12. package/scripts/doctor.mjs +207 -9
  13. package/scripts/install/cli-ui.mjs +17 -11
  14. package/scripts/install/config-migration.mjs +4 -6
  15. package/scripts/install/opencode.mjs +137 -35
  16. package/scripts/install/plugin.mjs +3 -33
  17. package/scripts/install/shared.mjs +43 -2
  18. package/scripts/install/strings.mjs +129 -0
  19. package/scripts/install-mcp.mjs +1 -0
  20. package/scripts/manifest-inventory.mjs +410 -0
  21. package/scripts/mcp_persistence_server.py +537 -128
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-context-rehydrate.mjs +240 -0
  24. package/scripts/probe-pantheon-cost.mjs +313 -0
  25. package/scripts/release-beta-version.mjs +56 -60
  26. package/scripts/sync-artifacts.mjs +169 -0
  27. package/scripts/sync-tui.mjs +121 -53
  28. package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
  29. package/scripts/validation-policy.mjs +7 -7
  30. package/scripts/version-check.mjs +113 -108
  31. package/scripts/versioning.mjs +230 -86
  32. package/src/agents/mnemosyne.md +1 -1
  33. package/src/agents/zeus.md +2 -2
  34. package/src/mcp/code_mode_server.py +431 -55
  35. package/src/mcp/mcp_persistence_server.py +537 -128
  36. package/src/pantheon/auto-wake.ts +1 -5
  37. package/src/pantheon/background-job-board.ts +43 -1
  38. package/src/pantheon/compaction-handoff.ts +304 -0
  39. package/src/pantheon/cost-command.ts +345 -62
  40. package/src/pantheon/delegate-manager.ts +390 -8
  41. package/src/pantheon/delegation-finalize.ts +32 -0
  42. package/src/pantheon/delegation.ts +235 -18
  43. package/src/pantheon/file-persistence-salvage.ts +64 -0
  44. package/src/pantheon/file-persistence.ts +194 -45
  45. package/src/pantheon/native-probe.ts +234 -0
  46. package/src/pantheon/native-task-status.ts +139 -0
  47. package/src/pantheon/provider-capabilities.ts +221 -0
  48. package/src/pantheon/provider-stream-tracker.ts +182 -0
  49. package/src/pantheon/shared-board.ts +37 -0
  50. package/src/pantheon/task-result-guard.ts +107 -0
  51. package/src/pantheon/token-opt.ts +146 -12
  52. package/src/pantheon/tool-ceiling.ts +159 -0
  53. package/src/pantheon/v2-events.ts +18 -1
  54. package/src/plugin-v2.ts +2 -14
  55. package/src/plugin.ts +28 -17
  56. package/src/plugins/pantheon-hooks.ts +51 -0
  57. package/src/plugins/tui/dist/server.d.ts +1 -1
  58. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  59. package/src/plugins/tui/dist/server.js +1 -1
  60. package/src/plugins/tui/dist/server.js.map +1 -1
  61. package/src/plugins/tui/dist/tui.d.ts +137 -27
  62. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  63. package/src/plugins/tui/dist/tui.js +459 -163
  64. package/src/plugins/tui/dist/tui.js.map +1 -1
  65. package/src/plugins/tui/dist/tui.tsx +506 -103
  66. package/src/plugins/tui/package-lock.json +2 -2
  67. package/src/plugins/tui/package.json +1 -1
  68. package/src/plugins/tui/src/index.tsx +506 -103
  69. package/.pantheon/code-mode/.gitkeep +0 -0
  70. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  71. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  72. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  73. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  74. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  75. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  76. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  77. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  78. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  79. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  80. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  81. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  82. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  83. package/src/pantheon/delegate-task-adapter.ts +0 -444
@@ -16,7 +16,8 @@ import os
16
16
  import re
17
17
  import sqlite3
18
18
  import sys
19
- import uuid
19
+ import threading
20
+ import time
20
21
  from datetime import UTC, datetime, timedelta
21
22
  from pathlib import Path
22
23
 
@@ -219,10 +220,11 @@ async def kv_store(
219
220
  Returns:
220
221
  Status dict with namespace and key.
221
222
  """
223
+ validated_ttl = _validate_ttl(ttl)
222
224
  conn = _db(scope)
223
225
  expires_at: str | None = None
224
- if ttl is not None:
225
- expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat()
226
+ if validated_ttl is not None:
227
+ expires_at = (datetime.now(UTC) + timedelta(seconds=validated_ttl)).isoformat()
226
228
 
227
229
  conn.execute(
228
230
  "INSERT INTO kv_store (namespace, key, value, expires_at, "
@@ -565,80 +567,428 @@ async def kv_delete_namespace(
565
567
 
566
568
  # ── Context Checkpoint Tools ────────────────────────────────────────────────────
567
569
  # Session-scoped context storage for deepwork checkpoints, phase state,
568
- # tool call history, and reasoning chains. All entries use namespace
569
- # "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.
570
573
 
571
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
+ )
572
900
 
573
901
 
574
902
  @mcp.tool(
575
903
  name="context_save",
576
904
  description="Save a context checkpoint for a session/phase. "
577
- "Stores structured JSON in persistence KV with auto-TTL of 4h.",
905
+ "Stores structured JSON in persistence KV with a key-specific TTL.",
578
906
  )
579
907
  async def context_save(
580
908
  slug: str,
581
909
  key: str,
582
910
  content: str,
583
- session_id: str | None = None,
911
+ session_id: str,
584
912
  ttl: int | None = None,
585
913
  scope: str = "project",
914
+ revision: int | None = None,
586
915
  ) -> dict:
587
916
  """Save a context checkpoint with session-level isolation.
588
917
 
589
- If session_id is provided, namespace becomes "checkpoint:{slug}:{session_id}"
590
- preventing cross-session reads. If omitted, a random UUID is auto-generated
591
- 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.
592
921
 
593
922
  Args:
594
923
  slug: Session identifier (e.g. "auth-refactor").
595
924
  key: Checkpoint key (e.g. "phase:3", "latest", "heartbeat").
596
925
  content: JSON-serializable string or any text content.
597
- session_id: Unique session ID for namespace isolation (auto-generated if None).
598
- 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).
599
928
  scope: 'project' (default) or 'global'.
929
+ revision: Optional monotonic revision. Older/equal revisions are no-ops.
600
930
 
601
931
  Returns:
602
932
  Status dict with namespace, key, and session_id for subsequent calls.
603
933
  """
604
- actual_session = session_id or uuid.uuid4().hex[:12]
605
- ns = f"checkpoint:{slug}:{actual_session}"
606
- 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)
607
946
 
608
947
  conn = _db(scope)
609
948
  expires_at = (datetime.now(UTC) + timedelta(seconds=actual_ttl)).isoformat()
610
949
 
611
- conn.execute(
612
- "INSERT INTO kv_store (namespace, key, value, expires_at, "
613
- "created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))"
614
- "ON CONFLICT(namespace, key) DO UPDATE SET "
615
- " value = excluded.value, "
616
- " expires_at = excluded.expires_at, "
617
- " updated_at = datetime('now')",
618
- (ns, key, content, expires_at),
619
- )
620
- conn.commit()
621
-
622
- # Also always update "latest" pointer
623
- if key != "latest":
624
- # Use a larger TTL for "latest" to outlive individual phase entries
625
- conn.execute(
626
- "INSERT INTO kv_store (namespace, key, value, expires_at, "
627
- "created_at, updated_at) VALUES (?, 'latest', ?, ?, datetime('now'), datetime('now'))"
628
- "ON CONFLICT(namespace, key) DO UPDATE SET "
629
- " value = excluded.value, "
630
- " expires_at = excluded.expires_at, "
631
- " updated_at = datetime('now')",
632
- (ns, content, expires_at),
633
- )
634
- 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
635
984
 
636
985
  return {
637
986
  "status": "stored",
638
987
  "namespace": ns,
639
- "key": key,
640
- "session_id": actual_session,
988
+ "key": valid_key,
989
+ "session_id": valid_session,
641
990
  "ttl": actual_ttl,
991
+ "revision": actual_revision,
642
992
  }
643
993
 
644
994
 
@@ -651,6 +1001,7 @@ async def context_get(
651
1001
  slug: str,
652
1002
  key: str = "latest",
653
1003
  session_id: str | None = None,
1004
+ legacy: bool = False,
654
1005
  scope: str = "project",
655
1006
  ) -> str | None:
656
1007
  """Get a context checkpoint with session isolation.
@@ -658,14 +1009,22 @@ async def context_get(
658
1009
  Args:
659
1010
  slug: Session identifier.
660
1011
  key: Checkpoint key (default "latest").
661
- 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.
662
1015
  scope: 'project' (default) or 'global'.
663
1016
 
664
1017
  Returns:
665
1018
  Content string or None if not found/expired.
666
1019
  """
667
1020
  conn = _db(scope)
668
- 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
669
1028
  row = conn.execute(
670
1029
  "SELECT value FROM kv_store "
671
1030
  "WHERE namespace = ? AND key = ? "
@@ -684,20 +1043,25 @@ async def context_get(
684
1043
  async def context_list(
685
1044
  slug: str,
686
1045
  session_id: str | None = None,
1046
+ legacy: bool = False,
687
1047
  scope: str = "project",
688
1048
  ) -> list[dict]:
689
1049
  """List all checkpoints for a session.
690
1050
 
691
1051
  Args:
692
1052
  slug: Session identifier.
693
- 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.
694
1056
  scope: 'project' (default) or 'global'.
695
1057
 
696
1058
  Returns:
697
1059
  List of dicts with key, created_at, expires_at.
698
1060
  """
699
1061
  conn = _db(scope)
700
- 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 []
701
1065
  rows = conn.execute(
702
1066
  "SELECT key, created_at, expires_at FROM kv_store "
703
1067
  "WHERE namespace = ? "
@@ -724,36 +1088,52 @@ async def context_list(
724
1088
  async def context_stats(
725
1089
  slug: str,
726
1090
  session_id: str | None = None,
1091
+ legacy: bool = False,
727
1092
  scope: str = "project",
728
1093
  ) -> dict:
729
1094
  """Return storage statistics for a session's context.
730
1095
 
731
1096
  Args:
732
1097
  slug: Session identifier.
733
- 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.
734
1101
  scope: 'project' (default) or 'global'.
735
1102
 
736
1103
  Returns:
737
1104
  Dict with entry_count, total_bytes, ttl_remaining.
738
1105
  """
739
1106
  conn = _db(scope)
740
- 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
+ }
741
1117
 
742
1118
  count = conn.execute(
743
- "SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
1119
+ "SELECT COUNT(*) FROM kv_store "
1120
+ "WHERE namespace = ? AND deleted_at IS NULL "
1121
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
744
1122
  (ns,),
745
1123
  ).fetchone()[0]
746
1124
 
747
1125
  expired = conn.execute(
748
1126
  "SELECT COUNT(*) FROM kv_store "
749
- "WHERE namespace = ? AND datetime(expires_at) < datetime('now') "
1127
+ "WHERE namespace = ? AND expires_at IS NOT NULL "
1128
+ "AND datetime(expires_at) <= datetime('now') "
750
1129
  "AND deleted_at IS NULL",
751
1130
  (ns,),
752
1131
  ).fetchone()[0]
753
1132
 
754
1133
  size = conn.execute(
755
1134
  "SELECT COALESCE(SUM(LENGTH(value)), 0) FROM kv_store "
756
- "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'))",
757
1137
  (ns,),
758
1138
  ).fetchone()[0]
759
1139
 
@@ -784,7 +1164,8 @@ async def context_stats(
784
1164
 
785
1165
  # ── Post-Compaction Injector (WS2 PR #94) ─────────────────────────────────────
786
1166
  # Deterministic rehydration after a native compaction event: a long session
787
- # reads ``latest`` + serialized ``tail`` from ``checkpoint:<slug>`` and
1167
+ # reads ``latest`` + serialized ``tail`` from
1168
+ # ``checkpoint:<slug>:<session_id>`` and
788
1169
  # rebuilds its critical context (active goal, in-flight delegations,
789
1170
  # current phase) WITHOUT any LLM call or generative embedding.
790
1171
  #
@@ -797,9 +1178,9 @@ async def context_stats(
797
1178
  # "heartbeat": {...}}
798
1179
  # ``tail`` may also live under a separate ``tail`` key (JSON array).
799
1180
  #
800
- # Guarantees: kill-switch PANTHEON_COMPACTION=off, TTL-respecting reads,
801
- # heartbeat TTL refresh (extended, never shortened), idempotent (pure reads
802
- # + TTL touch — no checkpoint rows are added or duplicated).
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).
803
1184
 
804
1185
  COMPACTION_KILL_SWITCH_ENV: str = "PANTHEON_COMPACTION"
805
1186
  SESSION_END_SUMMARY_ENV: str = "PANTHEON_SESSION_END_SUMMARY"
@@ -826,26 +1207,39 @@ def _read_checkpoint_value(
826
1207
  return row[0] if row else None
827
1208
 
828
1209
 
829
- def _parse_checkpoint(content: str) -> dict:
830
- """Parse checkpoint JSON; unparsable content degrades to {} (fail-open)."""
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
831
1214
  try:
832
1215
  data = json.loads(content)
833
1216
  except (json.JSONDecodeError, TypeError, ValueError):
834
- return {}
835
- return data if isinstance(data, dict) else {}
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
836
1225
 
837
1226
 
838
1227
  def _checkpoint_tail(checkpoint: dict, tail_raw: str | None) -> list[str]:
839
1228
  """Tail lines from embedded ``tail`` with fallback to the ``tail`` key."""
840
1229
  tail = checkpoint.get("tail")
841
1230
  if isinstance(tail, list):
842
- lines = [str(line) for line in tail]
1231
+ try:
1232
+ lines = _validate_tail(tail)
1233
+ except ValueError:
1234
+ return []
843
1235
  elif isinstance(tail_raw, str):
1236
+ if len(tail_raw.encode()) > MAX_CONTEXT_CONTENT_BYTES:
1237
+ return []
844
1238
  try:
845
1239
  parsed = json.loads(tail_raw)
1240
+ lines = _validate_tail(parsed, payload_bytes=len(tail_raw.encode()))
846
1241
  except (json.JSONDecodeError, TypeError, ValueError):
847
- parsed = None
848
- lines = [str(line) for line in parsed] if isinstance(parsed, list) else []
1242
+ return []
849
1243
  else:
850
1244
  lines = []
851
1245
  return [line for line in lines if line.strip()][:REHYDRATE_TAIL_CAP]
@@ -861,7 +1255,11 @@ def _mission_block(checkpoint: dict) -> str | None:
861
1255
  if status == "done" or not objective:
862
1256
  return None
863
1257
  gid = str(goal.get("id", "goal")).strip() or "goal"
864
- return f"<mission_context>\n [{gid}] {objective} — {status}"
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
+ )
865
1263
 
866
1264
 
867
1265
  def _phase_block(checkpoint: dict) -> str | None:
@@ -873,8 +1271,10 @@ def _phase_block(checkpoint: dict) -> str | None:
873
1271
  current = phase.get("current")
874
1272
  span = f"{current}/{total}" if total is not None else str(current)
875
1273
  name = str(phase.get("name", "")).strip()
876
- line = f" phase {span}" + (f" — {name}" if name else "")
877
- return f"<phase_context>\n{line}"
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}"
878
1278
 
879
1279
 
880
1280
  def _delegation_block(checkpoint: dict) -> str | None:
@@ -892,20 +1292,31 @@ def _delegation_block(checkpoint: dict) -> str | None:
892
1292
  alias = str(job.get("alias") or job.get("task_id") or "?").strip()
893
1293
  agent = str(job.get("agent", "?")).strip()
894
1294
  task_id = str(job.get("task_id", "")).strip()
895
- detail = f" — {task_id}" if task_id and task_id != alias else ""
896
- lines.append(f" [{alias}] {agent}{detail} [in-flight]")
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
+ )
897
1304
  if not lines:
898
1305
  return None
899
- return "<delegation_context>\n" + "\n".join(lines)
1306
+ return "<delegation_context>\n" + UNTRUSTED_CONTEXT_LABEL + "\n" + "\n".join(lines)
900
1307
 
901
1308
 
902
1309
  def _tail_block(checkpoint: dict) -> str | None:
903
1310
  """<tail_context> for the serialized tail (already capped on load)."""
904
1311
  tail_lines = checkpoint.get("_tail_raw")
905
- if not isinstance(tail_lines, list) or not tail_lines:
1312
+ try:
1313
+ validated_lines = _validate_tail(tail_lines)
1314
+ except ValueError:
906
1315
  return None
907
- quoted = "\n".join(f" - {line}" for line in tail_lines)
908
- return f"<tail_context>\n{quoted}"
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}"
909
1320
 
910
1321
 
911
1322
  def build_rehydration_blocks(checkpoint: dict) -> list[str] | None:
@@ -944,7 +1355,10 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
944
1355
  if isinstance(goal, dict):
945
1356
  objective = str(goal.get("objective", "")).strip()
946
1357
  if objective:
947
- parts.append(f"Goal: {objective} ({goal.get('status', '?')})")
1358
+ parts.append(
1359
+ f"Goal: {_safe_untrusted_text(objective)} "
1360
+ f"({_safe_untrusted_text(goal.get('status', '?'))})"
1361
+ )
948
1362
 
949
1363
  phase = checkpoint.get("phase")
950
1364
  if isinstance(phase, dict) and phase.get("current") is not None:
@@ -955,14 +1369,17 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
955
1369
  else phase.get("current")
956
1370
  )
957
1371
  name = str(phase.get("name", "")).strip()
958
- parts.append(f"Phase: {span}" + (f" {name}" if name else ""))
1372
+ parts.append(
1373
+ f"Phase: {_safe_untrusted_text(span)}"
1374
+ + (f" {_safe_untrusted_text(name)}" if name else "")
1375
+ )
959
1376
 
960
1377
  delegations = checkpoint.get("delegations")
961
1378
  if isinstance(delegations, dict):
962
1379
  in_flight = delegations.get("in_flight")
963
1380
  if isinstance(in_flight, list):
964
1381
  aliases = [
965
- str(job.get("alias") or job.get("task_id") or "?")
1382
+ _safe_untrusted_text(str(job.get("alias") or job.get("task_id") or "?"))
966
1383
  for job in in_flight
967
1384
  if isinstance(job, dict)
968
1385
  ]
@@ -971,7 +1388,7 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
971
1388
 
972
1389
  tail_raw = checkpoint.get("_tail_raw")
973
1390
  if isinstance(tail_raw, list) and tail_raw:
974
- kept = [str(line) for line in tail_raw[:SUMMARY_TAIL_CAP]]
1391
+ kept = [_safe_untrusted_text(str(line)) for line in tail_raw[:SUMMARY_TAIL_CAP]]
975
1392
  parts.append("Tail: " + " | ".join(kept))
976
1393
 
977
1394
  if not parts:
@@ -984,43 +1401,16 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
984
1401
  def _load_checkpoint_for_rehydrate(
985
1402
  conn: sqlite3.Connection, namespace: str
986
1403
  ) -> dict | None:
987
- """Load ``latest`` (+ ``tail`` fallback) or None when expired/absent.
988
-
989
- Resilient to pointer clobbering: ``context_save`` refreshes ``latest``
990
- on every write, so a later heartbeat can overwrite the checkpoint
991
- pointer. When ``latest`` is not a checkpoint, scan sibling keys
992
- newest-first and use the first value that rebuilds critical blocks.
993
- Deterministic (created_at DESC, bounded scan).
994
- """
1404
+ """Load exactly ``latest``; expired, malformed, or empty data fails closed."""
995
1405
  tail_raw = _read_checkpoint_value(conn, namespace, "tail")
996
-
997
- def _with_tail(raw: str | None) -> dict | None:
998
- if raw is None:
999
- return None
1000
- checkpoint = _parse_checkpoint(raw)
1001
- if not checkpoint:
1002
- return None
1003
- checkpoint["_tail_raw"] = _checkpoint_tail(checkpoint, tail_raw)
1004
- if build_rehydration_blocks(checkpoint) is None:
1005
- return None
1006
- return checkpoint
1007
-
1008
- found = _with_tail(_read_checkpoint_value(conn, namespace, "latest"))
1009
- if found is not None:
1010
- return found
1011
- rows = conn.execute(
1012
- "SELECT value FROM kv_store "
1013
- "WHERE namespace = ? AND key != 'latest' "
1014
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1015
- "AND deleted_at IS NULL "
1016
- "ORDER BY created_at DESC, key DESC LIMIT 20",
1017
- (namespace,),
1018
- ).fetchall()
1019
- for row in rows:
1020
- found = _with_tail(row[0] if row else None)
1021
- if found is not None:
1022
- return found
1023
- return None
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
1024
1414
 
1025
1415
 
1026
1416
  def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
@@ -1041,41 +1431,53 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1041
1431
  now = datetime.now(UTC)
1042
1432
  if current_exp.tzinfo is None:
1043
1433
  current_exp = current_exp.replace(tzinfo=UTC)
1044
- fresh_exp = now + timedelta(seconds=DEFAULT_CONTEXT_TTL)
1434
+ fresh_exp = now + timedelta(seconds=CONTEXT_HEARTBEAT_TTL)
1045
1435
  if fresh_exp <= current_exp:
1046
1436
  return
1047
- conn.execute(
1048
- "UPDATE kv_store SET expires_at = ?, updated_at = datetime('now') "
1049
- "WHERE namespace = ? AND key = 'heartbeat'",
1050
- (fresh_exp.isoformat(), namespace),
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]),
1051
1444
  )
1052
- conn.commit()
1445
+ if cursor.rowcount:
1446
+ conn.commit()
1053
1447
 
1054
1448
 
1055
1449
  @mcp.tool(
1056
1450
  name="context_rehydrate",
1057
1451
  description="Rehydrate critical context after native compaction. "
1058
- "Reads 'latest' + serialized 'tail' from checkpoint:<slug> and rebuilds "
1452
+ "Reads 'latest' + serialized 'tail' from a session-qualified checkpoint "
1453
+ "namespace and rebuilds "
1059
1454
  "goal/phase/delegation blocks deterministically (no LLM). Refreshes the "
1060
- "heartbeat TTL. Idempotent. Honors PANTHEON_COMPACTION=off.",
1455
+ "heartbeat TTL. Requires session_id and honors PANTHEON_COMPACTION=off.",
1061
1456
  )
1062
1457
  async def context_rehydrate(
1063
1458
  slug: str,
1064
- session_id: str | None = None,
1459
+ session_id: str,
1065
1460
  scope: str = "project",
1066
1461
  ) -> list[str] | None:
1067
- """Post-compaction injector: deterministic rehydration, fail-open."""
1462
+ """Rehydrate one known session; missing IDs fail closed."""
1068
1463
  if _compaction_disabled():
1069
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
+ )
1070
1472
  conn = _db(scope)
1071
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
1072
- checkpoint = _load_checkpoint_for_rehydrate(conn, ns)
1473
+ namespace = f"checkpoint:{valid_slug}:{valid_session}"
1474
+ checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1073
1475
  if checkpoint is None:
1074
1476
  return None
1075
1477
  blocks = build_rehydration_blocks(checkpoint)
1076
1478
  if blocks is None:
1077
1479
  return None
1078
- _refresh_heartbeat_ttl(conn, ns)
1480
+ _refresh_heartbeat_ttl(conn, namespace)
1079
1481
  return blocks
1080
1482
 
1081
1483
 
@@ -1084,19 +1486,26 @@ async def context_rehydrate(
1084
1486
  description="Tier 2 session-end summary without Themis approval. "
1085
1487
  "Compresses the checkpoint (goal, phase, in-flight, tail) into a "
1086
1488
  "memory-bank-ready summary deterministically (no LLM). "
1087
- "Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
1489
+ "Requires session_id. Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
1088
1490
  )
1089
1491
  async def context_session_summary(
1090
1492
  slug: str,
1091
- session_id: str | None = None,
1493
+ session_id: str,
1092
1494
  scope: str = "project",
1093
1495
  ) -> str | None:
1094
- """Session-end Tier 2 trigger: automatic, opt-out instead of opt-in."""
1496
+ """Summarize one known session; missing IDs fail closed."""
1095
1497
  if os.environ.get(SESSION_END_SUMMARY_ENV, "").lower() == "off":
1096
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
+ )
1097
1506
  conn = _db(scope)
1098
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
1099
- checkpoint = _load_checkpoint_for_rehydrate(conn, ns)
1507
+ namespace = f"checkpoint:{valid_slug}:{valid_session}"
1508
+ checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1100
1509
  if checkpoint is None:
1101
1510
  return None
1102
1511
  return build_session_end_summary(checkpoint)