pantheon-opencode 1.4.2 → 1.5.0-beta.1

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 (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -11,11 +11,13 @@ Usage:
11
11
  from __future__ import annotations
12
12
 
13
13
  import argparse
14
+ import json
15
+ import os
14
16
  import re
15
17
  import sqlite3
16
18
  import sys
17
19
  import uuid
18
- from datetime import datetime, timedelta, timezone
20
+ from datetime import UTC, datetime, timedelta
19
21
  from pathlib import Path
20
22
 
21
23
  from _pantheon_paths import pantheon_home, pantheon_project
@@ -159,7 +161,7 @@ def _write_deletelog(db_path: Path, count: int, keys: list[str]) -> None:
159
161
  log_path = db_path.with_name(db_path.name + ".deletelog")
160
162
  _rotate_deletelog(log_path)
161
163
 
162
- timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
164
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
163
165
  keys_str = ",".join(keys)
164
166
  with open(log_path, "a", encoding="utf-8") as f:
165
167
  f.write(f"[{timestamp}] PURGED={count} KEYS=[{keys_str}]\n")
@@ -220,7 +222,7 @@ async def kv_store(
220
222
  conn = _db(scope)
221
223
  expires_at: str | None = None
222
224
  if ttl is not None:
223
- expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl)).isoformat()
225
+ expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat()
224
226
 
