pantheon-opencode 1.1.3-beta.0 → 1.2.0
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/opencode.json +1 -34
- package/package.json +4 -4
- package/scripts/mcp_persistence_server.py +331 -1
- package/src/agents/aphrodite.md +7 -0
- package/src/agents/demeter.md +7 -0
- package/src/agents/hephaestus.md +7 -0
- package/src/agents/hermes.md +7 -0
- package/src/agents/zeus.md +31 -18
- package/src/instructions/agent-return-format.instructions.md +1 -0
- package/src/instructions/zeus-anti-stall.instructions.md +43 -5
- package/src/instructions/zeus-timeout-retry.instructions.md +47 -10
- package/src/mcp/mcp_persistence_server.py +331 -0
- package/src/routing.yml +31 -0
package/opencode.json
CHANGED
|
@@ -40,41 +40,8 @@
|
|
|
40
40
|
"themis": {
|
|
41
41
|
"model": "opencode-go/deepseek-v4-pro"
|
|
42
42
|
},
|
|
43
|
-
"zeus": {
|
|
44
|
-
"model": "opencode-go/deepseek-v4-pro"
|
|
45
|
-
},
|
|
46
43
|
"hephaestus": {
|
|
47
44
|
"model": "opencode-go/deepseek-v4-pro"
|
|
48
|
-
},
|
|
49
|
-
"hermes": {
|
|
50
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
51
|
-
},
|
|
52
|
-
"aphrodite": {
|
|
53
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
54
|
-
},
|
|
55
|
-
"demeter": {
|
|
56
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
57
|
-
},
|
|
58
|
-
"prometheus": {
|
|
59
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
60
|
-
},
|
|
61
|
-
"apollo": {
|
|
62
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
63
|
-
},
|
|
64
|
-
"nyx": {
|
|
65
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
66
|
-
},
|
|
67
|
-
"gaia": {
|
|
68
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
69
|
-
},
|
|
70
|
-
"iris": {
|
|
71
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
72
|
-
},
|
|
73
|
-
"mnemosyne": {
|
|
74
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
75
|
-
},
|
|
76
|
-
"talos": {
|
|
77
|
-
"model": "opencode-go/deepseek-v4-flash"
|
|
78
45
|
}
|
|
79
46
|
}
|
|
80
|
-
}
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-opencode",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Multi-agent orchestration for OpenCode
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Multi-agent orchestration for OpenCode \u2014 14 specialized agents, TDD, Themis review gates.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
7
7
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"opencode": "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode",
|
|
16
16
|
"start": "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode",
|
|
17
17
|
"prepublishOnly": "node scripts/validate-routing.mjs",
|
|
18
|
-
"postpublish": "node -e \"console.log('
|
|
18
|
+
"postpublish": "node -e \"console.log('\u2705 Published pantheon-opencode v' + require('./package.json').version + ' to npm')\"",
|
|
19
19
|
"version:status": "node scripts/versioning.mjs status",
|
|
20
20
|
"version:apply": "node scripts/versioning.mjs apply",
|
|
21
21
|
"release:dry-run": "node scripts/versioning.mjs status && npm pack --dry-run",
|
|
@@ -80,4 +80,4 @@
|
|
|
80
80
|
"node": ">=18"
|
|
81
81
|
},
|
|
82
82
|
"preferGlobal": true
|
|
83
|
-
}
|
|
83
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# Auto-generated: resolved symlink from ../src/mcp/mcp_persistence_server.py
|
|
2
1
|
#!/usr/bin/env python3
|
|
3
2
|
"""Pantheon Persistence MCP Server.
|
|
4
3
|
|
|
@@ -36,6 +35,9 @@ CREATE TABLE IF NOT EXISTS kv_store (
|
|
|
36
35
|
UNIQUE(namespace, key)
|
|
37
36
|
);
|
|
38
37
|
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_kv_namespace_expires
|
|
39
|
+
ON kv_store(namespace, expires_at);
|
|
40
|
+
|
|
39
41
|
CREATE VIRTUAL TABLE IF NOT EXISTS kv_store_fts USING fts5(
|
|
40
42
|
namespace, key, value,
|
|
41
43
|
content='kv_store', content_rowid='id',
|
|
@@ -52,6 +54,14 @@ CREATE TRIGGER IF NOT EXISTS kv_store_ad AFTER DELETE ON kv_store BEGIN
|
|
|
52
54
|
VALUES('delete', old.id, old.namespace, old.key, old.value);
|
|
53
55
|
END;
|
|
54
56
|
|
|
57
|
+
CREATE TRIGGER IF NOT EXISTS kv_store_softdelete_ad
|
|
58
|
+
AFTER UPDATE OF deleted_at ON kv_store
|
|
59
|
+
WHEN new.deleted_at IS NOT NULL AND old.deleted_at IS NULL
|
|
60
|
+
BEGIN
|
|
61
|
+
INSERT INTO kv_store_fts(kv_store_fts, rowid)
|
|
62
|
+
VALUES('delete', old.id);
|
|
63
|
+
END;
|
|
64
|
+
|
|
55
65
|
CREATE TRIGGER IF NOT EXISTS kv_store_au AFTER UPDATE ON kv_store BEGIN
|
|
56
66
|
INSERT INTO kv_store_fts(kv_store_fts, rowid, namespace, key, value)
|
|
57
67
|
VALUES('delete', old.id, old.namespace, old.key, old.value);
|
|
@@ -220,6 +230,10 @@ async def kv_store(
|
|
|
220
230
|
(namespace, key, value, expires_at),
|
|
221
231
|
)
|
|
222
232
|
conn.commit()
|
|
233
|
+
|
|
234
|
+
# Opportunistic auto-purge: if namespace has >500 entries, purge expired
|
|
235
|
+
_opportunistic_auto_purge(conn, namespace)
|
|
236
|
+
|
|
223
237
|
return {"status": "stored", "namespace": namespace, "key": key}
|
|
224
238
|
|
|
225
239
|
|
|
@@ -254,6 +268,52 @@ async def kv_get(
|
|
|
254
268
|
return str(row[0]) if row else None
|
|
255
269
|
|
|
256
270
|
|
|
271
|
+
@mcp.tool(
|
|
272
|
+
name="kv_stats",
|
|
273
|
+
description="Return storage statistics: total entries, expired count, per-namespace breakdown, DB file size.",
|
|
274
|
+
)
|
|
275
|
+
async def kv_stats(
|
|
276
|
+
scope: str = "project",
|
|
277
|
+
) -> dict:
|
|
278
|
+
"""Return storage statistics for the given scope.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
scope: 'project' (default) or 'global'.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Dict with total_entries, expired_entries, namespaces breakdown, db_size_bytes.
|
|
285
|
+
"""
|
|
286
|
+
conn = _db(scope)
|
|
287
|
+
|
|
288
|
+
total = conn.execute("SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL").fetchone()[0]
|
|
289
|
+
expired = conn.execute(
|
|
290
|
+
"SELECT COUNT(*) FROM kv_store "
|
|
291
|
+
"WHERE expires_at IS NOT NULL AND expires_at < datetime('now') "
|
|
292
|
+
"AND deleted_at IS NULL"
|
|
293
|
+
).fetchone()[0]
|
|
294
|
+
|
|
295
|
+
ns_rows = conn.execute(
|
|
296
|
+
"SELECT namespace, COUNT(*) as cnt, "
|
|
297
|
+
"SUM(CASE WHEN expires_at IS NOT NULL AND expires_at < datetime('now') THEN 1 ELSE 0 END) as expired_count "
|
|
298
|
+
"FROM kv_store WHERE deleted_at IS NULL GROUP BY namespace ORDER BY cnt DESC"
|
|
299
|
+
).fetchall()
|
|
300
|
+
|
|
301
|
+
namespaces = {
|
|
302
|
+
r[0]: {"count": r[1], "expired": r[2]}
|
|
303
|
+
for r in ns_rows
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
db_path = _resolve_db_path(scope)
|
|
307
|
+
db_size = db_path.stat().st_size if db_path and db_path.exists() else 0
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"scope": scope,
|
|
311
|
+
"total_entries": total,
|
|
312
|
+
"expired_entries": expired,
|
|
313
|
+
"namespaces": namespaces,
|
|
314
|
+
"db_size_bytes": db_size,
|
|
315
|
+
}
|
|
316
|
+
|
|
257
317
|
@mcp.tool(
|
|
258
318
|
name="kv_delete",
|
|
259
319
|
description="Delete a key-value pair by namespace and key. "
|
|
@@ -353,6 +413,7 @@ async def kv_search(
|
|
|
353
413
|
|
|
354
414
|
# Sanitize: extract up to 10 word tokens, wrap each in quotes
|
|
355
415
|
terms = re.findall(r"\w+", query)[:10]
|
|
416
|
+
terms = [re.escape(t) for t in terms]
|
|
356
417
|
if not terms:
|
|
357
418
|
return []
|
|
358
419
|
fts_query = " OR ".join(f'"{t}"' for t in terms)
|
|
@@ -441,6 +502,275 @@ async def purge_expired(
|
|
|
441
502
|
return {"purged": count, "dry_run": False}
|
|
442
503
|
|
|
443
504
|
|
|
505
|
+
|
|
506
|
+
def _opportunistic_auto_purge(conn: sqlite3.Connection, namespace: str, threshold: int = 500) -> None:
|
|
507
|
+
"""Lightweight auto-purge: if namespace exceeds threshold, soft-delete expired entries."""
|
|
508
|
+
count = conn.execute(
|
|
509
|
+
"SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
|
|
510
|
+
(namespace,),
|
|
511
|
+
).fetchone()[0]
|
|
512
|
+
if count <= threshold:
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
conn.execute(
|
|
516
|
+
"UPDATE kv_store SET deleted_at = datetime('now') "
|
|
517
|
+
"WHERE namespace = ? AND expires_at IS NOT NULL "
|
|
518
|
+
"AND expires_at < datetime('now') AND deleted_at IS NULL",
|
|
519
|
+
(namespace,),
|
|
520
|
+
)
|
|
521
|
+
conn.commit()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@mcp.tool(
|
|
525
|
+
name="kv_delete_namespace",
|
|
526
|
+
description="Delete all entries in a namespace. Optionally limit to entries older than N days.",
|
|
527
|
+
)
|
|
528
|
+
async def kv_delete_namespace(
|
|
529
|
+
namespace: str,
|
|
530
|
+
scope: str = "project",
|
|
531
|
+
older_than_days: int | None = None,
|
|
532
|
+
) -> dict:
|
|
533
|
+
"""Delete all entries in a namespace, optionally old entries only.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
namespace: Namespace to clear.
|
|
537
|
+
scope: 'project' (default) or 'global'.
|
|
538
|
+
older_than_days: If set, only delete entries older than this many days.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
Dict with deleted count.
|
|
542
|
+
"""
|
|
543
|
+
conn = _db(scope)
|
|
544
|
+
|
|
545
|
+
if older_than_days is not None:
|
|
546
|
+
cursor = conn.execute(
|
|
547
|
+
"DELETE FROM kv_store WHERE namespace = ? "
|
|
548
|
+
"AND datetime(created_at) < datetime('now', ? || ' days')",
|
|
549
|
+
(namespace, f"-{older_than_days}"),
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
cursor = conn.execute(
|
|
553
|
+
"DELETE FROM kv_store WHERE namespace = ?",
|
|
554
|
+
(namespace,),
|
|
555
|
+
)
|
|
556
|
+
conn.commit()
|
|
557
|
+
|
|
558
|
+
return {"deleted": cursor.rowcount}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ── Context Checkpoint Tools ────────────────────────────────────────────────────
|
|
562
|
+
# Session-scoped context storage for deepwork checkpoints, phase state,
|
|
563
|
+
# tool call history, and reasoning chains. All entries use namespace
|
|
564
|
+
# "checkpoint:{slug}" with auto-TTL of 4 hours (session duration).
|
|
565
|
+
|
|
566
|
+
DEFAULT_CONTEXT_TTL: int = 14400 # 4 hours
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@mcp.tool(
|
|
570
|
+
name="context_save",
|
|
571
|
+
description="Save a context checkpoint for a session/phase. "
|
|
572
|
+
"Stores structured JSON in persistence KV with auto-TTL of 4h.",
|
|
573
|
+
)
|
|
574
|
+
async def context_save(
|
|
575
|
+
slug: str,
|
|
576
|
+
key: str,
|
|
577
|
+
content: str,
|
|
578
|
+
session_id: str | None = None,
|
|
579
|
+
ttl: int | None = None,
|
|
580
|
+
scope: str = "project",
|
|
581
|
+
) -> dict:
|
|
582
|
+
"""Save a context checkpoint with session-level isolation.
|
|
583
|
+
|
|
584
|
+
If session_id is provided, namespace becomes "checkpoint:{slug}:{session_id}"
|
|
585
|
+
preventing cross-session reads. If omitted, a random UUID is auto-generated
|
|
586
|
+
and returned so the caller can pass it to subsequent calls.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
slug: Session identifier (e.g. "auth-refactor").
|
|
590
|
+
key: Checkpoint key (e.g. "phase:3", "latest", "heartbeat").
|
|
591
|
+
content: JSON-serializable string or any text content.
|
|
592
|
+
session_id: Unique session ID for namespace isolation (auto-generated if None).
|
|
593
|
+
ttl: TTL in seconds (default 4h / 14400). None = session duration.
|
|
594
|
+
scope: 'project' (default) or 'global'.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Status dict with namespace, key, and session_id for subsequent calls.
|
|
598
|
+
"""
|
|
599
|
+
actual_session = session_id or uuid.uuid4().hex[:12]
|
|
600
|
+
ns = f"checkpoint:{slug}:{actual_session}"
|
|
601
|
+
actual_ttl = ttl if ttl is not None else DEFAULT_CONTEXT_TTL
|
|
602
|
+
|
|
603
|
+
conn = _db(scope)
|
|
604
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=actual_ttl)).isoformat()
|
|
605
|
+
|
|
606
|
+
conn.execute(
|
|
607
|
+
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
608
|
+
"created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))"
|
|
609
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
610
|
+
" value = excluded.value, "
|
|
611
|
+
" expires_at = excluded.expires_at, "
|
|
612
|
+
" updated_at = datetime('now')",
|
|
613
|
+
(ns, key, content, expires_at),
|
|
614
|
+
)
|
|
615
|
+
conn.commit()
|
|
616
|
+
|
|
617
|
+
# Also always update "latest" pointer
|
|
618
|
+
if key != "latest":
|
|
619
|
+
# Use a larger TTL for "latest" to outlive individual phase entries
|
|
620
|
+
conn.execute(
|
|
621
|
+
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
622
|
+
"created_at, updated_at) VALUES (?, 'latest', ?, ?, datetime('now'), datetime('now'))"
|
|
623
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
624
|
+
" value = excluded.value, "
|
|
625
|
+
" expires_at = excluded.expires_at, "
|
|
626
|
+
" updated_at = datetime('now')",
|
|
627
|
+
(ns, content, expires_at),
|
|
628
|
+
)
|
|
629
|
+
conn.commit()
|
|
630
|
+
|
|
631
|
+
return {"status": "stored", "namespace": ns, "key": key, "session_id": actual_session, "ttl": actual_ttl}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@mcp.tool(
|
|
635
|
+
name="context_get",
|
|
636
|
+
description="Retrieve a context checkpoint by session slug and key. "
|
|
637
|
+
"Returns the raw content string or null if expired/not found.",
|
|
638
|
+
)
|
|
639
|
+
async def context_get(
|
|
640
|
+
slug: str,
|
|
641
|
+
key: str = "latest",
|
|
642
|
+
session_id: str | None = None,
|
|
643
|
+
scope: str = "project",
|
|
644
|
+
) -> str | None:
|
|
645
|
+
"""Get a context checkpoint with session isolation.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
slug: Session identifier.
|
|
649
|
+
key: Checkpoint key (default "latest").
|
|
650
|
+
session_id: Required for isolation. If None, searches without session scope.
|
|
651
|
+
scope: 'project' (default) or 'global'.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
Content string or None if not found/expired.
|
|
655
|
+
"""
|
|
656
|
+
conn = _db(scope)
|
|
657
|
+
ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
|
|
658
|
+
row = conn.execute(
|
|
659
|
+
"SELECT value FROM kv_store "
|
|
660
|
+
"WHERE namespace = ? AND key = ? "
|
|
661
|
+
"AND (expires_at IS NULL OR expires_at > datetime('now')) "
|
|
662
|
+
"AND deleted_at IS NULL",
|
|
663
|
+
(ns, key),
|
|
664
|
+
).fetchone()
|
|
665
|
+
return row[0] if row else None
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@mcp.tool(
|
|
669
|
+
name="context_list",
|
|
670
|
+
description="List all context checkpoints for a session slug. "
|
|
671
|
+
"Returns keys, timestamps, and TTL info.",
|
|
672
|
+
)
|
|
673
|
+
async def context_list(
|
|
674
|
+
slug: str,
|
|
675
|
+
session_id: str | None = None,
|
|
676
|
+
scope: str = "project",
|
|
677
|
+
) -> list[dict]:
|
|
678
|
+
"""List all checkpoints for a session.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
slug: Session identifier.
|
|
682
|
+
session_id: Optional. If provided, narrows to specific session.
|
|
683
|
+
scope: 'project' (default) or 'global'.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
List of dicts with key, created_at, expires_at.
|
|
687
|
+
"""
|
|
688
|
+
conn = _db(scope)
|
|
689
|
+
ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
|
|
690
|
+
rows = conn.execute(
|
|
691
|
+
"SELECT key, created_at, expires_at FROM kv_store "
|
|
692
|
+
"WHERE namespace = ? "
|
|
693
|
+
"AND (expires_at IS NULL OR expires_at > datetime('now')) "
|
|
694
|
+
"AND deleted_at IS NULL "
|
|
695
|
+
"ORDER BY created_at DESC LIMIT 50",
|
|
696
|
+
(ns,),
|
|
697
|
+
).fetchall()
|
|
698
|
+
return [
|
|
699
|
+
{
|
|
700
|
+
"key": r[0],
|
|
701
|
+
"created_at": r[1],
|
|
702
|
+
"expires_at": r[2],
|
|
703
|
+
}
|
|
704
|
+
for r in rows
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@mcp.tool(
|
|
709
|
+
name="context_stats",
|
|
710
|
+
description="Return session context statistics: "
|
|
711
|
+
"checkpoint count, TTL remaining, total size per slug.",
|
|
712
|
+
)
|
|
713
|
+
async def context_stats(
|
|
714
|
+
slug: str,
|
|
715
|
+
session_id: str | None = None,
|
|
716
|
+
scope: str = "project",
|
|
717
|
+
) -> dict:
|
|
718
|
+
"""Return storage statistics for a session's context.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
slug: Session identifier.
|
|
722
|
+
session_id: Optional. If provided, narrows to specific session.
|
|
723
|
+
scope: 'project' (default) or 'global'.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
Dict with entry_count, total_bytes, ttl_remaining.
|
|
727
|
+
"""
|
|
728
|
+
conn = _db(scope)
|
|
729
|
+
ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
|
|
730
|
+
|
|
731
|
+
count = conn.execute(
|
|
732
|
+
"SELECT COUNT(*) FROM kv_store "
|
|
733
|
+
"WHERE namespace = ? AND deleted_at IS NULL",
|
|
734
|
+
(ns,),
|
|
735
|
+
).fetchone()[0]
|
|
736
|
+
|
|
737
|
+
expired = conn.execute(
|
|
738
|
+
"SELECT COUNT(*) FROM kv_store "
|
|
739
|
+
"WHERE namespace = ? AND expires_at < datetime('now') "
|
|
740
|
+
"AND deleted_at IS NULL",
|
|
741
|
+
(ns,),
|
|
742
|
+
).fetchone()[0]
|
|
743
|
+
|
|
744
|
+
size = conn.execute(
|
|
745
|
+
"SELECT COALESCE(SUM(LENGTH(value)), 0) FROM kv_store "
|
|
746
|
+
"WHERE namespace = ? AND deleted_at IS NULL",
|
|
747
|
+
(ns,),
|
|
748
|
+
).fetchone()[0]
|
|
749
|
+
|
|
750
|
+
# Check TTL of latest entry
|
|
751
|
+
remaining: int | None = None
|
|
752
|
+
row = conn.execute(
|
|
753
|
+
"SELECT expires_at FROM kv_store "
|
|
754
|
+
"WHERE namespace = ? AND key = 'latest' AND deleted_at IS NULL",
|
|
755
|
+
(ns,),
|
|
756
|
+
).fetchone()
|
|
757
|
+
if row and row[0]:
|
|
758
|
+
try:
|
|
759
|
+
exp = datetime.fromisoformat(row[0])
|
|
760
|
+
now = datetime.now(timezone.utc)
|
|
761
|
+
remaining = max(0, int((exp - now).total_seconds()))
|
|
762
|
+
except (ValueError, TypeError):
|
|
763
|
+
pass
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
"slug": slug,
|
|
767
|
+
"namespace": ns,
|
|
768
|
+
"entry_count": count,
|
|
769
|
+
"expired_entries": expired,
|
|
770
|
+
"total_bytes": size,
|
|
771
|
+
"ttl_remaining_seconds": remaining,
|
|
772
|
+
}
|
|
773
|
+
|
|
444
774
|
def _resolve_db_path(scope: str) -> Path | None:
|
|
445
775
|
"""Resolve the database file path for a given scope."""
|
|
446
776
|
if scope == "global":
|
package/src/agents/aphrodite.md
CHANGED
|
@@ -118,3 +118,10 @@ Compress working context with the `context-compression` skill (L1, Pantheon-nati
|
|
|
118
118
|
|
|
119
119
|
## Skills
|
|
120
120
|
Frontend: `tdd-with-agents`, `visual-review-pipeline`, `incremental-implementation`
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
## Session Context Retrieval
|
|
124
|
+
When dispatched by Zeus, call context_get(slug, "latest", session_id=SESSION_ID)
|
|
125
|
+
if session_slug is provided in dispatch metadata. Apply any "remaining_tasks",
|
|
126
|
+
"current_phase", or "gotchas" from the retrieved context.
|
|
127
|
+
If context_get returns None, proceed fresh (first phase or expired session).
|
package/src/agents/demeter.md
CHANGED
|
@@ -107,3 +107,10 @@ Compress working context with the `context-compression` skill (L1, Pantheon-nati
|
|
|
107
107
|
|
|
108
108
|
## Skills
|
|
109
109
|
`tdd-with-agents`, `incremental-implementation`
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
## Session Context Retrieval
|
|
113
|
+
When dispatched by Zeus, call context_get(slug, "latest", session_id=SESSION_ID)
|
|
114
|
+
if session_slug is provided in dispatch metadata. Apply any "remaining_tasks",
|
|
115
|
+
"current_phase", or "gotchas" from the retrieved context.
|
|
116
|
+
If context_get returns None, proceed fresh (first phase or expired session).
|
package/src/agents/hephaestus.md
CHANGED
|
@@ -60,3 +60,10 @@ Compress working context with the `context-compression` skill (L1, Pantheon-nati
|
|
|
60
60
|
**How**: call `execute_code_script("compress-inline.py", args=["compress", "--text", "<content>"])`. Use `score` to preview priority, `batch` for multiple files. See the `context-compression` skill for the full protocol.
|
|
61
61
|
|
|
62
62
|
**Note**: scrubbing is automatic in the MCP layer; never embed raw secrets in the `--text` argument beyond what the tool scrubs.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
## Session Context Retrieval
|
|
66
|
+
When dispatched by Zeus, call context_get(slug, "latest", session_id=SESSION_ID)
|
|
67
|
+
if session_slug is provided in dispatch metadata. Apply any "remaining_tasks",
|
|
68
|
+
"current_phase", or "gotchas" from the retrieved context.
|
|
69
|
+
If context_get returns None, proceed fresh (first phase or expired session).
|
package/src/agents/hermes.md
CHANGED
|
@@ -265,3 +265,10 @@ Compress working context with the `context-compression` skill (L1, Pantheon-nati
|
|
|
265
265
|
|
|
266
266
|
## Skills
|
|
267
267
|
Implementação: `tdd-with-agents`, `incremental-implementation`, `code-review-checklist`, `git-workflow-and-versioning`
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
## Session Context Retrieval
|
|
271
|
+
When dispatched by Zeus, call context_get(slug, "latest", session_id=SESSION_ID)
|
|
272
|
+
if session_slug is provided in dispatch metadata. Apply any "remaining_tasks",
|
|
273
|
+
"current_phase", or "gotchas" from the retrieved context.
|
|
274
|
+
If context_get returns None, proceed fresh (first phase or expired session).
|
package/src/agents/zeus.md
CHANGED
|
@@ -4,10 +4,9 @@ mode: primary
|
|
|
4
4
|
reasoning_effort: medium
|
|
5
5
|
permission:
|
|
6
6
|
edit: deny
|
|
7
|
-
bash:
|
|
7
|
+
bash: deny
|
|
8
8
|
task:
|
|
9
9
|
"*": allow
|
|
10
|
-
pantheon-code-mode_*: ask
|
|
11
10
|
temperature: 0.2
|
|
12
11
|
steps: 45
|
|
13
12
|
mcp_tools:
|
|
@@ -16,8 +15,6 @@ mcp_tools:
|
|
|
16
15
|
- memory_recall
|
|
17
16
|
- memory_store
|
|
18
17
|
- memory_search
|
|
19
|
-
pantheon-code-mode:
|
|
20
|
-
- execute_code_script
|
|
21
18
|
pantheon-persistence:
|
|
22
19
|
- kv_get
|
|
23
20
|
- kv_store
|
|
@@ -42,6 +39,34 @@ skills:
|
|
|
42
39
|
|
|
43
40
|
**Coordenador APENAS.** Leu arquivo → delegue. Nunca implemente, nunca edite, nunca debugue.
|
|
44
41
|
|
|
42
|
+
|
|
43
|
+
## 🔒 Fusion-Style Enforcement
|
|
44
|
+
|
|
45
|
+
**Zeus NUNCA edita arquivos.** Esta é uma trava de PERMISSÃO, não só instrução:
|
|
46
|
+
- `edit: deny` — OpenCode bloqueia qualquer tentativa de edição
|
|
47
|
+
- `bash: deny` — Zero acesso a shell. Nem leitura, nem diagnóstico.
|
|
48
|
+
- `execute_code_script` removido — Zeus não executa scripts
|
|
49
|
+
|
|
50
|
+
O fluxo é SEMPRE: **Planejar → Especificar → Delegar → Revisar**. Zeus nunca toca no código.
|
|
51
|
+
|
|
52
|
+
### Bloqueios explícitos
|
|
53
|
+
| Ação | Status | Como fazer |
|
|
54
|
+
|------|--------|------------|
|
|
55
|
+
| Editar arquivo | ❌ BLOQUEADO | Delegar para @hermes, @aphrodite, @talos |
|
|
56
|
+
| Bash shell | ❌ BLOQUEADO | Usar task() com subagente apropriado |
|
|
57
|
+
| Executar script | ❌ BLOQUEADO | Delegar para @prometheus |
|
|
58
|
+
| Instalar dep | ❌ BLOQUEADO | Delegar para @prometheus |
|
|
59
|
+
| Git commit/push | ❌ BLOQUEADO | Delegar para @iris |
|
|
60
|
+
| Ler arquivo | ✅ Permitido | Via Read/Glob/Grep tools |
|
|
61
|
+
| task() delegar | ✅ Permitido | Única ferramenta de ação de Zeus |
|
|
62
|
+
| memory MCP | ✅ Permitido | memory_search, memory_store, memory_recall |
|
|
63
|
+
| skill() | ✅ Permitido | Carregar skills |
|
|
64
|
+
|
|
65
|
+
### Se algo precisar ser feito e não houver subagente apropriado
|
|
66
|
+
1. Consulte a árvore de roteamento (pantheon://routing)
|
|
67
|
+
2. Pergunte ao usuário qual agente usar
|
|
68
|
+
3. NUNCA tente fazer você mesmo
|
|
69
|
+
|
|
45
70
|
**Approval gates** (via `agent/askQuestions`):
|
|
46
71
|
0. Council -> FULL STOP -> AGUARDAR approve/changes/discard
|
|
47
72
|
1. Planejamento -> "Plan approved?"
|
|
@@ -50,18 +75,6 @@ skills:
|
|
|
50
75
|
|
|
51
76
|
**Auto-continue** só com pedido explícito do usuário.
|
|
52
77
|
|
|
53
|
-
## ⚠️ Bash Scoping
|
|
54
|
-
|
|
55
|
-
**`bash: allow` é para leitura e diagnóstico APENAS.**
|
|
56
|
-
NUNCA use bash para:
|
|
57
|
-
- Editar arquivos (use sed/echo/redirect → delegue para @hermes)
|
|
58
|
-
- Instalar dependências (delegue para @prometheus)
|
|
59
|
-
- Executar comandos que modificam o sistema
|
|
60
|
-
|
|
61
|
-
Comandos permitidos: `ls`, `git status`, `git log`, `ps`, `python3 -c`, `cat` (leitura), `npm test` (para verificar).
|
|
62
|
-
|
|
63
|
-
---
|
|
64
|
-
|
|
65
78
|
## Delegation Cache (Otimizacao de Tokens)
|
|
66
79
|
|
|
67
80
|
Antes de usar a arvore de roteamento, consulte o memory:
|
|
@@ -159,7 +172,7 @@ Limite maximo de 2 niveis de nesting: Zeus -> subagente -> sub-subagente.
|
|
|
159
172
|
|
|
160
173
|
```
|
|
161
174
|
depth = kv_get("deleg:depth") ?? 0
|
|
162
|
-
if depth >= 2 → NAO delegar,
|
|
175
|
+
if depth >= 2 → NAO delegar, ESCALAR para o usuario
|
|
163
176
|
else → kv_store("deleg:depth", depth + 1)
|
|
164
177
|
|
|
165
178
|
Quando subagente retornar:
|
|
@@ -177,7 +190,7 @@ Zeus (nivel 0) -> Apollo/Hermes (nivel 1) -> sub-subagente (nivel 2 max).
|
|
|
177
190
|
|
|
178
191
|
## MCP Tools
|
|
179
192
|
|
|
180
|
-
`memory_recall()` inicio, `memory_store()` apos cada fase. `pantheon://routing` para consultar.
|
|
193
|
+
`memory_recall()` inicio, `memory_store()` apos cada fase. `pantheon://routing` para consultar.
|
|
181
194
|
|
|
182
195
|
### References
|
|
183
196
|
- Routing: `pantheon://routing`
|
|
@@ -13,6 +13,7 @@ All implementation agents (Hermes, Aphrodite, Demeter, Hephaestus, Prometheus) M
|
|
|
13
13
|
**summary:** What was done, in 2-3 sentences
|
|
14
14
|
**tests:** ✅ All passing / ⚠️ X failing / ❌ Not run (reason)
|
|
15
15
|
**coverage:** X% (if applicable)
|
|
16
|
+
**tokens:** ~N input / ~M output (estimated)
|
|
16
17
|
**status:** complete | partial (reason) | escalated (reason)
|
|
17
18
|
**blockers:** [list any blockers or null]
|
|
18
19
|
|
|
@@ -54,14 +54,52 @@ On tasks expected to run > 5 turns:
|
|
|
54
54
|
|
|
55
55
|
## Heartbeat & Checkpoint Integration
|
|
56
56
|
|
|
57
|
+
All session state lives in **pantheon-persistence** (namespace `checkpoint:<slug>`).
|
|
58
|
+
No file I/O, no checkpoint_session.py — TTL (4h) handles cleanup automatically.
|
|
59
|
+
|
|
57
60
|
### Heartbeat Check
|
|
58
|
-
- If
|
|
59
|
-
- Write heartbeat after every anti-stall recovery action
|
|
61
|
+
- If `context_get(slug, "heartbeat")` returns a checkin older than 300s, log a stall warning and resume
|
|
62
|
+
- Write heartbeat after every anti-stall recovery action:
|
|
63
|
+
```
|
|
64
|
+
context_save(slug, "heartbeat", json({"status": "alive", "last_action": "...", "turn_count": N}))
|
|
65
|
+
```
|
|
60
66
|
|
|
61
|
-
### Checkpoint Auto-Save
|
|
67
|
+
### Checkpoint Auto-Save (Pré-Compactação)
|
|
62
68
|
Before ANY delegate dispatch, save a checkpoint:
|
|
63
|
-
```
|
|
64
|
-
|
|
69
|
+
```
|
|
70
|
+
context_save(slug, "phase:N", json({
|
|
71
|
+
"phase": N, "turn_count": N, "agent": "...", "summary": "..."
|
|
72
|
+
}), session_id=SESSION_ID)
|
|
73
|
+
```
|
|
74
|
+
All checkpoints auto-expire after 4h (TTL=14400).
|
|
75
|
+
|
|
76
|
+
### Gatilho de Pré-Compactação (Anti-perda de estado)
|
|
77
|
+
Antes da compactação nativa do OpenCode disparar (75-96% do context window),
|
|
78
|
+
o Zeus DEVE salvar o estado atual:
|
|
79
|
+
1. Capture session_id do primeiro `context_save` da sessão
|
|
80
|
+
2. Salve heartbeat + phase atual + tarefas pendentes
|
|
81
|
+
3. Só então permita que a compactação prossiga
|
|
82
|
+
```
|
|
83
|
+
# Ao iniciar sessão:
|
|
84
|
+
result = context_save(slug, "init", session_state)
|
|
85
|
+
SESSION_ID = result.session_id # ← guarde para toda a sessão
|
|
86
|
+
|
|
87
|
+
# Antes de CADA delegação:
|
|
88
|
+
context_save(slug, f"pre:{agent}", current_state, session_id=SESSION_ID)
|
|
89
|
+
|
|
90
|
+
# Após retorno do agente:
|
|
91
|
+
context_save(slug, f"post:{agent}", result_state, session_id=SESSION_ID)
|
|
92
|
+
```
|
|
93
|
+
Isso garante que o estado sobreviva à compactação — o "latest" pointer
|
|
94
|
+
sempre aponta para o checkpoint mais recente, mesmo após compactação.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
### Context Retrieval
|
|
98
|
+
Next-phase agents retrieve previous context via:
|
|
99
|
+
```
|
|
100
|
+
context_get(slug, "latest") # most recent checkpoint
|
|
101
|
+
context_get(slug, "phase:3") # specific phase
|
|
102
|
+
context_list(slug) # all checkpoints
|
|
65
103
|
```
|
|
66
104
|
|
|
67
105
|
### Long-Session Progress
|
|
@@ -10,24 +10,61 @@ When a delegated agent does not respond in time, enforce the timeout policy from
|
|
|
10
10
|
|
|
11
11
|
## Timeout Behavior by Agent Role
|
|
12
12
|
|
|
13
|
-
| Agent Role | Timeout | Retry Policy | Fallback | Partial Results OK? | Reasoning Effort |
|
|
13
|
+
| Agent Role | Timeout | Retry Policy | Fallback Chain | Partial Results OK? | Reasoning Effort |
|
|
14
14
|
|------------|---------|-------------|----------|---------------------|------------------|
|
|
15
|
-
| Explorer (@apollo) | 60s | 2 retries,
|
|
16
|
-
| Implementer (@hermes, @aphrodite, @demeter) | 180s | 3 retries,
|
|
17
|
-
| Reviewer (@themis) | 120s |
|
|
18
|
-
| Infrastructure (@prometheus) | 300s |
|
|
19
|
-
| Hotfix (@talos) | 30s |
|
|
20
|
-
| Remote Sensing (@gaia) | 120s | 2 retries,
|
|
15
|
+
| Explorer (@apollo) | 60s | 2 retries, exp backoff | @athena (plan) → @hermes (impl) | ✅ Yes | low |
|
|
16
|
+
| Implementer (@hermes, @aphrodite, @demeter) | 180s | 3 retries, exp backoff | @talos (hotfix) → @athena (replan) | ❌ No | medium |
|
|
17
|
+
| Reviewer (@themis) | 120s | 3 retries, exp backoff | @zeus (escalate) → user | ❌ No | high |
|
|
18
|
+
| Infrastructure (@prometheus) | 300s | 3 retries, exp backoff | @hermes (config) → @zeus (escalate) | ❌ No | medium |
|
|
19
|
+
| Hotfix (@talos) | 30s | 2 retries, no backoff | @hermes (full fix) | ✅ Yes | low |
|
|
20
|
+
| Remote Sensing (@gaia) | 120s | 2 retries, exp backoff | @hermes (generic) | ✅ Yes | high |
|
|
21
21
|
|
|
22
22
|
## Retry Flow
|
|
23
23
|
|
|
24
24
|
```
|
|
25
25
|
Task dispatch → timeout elapsed → log timeout
|
|
26
|
-
├─ retry_count > 0 → retry with backoff → decrement retry_count
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
├─ retry_count > 0 → retry with exponential backoff → decrement retry_count
|
|
27
|
+
│ └─ still failing? → retry again (up to retry_count limit)
|
|
28
|
+
│
|
|
29
|
+
├─ retries exhausted → FALLBACK CHAIN:
|
|
30
|
+
│ ├─ fallback[0] exists? → dispatch to fallback agent
|
|
31
|
+
│ │ └─ keep original task spec + context; add error context
|
|
32
|
+
│ ├─ fallback[1] exists? → dispatch to second fallback
|
|
33
|
+
│ └─ all fallbacks failed? → ESCALATE
|
|
34
|
+
│
|
|
35
|
+
├─ no fallbacks defined → return TIMEOUT error to user
|
|
36
|
+
│
|
|
37
|
+
└─ ESCALATE: "Agent X failed after N retries. Fallbacks Y, Z also failed.
|
|
38
|
+
Options: (a) try different agent, (b) simplify scope, (c) manual"
|
|
29
39
|
```
|
|
30
40
|
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Fallback Chain Definitions
|
|
44
|
+
|
|
45
|
+
Each fallback chain is evaluated left-to-right: if the first fallback fails, try the second, etc.
|
|
46
|
+
|
|
47
|
+
| Agent | Fallback[0] | Fallback[1] | Escalate To |
|
|
48
|
+
|-------|------------|------------|-------------|
|
|
49
|
+
| @apollo | @athena (plan scoped task) | @hermes (implement search) | @zeus |
|
|
50
|
+
| @hermes | @talos (minimal fix) | @athena (replan + simplify) | @zeus |
|
|
51
|
+
| @aphrodite | @talos (CSS/UX fix) | @hermes (generic fallback) | @zeus |
|
|
52
|
+
| @demeter | @hermes (generic backend) | @athena (replan schema) | @zeus |
|
|
53
|
+
| @themis | @zeus (direct escalation) | — | user |
|
|
54
|
+
| @prometheus | @hermes (config/deploy) | @zeus | user |
|
|
55
|
+
| @talos | @hermes (full implementation) | — | @zeus |
|
|
56
|
+
| @hephaestus | @nyx (observability debug) | @hermes (generic) | @zeus |
|
|
57
|
+
| @nyx | @hermes (generic) | — | @zeus |
|
|
58
|
+
| @iris | @zeus (manual override) | — | user |
|
|
59
|
+
| @mnemosyne | @zeus (manual) | — | user |
|
|
60
|
+
|
|
61
|
+
### Escalation Protocol
|
|
62
|
+
When ALL fallbacks fail:
|
|
63
|
+
1. Log the full failure chain: which agents were tried, what error each returned
|
|
64
|
+
2. Report to user: "Task [X] failed. Tried: [agent list]. Error: [summary]."
|
|
65
|
+
3. Offer options: (a) try different approach, (b) simplify scope, (c) manual fix
|
|
66
|
+
4. NEVER retry the same chain automatically — break the cycle
|
|
67
|
+
|
|
31
68
|
### Session Reuse Check
|
|
32
69
|
Before dispatching a task, check if a reusable session exists:
|
|
33
70
|
|
|
@@ -35,6 +35,9 @@ CREATE TABLE IF NOT EXISTS kv_store (
|
|
|
35
35
|
UNIQUE(namespace, key)
|
|
36
36
|
);
|
|
37
37
|
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_kv_namespace_expires
|
|
39
|
+
ON kv_store(namespace, expires_at);
|
|
40
|
+
|
|
38
41
|
CREATE VIRTUAL TABLE IF NOT EXISTS kv_store_fts USING fts5(
|
|
39
42
|
namespace, key, value,
|
|
40
43
|
content='kv_store', content_rowid='id',
|
|
@@ -51,6 +54,14 @@ CREATE TRIGGER IF NOT EXISTS kv_store_ad AFTER DELETE ON kv_store BEGIN
|
|
|
51
54
|
VALUES('delete', old.id, old.namespace, old.key, old.value);
|
|
52
55
|
END;
|
|
53
56
|
|
|
57
|
+
CREATE TRIGGER IF NOT EXISTS kv_store_softdelete_ad
|
|
58
|
+
AFTER UPDATE OF deleted_at ON kv_store
|
|
59
|
+
WHEN new.deleted_at IS NOT NULL AND old.deleted_at IS NULL
|
|
60
|
+
BEGIN
|
|
61
|
+
INSERT INTO kv_store_fts(kv_store_fts, rowid)
|
|
62
|
+
VALUES('delete', old.id);
|
|
63
|
+
END;
|
|
64
|
+
|
|
54
65
|
CREATE TRIGGER IF NOT EXISTS kv_store_au AFTER UPDATE ON kv_store BEGIN
|
|
55
66
|
INSERT INTO kv_store_fts(kv_store_fts, rowid, namespace, key, value)
|
|
56
67
|
VALUES('delete', old.id, old.namespace, old.key, old.value);
|
|
@@ -219,6 +230,10 @@ async def kv_store(
|
|
|
219
230
|
(namespace, key, value, expires_at),
|
|
220
231
|
)
|
|
221
232
|
conn.commit()
|
|
233
|
+
|
|
234
|
+
# Opportunistic auto-purge: if namespace has >500 entries, purge expired
|
|
235
|
+
_opportunistic_auto_purge(conn, namespace)
|
|
236
|
+
|
|
222
237
|
return {"status": "stored", "namespace": namespace, "key": key}
|
|
223
238
|
|
|
224
239
|
|
|
@@ -253,6 +268,52 @@ async def kv_get(
|
|
|
253
268
|
return str(row[0]) if row else None
|
|
254
269
|
|
|
255
270
|
|
|
271
|
+
@mcp.tool(
|
|
272
|
+
name="kv_stats",
|
|
273
|
+
description="Return storage statistics: total entries, expired count, per-namespace breakdown, DB file size.",
|
|
274
|
+
)
|
|
275
|
+
async def kv_stats(
|
|
276
|
+
scope: str = "project",
|
|
277
|
+
) -> dict:
|
|
278
|
+
"""Return storage statistics for the given scope.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
scope: 'project' (default) or 'global'.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Dict with total_entries, expired_entries, namespaces breakdown, db_size_bytes.
|
|
285
|
+
"""
|
|
286
|
+
conn = _db(scope)
|
|
287
|
+
|
|
288
|
+
total = conn.execute("SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL").fetchone()[0]
|
|
289
|
+
expired = conn.execute(
|
|
290
|
+
"SELECT COUNT(*) FROM kv_store "
|
|
291
|
+
"WHERE expires_at IS NOT NULL AND expires_at < datetime('now') "
|
|
292
|
+
"AND deleted_at IS NULL"
|
|
293
|
+
).fetchone()[0]
|
|
294
|
+
|
|
295
|
+
ns_rows = conn.execute(
|
|
296
|
+
"SELECT namespace, COUNT(*) as cnt, "
|
|
297
|
+
"SUM(CASE WHEN expires_at IS NOT NULL AND expires_at < datetime('now') THEN 1 ELSE 0 END) as expired_count "
|
|
298
|
+
"FROM kv_store WHERE deleted_at IS NULL GROUP BY namespace ORDER BY cnt DESC"
|
|
299
|
+
).fetchall()
|
|
300
|
+
|
|
301
|
+
namespaces = {
|
|
302
|
+
r[0]: {"count": r[1], "expired": r[2]}
|
|
303
|
+
for r in ns_rows
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
db_path = _resolve_db_path(scope)
|
|
307
|
+
db_size = db_path.stat().st_size if db_path and db_path.exists() else 0
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"scope": scope,
|
|
311
|
+
"total_entries": total,
|
|
312
|
+
"expired_entries": expired,
|
|
313
|
+
"namespaces": namespaces,
|
|
314
|
+
"db_size_bytes": db_size,
|
|
315
|
+
}
|
|
316
|
+
|
|
256
317
|
@mcp.tool(
|
|
257
318
|
name="kv_delete",
|
|
258
319
|
description="Delete a key-value pair by namespace and key. "
|
|
@@ -352,6 +413,7 @@ async def kv_search(
|
|
|
352
413
|
|
|
353
414
|
# Sanitize: extract up to 10 word tokens, wrap each in quotes
|
|
354
415
|
terms = re.findall(r"\w+", query)[:10]
|
|
416
|
+
terms = [re.escape(t) for t in terms]
|
|
355
417
|
if not terms:
|
|
356
418
|
return []
|
|
357
419
|
fts_query = " OR ".join(f'"{t}"' for t in terms)
|
|
@@ -440,6 +502,275 @@ async def purge_expired(
|
|
|
440
502
|
return {"purged": count, "dry_run": False}
|
|
441
503
|
|
|
442
504
|
|
|
505
|
+
|
|
506
|
+
def _opportunistic_auto_purge(conn: sqlite3.Connection, namespace: str, threshold: int = 500) -> None:
|
|
507
|
+
"""Lightweight auto-purge: if namespace exceeds threshold, soft-delete expired entries."""
|
|
508
|
+
count = conn.execute(
|
|
509
|
+
"SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
|
|
510
|
+
(namespace,),
|
|
511
|
+
).fetchone()[0]
|
|
512
|
+
if count <= threshold:
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
conn.execute(
|
|
516
|
+
"UPDATE kv_store SET deleted_at = datetime('now') "
|
|
517
|
+
"WHERE namespace = ? AND expires_at IS NOT NULL "
|
|
518
|
+
"AND expires_at < datetime('now') AND deleted_at IS NULL",
|
|
519
|
+
(namespace,),
|
|
520
|
+
)
|
|
521
|
+
conn.commit()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@mcp.tool(
|
|
525
|
+
name="kv_delete_namespace",
|
|
526
|
+
description="Delete all entries in a namespace. Optionally limit to entries older than N days.",
|
|
527
|
+
)
|
|
528
|
+
async def kv_delete_namespace(
|
|
529
|
+
namespace: str,
|
|
530
|
+
scope: str = "project",
|
|
531
|
+
older_than_days: int | None = None,
|
|
532
|
+
) -> dict:
|
|
533
|
+
"""Delete all entries in a namespace, optionally old entries only.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
namespace: Namespace to clear.
|
|
537
|
+
scope: 'project' (default) or 'global'.
|
|
538
|
+
older_than_days: If set, only delete entries older than this many days.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
Dict with deleted count.
|
|
542
|
+
"""
|
|
543
|
+
conn = _db(scope)
|
|
544
|
+
|
|
545
|
+
if older_than_days is not None:
|
|
546
|
+
cursor = conn.execute(
|
|
547
|
+
"DELETE FROM kv_store WHERE namespace = ? "
|
|
548
|
+
"AND datetime(created_at) < datetime('now', ? || ' days')",
|
|
549
|
+
(namespace, f"-{older_than_days}"),
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
cursor = conn.execute(
|
|
553
|
+
"DELETE FROM kv_store WHERE namespace = ?",
|
|
554
|
+
(namespace,),
|
|
555
|
+
)
|
|
556
|
+
conn.commit()
|
|
557
|
+
|
|
558
|
+
return {"deleted": cursor.rowcount}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ── Context Checkpoint Tools ────────────────────────────────────────────────────
|
|
562
|
+
# Session-scoped context storage for deepwork checkpoints, phase state,
|
|
563
|
+
# tool call history, and reasoning chains. All entries use namespace
|
|
564
|
+
# "checkpoint:{slug}" with auto-TTL of 4 hours (session duration).
|
|
565
|
+
|
|
566
|
+
DEFAULT_CONTEXT_TTL: int = 14400 # 4 hours
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@mcp.tool(
|
|
570
|
+
name="context_save",
|
|
571
|
+
description="Save a context checkpoint for a session/phase. "
|
|
572
|
+
"Stores structured JSON in persistence KV with auto-TTL of 4h.",
|
|
573
|
+
)
|
|
574
|
+
async def context_save(
|
|
575
|
+
slug: str,
|
|
576
|
+
key: str,
|
|
577
|
+
content: str,
|
|
578
|
+
session_id: str | None = None,
|
|
579
|
+
ttl: int | None = None,
|
|
580
|
+
scope: str = "project",
|
|
581
|
+
) -> dict:
|
|
582
|
+
"""Save a context checkpoint with session-level isolation.
|
|
583
|
+
|
|
584
|
+
If session_id is provided, namespace becomes "checkpoint:{slug}:{session_id}"
|
|
585
|
+
preventing cross-session reads. If omitted, a random UUID is auto-generated
|
|
586
|
+
and returned so the caller can pass it to subsequent calls.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
slug: Session identifier (e.g. "auth-refactor").
|
|
590
|
+
key: Checkpoint key (e.g. "phase:3", "latest", "heartbeat").
|
|
591
|
+
content: JSON-serializable string or any text content.
|
|
592
|
+
session_id: Unique session ID for namespace isolation (auto-generated if None).
|
|
593
|
+
ttl: TTL in seconds (default 4h / 14400). None = session duration.
|
|
594
|
+
scope: 'project' (default) or 'global'.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Status dict with namespace, key, and session_id for subsequent calls.
|
|
598
|
+
"""
|
|
599
|
+
actual_session = session_id or uuid.uuid4().hex[:12]
|
|
600
|
+
ns = f"checkpoint:{slug}:{actual_session}"
|
|
601
|
+
actual_ttl = ttl if ttl is not None else DEFAULT_CONTEXT_TTL
|
|
602
|
+
|
|
603
|
+
conn = _db(scope)
|
|
604
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=actual_ttl)).isoformat()
|
|
605
|
+
|
|
606
|
+
conn.execute(
|
|
607
|
+
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
608
|
+
"created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))"
|
|
609
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
610
|
+
" value = excluded.value, "
|
|
611
|
+
" expires_at = excluded.expires_at, "
|
|
612
|
+
" updated_at = datetime('now')",
|
|
613
|
+
(ns, key, content, expires_at),
|
|
614
|
+
)
|
|
615
|
+
conn.commit()
|
|
616
|
+
|
|
617
|
+
# Also always update "latest" pointer
|
|
618
|
+
if key != "latest":
|
|
619
|
+
# Use a larger TTL for "latest" to outlive individual phase entries
|
|
620
|
+
conn.execute(
|
|
621
|
+
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
622
|
+
"created_at, updated_at) VALUES (?, 'latest', ?, ?, datetime('now'), datetime('now'))"
|
|
623
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
624
|
+
" value = excluded.value, "
|
|
625
|
+
" expires_at = excluded.expires_at, "
|
|
626
|
+
" updated_at = datetime('now')",
|
|
627
|
+
(ns, content, expires_at),
|
|
628
|
+
)
|
|
629
|
+
conn.commit()
|
|
630
|
+
|
|
631
|
+
return {"status": "stored", "namespace": ns, "key": key, "session_id": actual_session, "ttl": actual_ttl}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@mcp.tool(
|
|
635
|
+
name="context_get",
|
|
636
|
+
description="Retrieve a context checkpoint by session slug and key. "
|
|
637
|
+
"Returns the raw content string or null if expired/not found.",
|
|
638
|
+
)
|
|
639
|
+
async def context_get(
|
|
640
|
+
slug: str,
|
|
641
|
+
key: str = "latest",
|
|
642
|
+
session_id: str | None = None,
|
|
643
|
+
scope: str = "project",
|
|
644
|
+
) -> str | None:
|
|
645
|
+
"""Get a context checkpoint with session isolation.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
slug: Session identifier.
|
|
649
|
+
key: Checkpoint key (default "latest").
|
|
650
|
+
session_id: Required for isolation. If None, searches without session scope.
|
|
651
|
+
scope: 'project' (default) or 'global'.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
Content string or None if not found/expired.
|
|
655
|
+
"""
|
|
656
|
+
conn = _db(scope)
|
|
657
|
+
ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
|
|
658
|
+
row = conn.execute(
|
|
659
|
+
"SELECT value FROM kv_store "
|
|
660
|
+
"WHERE namespace = ? AND key = ? "
|
|
661
|
+
"AND (expires_at IS NULL OR expires_at > datetime('now')) "
|
|
662
|
+
"AND deleted_at IS NULL",
|
|
663
|
+
(ns, key),
|
|
664
|
+
).fetchone()
|
|
665
|
+
return row[0] if row else None
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@mcp.tool(
|
|
669
|
+
name="context_list",
|
|
670
|
+
description="List all context checkpoints for a session slug. "
|
|
671
|
+
"Returns keys, timestamps, and TTL info.",
|
|
672
|
+
)
|
|
673
|
+
async def context_list(
|
|
674
|
+
slug: str,
|
|
675
|
+
session_id: str | None = None,
|
|
676
|
+
scope: str = "project",
|
|
677
|
+
) -> list[dict]:
|
|
678
|
+
"""List all checkpoints for a session.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
slug: Session identifier.
|
|
682
|
+
session_id: Optional. If provided, narrows to specific session.
|
|
683
|
+
scope: 'project' (default) or 'global'.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
List of dicts with key, created_at, expires_at.
|
|
687
|
+
"""
|
|
688
|
+
conn = _db(scope)
|
|
689
|
+
ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
|
|
690
|
+
rows = conn.execute(
|
|
691
|
+
"SELECT key, created_at, expires_at FROM kv_store "
|
|
692
|
+
"WHERE namespace = ? "
|
|
693
|
+
"AND (expires_at IS NULL OR expires_at > datetime('now')) "
|
|
694
|
+
"AND deleted_at IS NULL "
|
|
695
|
+
"ORDER BY created_at DESC LIMIT 50",
|
|
696
|
+
(ns,),
|
|
697
|
+
).fetchall()
|
|
698
|
+
return [
|
|
699
|
+
{
|
|
700
|
+
"key": r[0],
|
|
701
|
+
"created_at": r[1],
|
|
702
|
+
"expires_at": r[2],
|
|
703
|
+
}
|
|
704
|
+
for r in rows
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@mcp.tool(
|
|
709
|
+
name="context_stats",
|
|
710
|
+
description="Return session context statistics: "
|
|
711
|
+
"checkpoint count, TTL remaining, total size per slug.",
|
|
712
|
+
)
|
|
713
|
+
async def context_stats(
|
|
714
|
+
slug: str,
|
|
715
|
+
session_id: str | None = None,
|
|
716
|
+
scope: str = "project",
|
|
717
|
+
) -> dict:
|
|
718
|
+
"""Return storage statistics for a session's context.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
slug: Session identifier.
|
|
722
|
+
session_id: Optional. If provided, narrows to specific session.
|
|
723
|
+
scope: 'project' (default) or 'global'.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
Dict with entry_count, total_bytes, ttl_remaining.
|
|
727
|
+
"""
|
|
728
|
+
conn = _db(scope)
|
|
729
|
+
ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
|
|
730
|
+
|
|
731
|
+
count = conn.execute(
|
|
732
|
+
"SELECT COUNT(*) FROM kv_store "
|
|
733
|
+
"WHERE namespace = ? AND deleted_at IS NULL",
|
|
734
|
+
(ns,),
|
|
735
|
+
).fetchone()[0]
|
|
736
|
+
|
|
737
|
+
expired = conn.execute(
|
|
738
|
+
"SELECT COUNT(*) FROM kv_store "
|
|
739
|
+
"WHERE namespace = ? AND expires_at < datetime('now') "
|
|
740
|
+
"AND deleted_at IS NULL",
|
|
741
|
+
(ns,),
|
|
742
|
+
).fetchone()[0]
|
|
743
|
+
|
|
744
|
+
size = conn.execute(
|
|
745
|
+
"SELECT COALESCE(SUM(LENGTH(value)), 0) FROM kv_store "
|
|
746
|
+
"WHERE namespace = ? AND deleted_at IS NULL",
|
|
747
|
+
(ns,),
|
|
748
|
+
).fetchone()[0]
|
|
749
|
+
|
|
750
|
+
# Check TTL of latest entry
|
|
751
|
+
remaining: int | None = None
|
|
752
|
+
row = conn.execute(
|
|
753
|
+
"SELECT expires_at FROM kv_store "
|
|
754
|
+
"WHERE namespace = ? AND key = 'latest' AND deleted_at IS NULL",
|
|
755
|
+
(ns,),
|
|
756
|
+
).fetchone()
|
|
757
|
+
if row and row[0]:
|
|
758
|
+
try:
|
|
759
|
+
exp = datetime.fromisoformat(row[0])
|
|
760
|
+
now = datetime.now(timezone.utc)
|
|
761
|
+
remaining = max(0, int((exp - now).total_seconds()))
|
|
762
|
+
except (ValueError, TypeError):
|
|
763
|
+
pass
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
"slug": slug,
|
|
767
|
+
"namespace": ns,
|
|
768
|
+
"entry_count": count,
|
|
769
|
+
"expired_entries": expired,
|
|
770
|
+
"total_bytes": size,
|
|
771
|
+
"ttl_remaining_seconds": remaining,
|
|
772
|
+
}
|
|
773
|
+
|
|
443
774
|
def _resolve_db_path(scope: str) -> Path | None:
|
|
444
775
|
"""Resolve the database file path for a given scope."""
|
|
445
776
|
if scope == "global":
|
package/src/routing.yml
CHANGED
|
@@ -354,6 +354,37 @@ handoffs:
|
|
|
354
354
|
with priority-scored entries, write compressed entries to 01-active-context.md
|
|
355
355
|
(priority-aware, budget-allocated), archive IMPL/REVIEW to 02-progress-log.md,
|
|
356
356
|
update _xref/index.md cross-references.'
|
|
357
|
+
|
|
358
|
+
fallback_chains:
|
|
359
|
+
apollo:
|
|
360
|
+
- athena
|
|
361
|
+
- hermes
|
|
362
|
+
hermes:
|
|
363
|
+
- talos
|
|
364
|
+
- athena
|
|
365
|
+
aphrodite:
|
|
366
|
+
- talos
|
|
367
|
+
- hermes
|
|
368
|
+
demeter:
|
|
369
|
+
- hermes
|
|
370
|
+
- athena
|
|
371
|
+
themis:
|
|
372
|
+
- zeus
|
|
373
|
+
prometheus:
|
|
374
|
+
- hermes
|
|
375
|
+
- zeus
|
|
376
|
+
talos:
|
|
377
|
+
- hermes
|
|
378
|
+
hephaestus:
|
|
379
|
+
- nyx
|
|
380
|
+
- hermes
|
|
381
|
+
nyx:
|
|
382
|
+
- hermes
|
|
383
|
+
iris:
|
|
384
|
+
- zeus
|
|
385
|
+
mnemosyne:
|
|
386
|
+
- zeus
|
|
387
|
+
|
|
357
388
|
platforms:
|
|
358
389
|
opencode:
|
|
359
390
|
display_name: OpenCode
|