pantheon-opencode 1.3.7 → 1.4.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.
- package/README.md +36 -46
- package/opencode.json +2 -3
- package/package.json +1 -1
- package/scripts/install/opencode.mjs +2 -0
- package/src/mcp/mcp_codemap_module.py +185 -0
- package/src/mcp/memory_mcp_server.py +91 -3
- package/src/pantheon/chat-reminders.ts +4 -74
- package/src/pantheon/command-normalizer.ts +101 -0
- package/src/pantheon/compaction-assert.ts +28 -28
- package/src/pantheon/context-sandbox.ts +273 -0
- package/src/pantheon/delegation-finalize.ts +44 -11
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +20 -106
- package/src/pantheon/presets.d.mts +4 -3
- package/src/pantheon/presets.mjs +17 -21
- package/src/plugin.ts +62 -33
- package/src/plugins/pantheon-hooks.ts +15 -158
- package/src/plugins/tui/dist/tui.d.ts +18 -3
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +42 -10
- package/src/plugins/tui/dist/tui.tsx +69 -17
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +14 -10
- package/src/skills/codemap/SKILL.md +7 -0
- package/src/skills/context-compression/SKILL.md +39 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<h1 align="center">Pantheon</h1>
|
|
3
3
|
|
|
4
4
|
<p align="center">
|
|
5
|
-
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.
|
|
5
|
+
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.4.1--candidate-blue" alt="Version"></a>
|
|
6
6
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License"></a>
|
|
7
7
|
<a href="docs/agents/README.md"><img src="https://img.shields.io/badge/agents-14-purple" alt="Agents"></a>
|
|
8
8
|
<a href="src/skills/README.md"><img src="https://img.shields.io/badge/skills-21-orange" alt="Skills"></a>
|
|
@@ -239,17 +239,16 @@ Fail-open: if the child session messages are unavailable (or empty), the read
|
|
|
239
239
|
returns the report exactly as before — the activity sampling never breaks the
|
|
240
240
|
delegation read.
|
|
241
241
|
|
|
242
|
-
**Completion visibility —
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
(
|
|
251
|
-
|
|
252
|
-
transition.
|
|
242
|
+
**Completion visibility — zero chat noise.** Delegation notifications are never
|
|
243
|
+
injected into the chat transcript: neither `chat.message` nor any other chat
|
|
244
|
+
hook or transcript marker is used as a delivery channel. When a job reaches a
|
|
245
|
+
terminal state (completion observed via `session.idle`/`session.error` on the
|
|
246
|
+
child, or the timeout finalize path), the plugin writes a file-only audit log
|
|
247
|
+
line (echo opt-in via `PANTHEON_HOOKS_LOG`). Completion visibility lives in the
|
|
248
|
+
legitimate channels: the board `[unread]` marker
|
|
249
|
+
(`pantheon_delegation_list`), `pantheon_delegation_read`, TUI toasts
|
|
250
|
+
(`PANTHEON_TOASTS` gate), and compaction carry-forward. Reconcile is an
|
|
251
|
+
acknowledgment, not a completion — it never re-fires the terminal transition.
|
|
253
252
|
|
|
254
253
|
**Timeout:** `background_delegation.timeout_ms` (default `900000` = 15 min). A job
|
|
255
254
|
that has not reached a terminal state is finalized as `error`/`timedOut`.
|
|
@@ -338,10 +337,9 @@ logged warn and pass-through.
|
|
|
338
337
|
|
|
339
338
|
**Post-compaction state re-assertion (1.3.4):** on `session.compacted`, fresh
|
|
340
339
|
state — running/unread delegations from the board + active goals, capped at 10
|
|
341
|
-
lines —
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
assert is a silent skip.
|
|
340
|
+
lines — remains available through compaction carry-forward and the board
|
|
341
|
+
(`pantheon_delegation_list` / `pantheon_delegation_read`). A session with
|
|
342
|
+
nothing to assert is a silent skip; no chat transcript injection is performed.
|
|
345
343
|
|
|
346
344
|
**Preemptive compaction check (1.3.4):** a pure threshold core
|
|
347
345
|
(`preemptive-compact.ts`) warns the model before the context fills: at 78%
|
|
@@ -350,36 +348,22 @@ dormant / ready-to-wire — opencode 1.18.x exposes no runtime context-usage
|
|
|
350
348
|
percentage, so nothing observes it yet; when a source appears, the caller
|
|
351
349
|
wires it in (the enqueue callback is injected).
|
|
352
350
|
|
|
353
|
-
**Model
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
`
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
Setting `PANTHEON_MODEL_PRESET` to a preset whose providers have keys, or
|
|
364
|
-
filling the required `PANTHEON_*_API_KEY` env var for the preset's provider,
|
|
365
|
-
resolves the fallback path. If nothing resolves at all (no model, no preset),
|
|
366
|
-
the child keeps using opencode's default model (warned).
|
|
367
|
-
|
|
368
|
-
**agentModels wiring (1.3.4):** the delegate now also resolves the child model
|
|
369
|
-
via `routing.yml` — `loadRoutingAgentModels` extracts the per-agent models of
|
|
370
|
-
the FIRST-listed preset (the static default, `go-deepseek` today) and passes
|
|
371
|
-
them as `options.agentModels`, branch (b) of the resolve precedence: explicit
|
|
372
|
-
`model` > `options.agentModels` > active preset > opencode default. Delegation
|
|
373
|
-
no longer depends exclusively on the active preset — a delegated child gets a
|
|
374
|
-
sane per-agent model even when no preset is active. Fail-open: a missing or
|
|
375
|
-
unparseable routing.yml yields `{}` (previous behavior, warned).
|
|
351
|
+
**Model selection (1.4.1):** the child session inherits the parent session's
|
|
352
|
+
model through OpenCode. A model override is supplied only when the active
|
|
353
|
+
routing profile defines one; no model API-key validation is performed here.
|
|
354
|
+
|
|
355
|
+
**agentModels wiring (1.3.4):** `loadRoutingAgentModels` extracts only the
|
|
356
|
+
per-agent models from the explicitly active routing profile and passes them as
|
|
357
|
+
`options.agentModels`. No active profile, missing profile entry, or profile
|
|
358
|
+
without `model` produces an empty map; there is no implicit first-preset or
|
|
359
|
+
DeepSeek fallback. A missing or unparseable `routing.yml` is fail-open and
|
|
360
|
+
omits the child model.
|
|
376
361
|
|
|
377
362
|
**Delegation log hygiene (1.3.4):** `delegations.log` now records the real
|
|
378
363
|
`task_id` (omitted when empty, never `""`) and a **numeric** `duration_ms`
|
|
379
|
-
(null when unset) so downstream aggregation works.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
duplicate lines for the same notification.
|
|
364
|
+
(null when unset) so downstream aggregation works. Terminal events are
|
|
365
|
+
file-only audit entries; completion is surfaced by TUI toast (when enabled),
|
|
366
|
+
the board, and the list/read tools — never by chat delivery.
|
|
383
367
|
|
|
384
368
|
**Env vars & config:**
|
|
385
369
|
|
|
@@ -1040,11 +1024,17 @@ Use the isolated sandbox (`~/pantheon-sandbox/`) — **never** the dev environme
|
|
|
1040
1024
|
|
|
1041
1025
|
**Happy path (clean edit):** any normal tool call (exit 0) is **silent** — no console output by design, even though the audit hooks append their log files (`sessions.log`, `delegations.log`). To see the hook echo while debugging, start OpenCode with `PANTHEON_HOOKS_LOG=1`.
|
|
1042
1026
|
|
|
1043
|
-
**Delegation path (chat
|
|
1027
|
+
**Delegation path (zero chat noise):** delegation signals are surfaced through
|
|
1028
|
+
TUI toasts (when enabled), the board's `[unread]` marker, and the
|
|
1029
|
+
`pantheon_delegation_list` / `pantheon_delegation_read` tools. Ask something
|
|
1030
|
+
that dispatches subagents (e.g. *"dispare 2 subagentes apollo em paralelo para
|
|
1031
|
+
listar arquivos e comparar resultados"*) and expect:
|
|
1044
1032
|
|
|
1045
|
-
- A
|
|
1033
|
+
- A TUI toast with `🚀 apollo em execução` / `✅ apollo concluiu` when
|
|
1034
|
+
`PANTHEON_TOASTS` permits it
|
|
1035
|
+
- `[unread]` in `pantheon_delegation_list` until the terminal result is read
|
|
1046
1036
|
- An honest append to `logs/agent-sessions/delegations.log`: the **real agent name** (extracted from `tool_input.subagent_type`, never `unknown` when present) and a **non-fabricated status** — `success` only on explicit completion evidence, `failure` for refusals/errors, `unknown` otherwise
|
|
1047
|
-
- With `export PANTHEON_TOASTS=off` before starting OpenCode: no toasts/
|
|
1037
|
+
- With `export PANTHEON_TOASTS=off` before starting OpenCode: no toasts; board/list/read and audit-log visibility remain available
|
|
1048
1038
|
|
|
1049
1039
|
### Pre-commit hooks (local secret gate)
|
|
1050
1040
|
|
package/opencode.json
CHANGED
|
@@ -14,8 +14,6 @@
|
|
|
14
14
|
"experimental": {
|
|
15
15
|
"subagent_depth": 2
|
|
16
16
|
},
|
|
17
|
-
"model": "opencode-go/deepseek-v4-flash",
|
|
18
|
-
"small_model": "opencode-go/deepseek-v4-flash",
|
|
19
17
|
"permission": {
|
|
20
18
|
"skill": {
|
|
21
19
|
"*": "allow"
|
|
@@ -27,7 +25,8 @@
|
|
|
27
25
|
"npx *": "allow",
|
|
28
26
|
"pytest *": "allow",
|
|
29
27
|
"ruff *": "allow",
|
|
30
|
-
"pip *": "allow"
|
|
28
|
+
"pip *": "allow",
|
|
29
|
+
"python3 *": "allow"
|
|
31
30
|
}
|
|
32
31
|
},
|
|
33
32
|
"mcp": {
|
package/package.json
CHANGED
|
@@ -450,6 +450,7 @@ export async function installOpenCode(
|
|
|
450
450
|
'memory_mcp_server.py',
|
|
451
451
|
'scrub-secrets.py',
|
|
452
452
|
'_pantheon_paths.py',
|
|
453
|
+
'mcp_codemap_module.py',
|
|
453
454
|
'mcp_persistence_server.py',
|
|
454
455
|
'pantheon_vision_server.py',
|
|
455
456
|
'eval_store.py',
|
|
@@ -465,6 +466,7 @@ export async function installOpenCode(
|
|
|
465
466
|
'code_mode_server.py': join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
|
|
466
467
|
'memory_mcp_server.py': join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
|
|
467
468
|
'_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
|
|
469
|
+
'mcp_codemap_module.py': join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
|
|
468
470
|
'mcp_persistence_server.py': join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
|
|
469
471
|
'pantheon_vision_server.py': join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
|
|
470
472
|
'eval_store.py': join(ROOT, 'src', 'mcp', 'eval_store.py'),
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# ruff: noqa: E701,E702,E741,I001,E401,RUF100,W292
|
|
2
|
+
"""Pantheon Codemap — lean KG for Python/TS (ast+regex), 3 tables+FTS5."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import ast, hashlib, re, sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
LARGE_FILE_THRESHOLD = 512 * 1024
|
|
8
|
+
SUPPORTED_EXTS: dict[str, str] = {".py":"python",".ts":"typescript",".tsx":"typescript",".js":"typescript",".jsx":"typescript",".mts":"typescript",".cts":"typescript"}
|
|
9
|
+
CODEMAP_SCHEMA_SQL = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS code_entities (id TEXT PRIMARY KEY,file_path TEXT NOT NULL,name TEXT NOT NULL,type TEXT NOT NULL,language TEXT NOT NULL,start_line INTEGER,end_line INTEGER,signature TEXT,docstring TEXT);
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_code_entities_type ON code_entities(type);
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_code_entities_name ON code_entities(name);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_code_entities_path ON code_entities(file_path);
|
|
14
|
+
CREATE TABLE IF NOT EXISTS code_relations (id INTEGER PRIMARY KEY AUTOINCREMENT,source_id TEXT NOT NULL,target_id TEXT NOT NULL,type TEXT NOT NULL,FOREIGN KEY(source_id) REFERENCES code_entities(id),FOREIGN KEY(target_id) REFERENCES code_entities(id));
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_code_relations_source ON code_relations(source_id);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_code_relations_target ON code_relations(target_id);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS code_files (path TEXT PRIMARY KEY,hash TEXT NOT NULL,language TEXT NOT NULL,indexed_at TEXT NOT NULL DEFAULT (datetime('now')));
|
|
18
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS code_entities_fts USING fts5(name,signature,docstring,content='code_entities',content_rowid='rowid',tokenize='porter unicode61');
|
|
19
|
+
CREATE TRIGGER IF NOT EXISTS code_entities_ai AFTER INSERT ON code_entities BEGIN INSERT INTO code_entities_fts(rowid,name,signature,docstring) VALUES (new.rowid,new.name,new.signature,new.docstring); END;
|
|
20
|
+
CREATE TRIGGER IF NOT EXISTS code_entities_ad AFTER DELETE ON code_entities BEGIN INSERT INTO code_entities_fts(code_entities_fts,rowid,name,signature,docstring) VALUES ('delete',old.rowid,old.name,old.signature,old.docstring); END;
|
|
21
|
+
CREATE TRIGGER IF NOT EXISTS code_entities_au AFTER UPDATE ON code_entities BEGIN INSERT INTO code_entities_fts(code_entities_fts,rowid,name,signature,docstring) VALUES ('delete',old.rowid,old.name,old.signature,old.docstring); INSERT INTO code_entities_fts(rowid,name,signature,docstring) VALUES (new.rowid,new.name,new.signature,new.docstring); END;
|
|
22
|
+
"""
|
|
23
|
+
def ensure_codemap_schema(conn: sqlite3.Connection) -> None:
|
|
24
|
+
conn.executescript(CODEMAP_SCHEMA_SQL); conn.commit()
|
|
25
|
+
def drop_codemap_schema(conn: sqlite3.Connection) -> None:
|
|
26
|
+
conn.executescript("DROP TRIGGER IF EXISTS code_entities_ai;DROP TRIGGER IF EXISTS code_entities_ad;DROP TRIGGER IF EXISTS code_entities_au;DROP TABLE IF EXISTS code_entities_fts;DROP TABLE IF EXISTS code_relations;DROP TABLE IF EXISTS code_entities;DROP TABLE IF EXISTS code_files;"); conn.commit()
|
|
27
|
+
def _hash_content(c: str) -> str: return hashlib.sha256(c.encode()).hexdigest()
|
|
28
|
+
def _entity_id(fp: str, name: str, tp: str) -> str: return hashlib.sha256(f"{fp}:{name}:{tp}".encode()).hexdigest()[:16]
|
|
29
|
+
def _parse_python_entities(fp: str, content: str) -> tuple[list[dict[str,Any]], list[dict[str,Any]]]: # noqa: C901, PLR0912, PLR0915
|
|
30
|
+
ents: list[dict[str,Any]] = []; rels: list[dict[str,Any]] = []; ph: dict[str,dict[str,Any]] = {}
|
|
31
|
+
try: tree = ast.parse(content, filename=fp)
|
|
32
|
+
except SyntaxError: return [], []
|
|
33
|
+
file_mod: str|None = None
|
|
34
|
+
def _file_mod() -> str:
|
|
35
|
+
nonlocal file_mod
|
|
36
|
+
if file_mod is None:
|
|
37
|
+
stem = Path(fp).stem; fid=_entity_id(fp,stem,"module")
|
|
38
|
+
if fid not in ph: ph[fid]={"id":fid,"file_path":fp,"name":stem,"type":"module","language":"python","start_line":1,"end_line":1,"signature":f"module {stem}","docstring":""}
|
|
39
|
+
file_mod=fid
|
|
40
|
+
return file_mod
|
|
41
|
+
for node in tree.body:
|
|
42
|
+
if isinstance(node, ast.ClassDef):
|
|
43
|
+
cid=_entity_id(fp,node.name,"class"); doc=ast.get_docstring(node) or ""
|
|
44
|
+
bases=[]
|
|
45
|
+
for b in node.bases:
|
|
46
|
+
if isinstance(b, ast.Name): bases.append(b.id)
|
|
47
|
+
elif isinstance(b, ast.Attribute): bases.append(b.attr)
|
|
48
|
+
sig=f"class {node.name}({', '.join(bases)})" if bases else f"class {node.name}"
|
|
49
|
+
ents.append({"id":cid,"file_path":fp,"name":node.name,"type":"class","language":"python","start_line":getattr(node,"lineno",0),"end_line":getattr(node,"end_lineno",0) or getattr(node,"lineno",0),"signature":sig,"docstring":doc})
|
|
50
|
+
for base in bases:
|
|
51
|
+
tid=_entity_id(f"external:{base}",base,"class")
|
|
52
|
+
if tid not in ph: ph[tid]={"id":tid,"file_path":f"external:{base}","name":base,"type":"class","language":"python","start_line":0,"end_line":0,"signature":f"class {base}","docstring":""}
|
|
53
|
+
rels.append({"source_id":cid,"target_id":tid,"type":"inherits"})
|
|
54
|
+
for item in node.body:
|
|
55
|
+
if isinstance(item,(ast.FunctionDef,ast.AsyncFunctionDef)):
|
|
56
|
+
mid=_entity_id(fp,f"{node.name}.{item.name}","method"); docm=ast.get_docstring(item) or ""; args=", ".join(a.arg for a in item.args.args); sig_m=f"def {item.name}({args})"
|
|
57
|
+
ents.append({"id":mid,"file_path":fp,"name":f"{node.name}.{item.name}","type":"method","language":"python","start_line":getattr(item,"lineno",0),"end_line":getattr(item,"end_lineno",0) or getattr(item,"lineno",0),"signature":sig_m,"docstring":docm})
|
|
58
|
+
elif isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)):
|
|
59
|
+
fid=_entity_id(fp,node.name,"function"); doc=ast.get_docstring(node) or ""; args=", ".join(a.arg for a in node.args.args); sig=f"def {node.name}({args})"
|
|
60
|
+
ents.append({"id":fid,"file_path":fp,"name":node.name,"type":"function","language":"python","start_line":getattr(node,"lineno",0),"end_line":getattr(node,"end_lineno",0) or getattr(node,"lineno",0),"signature":sig,"docstring":doc})
|
|
61
|
+
elif isinstance(node, ast.Import):
|
|
62
|
+
for alias in node.names:
|
|
63
|
+
mod=alias.name.split(".")[0]; tid=_entity_id(f"external:{mod}",mod,"module")
|
|
64
|
+
if tid not in ph: ph[tid]={"id":tid,"file_path":f"external:{mod}","name":mod,"type":"module","language":"python","start_line":0,"end_line":0,"signature":f"import {mod}","docstring":""}
|
|
65
|
+
src=_file_mod(); rels.append({"source_id":src,"target_id":tid,"type":"imports"})
|
|
66
|
+
elif isinstance(node, ast.ImportFrom):
|
|
67
|
+
mod=(node.module or "").split(".")[0]
|
|
68
|
+
if mod:
|
|
69
|
+
tid=_entity_id(f"external:{mod}",mod,"module")
|
|
70
|
+
if tid not in ph: ph[tid]={"id":tid,"file_path":f"external:{mod}","name":mod,"type":"module","language":"python","start_line":0,"end_line":0,"signature":f"from {mod} import ...","docstring":""}
|
|
71
|
+
src=_file_mod(); rels.append({"source_id":src,"target_id":tid,"type":"imports"})
|
|
72
|
+
eids={e["id"] for e in ents}
|
|
73
|
+
for pid,pent in ph.items():
|
|
74
|
+
if pid not in eids: ents.append(pent)
|
|
75
|
+
return ents, rels
|
|
76
|
+
_TS_CLASS_RE=re.compile(r"^\s*(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?",re.MULTILINE)
|
|
77
|
+
_TS_INTERFACE_RE=re.compile(r"^\s*(?:export\s+)?interface\s+(\w+)",re.MULTILINE)
|
|
78
|
+
_TS_FUNC_RE=re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(",re.MULTILINE)
|
|
79
|
+
_TS_CONST_RE=re.compile(r"^\s*(?:export\s+)?const\s+(\w+)\s*(?:[:=])",re.MULTILINE)
|
|
80
|
+
_TS_IMPORT_RE=re.compile(r"^\s*import\s+.*?from\s+['\"]([^'\"]+)['\"]",re.MULTILINE)
|
|
81
|
+
def _parse_typescript_entities(fp: str, content: str) -> tuple[list[dict[str,Any]], list[dict[str,Any]]]: # noqa: C901
|
|
82
|
+
ents: list[dict[str,Any]]=[]; rels: list[dict[str,Any]]=[]; ph: dict[str,dict[str,Any]]={}
|
|
83
|
+
def _ph(name: str, tp: str, sig: str) -> str:
|
|
84
|
+
tid=_entity_id(f"external:{name}",name,tp)
|
|
85
|
+
if tid not in ph: ph[tid]={"id":tid,"file_path":f"external:{name}","name":name,"type":tp,"language":"typescript","start_line":0,"end_line":0,"signature":sig,"docstring":""}
|
|
86
|
+
return tid
|
|
87
|
+
lines=content.splitlines()
|
|
88
|
+
def _line(pat: str, name: str) -> int:
|
|
89
|
+
for i,l in enumerate(lines, start=1):
|
|
90
|
+
if pat in l and name in l: return i
|
|
91
|
+
return 1
|
|
92
|
+
for m in _TS_CLASS_RE.finditer(content):
|
|
93
|
+
name=m.group(1); base=m.group(2); cid=_entity_id(fp,name,"class"); sig=f"class {name}"+(f" extends {base}" if base else "")
|
|
94
|
+
ents.append({"id":cid,"file_path":fp,"name":name,"type":"class","language":"typescript","start_line":_line("class",name),"end_line":_line("class",name),"signature":sig,"docstring":""})
|
|
95
|
+
if base: tid=_ph(base,"class",f"class {base}"); rels.append({"source_id":cid,"target_id":tid,"type":"inherits"})
|
|
96
|
+
for m in _TS_INTERFACE_RE.finditer(content):
|
|
97
|
+
name=m.group(1); iid=_entity_id(fp,name,"interface")
|
|
98
|
+
ents.append({"id":iid,"file_path":fp,"name":name,"type":"interface","language":"typescript","start_line":_line("interface",name),"end_line":_line("interface",name),"signature":f"interface {name}","docstring":""})
|
|
99
|
+
for m in _TS_FUNC_RE.finditer(content):
|
|
100
|
+
name=m.group(1); fid=_entity_id(fp,name,"function")
|
|
101
|
+
ents.append({"id":fid,"file_path":fp,"name":name,"type":"function","language":"typescript","start_line":_line("function",name),"end_line":_line("function",name),"signature":f"function {name}()","docstring":""})
|
|
102
|
+
for m in _TS_CONST_RE.finditer(content):
|
|
103
|
+
name=m.group(1); cid=_entity_id(fp,name,"const")
|
|
104
|
+
ents.append({"id":cid,"file_path":fp,"name":name,"type":"const","language":"typescript","start_line":_line("const",name),"end_line":_line("const",name),"signature":f"const {name}","docstring":""})
|
|
105
|
+
for m in _TS_IMPORT_RE.finditer(content):
|
|
106
|
+
raw=m.group(1); mod=raw.split("/")[-1].replace(".js","").replace(".ts","") or raw; tid=_ph(mod,"module",f"import {raw}"); stem=Path(fp).stem; src=_entity_id(fp,stem,"module")
|
|
107
|
+
if src not in {e["id"] for e in ents} and src not in ph: ph[src]={"id":src,"file_path":fp,"name":stem,"type":"module","language":"typescript","start_line":1,"end_line":1,"signature":f"module {stem}","docstring":""}
|
|
108
|
+
rels.append({"source_id":src,"target_id":tid,"type":"imports"})
|
|
109
|
+
eids={e["id"] for e in ents}
|
|
110
|
+
for pid,pent in ph.items():
|
|
111
|
+
if pid not in eids: ents.append(pent)
|
|
112
|
+
return ents, rels
|
|
113
|
+
def _insert_entities_and_relations(conn: sqlite3.Connection, ents: list[dict[str,Any]], rels: list[dict[str,Any]]) -> None:
|
|
114
|
+
for e in ents:
|
|
115
|
+
conn.execute("INSERT OR REPLACE INTO code_entities (id,file_path,name,type,language,start_line,end_line,signature,docstring) VALUES (?,?,?,?,?,?,?,?,?)",[e["id"],e["file_path"],e["name"],e["type"],e["language"],e["start_line"],e["end_line"],e["signature"],e["docstring"]])
|
|
116
|
+
for r in rels:
|
|
117
|
+
if not conn.execute("SELECT 1 FROM code_relations WHERE source_id=? AND target_id=? AND type=?", [r["source_id"],r["target_id"],r["type"]]).fetchone():
|
|
118
|
+
conn.execute("INSERT INTO code_relations (source_id,target_id,type) VALUES (?,?,?)", [r["source_id"],r["target_id"],r["type"]])
|
|
119
|
+
def code_index(conn: sqlite3.Connection, path: str|Path|None=None, force: bool=False) -> dict[str,Any]: # noqa: C901, PLR0912, PLR0915
|
|
120
|
+
ensure_codemap_schema(conn)
|
|
121
|
+
base=Path(path) if path else Path.cwd()
|
|
122
|
+
if not base.exists(): return {"error":f"Path not found: {base}","indexed":0,"skipped":0}
|
|
123
|
+
files: list[Path]=[base] if base.is_file() else [p for ext in SUPPORTED_EXTS for p in base.rglob(f"*{ext}")]
|
|
124
|
+
indexed=skipped=errors=large_skipped=unsupported=0
|
|
125
|
+
for f in files:
|
|
126
|
+
if any(p.startswith(".") for p in f.parts) and ".pantheon" not in str(f) and (".git" in f.parts or "__pycache__" in f.parts): continue
|
|
127
|
+
if "node_modules" in f.parts or ".venv" in f.parts or "venv" in f.parts: continue
|
|
128
|
+
ext=f.suffix.lower(); lang=SUPPORTED_EXTS.get(ext)
|
|
129
|
+
if not lang: unsupported+=1; continue
|
|
130
|
+
try:
|
|
131
|
+
if f.stat().st_size > LARGE_FILE_THRESHOLD: large_skipped+=1; continue
|
|
132
|
+
content=f.read_text(encoding="utf-8",errors="ignore")
|
|
133
|
+
if not content.strip():
|
|
134
|
+
h=_hash_content(content); conn.execute("INSERT OR REPLACE INTO code_files (path,hash,language) VALUES (?,?,?)",[str(f),h,lang]); conn.commit(); skipped+=1; continue
|
|
135
|
+
h=_hash_content(content)
|
|
136
|
+
row=conn.execute("SELECT hash FROM code_files WHERE path=?",[str(f)]).fetchone()
|
|
137
|
+
if row and row[0]==h and not force: skipped+=1; continue
|
|
138
|
+
ents,rels=_parse_python_entities(str(f),content) if lang=="python" else _parse_typescript_entities(str(f),content)
|
|
139
|
+
if not ents and not rels:
|
|
140
|
+
conn.execute("INSERT OR REPLACE INTO code_files (path,hash,language) VALUES (?,?,?)",[str(f),h,lang]); conn.commit()
|
|
141
|
+
try: ast.parse(content)
|
|
142
|
+
except SyntaxError: errors+=1; continue
|
|
143
|
+
skipped+=1; continue
|
|
144
|
+
_insert_entities_and_relations(conn,ents,rels)
|
|
145
|
+
conn.execute("INSERT OR REPLACE INTO code_files (path,hash,language) VALUES (?,?,?)",[str(f),h,lang]); conn.commit(); indexed+=1
|
|
146
|
+
except Exception: errors+=1; continue
|
|
147
|
+
return {"indexed":indexed,"skipped":skipped,"errors":errors,"large_skipped":large_skipped,"unsupported":unsupported}
|
|
148
|
+
def code_query(conn: sqlite3.Connection, query: str, type_filter: str|None=None, limit: int=10) -> list[dict[str,Any]]:
|
|
149
|
+
if not query or not query.strip(): return []
|
|
150
|
+
limit=max(1,min(50,int(limit))); q=query.strip(); ensure_codemap_schema(conn)
|
|
151
|
+
try:
|
|
152
|
+
fts_q=" OR ".join(f'"{w}"*' for w in q.split() if w)
|
|
153
|
+
if not fts_q: raise ValueError
|
|
154
|
+
if type_filter:
|
|
155
|
+
rows=conn.execute("SELECT e.id,e.file_path,e.name,e.type,e.language,e.start_line,e.end_line,e.signature,e.docstring FROM code_entities_fts f JOIN code_entities e ON e.rowid=f.rowid WHERE code_entities_fts MATCH ? AND e.type=? ORDER BY rank LIMIT ?",[fts_q,type_filter,limit]).fetchall()
|
|
156
|
+
else:
|
|
157
|
+
rows=conn.execute("SELECT e.id,e.file_path,e.name,e.type,e.language,e.start_line,e.end_line,e.signature,e.docstring FROM code_entities_fts f JOIN code_entities e ON e.rowid=f.rowid WHERE code_entities_fts MATCH ? ORDER BY rank LIMIT ?",[fts_q,limit]).fetchall()
|
|
158
|
+
if rows: return [dict(r) for r in rows]
|
|
159
|
+
except Exception: pass
|
|
160
|
+
try:
|
|
161
|
+
like=f"%{q}%"
|
|
162
|
+
if type_filter:
|
|
163
|
+
rows=conn.execute("SELECT id,file_path,name,type,language,start_line,end_line,signature,docstring FROM code_entities WHERE (name LIKE ? OR signature LIKE ? OR docstring LIKE ?) AND type=? LIMIT ?",[like,like,like,type_filter,limit]).fetchall()
|
|
164
|
+
else:
|
|
165
|
+
rows=conn.execute("SELECT id,file_path,name,type,language,start_line,end_line,signature,docstring FROM code_entities WHERE name LIKE ? OR signature LIKE ? OR docstring LIKE ? LIMIT ?",[like,like,like,limit]).fetchall()
|
|
166
|
+
return [dict(r) for r in rows]
|
|
167
|
+
except Exception: return []
|
|
168
|
+
def code_neighbors(conn: sqlite3.Connection, entity_id: str, depth: int=1) -> dict[str,Any]:
|
|
169
|
+
ensure_codemap_schema(conn); depth=max(1,min(3,int(depth)))
|
|
170
|
+
row=conn.execute("SELECT * FROM code_entities WHERE id=?",[entity_id]).fetchone()
|
|
171
|
+
if not row: return {"error":"Entity not found","entity_id":entity_id}
|
|
172
|
+
start=dict(row); visited: dict[str,dict[str,Any]]={entity_id:start}; queue=[(entity_id,0)]; edges=[]; seen=set()
|
|
173
|
+
while queue:
|
|
174
|
+
cur,d=queue.pop(0)
|
|
175
|
+
if d>=depth: continue
|
|
176
|
+
for r in conn.execute("SELECT * FROM code_relations WHERE source_id=? OR target_id=?",[cur,cur]).fetchall():
|
|
177
|
+
rel=dict(r); key=(rel["source_id"],rel["target_id"],rel["type"])
|
|
178
|
+
if key in seen: continue
|
|
179
|
+
seen.add(key); edges.append(rel)
|
|
180
|
+
nid=rel["target_id"] if rel["source_id"]==cur else rel["source_id"]
|
|
181
|
+
if nid not in visited:
|
|
182
|
+
nrow=conn.execute("SELECT * FROM code_entities WHERE id=?",[nid]).fetchone()
|
|
183
|
+
visited[nid]=dict(nrow) if nrow else {"id":nid,"file_path":"external:unknown","name":nid,"type":"placeholder","language":"","start_line":0,"end_line":0,"signature":"","docstring":""}
|
|
184
|
+
if d+1<depth: queue.append((nid,d+1))
|
|
185
|
+
return {"entity":start,"neighbors":[v for k,v in visited.items() if k!=entity_id],"relations":edges,"depth":depth}
|
|
@@ -20,12 +20,13 @@ Usage:
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import contextlib
|
|
23
24
|
import functools
|
|
24
25
|
import json
|
|
25
26
|
import os
|
|
26
27
|
import sqlite3
|
|
27
28
|
import time
|
|
28
|
-
from datetime import
|
|
29
|
+
from datetime import UTC, datetime
|
|
29
30
|
from pathlib import Path
|
|
30
31
|
from typing import Any
|
|
31
32
|
|
|
@@ -34,6 +35,18 @@ from _pantheon_paths import pantheon_home
|
|
|
34
35
|
from fastembed import TextEmbedding
|
|
35
36
|
from mcp.server.fastmcp import FastMCP
|
|
36
37
|
|
|
38
|
+
try:
|
|
39
|
+
import mcp_codemap_module as _codemap
|
|
40
|
+
except ImportError: # pragma: no cover - deployed runtime may need path fix
|
|
41
|
+
try:
|
|
42
|
+
import sys as _sys
|
|
43
|
+
from pathlib import Path as _Path
|
|
44
|
+
|
|
45
|
+
_sys.path.insert(0, str(_Path(__file__).parent))
|
|
46
|
+
import mcp_codemap_module as _codemap # type: ignore[no-redef]
|
|
47
|
+
except ImportError:
|
|
48
|
+
_codemap = None # type: ignore[assignment]
|
|
49
|
+
|
|
37
50
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
38
51
|
|
|
39
52
|
DB_PATH = pantheon_home() / "memory" / "memory.db"
|
|
@@ -128,6 +141,9 @@ def _get_db() -> sqlite3.Connection:
|
|
|
128
141
|
|
|
129
142
|
sqlite_vec.load(conn)
|
|
130
143
|
conn.executescript(SCHEMA_SQL)
|
|
144
|
+
if _codemap is not None:
|
|
145
|
+
with contextlib.suppress(Exception):
|
|
146
|
+
_codemap.ensure_codemap_schema(conn)
|
|
131
147
|
return conn
|
|
132
148
|
|
|
133
149
|
|
|
@@ -198,7 +214,7 @@ def _parse_iso_ts(value: str) -> float:
|
|
|
198
214
|
"""
|
|
199
215
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
200
216
|
if dt.tzinfo is None:
|
|
201
|
-
dt = dt.replace(tzinfo=
|
|
217
|
+
dt = dt.replace(tzinfo=UTC)
|
|
202
218
|
return dt.timestamp()
|
|
203
219
|
|
|
204
220
|
|
|
@@ -351,7 +367,7 @@ def memory_store(
|
|
|
351
367
|
"freshness half-life (2^(-days/decay_days)) so recent entries rank "
|
|
352
368
|
"higher; default None keeps the original rank-only behavior.",
|
|
353
369
|
)
|
|
354
|
-
def memory_search( # noqa: PLR0912
|
|
370
|
+
def memory_search( # noqa: C901, PLR0912
|
|
355
371
|
query: str,
|
|
356
372
|
namespace: str | None = None,
|
|
357
373
|
top_k: int = 5,
|
|
@@ -675,6 +691,78 @@ def memory_stats() -> dict[str, Any]:
|
|
|
675
691
|
return stats
|
|
676
692
|
|
|
677
693
|
|
|
694
|
+
# ── Codemap Tools ───────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@mcp.tool(
|
|
698
|
+
description="Index codebase files incrementally (hash-based skip). "
|
|
699
|
+
"Parses Python (ast) and TypeScript (regex) to build knowledge graph.",
|
|
700
|
+
)
|
|
701
|
+
def code_index(
|
|
702
|
+
path: str | None = None,
|
|
703
|
+
force: bool = False,
|
|
704
|
+
) -> dict[str, Any]:
|
|
705
|
+
"""Index code files into knowledge graph.
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
path: File or directory to index; None = cwd.
|
|
709
|
+
force: Re-parse even if hash unchanged.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
Dict with indexed, skipped, errors, large_skipped, unsupported.
|
|
713
|
+
"""
|
|
714
|
+
if _codemap is None:
|
|
715
|
+
return {"error": "codemap module not available"}
|
|
716
|
+
db = _get_conn()
|
|
717
|
+
return _codemap.code_index(db, path, force)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@mcp.tool(
|
|
721
|
+
description="Search code entities via FTS5 (with LIKE fallback) and optional type filter.",
|
|
722
|
+
)
|
|
723
|
+
def code_query(
|
|
724
|
+
query: str,
|
|
725
|
+
type: str | None = None,
|
|
726
|
+
limit: int = 10,
|
|
727
|
+
) -> list[dict[str, Any]]:
|
|
728
|
+
"""Search code entities.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
query: Search term (FTS5).
|
|
732
|
+
type: Optional entity type filter (class, function, method, etc.).
|
|
733
|
+
limit: Max results (1-50).
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
List of matching entities.
|
|
737
|
+
"""
|
|
738
|
+
if _codemap is None:
|
|
739
|
+
return [{"error": "codemap module not available"}]
|
|
740
|
+
db = _get_conn()
|
|
741
|
+
return _codemap.code_query(db, query, type, limit)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
@mcp.tool(
|
|
745
|
+
description="Get neighbors of a code entity via relations graph (BFS depth 1-3).",
|
|
746
|
+
)
|
|
747
|
+
def code_neighbors(
|
|
748
|
+
entity_id: str,
|
|
749
|
+
depth: int = 1,
|
|
750
|
+
) -> dict[str, Any]:
|
|
751
|
+
"""Get graph neighbors for an entity.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
entity_id: Entity ID (hash).
|
|
755
|
+
depth: BFS depth 1-3.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
Dict with entity, neighbors, relations or error if not found.
|
|
759
|
+
"""
|
|
760
|
+
if _codemap is None:
|
|
761
|
+
return {"error": "codemap module not available"}
|
|
762
|
+
db = _get_conn()
|
|
763
|
+
return _codemap.code_neighbors(db, entity_id, depth)
|
|
764
|
+
|
|
765
|
+
|
|
678
766
|
# ── Main Entrypoint ───────────────────────────────────────────────────────────
|
|
679
767
|
|
|
680
768
|
if __name__ == "__main__":
|
|
@@ -1,79 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Retired transcript-notification module.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `Object.values(mod)` and treats EVERY export as a plugin factory —
|
|
8
|
-
* a non-function export throws `TypeError("Plugin export is not a
|
|
9
|
-
* function")` and a function export is invoked with a PluginInput.
|
|
10
|
-
* pantheon-hooks.ts therefore cannot EXPORT the enqueue API.
|
|
11
|
-
* 2. src/plugin.ts (a separate plugin file) must ENQUEUE into the SAME
|
|
12
|
-
* buffer the pantheon-hooks `chat.message` hook drains — a local copy
|
|
13
|
-
* would enqueue into a buffer that is never delivered.
|
|
14
|
-
* 3. opencode loads plugins via plain `await import(entry)` (PluginLoader),
|
|
15
|
-
* so both plugin files importing this module resolve to ONE module
|
|
16
|
-
* instance (Bun/Node module cache) → one shared buffer.
|
|
17
|
-
*
|
|
18
|
-
* release-134 Phase 4 uses this buffer for the post-compaction state
|
|
19
|
-
* re-assertion: `reassertAfterCompaction` (compaction-assert.ts) enqueues a
|
|
20
|
-
* fresh-state block here, and the existing chat.message delivery path
|
|
21
|
-
* (pantheon-hooks.ts) injects it as a <system-reminder> into the session's
|
|
22
|
-
* next message. Zero new delivery code — the P0 messageID guard already
|
|
23
|
-
* protects subagent promptAsync fires.
|
|
24
|
-
*
|
|
25
|
-
* Bounded: at most CHAT_REMINDER_MAX entries, each expiring
|
|
26
|
-
* CHAT_REMINDER_TTL_MS after enqueue. Full-buffer enqueues are SKIPPED,
|
|
27
|
-
* never unbounded. No background timers — expiry is only checked on enqueue
|
|
28
|
-
* and on delivery (drainFreshChatReminders).
|
|
4
|
+
* Delegation lifecycle state must not be queued for delivery into user
|
|
5
|
+
* messages. Visibility is provided by the board/list/read tools and TUI
|
|
6
|
+
* toasts; this intentionally exports no delivery API.
|
|
29
7
|
*
|
|
30
8
|
* @module chat-reminders
|
|
31
9
|
*/
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Hard cap on queued reminder entries (anti-spam philosophy, matching the
|
|
35
|
-
* "throttled signals are skipped, never backlogged" rule).
|
|
36
|
-
*/
|
|
37
|
-
export const CHAT_REMINDER_MAX = 10
|
|
38
|
-
|
|
39
|
-
/** How long a queued reminder stays deliverable (pruned after ~60s). */
|
|
40
|
-
export const CHAT_REMINDER_TTL_MS = 60_000
|
|
41
|
-
|
|
42
|
-
/** One queued reminder: the text plus its enqueue timestamp. */
|
|
43
|
-
export interface ChatReminderEntry {
|
|
44
|
-
text: string
|
|
45
|
-
at: number
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The shared buffer — module-level state, consumed by the chat.message hook. */
|
|
49
|
-
export const pendingChatReminders: ChatReminderEntry[] = []
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Queue one chat-reminder entry, pruning expired entries first. Never throws.
|
|
53
|
-
* A full buffer skips the push (bounded, never backlogged).
|
|
54
|
-
*/
|
|
55
|
-
export function enqueueChatReminder(text: string): void {
|
|
56
|
-
const now = Date.now()
|
|
57
|
-
for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
|
|
58
|
-
const r = pendingChatReminders[i]
|
|
59
|
-
if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
|
|
60
|
-
}
|
|
61
|
-
if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
|
|
62
|
-
pendingChatReminders.push({ text, at: now })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Deliver (drain) every FRESH reminder as one joined block: expired entries
|
|
67
|
-
* are dropped, the buffer is cleared in all cases (no leak). Returns
|
|
68
|
-
* undefined when nothing fresh remains — the caller skips injection.
|
|
69
|
-
*
|
|
70
|
-
* This is the delivery helper the pantheon-hooks `chat.message` hook calls
|
|
71
|
-
* AFTER its P0 messageID guard (empty messageID → early return, buffer kept).
|
|
72
|
-
*/
|
|
73
|
-
export function drainFreshChatReminders(): string | undefined {
|
|
74
|
-
const now = Date.now()
|
|
75
|
-
const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
|
|
76
|
-
pendingChatReminders.length = 0
|
|
77
|
-
if (fresh.length === 0) return undefined
|
|
78
|
-
return fresh.map((r) => r.text).join('\n')
|
|
79
|
-
}
|