pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.3
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/README.md +41 -15
- package/README.pt-BR.md +39 -12
- package/package.json +7 -3
- package/plugin.json +1 -1
- package/pyproject.toml +57 -0
- package/scripts/code_mode_server.py +101 -36
- package/scripts/install/opencode.mjs +6 -0
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +516 -163
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/release-beta-version.mjs +61 -60
- package/scripts/sync-tui.mjs +5 -27
- package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +131 -88
- package/src/mcp/code_mode_server.py +101 -36
- package/src/mcp/mcp_persistence_server.py +516 -163
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
|
@@ -16,7 +16,8 @@ import os
|
|
|
16
16
|
import re
|
|
17
17
|
import sqlite3
|
|
18
18
|
import sys
|
|
19
|
-
import
|
|
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
|
|
225
|
-
expires_at = (datetime.now(UTC) + timedelta(seconds=
|
|
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,409 @@ 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.
|
|
569
|
-
# "checkpoint:{slug}:{session}"
|
|
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_checkpoint_shape(checkpoint: dict) -> None:
|
|
781
|
+
"""Validate bounded structured checkpoint fields without sanitizing them."""
|
|
782
|
+
version = checkpoint.get("version")
|
|
783
|
+
if version is not None and (
|
|
784
|
+
isinstance(version, bool) or not isinstance(version, int) or version < 1
|
|
785
|
+
):
|
|
786
|
+
raise ValueError("version must be a positive integer")
|
|
787
|
+
validators = (
|
|
788
|
+
("goal", _validate_goal),
|
|
789
|
+
("phase", _validate_phase),
|
|
790
|
+
("delegations", _validate_delegations),
|
|
791
|
+
("tail", _validate_tail),
|
|
792
|
+
("heartbeat", _validate_heartbeat),
|
|
793
|
+
)
|
|
794
|
+
for field, validator in validators:
|
|
795
|
+
value = checkpoint.get(field)
|
|
796
|
+
if value is not None:
|
|
797
|
+
validator(value)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _validate_context_content(content: str, key: str) -> None:
|
|
801
|
+
"""Bound context content and validate structured JSON when supplied."""
|
|
802
|
+
if not isinstance(content, str):
|
|
803
|
+
raise ValueError("content must be a string")
|
|
804
|
+
if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
|
|
805
|
+
raise ValueError(f"content exceeds the {MAX_CONTEXT_CONTENT_BYTES}-byte limit")
|
|
806
|
+
try:
|
|
807
|
+
parsed = json.loads(content)
|
|
808
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
809
|
+
# Raw text remains a compatibility format, but rehydrate never treats
|
|
810
|
+
# it as a checkpoint. It is still bounded above.
|
|
811
|
+
return
|
|
812
|
+
if isinstance(parsed, list) and key == "tail":
|
|
813
|
+
if len(parsed) > MAX_TAIL_ITEMS or any(
|
|
814
|
+
not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH
|
|
815
|
+
for line in parsed
|
|
816
|
+
):
|
|
817
|
+
raise ValueError("tail exceeds its size limit")
|
|
818
|
+
return
|
|
819
|
+
if not isinstance(parsed, dict):
|
|
820
|
+
raise ValueError("structured context content must be a JSON object")
|
|
821
|
+
_validate_checkpoint_shape(parsed)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _safe_untrusted_text(value: object) -> str:
|
|
825
|
+
"""Escape control/markup delimiters while preserving untrusted content."""
|
|
826
|
+
text = str(value)
|
|
827
|
+
return (
|
|
828
|
+
text.replace("<", r"\u003c")
|
|
829
|
+
.replace(">", r"\u003e")
|
|
830
|
+
.replace("\r", r"\r")
|
|
831
|
+
.replace("\n", r"\n")
|
|
832
|
+
.replace("\x00", r"\u0000")
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _stored_revision(raw: object) -> int:
|
|
837
|
+
"""Parse a context revision; legacy datetime values are revision zero."""
|
|
838
|
+
try:
|
|
839
|
+
revision = int(str(raw))
|
|
840
|
+
except (TypeError, ValueError):
|
|
841
|
+
return 0
|
|
842
|
+
return revision if 0 <= revision <= MAX_REVISION else 0
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _next_context_revision(
|
|
846
|
+
conn: sqlite3.Connection, namespace: str, key: str, latest_key: str | None = None
|
|
847
|
+
) -> int:
|
|
848
|
+
"""Choose a persisted monotonic revision while the write lock is held."""
|
|
849
|
+
if latest_key is None:
|
|
850
|
+
rows = conn.execute(
|
|
851
|
+
"SELECT updated_at FROM kv_store WHERE namespace = ? AND key = ?",
|
|
852
|
+
(namespace, key),
|
|
853
|
+
).fetchall()
|
|
854
|
+
else:
|
|
855
|
+
rows = conn.execute(
|
|
856
|
+
"SELECT updated_at FROM kv_store WHERE namespace = ? AND key IN (?, ?)",
|
|
857
|
+
(namespace, key, latest_key),
|
|
858
|
+
).fetchall()
|
|
859
|
+
current = max((_stored_revision(row[0]) for row in rows), default=0)
|
|
860
|
+
return min(MAX_REVISION, max(time.time_ns(), current + 1))
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _upsert_context_entry(
|
|
864
|
+
conn: sqlite3.Connection,
|
|
865
|
+
namespace: str,
|
|
866
|
+
key: str,
|
|
867
|
+
content: str,
|
|
868
|
+
expires_at: str,
|
|
869
|
+
revision: int,
|
|
870
|
+
) -> None:
|
|
871
|
+
"""Upsert one context row inside the caller's transaction."""
|
|
872
|
+
conn.execute(
|
|
873
|
+
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
874
|
+
"created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), ?)"
|
|
875
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
876
|
+
" value = excluded.value, "
|
|
877
|
+
" expires_at = excluded.expires_at, "
|
|
878
|
+
" updated_at = excluded.updated_at",
|
|
879
|
+
(namespace, key, content, expires_at, str(revision)),
|
|
880
|
+
)
|
|
572
881
|
|
|
573
882
|
|
|
574
883
|
@mcp.tool(
|
|
575
884
|
name="context_save",
|
|
576
885
|
description="Save a context checkpoint for a session/phase. "
|
|
577
|
-
"Stores structured JSON in persistence KV with
|
|
886
|
+
"Stores structured JSON in persistence KV with a key-specific TTL.",
|
|
578
887
|
)
|
|
579
888
|
async def context_save(
|
|
580
889
|
slug: str,
|
|
581
890
|
key: str,
|
|
582
891
|
content: str,
|
|
583
|
-
session_id: str
|
|
892
|
+
session_id: str,
|
|
584
893
|
ttl: int | None = None,
|
|
585
894
|
scope: str = "project",
|
|
895
|
+
revision: int | None = None,
|
|
586
896
|
) -> dict:
|
|
587
897
|
"""Save a context checkpoint with session-level isolation.
|
|
588
898
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
899
|
+
Namespace is always "checkpoint:{slug}:{session_id}", preventing
|
|
900
|
+
cross-session reads. The session_id must be supplied by the autonomy
|
|
901
|
+
caller; this function never generates one.
|
|
592
902
|
|
|
593
903
|
Args:
|
|
594
904
|
slug: Session identifier (e.g. "auth-refactor").
|
|
595
905
|
key: Checkpoint key (e.g. "phase:3", "latest", "heartbeat").
|
|
596
906
|
content: JSON-serializable string or any text content.
|
|
597
|
-
session_id:
|
|
598
|
-
ttl: TTL in seconds (
|
|
907
|
+
session_id: Required unique session ID for namespace isolation.
|
|
908
|
+
ttl: TTL in seconds (defaults to the key-specific context policy).
|
|
599
909
|
scope: 'project' (default) or 'global'.
|
|
910
|
+
revision: Optional monotonic revision. Older/equal revisions are no-ops.
|
|
600
911
|
|
|
601
912
|
Returns:
|
|
602
913
|
Status dict with namespace, key, and session_id for subsequent calls.
|
|
603
914
|
"""
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
915
|
+
valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
|
|
916
|
+
valid_key = _validate_identifier(key, "key", MAX_CONTEXT_KEY_LENGTH)
|
|
917
|
+
actual_session = _normalize_session_id(session_id)
|
|
918
|
+
if actual_session is None:
|
|
919
|
+
raise ValueError("session_id is required and must be non-empty")
|
|
920
|
+
valid_session = _validate_identifier(
|
|
921
|
+
actual_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
|
|
922
|
+
)
|
|
923
|
+
_validate_context_content(content, valid_key)
|
|
924
|
+
requested_revision = _validate_revision(revision)
|
|
925
|
+
ns = f"checkpoint:{valid_slug}:{valid_session}"
|
|
926
|
+
actual_ttl = _context_ttl(valid_key, ttl)
|
|
607
927
|
|
|
608
928
|
conn = _db(scope)
|
|
609
929
|
expires_at = (datetime.now(UTC) + timedelta(seconds=actual_ttl)).isoformat()
|
|
610
930
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
(
|
|
633
|
-
|
|
634
|
-
|
|
931
|
+
# BEGIN IMMEDIATE serializes writers in this process and across SQLite
|
|
932
|
+
# connections. The checkpoint and raw-content latest pointer commit as one
|
|
933
|
+
# unit; a crash or failed second write therefore cannot expose half a save.
|
|
934
|
+
with _context_write_lock:
|
|
935
|
+
try:
|
|
936
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
937
|
+
latest_key = "latest" if _should_update_latest(valid_key) else None
|
|
938
|
+
actual_revision = requested_revision or _next_context_revision(
|
|
939
|
+
conn, ns, valid_key, latest_key
|
|
940
|
+
)
|
|
941
|
+
current_revision = _current_context_revision(conn, ns, valid_key)
|
|
942
|
+
if actual_revision <= current_revision:
|
|
943
|
+
conn.rollback()
|
|
944
|
+
return {
|
|
945
|
+
"status": "stale",
|
|
946
|
+
"namespace": ns,
|
|
947
|
+
"key": valid_key,
|
|
948
|
+
"session_id": valid_session,
|
|
949
|
+
"ttl": actual_ttl,
|
|
950
|
+
"revision": current_revision,
|
|
951
|
+
}
|
|
952
|
+
_upsert_context_entry(
|
|
953
|
+
conn, ns, valid_key, content, expires_at, actual_revision
|
|
954
|
+
)
|
|
955
|
+
if latest_key is not None and actual_revision > _current_context_revision(
|
|
956
|
+
conn, ns, latest_key
|
|
957
|
+
):
|
|
958
|
+
_upsert_context_entry(
|
|
959
|
+
conn, ns, latest_key, content, expires_at, actual_revision
|
|
960
|
+
)
|
|
961
|
+
conn.commit()
|
|
962
|
+
except Exception:
|
|
963
|
+
conn.rollback()
|
|
964
|
+
raise
|
|
635
965
|
|
|
636
966
|
return {
|
|
637
967
|
"status": "stored",
|
|
638
968
|
"namespace": ns,
|
|
639
|
-
"key":
|
|
640
|
-
"session_id":
|
|
969
|
+
"key": valid_key,
|
|
970
|
+
"session_id": valid_session,
|
|
641
971
|
"ttl": actual_ttl,
|
|
972
|
+
"revision": actual_revision,
|
|
642
973
|
}
|
|
643
974
|
|
|
644
975
|
|
|
@@ -651,6 +982,7 @@ async def context_get(
|
|
|
651
982
|
slug: str,
|
|
652
983
|
key: str = "latest",
|
|
653
984
|
session_id: str | None = None,
|
|
985
|
+
legacy: bool = False,
|
|
654
986
|
scope: str = "project",
|
|
655
987
|
) -> str | None:
|
|
656
988
|
"""Get a context checkpoint with session isolation.
|
|
@@ -658,14 +990,22 @@ async def context_get(
|
|
|
658
990
|
Args:
|
|
659
991
|
slug: Session identifier.
|
|
660
992
|
key: Checkpoint key (default "latest").
|
|
661
|
-
session_id: Required for
|
|
993
|
+
session_id: Required for session-scoped reads.
|
|
994
|
+
legacy: Explicitly opt into the unqualified legacy namespace when no
|
|
995
|
+
session_id is available.
|
|
662
996
|
scope: 'project' (default) or 'global'.
|
|
663
997
|
|
|
664
998
|
Returns:
|
|
665
999
|
Content string or None if not found/expired.
|
|
666
1000
|
"""
|
|
667
1001
|
conn = _db(scope)
|
|
668
|
-
|
|
1002
|
+
try:
|
|
1003
|
+
_validate_identifier(key, "key", MAX_CONTEXT_KEY_LENGTH)
|
|
1004
|
+
except ValueError:
|
|
1005
|
+
return None
|
|
1006
|
+
ns = _context_namespace(slug, session_id, legacy=legacy)
|
|
1007
|
+
if ns is None:
|
|
1008
|
+
return None
|
|
669
1009
|
row = conn.execute(
|
|
670
1010
|
"SELECT value FROM kv_store "
|
|
671
1011
|
"WHERE namespace = ? AND key = ? "
|
|
@@ -684,20 +1024,25 @@ async def context_get(
|
|
|
684
1024
|
async def context_list(
|
|
685
1025
|
slug: str,
|
|
686
1026
|
session_id: str | None = None,
|
|
1027
|
+
legacy: bool = False,
|
|
687
1028
|
scope: str = "project",
|
|
688
1029
|
) -> list[dict]:
|
|
689
1030
|
"""List all checkpoints for a session.
|
|
690
1031
|
|
|
691
1032
|
Args:
|
|
692
1033
|
slug: Session identifier.
|
|
693
|
-
session_id:
|
|
1034
|
+
session_id: Required for session-scoped reads.
|
|
1035
|
+
legacy: Explicitly opt into the unqualified legacy namespace when no
|
|
1036
|
+
session_id is available.
|
|
694
1037
|
scope: 'project' (default) or 'global'.
|
|
695
1038
|
|
|
696
1039
|
Returns:
|
|
697
1040
|
List of dicts with key, created_at, expires_at.
|
|
698
1041
|
"""
|
|
699
1042
|
conn = _db(scope)
|
|
700
|
-
ns =
|
|
1043
|
+
ns = _context_namespace(slug, session_id, legacy=legacy)
|
|
1044
|
+
if ns is None:
|
|
1045
|
+
return []
|
|
701
1046
|
rows = conn.execute(
|
|
702
1047
|
"SELECT key, created_at, expires_at FROM kv_store "
|
|
703
1048
|
"WHERE namespace = ? "
|
|
@@ -724,36 +1069,52 @@ async def context_list(
|
|
|
724
1069
|
async def context_stats(
|
|
725
1070
|
slug: str,
|
|
726
1071
|
session_id: str | None = None,
|
|
1072
|
+
legacy: bool = False,
|
|
727
1073
|
scope: str = "project",
|
|
728
1074
|
) -> dict:
|
|
729
1075
|
"""Return storage statistics for a session's context.
|
|
730
1076
|
|
|
731
1077
|
Args:
|
|
732
1078
|
slug: Session identifier.
|
|
733
|
-
session_id:
|
|
1079
|
+
session_id: Required for session-scoped reads.
|
|
1080
|
+
legacy: Explicitly opt into the unqualified legacy namespace when no
|
|
1081
|
+
session_id is available.
|
|
734
1082
|
scope: 'project' (default) or 'global'.
|
|
735
1083
|
|
|
736
1084
|
Returns:
|
|
737
1085
|
Dict with entry_count, total_bytes, ttl_remaining.
|
|
738
1086
|
"""
|
|
739
1087
|
conn = _db(scope)
|
|
740
|
-
ns =
|
|
1088
|
+
ns = _context_namespace(slug, session_id, legacy=legacy)
|
|
1089
|
+
if ns is None:
|
|
1090
|
+
return {
|
|
1091
|
+
"slug": slug,
|
|
1092
|
+
"namespace": None,
|
|
1093
|
+
"entry_count": 0,
|
|
1094
|
+
"expired_entries": 0,
|
|
1095
|
+
"total_bytes": 0,
|
|
1096
|
+
"ttl_remaining_seconds": None,
|
|
1097
|
+
}
|
|
741
1098
|
|
|
742
1099
|
count = conn.execute(
|
|
743
|
-
"SELECT COUNT(*) FROM kv_store
|
|
1100
|
+
"SELECT COUNT(*) FROM kv_store "
|
|
1101
|
+
"WHERE namespace = ? AND deleted_at IS NULL "
|
|
1102
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
|
|
744
1103
|
(ns,),
|
|
745
1104
|
).fetchone()[0]
|
|
746
1105
|
|
|
747
1106
|
expired = conn.execute(
|
|
748
1107
|
"SELECT COUNT(*) FROM kv_store "
|
|
749
|
-
"WHERE namespace = ? AND
|
|
1108
|
+
"WHERE namespace = ? AND expires_at IS NOT NULL "
|
|
1109
|
+
"AND datetime(expires_at) <= datetime('now') "
|
|
750
1110
|
"AND deleted_at IS NULL",
|
|
751
1111
|
(ns,),
|
|
752
1112
|
).fetchone()[0]
|
|
753
1113
|
|
|
754
1114
|
size = conn.execute(
|
|
755
1115
|
"SELECT COALESCE(SUM(LENGTH(value)), 0) FROM kv_store "
|
|
756
|
-
"WHERE namespace = ? AND deleted_at IS NULL"
|
|
1116
|
+
"WHERE namespace = ? AND deleted_at IS NULL "
|
|
1117
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
|
|
757
1118
|
(ns,),
|
|
758
1119
|
).fetchone()[0]
|
|
759
1120
|
|
|
@@ -784,7 +1145,8 @@ async def context_stats(
|
|
|
784
1145
|
|
|
785
1146
|
# ── Post-Compaction Injector (WS2 PR #94) ─────────────────────────────────────
|
|
786
1147
|
# Deterministic rehydration after a native compaction event: a long session
|
|
787
|
-
# reads ``latest`` + serialized ``tail`` from
|
|
1148
|
+
# reads ``latest`` + serialized ``tail`` from
|
|
1149
|
+
# ``checkpoint:<slug>:<session_id>`` and
|
|
788
1150
|
# rebuilds its critical context (active goal, in-flight delegations,
|
|
789
1151
|
# current phase) WITHOUT any LLM call or generative embedding.
|
|
790
1152
|
#
|
|
@@ -797,9 +1159,9 @@ async def context_stats(
|
|
|
797
1159
|
# "heartbeat": {...}}
|
|
798
1160
|
# ``tail`` may also live under a separate ``tail`` key (JSON array).
|
|
799
1161
|
#
|
|
800
|
-
# Guarantees: kill-switch PANTHEON_COMPACTION=off,
|
|
801
|
-
#
|
|
802
|
-
#
|
|
1162
|
+
# Guarantees: required session_id, kill-switch PANTHEON_COMPACTION=off,
|
|
1163
|
+
# TTL-respecting reads, no sibling fallback, conditional heartbeat TTL refresh,
|
|
1164
|
+
# and idempotent recovery (no checkpoint rows are added or duplicated).
|
|
803
1165
|
|
|
804
1166
|
COMPACTION_KILL_SWITCH_ENV: str = "PANTHEON_COMPACTION"
|
|
805
1167
|
SESSION_END_SUMMARY_ENV: str = "PANTHEON_SESSION_END_SUMMARY"
|
|
@@ -826,26 +1188,39 @@ def _read_checkpoint_value(
|
|
|
826
1188
|
return row[0] if row else None
|
|
827
1189
|
|
|
828
1190
|
|
|
829
|
-
def _parse_checkpoint(content: str) -> dict:
|
|
830
|
-
"""Parse
|
|
1191
|
+
def _parse_checkpoint(content: str) -> dict | None:
|
|
1192
|
+
"""Parse and validate one checkpoint; invalid JSON fails closed."""
|
|
1193
|
+
if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
|
|
1194
|
+
return None
|
|
831
1195
|
try:
|
|
832
1196
|
data = json.loads(content)
|
|
833
1197
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
834
|
-
return
|
|
835
|
-
|
|
1198
|
+
return None
|
|
1199
|
+
if not isinstance(data, dict):
|
|
1200
|
+
return None
|
|
1201
|
+
try:
|
|
1202
|
+
_validate_checkpoint_shape(data)
|
|
1203
|
+
except ValueError:
|
|
1204
|
+
return None
|
|
1205
|
+
return data
|
|
836
1206
|
|
|
837
1207
|
|
|
838
1208
|
def _checkpoint_tail(checkpoint: dict, tail_raw: str | None) -> list[str]:
|
|
839
1209
|
"""Tail lines from embedded ``tail`` with fallback to the ``tail`` key."""
|
|
840
1210
|
tail = checkpoint.get("tail")
|
|
841
1211
|
if isinstance(tail, list):
|
|
842
|
-
|
|
1212
|
+
try:
|
|
1213
|
+
lines = _validate_tail(tail)
|
|
1214
|
+
except ValueError:
|
|
1215
|
+
return []
|
|
843
1216
|
elif isinstance(tail_raw, str):
|
|
1217
|
+
if len(tail_raw.encode()) > MAX_CONTEXT_CONTENT_BYTES:
|
|
1218
|
+
return []
|
|
844
1219
|
try:
|
|
845
1220
|
parsed = json.loads(tail_raw)
|
|
1221
|
+
lines = _validate_tail(parsed, payload_bytes=len(tail_raw.encode()))
|
|
846
1222
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
847
|
-
|
|
848
|
-
lines = [str(line) for line in parsed] if isinstance(parsed, list) else []
|
|
1223
|
+
return []
|
|
849
1224
|
else:
|
|
850
1225
|
lines = []
|
|
851
1226
|
return [line for line in lines if line.strip()][:REHYDRATE_TAIL_CAP]
|
|
@@ -861,7 +1236,11 @@ def _mission_block(checkpoint: dict) -> str | None:
|
|
|
861
1236
|
if status == "done" or not objective:
|
|
862
1237
|
return None
|
|
863
1238
|
gid = str(goal.get("id", "goal")).strip() or "goal"
|
|
864
|
-
return
|
|
1239
|
+
return (
|
|
1240
|
+
f"<mission_context>\n{UNTRUSTED_CONTEXT_LABEL}\n"
|
|
1241
|
+
f" [{_safe_untrusted_text(gid)}] {_safe_untrusted_text(objective)}"
|
|
1242
|
+
f" — {_safe_untrusted_text(status)}"
|
|
1243
|
+
)
|
|
865
1244
|
|
|
866
1245
|
|
|
867
1246
|
def _phase_block(checkpoint: dict) -> str | None:
|
|
@@ -873,8 +1252,10 @@ def _phase_block(checkpoint: dict) -> str | None:
|
|
|
873
1252
|
current = phase.get("current")
|
|
874
1253
|
span = f"{current}/{total}" if total is not None else str(current)
|
|
875
1254
|
name = str(phase.get("name", "")).strip()
|
|
876
|
-
line = f" phase {span}"
|
|
877
|
-
|
|
1255
|
+
line = f" phase {_safe_untrusted_text(span)}"
|
|
1256
|
+
if name:
|
|
1257
|
+
line += f" — {_safe_untrusted_text(name)}"
|
|
1258
|
+
return f"<phase_context>\n{UNTRUSTED_CONTEXT_LABEL}\n{line}"
|
|
878
1259
|
|
|
879
1260
|
|
|
880
1261
|
def _delegation_block(checkpoint: dict) -> str | None:
|
|
@@ -892,20 +1273,31 @@ def _delegation_block(checkpoint: dict) -> str | None:
|
|
|
892
1273
|
alias = str(job.get("alias") or job.get("task_id") or "?").strip()
|
|
893
1274
|
agent = str(job.get("agent", "?")).strip()
|
|
894
1275
|
task_id = str(job.get("task_id", "")).strip()
|
|
895
|
-
detail =
|
|
896
|
-
|
|
1276
|
+
detail = (
|
|
1277
|
+
f" — {_safe_untrusted_text(task_id)}"
|
|
1278
|
+
if task_id and task_id != alias
|
|
1279
|
+
else ""
|
|
1280
|
+
)
|
|
1281
|
+
lines.append(
|
|
1282
|
+
f" [{_safe_untrusted_text(alias)}] {_safe_untrusted_text(agent)}"
|
|
1283
|
+
f"{detail} [in-flight]"
|
|
1284
|
+
)
|
|
897
1285
|
if not lines:
|
|
898
1286
|
return None
|
|
899
|
-
return "<delegation_context>\n" + "\n".join(lines)
|
|
1287
|
+
return "<delegation_context>\n" + UNTRUSTED_CONTEXT_LABEL + "\n" + "\n".join(lines)
|
|
900
1288
|
|
|
901
1289
|
|
|
902
1290
|
def _tail_block(checkpoint: dict) -> str | None:
|
|
903
1291
|
"""<tail_context> for the serialized tail (already capped on load)."""
|
|
904
1292
|
tail_lines = checkpoint.get("_tail_raw")
|
|
905
|
-
|
|
1293
|
+
try:
|
|
1294
|
+
validated_lines = _validate_tail(tail_lines)
|
|
1295
|
+
except ValueError:
|
|
1296
|
+
return None
|
|
1297
|
+
if not validated_lines:
|
|
906
1298
|
return None
|
|
907
|
-
quoted = "\n".join(f" - {line}" for line in
|
|
908
|
-
return f"<tail_context>\n{quoted}"
|
|
1299
|
+
quoted = "\n".join(f" - {_safe_untrusted_text(line)}" for line in validated_lines)
|
|
1300
|
+
return f"<tail_context>\n{UNTRUSTED_CONTEXT_LABEL}\n{quoted}"
|
|
909
1301
|
|
|
910
1302
|
|
|
911
1303
|
def build_rehydration_blocks(checkpoint: dict) -> list[str] | None:
|
|
@@ -944,7 +1336,10 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
|
|
|
944
1336
|
if isinstance(goal, dict):
|
|
945
1337
|
objective = str(goal.get("objective", "")).strip()
|
|
946
1338
|
if objective:
|
|
947
|
-
parts.append(
|
|
1339
|
+
parts.append(
|
|
1340
|
+
f"Goal: {_safe_untrusted_text(objective)} "
|
|
1341
|
+
f"({_safe_untrusted_text(goal.get('status', '?'))})"
|
|
1342
|
+
)
|
|
948
1343
|
|
|
949
1344
|
phase = checkpoint.get("phase")
|
|
950
1345
|
if isinstance(phase, dict) and phase.get("current") is not None:
|
|
@@ -955,14 +1350,17 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
|
|
|
955
1350
|
else phase.get("current")
|
|
956
1351
|
)
|
|
957
1352
|
name = str(phase.get("name", "")).strip()
|
|
958
|
-
parts.append(
|
|
1353
|
+
parts.append(
|
|
1354
|
+
f"Phase: {_safe_untrusted_text(span)}"
|
|
1355
|
+
+ (f" {_safe_untrusted_text(name)}" if name else "")
|
|
1356
|
+
)
|
|
959
1357
|
|
|
960
1358
|
delegations = checkpoint.get("delegations")
|
|
961
1359
|
if isinstance(delegations, dict):
|
|
962
1360
|
in_flight = delegations.get("in_flight")
|
|
963
1361
|
if isinstance(in_flight, list):
|
|
964
1362
|
aliases = [
|
|
965
|
-
str(job.get("alias") or job.get("task_id") or "?")
|
|
1363
|
+
_safe_untrusted_text(str(job.get("alias") or job.get("task_id") or "?"))
|
|
966
1364
|
for job in in_flight
|
|
967
1365
|
if isinstance(job, dict)
|
|
968
1366
|
]
|
|
@@ -971,7 +1369,7 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
|
|
|
971
1369
|
|
|
972
1370
|
tail_raw = checkpoint.get("_tail_raw")
|
|
973
1371
|
if isinstance(tail_raw, list) and tail_raw:
|
|
974
|
-
kept = [str(line) for line in tail_raw[:SUMMARY_TAIL_CAP]]
|
|
1372
|
+
kept = [_safe_untrusted_text(str(line)) for line in tail_raw[:SUMMARY_TAIL_CAP]]
|
|
975
1373
|
parts.append("Tail: " + " | ".join(kept))
|
|
976
1374
|
|
|
977
1375
|
if not parts:
|
|
@@ -984,68 +1382,16 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
|
|
|
984
1382
|
def _load_checkpoint_for_rehydrate(
|
|
985
1383
|
conn: sqlite3.Connection, namespace: str
|
|
986
1384
|
) -> dict | None:
|
|
987
|
-
"""Load ``latest
|
|
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
|
-
"""
|
|
1385
|
+
"""Load exactly ``latest``; expired, malformed, or empty data fails closed."""
|
|
995
1386
|
tail_raw = _read_checkpoint_value(conn, namespace, "tail")
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
def _checkpoint_namespaces(
|
|
1027
|
-
conn: sqlite3.Connection, slug: str, session_id: str | None
|
|
1028
|
-
) -> list[str]:
|
|
1029
|
-
"""Resolve checkpoint namespaces while preserving session isolation.
|
|
1030
|
-
|
|
1031
|
-
``context_save`` always creates a session-qualified namespace, including
|
|
1032
|
-
when the caller omits ``session_id`` (it generates one and returns it).
|
|
1033
|
-
Runtime callers may not retain that return value after compaction, so an
|
|
1034
|
-
unqualified rehydrate/summary must discover the newest matching session.
|
|
1035
|
-
The prefix comparison is parameterized and avoids LIKE wildcard leakage.
|
|
1036
|
-
"""
|
|
1037
|
-
if session_id:
|
|
1038
|
-
return [f"checkpoint:{slug}:{session_id}"]
|
|
1039
|
-
prefix = f"checkpoint:{slug}:"
|
|
1040
|
-
rows = conn.execute(
|
|
1041
|
-
"SELECT namespace FROM kv_store "
|
|
1042
|
-
"WHERE substr(namespace, 1, ?) = ? "
|
|
1043
|
-
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
|
|
1044
|
-
"AND deleted_at IS NULL "
|
|
1045
|
-
"GROUP BY namespace ORDER BY MAX(updated_at) DESC LIMIT 20",
|
|
1046
|
-
(len(prefix), prefix),
|
|
1047
|
-
).fetchall()
|
|
1048
|
-
return [str(row[0]) for row in rows if row and row[0]]
|
|
1387
|
+
raw = _read_checkpoint_value(conn, namespace, "latest")
|
|
1388
|
+
if raw is None:
|
|
1389
|
+
return None
|
|
1390
|
+
checkpoint = _parse_checkpoint(raw)
|
|
1391
|
+
if checkpoint is None:
|
|
1392
|
+
return None
|
|
1393
|
+
checkpoint["_tail_raw"] = _checkpoint_tail(checkpoint, tail_raw)
|
|
1394
|
+
return checkpoint if build_rehydration_blocks(checkpoint) is not None else None
|
|
1049
1395
|
|
|
1050
1396
|
|
|
1051
1397
|
def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
|
|
@@ -1066,15 +1412,19 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
|
|
|
1066
1412
|
now = datetime.now(UTC)
|
|
1067
1413
|
if current_exp.tzinfo is None:
|
|
1068
1414
|
current_exp = current_exp.replace(tzinfo=UTC)
|
|
1069
|
-
fresh_exp = now + timedelta(seconds=
|
|
1415
|
+
fresh_exp = now + timedelta(seconds=CONTEXT_HEARTBEAT_TTL)
|
|
1070
1416
|
if fresh_exp <= current_exp:
|
|
1071
1417
|
return
|
|
1072
|
-
conn
|
|
1073
|
-
|
|
1074
|
-
"
|
|
1075
|
-
|
|
1418
|
+
revision = _next_context_revision(conn, namespace, "heartbeat")
|
|
1419
|
+
cursor = conn.execute(
|
|
1420
|
+
"UPDATE kv_store SET expires_at = ?, updated_at = ? "
|
|
1421
|
+
"WHERE namespace = ? AND key = 'heartbeat' AND expires_at = ? "
|
|
1422
|
+
"AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
|
|
1423
|
+
"AND deleted_at IS NULL",
|
|
1424
|
+
(fresh_exp.isoformat(), str(revision), namespace, row[0]),
|
|
1076
1425
|
)
|
|
1077
|
-
|
|
1426
|
+
if cursor.rowcount:
|
|
1427
|
+
conn.commit()
|
|
1078
1428
|
|
|
1079
1429
|
|
|
1080
1430
|
@mcp.tool(
|
|
@@ -1083,32 +1433,32 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
|
|
|
1083
1433
|
"Reads 'latest' + serialized 'tail' from a session-qualified checkpoint "
|
|
1084
1434
|
"namespace and rebuilds "
|
|
1085
1435
|
"goal/phase/delegation blocks deterministically (no LLM). Refreshes the "
|
|
1086
|
-
"heartbeat TTL.
|
|
1436
|
+
"heartbeat TTL. Requires session_id and honors PANTHEON_COMPACTION=off.",
|
|
1087
1437
|
)
|
|
1088
1438
|
async def context_rehydrate(
|
|
1089
1439
|
slug: str,
|
|
1090
|
-
session_id: str
|
|
1440
|
+
session_id: str,
|
|
1091
1441
|
scope: str = "project",
|
|
1092
1442
|
) -> list[str] | None:
|
|
1093
|
-
"""
|
|
1443
|
+
"""Rehydrate one known session; missing IDs fail closed."""
|
|
1094
1444
|
if _compaction_disabled():
|
|
1095
1445
|
return None
|
|
1446
|
+
normalized_session = _normalize_session_id(session_id)
|
|
1447
|
+
if normalized_session is None:
|
|
1448
|
+
raise ValueError("session_id is required and must be non-empty")
|
|
1449
|
+
valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
|
|
1450
|
+
valid_session = _validate_identifier(
|
|
1451
|
+
normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
|
|
1452
|
+
)
|
|
1096
1453
|
conn = _db(scope)
|
|
1097
|
-
|
|
1098
|
-
checkpoint =
|
|
1099
|
-
selected_namespace = None
|
|
1100
|
-
for namespace in namespaces:
|
|
1101
|
-
checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
|
|
1102
|
-
if checkpoint is not None:
|
|
1103
|
-
selected_namespace = namespace
|
|
1104
|
-
break
|
|
1454
|
+
namespace = f"checkpoint:{valid_slug}:{valid_session}"
|
|
1455
|
+
checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
|
|
1105
1456
|
if checkpoint is None:
|
|
1106
1457
|
return None
|
|
1107
1458
|
blocks = build_rehydration_blocks(checkpoint)
|
|
1108
1459
|
if blocks is None:
|
|
1109
1460
|
return None
|
|
1110
|
-
|
|
1111
|
-
_refresh_heartbeat_ttl(conn, selected_namespace)
|
|
1461
|
+
_refresh_heartbeat_ttl(conn, namespace)
|
|
1112
1462
|
return blocks
|
|
1113
1463
|
|
|
1114
1464
|
|
|
@@ -1117,23 +1467,26 @@ async def context_rehydrate(
|
|
|
1117
1467
|
description="Tier 2 session-end summary without Themis approval. "
|
|
1118
1468
|
"Compresses the checkpoint (goal, phase, in-flight, tail) into a "
|
|
1119
1469
|
"memory-bank-ready summary deterministically (no LLM). "
|
|
1120
|
-
"Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
|
|
1470
|
+
"Requires session_id. Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
|
|
1121
1471
|
)
|
|
1122
1472
|
async def context_session_summary(
|
|
1123
1473
|
slug: str,
|
|
1124
|
-
session_id: str
|
|
1474
|
+
session_id: str,
|
|
1125
1475
|
scope: str = "project",
|
|
1126
1476
|
) -> str | None:
|
|
1127
|
-
"""
|
|
1477
|
+
"""Summarize one known session; missing IDs fail closed."""
|
|
1128
1478
|
if os.environ.get(SESSION_END_SUMMARY_ENV, "").lower() == "off":
|
|
1129
1479
|
return None
|
|
1480
|
+
normalized_session = _normalize_session_id(session_id)
|
|
1481
|
+
if normalized_session is None:
|
|
1482
|
+
raise ValueError("session_id is required and must be non-empty")
|
|
1483
|
+
valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
|
|
1484
|
+
valid_session = _validate_identifier(
|
|
1485
|
+
normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
|
|
1486
|
+
)
|
|
1130
1487
|
conn = _db(scope)
|
|
1131
|
-
|
|
1132
|
-
checkpoint =
|
|
1133
|
-
for namespace in namespaces:
|
|
1134
|
-
checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
|
|
1135
|
-
if checkpoint is not None:
|
|
1136
|
-
break
|
|
1488
|
+
namespace = f"checkpoint:{valid_slug}:{valid_session}"
|
|
1489
|
+
checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
|
|
1137
1490
|
if checkpoint is None:
|
|
1138
1491
|
return None
|
|
1139
1492
|
return build_session_end_summary(checkpoint)
|