pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pantheon/code-mode/manifest.json +21 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +171 -16
- package/README.pt-BR.md +99 -13
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +23 -14
- package/plugin.json +1 -1
- package/pyproject.toml +65 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +178 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +137 -35
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +537 -128
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +121 -53
- package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +537 -128
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +43 -1
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +345 -62
- package/src/pantheon/delegate-manager.ts +390 -8
- package/src/pantheon/delegation-finalize.ts +32 -0
- package/src/pantheon/delegation.ts +235 -18
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +146 -12
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +18 -1
- package/src/plugin-v2.ts +2 -14
- package/src/plugin.ts +28 -17
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +116 -27
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +414 -150
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +447 -84
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +447 -84
- package/.pantheon/code-mode/.gitkeep +0 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
- 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
|
|
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,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.
|
|
569
|
-
# "checkpoint:{slug}"
|
|
570
|
+
# tool call history, and reasoning chains. Session-qualified entries use the
|
|
571
|
+
# namespace "checkpoint:{slug}:{session}"; direct unqualified KV access is
|
|
572
|
+
# retained only for legacy context_get/list/stats callers.
|
|
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
|
|
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
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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:
|
|
598
|
-
ttl: TTL in seconds (
|
|
926
|
+
session_id: Required unique session ID for namespace isolation.
|
|
927
|
+
ttl: TTL in seconds (defaults to the key-specific context policy).
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
(
|
|
633
|
-
|
|
634
|
-
|
|
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":
|
|
640
|
-
"session_id":
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
801
|
-
#
|
|
802
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}"
|
|
877
|
-
|
|
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 =
|
|
896
|
-
|
|
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
|
-
|
|
1312
|
+
try:
|
|
1313
|
+
validated_lines = _validate_tail(tail_lines)
|
|
1314
|
+
except ValueError:
|
|
906
1315
|
return None
|
|
907
|
-
|
|
908
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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=
|
|
1434
|
+
fresh_exp = now + timedelta(seconds=CONTEXT_HEARTBEAT_TTL)
|
|
1045
1435
|
if fresh_exp <= current_exp:
|
|
1046
1436
|
return
|
|
1047
|
-
conn
|
|
1048
|
-
|
|
1049
|
-
"
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
1459
|
+
session_id: str,
|
|
1065
1460
|
scope: str = "project",
|
|
1066
1461
|
) -> list[str] | None:
|
|
1067
|
-
"""
|
|
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
|
-
|
|
1072
|
-
checkpoint = _load_checkpoint_for_rehydrate(conn,
|
|
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,
|
|
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
|
|
1493
|
+
session_id: str,
|
|
1092
1494
|
scope: str = "project",
|
|
1093
1495
|
) -> str | None:
|
|
1094
|
-
"""
|
|
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
|
-
|
|
1099
|
-
checkpoint = _load_checkpoint_for_rehydrate(conn,
|
|
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)
|