225
227
  conn.execute(
226
228
  "INSERT INTO kv_store (namespace, key, value, expires_at, "
@@ -287,7 +289,9 @@ async def kv_stats(
287
289
  """
288
290
  conn = _db(scope)
289
291
 
290
- total = conn.execute("SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL").fetchone()[0]
292
+ total = conn.execute(
293
+ "SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL"
294
+ ).fetchone()[0]
291
295
  expired = conn.execute(
292
296
  "SELECT COUNT(*) FROM kv_store "
293
297
  "WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') "
@@ -300,10 +304,7 @@ async def kv_stats(
300
304
  "FROM kv_store WHERE deleted_at IS NULL GROUP BY namespace ORDER BY cnt DESC"
301
305
  ).fetchall()
302
306
 
303
- namespaces = {
304
- r[0]: {"count": r[1], "expired": r[2]}
305
- for r in ns_rows
306
- }
307
+ namespaces = {r[0]: {"count": r[1], "expired": r[2]} for r in ns_rows}
307
308
 
308
309
  db_path = _resolve_db_path(scope)
309
310
  db_size = db_path.stat().st_size if db_path and db_path.exists() else 0
@@ -316,6 +317,7 @@ async def kv_stats(
316
317
  "db_size_bytes": db_size,
317
318
  }
318
319
 
320
+
319
321
  @mcp.tool(
320
322
  name="kv_delete",
321
323
  description="Delete a key-value pair by namespace and key. "
@@ -504,8 +506,9 @@ async def purge_expired(
504
506
  return {"purged": count, "dry_run": False}
505
507
 
506
508
 
507
-
508
- def _opportunistic_auto_purge(conn: sqlite3.Connection, namespace: str, threshold: int = 500) -> None:
509
+ def _opportunistic_auto_purge(
510
+ conn: sqlite3.Connection, namespace: str, threshold: int = 500
511
+ ) -> None:
509
512
  """Lightweight auto-purge: if namespace exceeds threshold, soft-delete expired entries."""
510
513
  count = conn.execute(
511
514
  "SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
@@ -603,7 +606,7 @@ async def context_save(
603
606
  actual_ttl = ttl if ttl is not None else DEFAULT_CONTEXT_TTL
604
607
 
605
608
  conn = _db(scope)
606
- expires_at = (datetime.now(timezone.utc) + timedelta(seconds=actual_ttl)).isoformat()
609
+ expires_at = (datetime.now(UTC) + timedelta(seconds=actual_ttl)).isoformat()
607
610
 
608
611
  conn.execute(
609
612
  "INSERT INTO kv_store (namespace, key, value, expires_at, "
@@ -630,7 +633,13 @@ async def context_save(
630
633
  )
631
634
  conn.commit()
632
635
 
633
- return {"status": "stored", "namespace": ns, "key": key, "session_id": actual_session, "ttl": actual_ttl}
636
+ return {
637
+ "status": "stored",
638
+ "namespace": ns,
639
+ "key": key,
640
+ "session_id": actual_session,
641
+ "ttl": actual_ttl,
642
+ }
634
643
 
635
644
 
636
645
  @mcp.tool(
@@ -731,8 +740,7 @@ async def context_stats(
731
740
  ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
732
741
 
733
742
  count = conn.execute(
734
- "SELECT COUNT(*) FROM kv_store "
735
- "WHERE namespace = ? AND deleted_at IS NULL",
743
+ "SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
736
744
  (ns,),
737
745
  ).fetchone()[0]
738
746
 
@@ -759,7 +767,7 @@ async def context_stats(
759
767
  if row and row[0]:
760
768
  try:
761
769
  exp = datetime.fromisoformat(row[0])
762
- now = datetime.now(timezone.utc)
770
+ now = datetime.now(UTC)
763
771
  remaining = max(0, int((exp - now).total_seconds()))
764
772
  except (ValueError, TypeError):
765
773
  pass
@@ -773,6 +781,327 @@ async def context_stats(
773
781
  "ttl_remaining_seconds": remaining,
774
782
  }
775
783
 
784
+
785
+ # ── Post-Compaction Injector (WS2 PR #94) ─────────────────────────────────────
786
+ # Deterministic rehydration after a native compaction event: a long session
787
+ # reads ``latest`` + serialized ``tail`` from ``checkpoint:<slug>`` and
788
+ # rebuilds its critical context (active goal, in-flight delegations,
789
+ # current phase) WITHOUT any LLM call or generative embedding.
790
+ #
791
+ # Checkpoint JSON convention (written by Zeus via context_save):
792
+ # {"version": 1,
793
+ # "goal": {"id": ..., "objective": ..., "status": ...},
794
+ # "phase": {"current": ..., "total": ..., "name": ...},
795
+ # "delegations": {"in_flight": [{"alias": ..., "agent": ..., "task_id": ...}]},
796
+ # "tail": ["phase:1 ...", ...], # serialized last-N phase digests
797
+ # "heartbeat": {...}}
798
+ # ``tail`` may also live under a separate ``tail`` key (JSON array).
799
+ #
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).
803
+
804
+ COMPACTION_KILL_SWITCH_ENV: str = "PANTHEON_COMPACTION"
805
+ SESSION_END_SUMMARY_ENV: str = "PANTHEON_SESSION_END_SUMMARY"
806
+ REHYDRATE_TAIL_CAP: int = 10
807
+ SUMMARY_TAIL_CAP: int = 5
808
+
809
+
810
+ def _compaction_disabled() -> bool:
811
+ """True when the PANTHEON_COMPACTION kill-switch is off."""
812
+ return os.environ.get(COMPACTION_KILL_SWITCH_ENV, "").lower() == "off"
813
+
814
+
815
+ def _read_checkpoint_value(
816
+ conn: sqlite3.Connection, namespace: str, key: str
817
+ ) -> str | None:
818
+ """Read one unexpired, non-deleted checkpoint value (None when absent)."""
819
+ row = conn.execute(
820
+ "SELECT value FROM kv_store "
821
+ "WHERE namespace = ? AND key = ? "
822
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
823
+ "AND deleted_at IS NULL",
824
+ (namespace, key),
825
+ ).fetchone()
826
+ return row[0] if row else None
827
+
828
+
829
+ def _parse_checkpoint(content: str) -> dict:
830
+ """Parse checkpoint JSON; unparsable content degrades to {} (fail-open)."""
831
+ try:
832
+ data = json.loads(content)
833
+ except (json.JSONDecodeError, TypeError, ValueError):
834
+ return {}
835
+ return data if isinstance(data, dict) else {}
836
+
837
+
838
+ def _checkpoint_tail(checkpoint: dict, tail_raw: str | None) -> list[str]:
839
+ """Tail lines from embedded ``tail`` with fallback to the ``tail`` key."""
840
+ tail = checkpoint.get("tail")
841
+ if isinstance(tail, list):
842
+ lines = [str(line) for line in tail]
843
+ elif isinstance(tail_raw, str):
844
+ try:
845
+ parsed = json.loads(tail_raw)
846
+ except (json.JSONDecodeError, TypeError, ValueError):
847
+ parsed = None
848
+ lines = [str(line) for line in parsed] if isinstance(parsed, list) else []
849
+ else:
850
+ lines = []
851
+ return [line for line in lines if line.strip()][:REHYDRATE_TAIL_CAP]
852
+
853
+
854
+ def _mission_block(checkpoint: dict) -> str | None:
855
+ """<mission_context> for a non-done goal (done goals stay buried)."""
856
+ goal = checkpoint.get("goal")
857
+ if not isinstance(goal, dict):
858
+ return None
859
+ status = str(goal.get("status", ""))
860
+ objective = str(goal.get("objective", "")).strip()
861
+ if status == "done" or not objective:
862
+ return None
863
+ gid = str(goal.get("id", "goal")).strip() or "goal"
864
+ return f"<mission_context>\n [{gid}] {objective} — {status}"
865
+
866
+
867
+ def _phase_block(checkpoint: dict) -> str | None:
868
+ """<phase_context> for the current phase (omitted when unknown)."""
869
+ phase = checkpoint.get("phase")
870
+ if not isinstance(phase, dict) or phase.get("current") is None:
871
+ return None
872
+ total = phase.get("total")
873
+ current = phase.get("current")
874
+ span = f"{current}/{total}" if total is not None else str(current)
875
+ name = str(phase.get("name", "")).strip()
876
+ line = f" phase {span}" + (f" — {name}" if name else "")
877
+ return f"<phase_context>\n{line}"
878
+
879
+
880
+ def _delegation_block(checkpoint: dict) -> str | None:
881
+ """<delegation_context> for in-flight delegations (never capped)."""
882
+ delegations = checkpoint.get("delegations")
883
+ if not isinstance(delegations, dict):
884
+ return None
885
+ in_flight = delegations.get("in_flight")
886
+ if not isinstance(in_flight, list):
887
+ return None
888
+ lines = []
889
+ for job in in_flight:
890
+ if not isinstance(job, dict):
891
+ continue
892
+ alias = str(job.get("alias") or job.get("task_id") or "?").strip()
893
+ agent = str(job.get("agent", "?")).strip()
894
+ 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]")
897
+ if not lines:
898
+ return None
899
+ return "<delegation_context>\n" + "\n".join(lines)
900
+
901
+
902
+ def _tail_block(checkpoint: dict) -> str | None:
903
+ """<tail_context> for the serialized tail (already capped on load)."""
904
+ tail_lines = checkpoint.get("_tail_raw")
905
+ if not isinstance(tail_lines, list) or not tail_lines:
906
+ return None
907
+ quoted = "\n".join(f" - {line}" for line in tail_lines)
908
+ return f"<tail_context>\n{quoted}"
909
+
910
+
911
+ def build_rehydration_blocks(checkpoint: dict) -> list[str] | None:
912
+ """Build deterministic rehydration blocks from a checkpoint dict.
913
+
914
+ Pure string templating — no LLM, no embeddings. Mirrors the
915
+ ``<mission_context>`` convention of delegation-compaction.ts: only a
916
+ non-done goal is re-emitted. Returns None when nothing critical remains.
917
+ """
918
+ if not isinstance(checkpoint, dict) or not checkpoint:
919
+ return None
920
+ blocks = [
921
+ block
922
+ for block in (
923
+ _mission_block(checkpoint),
924
+ _phase_block(checkpoint),
925
+ _delegation_block(checkpoint),
926
+ _tail_block(checkpoint),
927
+ )
928
+ if block is not None
929
+ ]
930
+ return blocks if blocks else None
931
+
932
+
933
+ def build_session_end_summary(checkpoint: dict) -> str | None:
934
+ """Build the Tier 2 session-end summary deterministically (no LLM).
935
+
936
+ Fires automatically at session end — no Themis approval gate. Returns
937
+ None when the checkpoint carries nothing worth compressing.
938
+ """
939
+ if not isinstance(checkpoint, dict) or not checkpoint:
940
+ return None
941
+ parts: list[str] = []
942
+
943
+ goal = checkpoint.get("goal")
944
+ if isinstance(goal, dict):
945
+ objective = str(goal.get("objective", "")).strip()
946
+ if objective:
947
+ parts.append(f"Goal: {objective} ({goal.get('status', '?')})")
948
+
949
+ phase = checkpoint.get("phase")
950
+ if isinstance(phase, dict) and phase.get("current") is not None:
951
+ total = phase.get("total")
952
+ span = (
953
+ f"{phase.get('current')}/{total}"
954
+ if total is not None
955
+ else phase.get("current")
956
+ )
957
+ name = str(phase.get("name", "")).strip()
958
+ parts.append(f"Phase: {span}" + (f" {name}" if name else ""))
959
+
960
+ delegations = checkpoint.get("delegations")
961
+ if isinstance(delegations, dict):
962
+ in_flight = delegations.get("in_flight")
963
+ if isinstance(in_flight, list):
964
+ aliases = [
965
+ str(job.get("alias") or job.get("task_id") or "?")
966
+ for job in in_flight
967
+ if isinstance(job, dict)
968
+ ]
969
+ if aliases:
970
+ parts.append(f"In-flight: {', '.join(aliases)}")
971
+
972
+ tail_raw = checkpoint.get("_tail_raw")
973
+ if isinstance(tail_raw, list) and tail_raw:
974
+ kept = [str(line) for line in tail_raw[:SUMMARY_TAIL_CAP]]
975
+ parts.append("Tail: " + " | ".join(kept))
976
+
977
+ if not parts:
978
+ return None
979
+ return "# Session summary (Tier 2 auto — no approval gate)\n" + "\n".join(
980
+ f"- {part}" for part in parts
981
+ )
982
+
983
+
984
+ def _load_checkpoint_for_rehydrate(
985
+ conn: sqlite3.Connection, namespace: str
986
+ ) -> 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
+ """
995
+ 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
1024
+
1025
+
1026
+ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1027
+ """Extend an unexpired heartbeat TTL; never shorten, never resurrect."""
1028
+ row = conn.execute(
1029
+ "SELECT expires_at FROM kv_store "
1030
+ "WHERE namespace = ? AND key = 'heartbeat' "
1031
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1032
+ "AND deleted_at IS NULL",
1033
+ (namespace,),
1034
+ ).fetchone()
1035
+ if not row or not row[0]:
1036
+ return
1037
+ try:
1038
+ current_exp = datetime.fromisoformat(row[0])
1039
+ except (ValueError, TypeError):
1040
+ return
1041
+ now = datetime.now(UTC)
1042
+ if current_exp.tzinfo is None:
1043
+ current_exp = current_exp.replace(tzinfo=UTC)
1044
+ fresh_exp = now + timedelta(seconds=DEFAULT_CONTEXT_TTL)
1045
+ if fresh_exp <= current_exp:
1046
+ 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),
1051
+ )
1052
+ conn.commit()
1053
+
1054
+
1055
+ @mcp.tool(
1056
+ name="context_rehydrate",
1057
+ description="Rehydrate critical context after native compaction. "
1058
+ "Reads 'latest' + serialized 'tail' from checkpoint:<slug> and rebuilds "
1059
+ "goal/phase/delegation blocks deterministically (no LLM). Refreshes the "
1060
+ "heartbeat TTL. Idempotent. Honors PANTHEON_COMPACTION=off.",
1061
+ )
1062
+ async def context_rehydrate(
1063
+ slug: str,
1064
+ session_id: str | None = None,
1065
+ scope: str = "project",
1066
+ ) -> list[str] | None:
1067
+ """Post-compaction injector: deterministic rehydration, fail-open."""
1068
+ if _compaction_disabled():
1069
+ return None
1070
+ 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)
1073
+ if checkpoint is None:
1074
+ return None
1075
+ blocks = build_rehydration_blocks(checkpoint)
1076
+ if blocks is None:
1077
+ return None
1078
+ _refresh_heartbeat_ttl(conn, ns)
1079
+ return blocks
1080
+
1081
+
1082
+ @mcp.tool(
1083
+ name="context_session_summary",
1084
+ description="Tier 2 session-end summary without Themis approval. "
1085
+ "Compresses the checkpoint (goal, phase, in-flight, tail) into a "
1086
+ "memory-bank-ready summary deterministically (no LLM). "
1087
+ "Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
1088
+ )
1089
+ async def context_session_summary(
1090
+ slug: str,
1091
+ session_id: str | None = None,
1092
+ scope: str = "project",
1093
+ ) -> str | None:
1094
+ """Session-end Tier 2 trigger: automatic, opt-out instead of opt-in."""
1095
+ if os.environ.get(SESSION_END_SUMMARY_ENV, "").lower() == "off":
1096
+ return None
1097
+ 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)
1100
+ if checkpoint is None:
1101
+ return None
1102
+ return build_session_end_summary(checkpoint)
1103
+
1104
+
776
1105
  def _resolve_db_path(scope: str) -> Path | None:
777
1106
  """Resolve the database file path for a given scope.
778
1107