pantheon-opencode 1.3.7 → 1.4.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 +2 -1
- 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/command-normalizer.ts +101 -0
- package/src/pantheon/context-sandbox.ts +273 -0
- package/src/plugin.ts +49 -4
- 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.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/opencode.json
CHANGED
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__":
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Normalizer (Fase 1) — deterministic `python` → `python3` rewrite.
|
|
3
|
+
*
|
|
4
|
+
* Ubuntu 20.04+ ships only `python3` (PEP 394); the bare `python` command does
|
|
5
|
+
* not exist. Agents frequently emit `python` instead of `python3`, so every
|
|
6
|
+
* `bash` command is normalized BEFORE execution via the `tool.execute.before`
|
|
7
|
+
* hook. Unlike the read-only enforcement guard (which THROWS to deny), this
|
|
8
|
+
* guard REWRITES the command in place and lets it run.
|
|
9
|
+
*
|
|
10
|
+
* The rewrite is anchored to the regex below — it only matches `python` as a
|
|
11
|
+
* standalone command token (start of line or after a shell delimiter), never
|
|
12
|
+
* as a substring. This is what keeps `pip install python-dateutil` intact.
|
|
13
|
+
*
|
|
14
|
+
* @module command-normalizer
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Matches `python` as a command token: at the start of the command or after a
|
|
18
|
+
// shell delimiter (`;`, `&`, `|`, `(`, `)`, or a quote), followed by
|
|
19
|
+
// whitespace or end. Quotes are included so `bash -c "python foo"` (a common
|
|
20
|
+
// bypass) is normalized too. Deliberately NOT `\bpython\b` — that would
|
|
21
|
+
// corrupt `python-dateutil` in `pip install python-dateutil` (the `-` is a
|
|
22
|
+
// word boundary).
|
|
23
|
+
const PYTHON_NORMALIZE_RE = /(^|[\s;&|()"'])python([\s]|$)/g
|
|
24
|
+
|
|
25
|
+
/** Result of normalizing a command string. */
|
|
26
|
+
export interface NormalizeResult {
|
|
27
|
+
/** The command with `python` → `python3` applied (unchanged if no match). */
|
|
28
|
+
normalized: string
|
|
29
|
+
/** Whether any rewrite actually happened. */
|
|
30
|
+
wasRewritten: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether the command invokes `python -c` (arbitrary eval — flagged as risk). */
|
|
34
|
+
const PYTHON_C_RE = /(^|[\s;&|()"'])python([\s]+)-c([\s]|$)/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a bash command string, rewriting standalone `python` tokens to
|
|
38
|
+
* `python3`. Pure function — no side effects, safe to unit test directly.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizePythonCommand(command: string): NormalizeResult {
|
|
41
|
+
// Reset lastIndex first — the regex is global and `.test()` advances it.
|
|
42
|
+
PYTHON_NORMALIZE_RE.lastIndex = 0
|
|
43
|
+
const wasRewritten = PYTHON_NORMALIZE_RE.test(command)
|
|
44
|
+
PYTHON_NORMALIZE_RE.lastIndex = 0
|
|
45
|
+
const normalized = command.replace(PYTHON_NORMALIZE_RE, '$1python3$2')
|
|
46
|
+
return { normalized, wasRewritten }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the command contains a `python -c` invocation (arbitrary eval). */
|
|
50
|
+
export function isPythonCEval(command: string): boolean {
|
|
51
|
+
PYTHON_C_RE.lastIndex = 0
|
|
52
|
+
return PYTHON_C_RE.test(command)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Minimal logger surface the guard needs (matches plugin logger shape). */
|
|
56
|
+
export interface NormalizerLogger {
|
|
57
|
+
warn(message: string): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Options for createCommandNormalizer(). */
|
|
61
|
+
export interface CommandNormalizerOptions {
|
|
62
|
+
/** Audit sink for rewrites and `python -c` warnings. */
|
|
63
|
+
logger?: NormalizerLogger
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the `tool.execute.before` handler that rewrites `bash` commands.
|
|
68
|
+
* Only the `bash` tool is touched; every other tool passes through untouched.
|
|
69
|
+
* The rewrite mutates `output.args.command` so the normalized command is what
|
|
70
|
+
* actually executes. Never throws — normalization must not break a session.
|
|
71
|
+
*/
|
|
72
|
+
export function createCommandNormalizer(
|
|
73
|
+
options: CommandNormalizerOptions = {},
|
|
74
|
+
): (input: { tool: string; sessionID: string }, output?: { args?: unknown }) => Promise<void> {
|
|
75
|
+
const logger = options.logger
|
|
76
|
+
|
|
77
|
+
return async (input, output): Promise<void> => {
|
|
78
|
+
if (input.tool !== 'bash') return
|
|
79
|
+
if (output?.args === null || typeof output?.args !== 'object') return
|
|
80
|
+
|
|
81
|
+
const args = output.args as Record<string, unknown>
|
|
82
|
+
const command = args.command
|
|
83
|
+
if (typeof command !== 'string' || command.trim() === '') return
|
|
84
|
+
|
|
85
|
+
const { normalized, wasRewritten } = normalizePythonCommand(command)
|
|
86
|
+
|
|
87
|
+
if (isPythonCEval(command)) {
|
|
88
|
+
logger?.warn(
|
|
89
|
+
`[command-normalizer] WARNING python -c detected: session=${input.sessionID} command="${command}"`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (wasRewritten) {
|
|
94
|
+
args.command = normalized
|
|
95
|
+
logger?.warn(
|
|
96
|
+
`[command-normalizer] python→python3 rewritten: session=${input.sessionID} ` +
|
|
97
|
+
`original="${command}" normalized="${normalized}"`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Sandboxing — Context Window Optimization (v2 P0)
|
|
3
|
+
*
|
|
4
|
+
* Truncates oversized tool outputs before they flood the context window.
|
|
5
|
+
* Pure truncation with metadata — no tiktoken, no LLM summarization (YAGNI).
|
|
6
|
+
*
|
|
7
|
+
* Hook: tool.execute.after — intercepts read/grep/glob/webfetch outputs.
|
|
8
|
+
*
|
|
9
|
+
* @module pantheon/context-sandbox
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ToolExecuteAfterInput {
|
|
13
|
+
tool: string
|
|
14
|
+
sessionID: string
|
|
15
|
+
callID: string
|
|
16
|
+
args?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToolExecuteAfterOutput {
|
|
20
|
+
title?: string
|
|
21
|
+
output: string
|
|
22
|
+
metadata?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ToolExecuteAfterHandler = (
|
|
26
|
+
input: ToolExecuteAfterInput,
|
|
27
|
+
output: ToolExecuteAfterOutput,
|
|
28
|
+
) => Promise<void>
|
|
29
|
+
|
|
30
|
+
export interface SandboxToolLimits {
|
|
31
|
+
read: { maxLines: number; keepHead: number; keepTail: number }
|
|
32
|
+
grep: { maxResults: number; keepTop: number }
|
|
33
|
+
glob: { maxFiles: number; keepTop: number }
|
|
34
|
+
webfetch: { maxChars: number; keepHead: number }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ContextSandboxConfig {
|
|
38
|
+
enabled: boolean
|
|
39
|
+
limits: SandboxToolLimits
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Default limits — sensatos, cobrem 80% do problema com ~30 LOC. */
|
|
43
|
+
export const DEFAULT_LIMITS: SandboxToolLimits = {
|
|
44
|
+
read: { maxLines: 200, keepHead: 50, keepTail: 10 },
|
|
45
|
+
grep: { maxResults: 20, keepTop: 10 },
|
|
46
|
+
glob: { maxFiles: 50, keepTop: 20 },
|
|
47
|
+
webfetch: { maxChars: 5000, keepHead: 2000 },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_CONFIG: ContextSandboxConfig = {
|
|
51
|
+
enabled: true,
|
|
52
|
+
limits: DEFAULT_LIMITS,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Pure sandbox functions ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sandbox truncation for `read` tool output.
|
|
59
|
+
* Input is expected to be line-numbered read output (e.g. "1: foo").
|
|
60
|
+
* If lines > maxLines, keep head + marker + tail.
|
|
61
|
+
*/
|
|
62
|
+
export function sandboxRead(output: string, limits = DEFAULT_LIMITS.read): string {
|
|
63
|
+
const lines = output.split('\n')
|
|
64
|
+
// trailing empty after final newline is not a real line — drop it for counting
|
|
65
|
+
const effectiveCount =
|
|
66
|
+
lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
|
|
67
|
+
if (effectiveCount <= limits.maxLines) return output
|
|
68
|
+
|
|
69
|
+
const head = lines.slice(0, limits.keepHead)
|
|
70
|
+
const tail =
|
|
71
|
+
limits.keepTail > 0
|
|
72
|
+
? lines.slice(Math.max(limits.keepHead, effectiveCount - limits.keepTail), effectiveCount)
|
|
73
|
+
: []
|
|
74
|
+
const hidden = effectiveCount - head.length - tail.length
|
|
75
|
+
const marker = `[TRUNCATED: ${hidden} lines hidden — showing ${head.length} head + ${tail.length} tail of ${effectiveCount} total lines]`
|
|
76
|
+
|
|
77
|
+
// Preserve trailing newline semantics of original if present
|
|
78
|
+
const hadTrailingNewline = output.endsWith('\n')
|
|
79
|
+
const parts = [...head, marker, ...tail]
|
|
80
|
+
let result = parts.join('\n')
|
|
81
|
+
if (hadTrailingNewline) result += '\n'
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sandbox truncation for `grep` tool output.
|
|
87
|
+
* Each non-empty line is considered a match. Keeps top N, drops tail.
|
|
88
|
+
*/
|
|
89
|
+
export function sandboxGrep(output: string, limits = DEFAULT_LIMITS.grep): string {
|
|
90
|
+
const lines = output.split('\n')
|
|
91
|
+
const effectiveCount =
|
|
92
|
+
lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
|
|
93
|
+
// Count non-empty? spec says 20 results — treat every line as result for simplicity,
|
|
94
|
+
// but filter obvious empties: if effective count includes empty trailing, already handled.
|
|
95
|
+
// Use effectiveCount as result count.
|
|
96
|
+
if (effectiveCount <= limits.maxResults) return output
|
|
97
|
+
|
|
98
|
+
const head = lines.slice(0, limits.keepTop)
|
|
99
|
+
const hidden = effectiveCount - head.length
|
|
100
|
+
const marker = `[TRUNCATED: Showing ${head.length} of ${effectiveCount} matches — ${hidden} more hidden. Use more specific pattern.]`
|
|
101
|
+
const hadTrailingNewline = output.endsWith('\n')
|
|
102
|
+
let result = [...head, marker].join('\n')
|
|
103
|
+
if (hadTrailingNewline) result += '\n'
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sandbox truncation for `glob` tool output.
|
|
109
|
+
* Each line is a file path. Keeps top N.
|
|
110
|
+
*/
|
|
111
|
+
export function sandboxGlob(output: string, limits = DEFAULT_LIMITS.glob): string {
|
|
112
|
+
const lines = output.split('\n')
|
|
113
|
+
const effectiveCount =
|
|
114
|
+
lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
|
|
115
|
+
if (effectiveCount <= limits.maxFiles) return output
|
|
116
|
+
|
|
117
|
+
const head = lines.slice(0, limits.keepTop)
|
|
118
|
+
const hidden = effectiveCount - head.length
|
|
119
|
+
const marker = `[TRUNCATED: showing ${head.length} of ${effectiveCount} files — ${hidden} more hidden]`
|
|
120
|
+
const hadTrailingNewline = output.endsWith('\n')
|
|
121
|
+
let result = [...head, marker].join('\n')
|
|
122
|
+
if (hadTrailingNewline) result += '\n'
|
|
123
|
+
return result
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Sandbox truncation for `webfetch` tool output.
|
|
128
|
+
* Character-based: keep head chars, drop rest.
|
|
129
|
+
*/
|
|
130
|
+
export function sandboxWebfetch(output: string, limits = DEFAULT_LIMITS.webfetch): string {
|
|
131
|
+
if (output.length <= limits.maxChars) return output
|
|
132
|
+
const head = output.slice(0, limits.keepHead)
|
|
133
|
+
const hidden = output.length - head.length
|
|
134
|
+
const marker = `\n[TRUNCATED: Content truncated — showing first ${head.length} of ${output.length} chars, ${hidden} chars hidden]\n`
|
|
135
|
+
return head + marker
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Dispatch sandbox truncation by tool name. Pure function — no I/O.
|
|
140
|
+
* Unknown tools pass through unchanged.
|
|
141
|
+
*/
|
|
142
|
+
export function sandboxOutput(
|
|
143
|
+
tool: string,
|
|
144
|
+
output: string,
|
|
145
|
+
limits: SandboxToolLimits = DEFAULT_LIMITS,
|
|
146
|
+
): string {
|
|
147
|
+
if (typeof output !== 'string' || output.length === 0) return output
|
|
148
|
+
const normalized = tool.trim().toLowerCase()
|
|
149
|
+
switch (normalized) {
|
|
150
|
+
case 'read':
|
|
151
|
+
return sandboxRead(output, limits.read)
|
|
152
|
+
case 'grep':
|
|
153
|
+
return sandboxGrep(output, limits.grep)
|
|
154
|
+
case 'glob':
|
|
155
|
+
return sandboxGlob(output, limits.glob)
|
|
156
|
+
case 'webfetch':
|
|
157
|
+
case 'fetch':
|
|
158
|
+
case 'web_fetch':
|
|
159
|
+
return sandboxWebfetch(output, limits.webfetch)
|
|
160
|
+
default:
|
|
161
|
+
return output
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Plugin hook factory ───────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve sandbox config from opencode.json `context_sandbox` block.
|
|
169
|
+
* Fail-open: any missing/invalid field falls back to defaults.
|
|
170
|
+
*/
|
|
171
|
+
export function resolveSandboxConfig(raw?: unknown): ContextSandboxConfig {
|
|
172
|
+
if (raw === null || raw === undefined || typeof raw !== 'object')
|
|
173
|
+
return { ...DEFAULT_CONFIG, limits: { ...DEFAULT_LIMITS } }
|
|
174
|
+
const obj = raw as Record<string, unknown>
|
|
175
|
+
const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : true
|
|
176
|
+
const limitsRaw = obj.limits as Record<string, unknown> | undefined
|
|
177
|
+
|
|
178
|
+
const limits: SandboxToolLimits = {
|
|
179
|
+
read: {
|
|
180
|
+
maxLines:
|
|
181
|
+
typeof limitsRaw?.read === 'object' &&
|
|
182
|
+
limitsRaw.read !== null &&
|
|
183
|
+
typeof (limitsRaw.read as Record<string, unknown>).maxLines === 'number'
|
|
184
|
+
? ((limitsRaw.read as Record<string, unknown>).maxLines as number)
|
|
185
|
+
: DEFAULT_LIMITS.read.maxLines,
|
|
186
|
+
keepHead:
|
|
187
|
+
typeof limitsRaw?.read === 'object' &&
|
|
188
|
+
limitsRaw.read !== null &&
|
|
189
|
+
typeof (limitsRaw.read as Record<string, unknown>).keepHead === 'number'
|
|
190
|
+
? ((limitsRaw.read as Record<string, unknown>).keepHead as number)
|
|
191
|
+
: DEFAULT_LIMITS.read.keepHead,
|
|
192
|
+
keepTail:
|
|
193
|
+
typeof limitsRaw?.read === 'object' &&
|
|
194
|
+
limitsRaw.read !== null &&
|
|
195
|
+
typeof (limitsRaw.read as Record<string, unknown>).keepTail === 'number'
|
|
196
|
+
? ((limitsRaw.read as Record<string, unknown>).keepTail as number)
|
|
197
|
+
: DEFAULT_LIMITS.read.keepTail,
|
|
198
|
+
},
|
|
199
|
+
grep: {
|
|
200
|
+
maxResults:
|
|
201
|
+
typeof limitsRaw?.grep === 'object' &&
|
|
202
|
+
limitsRaw.grep !== null &&
|
|
203
|
+
typeof (limitsRaw.grep as Record<string, unknown>).maxResults === 'number'
|
|
204
|
+
? ((limitsRaw.grep as Record<string, unknown>).maxResults as number)
|
|
205
|
+
: DEFAULT_LIMITS.grep.maxResults,
|
|
206
|
+
keepTop:
|
|
207
|
+
typeof limitsRaw?.grep === 'object' &&
|
|
208
|
+
limitsRaw.grep !== null &&
|
|
209
|
+
typeof (limitsRaw.grep as Record<string, unknown>).keepTop === 'number'
|
|
210
|
+
? ((limitsRaw.grep as Record<string, unknown>).keepTop as number)
|
|
211
|
+
: DEFAULT_LIMITS.grep.keepTop,
|
|
212
|
+
},
|
|
213
|
+
glob: {
|
|
214
|
+
maxFiles:
|
|
215
|
+
typeof limitsRaw?.glob === 'object' &&
|
|
216
|
+
limitsRaw.glob !== null &&
|
|
217
|
+
typeof (limitsRaw.glob as Record<string, unknown>).maxFiles === 'number'
|
|
218
|
+
? ((limitsRaw.glob as Record<string, unknown>).maxFiles as number)
|
|
219
|
+
: DEFAULT_LIMITS.glob.maxFiles,
|
|
220
|
+
keepTop:
|
|
221
|
+
typeof limitsRaw?.glob === 'object' &&
|
|
222
|
+
limitsRaw.glob !== null &&
|
|
223
|
+
typeof (limitsRaw.glob as Record<string, unknown>).keepTop === 'number'
|
|
224
|
+
? ((limitsRaw.glob as Record<string, unknown>).keepTop as number)
|
|
225
|
+
: DEFAULT_LIMITS.glob.keepTop,
|
|
226
|
+
},
|
|
227
|
+
webfetch: {
|
|
228
|
+
maxChars:
|
|
229
|
+
typeof limitsRaw?.webfetch === 'object' &&
|
|
230
|
+
limitsRaw.webfetch !== null &&
|
|
231
|
+
typeof (limitsRaw.webfetch as Record<string, unknown>).maxChars === 'number'
|
|
232
|
+
? ((limitsRaw.webfetch as Record<string, unknown>).maxChars as number)
|
|
233
|
+
: DEFAULT_LIMITS.webfetch.maxChars,
|
|
234
|
+
keepHead:
|
|
235
|
+
typeof limitsRaw?.webfetch === 'object' &&
|
|
236
|
+
limitsRaw.webfetch !== null &&
|
|
237
|
+
typeof (limitsRaw.webfetch as Record<string, unknown>).keepHead === 'number'
|
|
238
|
+
? ((limitsRaw.webfetch as Record<string, unknown>).keepHead as number)
|
|
239
|
+
: DEFAULT_LIMITS.webfetch.keepHead,
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
return { enabled, limits }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Create the `tool.execute.after` handler that sandboxes oversized outputs.
|
|
247
|
+
* The handler mutates `output.output` in-place and tags metadata when truncated.
|
|
248
|
+
* Fail-open: never throws.
|
|
249
|
+
*/
|
|
250
|
+
export function createContextSandbox(
|
|
251
|
+
config: ContextSandboxConfig = DEFAULT_CONFIG,
|
|
252
|
+
): ToolExecuteAfterHandler {
|
|
253
|
+
// Capture mutable reference — caller (plugin config hook) may update limits live
|
|
254
|
+
const cfgRef = config
|
|
255
|
+
return async (input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput): Promise<void> => {
|
|
256
|
+
try {
|
|
257
|
+
if (!cfgRef.enabled) return
|
|
258
|
+
if (typeof output.output !== 'string') return
|
|
259
|
+
const before = output.output
|
|
260
|
+
const after = sandboxOutput(input.tool, before, cfgRef.limits)
|
|
261
|
+
if (after !== before) {
|
|
262
|
+
output.output = after
|
|
263
|
+
output.metadata = {
|
|
264
|
+
...(output.metadata ?? {}),
|
|
265
|
+
truncated: true,
|
|
266
|
+
sandbox: input.tool.toLowerCase(),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// never break the tool result
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
2
|
-
import type { PluginConfig } from 'opencode'
|
|
3
1
|
import { existsSync } from 'node:fs'
|
|
4
2
|
import { join } from 'node:path'
|
|
3
|
+
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
4
|
+
import type { PluginConfig } from 'opencode'
|
|
5
5
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
6
|
+
import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
|
|
6
7
|
import { reassertAfterCompaction } from './pantheon/compaction-assert.ts'
|
|
8
|
+
import {
|
|
9
|
+
type ContextSandboxConfig,
|
|
10
|
+
createContextSandbox,
|
|
11
|
+
resolveSandboxConfig,
|
|
12
|
+
DEFAULT_CONFIG as SANDBOX_DEFAULT_CONFIG,
|
|
13
|
+
DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
|
|
14
|
+
} from './pantheon/context-sandbox.ts'
|
|
7
15
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
8
16
|
import {
|
|
9
17
|
collectRootSessionIDs,
|
|
@@ -131,6 +139,13 @@ const enforcementGuard = createEnforcementGuard({
|
|
|
131
139
|
},
|
|
132
140
|
})
|
|
133
141
|
|
|
142
|
+
// Fase 1 (command-normalizer): deterministic `python` → `python3` rewrite for
|
|
143
|
+
// `bash` commands (PEP 394 — Ubuntu 20.04+ has no bare `python`). Unlike the
|
|
144
|
+
// enforcement guard above (which THROWS to deny), this guard REWRITES the
|
|
145
|
+
// command in place and lets it run. Logs every rewrite + `python -c` warning
|
|
146
|
+
// to the shared hooks.log for @nyx frequency measurement.
|
|
147
|
+
const commandNormalizer = createCommandNormalizer({ logger: log })
|
|
148
|
+
|
|
134
149
|
// Mirrors routing.yml background_delegation.max_compaction_items.
|
|
135
150
|
const COMPACTION_MAX_ITEMS = 10
|
|
136
151
|
|
|
@@ -436,6 +451,24 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
436
451
|
const hashlineEdit = createHashlineEditTool()
|
|
437
452
|
const readEnhancer = createReadEnhancer()
|
|
438
453
|
|
|
454
|
+
// Context Window Optimization (v2 P0) — tool output sandboxing
|
|
455
|
+
// Pure truncation hook: read/grep/glob/webfetch → head + [TRUNCATED] + tail
|
|
456
|
+
// Fail-open: never throws, disabled via opencode.json context_sandbox.enabled
|
|
457
|
+
const sandboxConfig: ContextSandboxConfig = {
|
|
458
|
+
enabled: SANDBOX_DEFAULT_CONFIG.enabled,
|
|
459
|
+
limits: {
|
|
460
|
+
read: { ...SANDBOX_DEFAULT_LIMITS.read },
|
|
461
|
+
grep: { ...SANDBOX_DEFAULT_LIMITS.grep },
|
|
462
|
+
glob: { ...SANDBOX_DEFAULT_LIMITS.glob },
|
|
463
|
+
webfetch: { ...SANDBOX_DEFAULT_LIMITS.webfetch },
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
const sandboxHandler = createContextSandbox(sandboxConfig)
|
|
467
|
+
const combinedAfter: typeof readEnhancer = async (input, output) => {
|
|
468
|
+
await sandboxHandler(input, output)
|
|
469
|
+
await readEnhancer(input, output)
|
|
470
|
+
}
|
|
471
|
+
|
|
439
472
|
// Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
|
|
440
473
|
// opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
|
|
441
474
|
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
@@ -452,6 +485,15 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
452
485
|
// WITHOUT a parentID is a root. Fail-open: a failed/throttled list
|
|
453
486
|
// leaves the set untouched (delegation.ts treats unknown sessions as
|
|
454
487
|
// roots), and config setup must never wait for the session API.
|
|
488
|
+
// Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
|
|
489
|
+
try {
|
|
490
|
+
const raw = (config as unknown as Record<string, unknown>).context_sandbox
|
|
491
|
+
const resolved = resolveSandboxConfig(raw)
|
|
492
|
+
sandboxConfig.enabled = resolved.enabled
|
|
493
|
+
sandboxConfig.limits = resolved.limits
|
|
494
|
+
} catch {
|
|
495
|
+
// fail-open: keep defaults
|
|
496
|
+
}
|
|
455
497
|
seedRootSessionsInBackground(input)
|
|
456
498
|
config.agentsPath = config.agentsPath ?? []
|
|
457
499
|
config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
|
|
@@ -578,13 +620,16 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
578
620
|
// no-ops for non-matching sessions/tools.
|
|
579
621
|
zeusReadGuard(input.tool, output?.args, sessionAgents.get(input.sessionID))
|
|
580
622
|
await enforcementGuard(input, output)
|
|
623
|
+
await commandNormalizer(input, output)
|
|
581
624
|
await todoPreserver.beforeTodoWrite(input, output)
|
|
582
625
|
},
|
|
583
|
-
// Wave 2 (PR #46)
|
|
626
|
+
// Wave 2 (PR #46) + Context Sandbox (v2 P0): sandbox truncates oversized
|
|
627
|
+
// outputs BEFORE hashline tags (so tags remain stable on kept lines).
|
|
628
|
+
// Additive —
|
|
584
629
|
// pantheon-hooks.ts (a separate plugin instance) owns its own
|
|
585
630
|
// tool.execute.after, so there is no key collision. Non-read tools pass
|
|
586
631
|
// through untouched.
|
|
587
|
-
'tool.execute.after':
|
|
632
|
+
'tool.execute.after': combinedAfter,
|
|
588
633
|
// Phase 4 + release-134 Phase 2: keep the session's working state across
|
|
589
634
|
// compaction — preservation directive, active goals (<mission_context>),
|
|
590
635
|
// pending todos (<todo_context>), and in-flight background delegations
|
|
@@ -11,7 +11,7 @@ type DelegationEntry = {
|
|
|
11
11
|
taskID?: string;
|
|
12
12
|
/** Agent name, e.g. "apollo". */
|
|
13
13
|
agent: string;
|
|
14
|
-
state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled';
|
|
14
|
+
state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled' | 'stale-running';
|
|
15
15
|
/** Epoch ms of the `Started` header. */
|
|
16
16
|
startedAt: number;
|
|
17
17
|
/** Epoch ms of the `Finalized` header — null while still running. */
|
|
@@ -77,7 +77,22 @@ declare function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry
|
|
|
77
77
|
* recent terminal reports (capped). Pure — so the history-only panel (no
|
|
78
78
|
* sessionID) is testable without the TUI runtime. The header count uses the
|
|
79
79
|
* same "running + recentes" list. */
|
|
80
|
-
declare function visibleDelegationList(all: readonly DelegationEntry[], maxTerminal?: number): DelegationEntry[];
|
|
80
|
+
declare function visibleDelegationList(all: readonly DelegationEntry[], maxTerminal?: number, now?: number, staleThresholdMs?: number): DelegationEntry[];
|
|
81
|
+
/** Default stale-running threshold: 30 minutes. */
|
|
82
|
+
declare const STALE_RUNNING_THRESHOLD_MS: number;
|
|
83
|
+
/** Idle silence window: if no updatedAt change in this window, the entry is
|
|
84
|
+
* considered stale. Combined with the stale-running threshold to produce the
|
|
85
|
+
* display-only `stale-running` state. */
|
|
86
|
+
declare const IDLE_SILENCE_MS: number;
|
|
87
|
+
/**
|
|
88
|
+
* Mark a running entry as `stale-running` if it has been running longer than
|
|
89
|
+
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
90
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
|
|
91
|
+
*
|
|
92
|
+
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
93
|
+
* delegation is still treated as running by the backend.
|
|
94
|
+
*/
|
|
95
|
+
declare function markStaleIfRunning(entry: DelegationEntry, now: number, thresholdMs?: number): DelegationEntry;
|
|
81
96
|
/** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
|
|
82
97
|
* second for running jobs. */
|
|
83
98
|
declare function fmtElapsed(ms: number): string;
|
|
@@ -306,5 +321,5 @@ declare const plugin: TuiPluginModule & {
|
|
|
306
321
|
id: string;
|
|
307
322
|
};
|
|
308
323
|
//#endregion
|
|
309
|
-
export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
|
|
324
|
+
export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, IDLE_SILENCE_MS, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, STALE_RUNNING_THRESHOLD_MS, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
|
|
310
325
|
//# sourceMappingURL=tui.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAy6BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EASA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAkHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,sBACA,cACA,4BACC;;cASU;;;;cAKA;;;;;;;;;iBAUG,mBACd,OAAO,iBACP,aACA,uBACC;;;iBAWa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAQ9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KAmFS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBA0Ca,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KAiLU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAucI,QAAQ;EAAoB"}
|
|
@@ -120,8 +120,14 @@ async function resolvePresetForTui(env, cwd) {
|
|
|
120
120
|
}
|
|
121
121
|
async function detectVersion(api) {
|
|
122
122
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
123
|
+
const pkgContent = await readFile(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8");
|
|
124
|
+
const pkg = JSON.parse(pkgContent);
|
|
125
|
+
if (pkg.version) return pkg.version;
|
|
126
|
+
} catch {}
|
|
127
|
+
try {
|
|
128
|
+
const pkgContent = await readFile(fileURLToPath(new URL("../../../../package.json", import.meta.url)), "utf8");
|
|
129
|
+
const pkg = JSON.parse(pkgContent);
|
|
130
|
+
if (pkg.version) return pkg.version;
|
|
125
131
|
} catch {}
|
|
126
132
|
try {
|
|
127
133
|
const wt = api.state.path?.worktree ?? "";
|
|
@@ -765,7 +771,8 @@ function parseDelegationMarkdown(raw, fileAlias, sessionID = "") {
|
|
|
765
771
|
const startedAt = started !== void 0 ? Date.parse(started) : NaN;
|
|
766
772
|
if (agent === void 0 || state === void 0 || Number.isNaN(startedAt)) return null;
|
|
767
773
|
const normalized = state.toLowerCase();
|
|
768
|
-
if (normalized
|
|
774
|
+
if (normalized === "running") return null;
|
|
775
|
+
if (normalized !== "completed" && normalized !== "error" && normalized !== "startup_failed" && normalized !== "startup_unknown" && normalized !== "cancelled") return null;
|
|
769
776
|
const finalizedAt = finalized !== void 0 ? Date.parse(finalized) : NaN;
|
|
770
777
|
return {
|
|
771
778
|
alias: title ?? (fileAlias !== void 0 ? stripMdSuffix(fileAlias) : "unknown"),
|
|
@@ -861,8 +868,8 @@ function tuiLogPath(projectRoot) {
|
|
|
861
868
|
* falling back to startedAt, descending). Shared by the md reader and
|
|
862
869
|
* mergeDelegationSources. */
|
|
863
870
|
function compareDelegationEntries(a, b) {
|
|
864
|
-
const aRun = a.state === "running" ? 1 : 0;
|
|
865
|
-
const bRun = b.state === "running" ? 1 : 0;
|
|
871
|
+
const aRun = a.state === "running" || a.state === "stale-running" ? 1 : 0;
|
|
872
|
+
const bRun = b.state === "running" || b.state === "stale-running" ? 1 : 0;
|
|
866
873
|
if (aRun !== bRun) return bRun - aRun;
|
|
867
874
|
return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt);
|
|
868
875
|
}
|
|
@@ -870,11 +877,34 @@ function compareDelegationEntries(a, b) {
|
|
|
870
877
|
* recent terminal reports (capped). Pure — so the history-only panel (no
|
|
871
878
|
* sessionID) is testable without the TUI runtime. The header count uses the
|
|
872
879
|
* same "running + recentes" list. */
|
|
873
|
-
function visibleDelegationList(all, maxTerminal = 8) {
|
|
874
|
-
const running = all.filter((d) => d.state === "running");
|
|
880
|
+
function visibleDelegationList(all, maxTerminal = 8, now = Date.now(), staleThresholdMs = 1800 * 1e3) {
|
|
881
|
+
const running = all.filter((d) => d.state === "running").map((d) => markStaleIfRunning(d, now, staleThresholdMs));
|
|
875
882
|
const terminal = all.filter((d) => d.state !== "running").slice(0, maxTerminal);
|
|
876
883
|
return [...running, ...terminal];
|
|
877
884
|
}
|
|
885
|
+
/** Default stale-running threshold: 30 minutes. */
|
|
886
|
+
const STALE_RUNNING_THRESHOLD_MS = 1800 * 1e3;
|
|
887
|
+
/** Idle silence window: if no updatedAt change in this window, the entry is
|
|
888
|
+
* considered stale. Combined with the stale-running threshold to produce the
|
|
889
|
+
* display-only `stale-running` state. */
|
|
890
|
+
const IDLE_SILENCE_MS = 60 * 1e3;
|
|
891
|
+
/**
|
|
892
|
+
* Mark a running entry as `stale-running` if it has been running longer than
|
|
893
|
+
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
894
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
|
|
895
|
+
*
|
|
896
|
+
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
897
|
+
* delegation is still treated as running by the backend.
|
|
898
|
+
*/
|
|
899
|
+
function markStaleIfRunning(entry, now, thresholdMs = STALE_RUNNING_THRESHOLD_MS) {
|
|
900
|
+
if (entry.state !== "running") return entry;
|
|
901
|
+
if (now - entry.startedAt < thresholdMs) return entry;
|
|
902
|
+
if (entry.updatedAt !== null && now - entry.updatedAt < 6e4) return entry;
|
|
903
|
+
return {
|
|
904
|
+
...entry,
|
|
905
|
+
state: "stale-running"
|
|
906
|
+
};
|
|
907
|
+
}
|
|
878
908
|
/** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
|
|
879
909
|
* second for running jobs. */
|
|
880
910
|
function fmtElapsed(ms) {
|
|
@@ -891,7 +921,7 @@ function fmtElapsed(ms) {
|
|
|
891
921
|
/** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
|
|
892
922
|
* → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
|
|
893
923
|
function delegationElapsed(entry, now) {
|
|
894
|
-
if (entry.state === "running") return fmtElapsed(now - entry.startedAt);
|
|
924
|
+
if (entry.state === "running" || entry.state === "stale-running") return fmtElapsed(now - entry.startedAt);
|
|
895
925
|
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : "—";
|
|
896
926
|
}
|
|
897
927
|
/** The activity labels shown by the animated row. Keeping this pure makes the
|
|
@@ -1318,7 +1348,7 @@ function childrenToDelegationEntries(children, md, now = Date.now()) {
|
|
|
1318
1348
|
agent: mdEntry?.agent ?? child.agent ?? "agent",
|
|
1319
1349
|
state,
|
|
1320
1350
|
startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
|
|
1321
|
-
updatedAt: mdEntry?.updatedAt ?? (state === "running" ? null : child.time?.updated ?? null),
|
|
1351
|
+
updatedAt: mdEntry?.updatedAt ?? (state === "running" || state === "stale-running" ? null : child.time?.updated ?? null),
|
|
1322
1352
|
timedOut: mdEntry?.timedOut ?? false,
|
|
1323
1353
|
description: mdEntry !== void 0 && mdEntry.description !== "" ? mdEntry.description : child.title ?? "",
|
|
1324
1354
|
source: mdEntry !== void 0 ? "md" : "children-only"
|
|
@@ -1399,6 +1429,7 @@ function DelegationRow(props) {
|
|
|
1399
1429
|
const t = theme();
|
|
1400
1430
|
switch (props.job.state) {
|
|
1401
1431
|
case "running": return t.warning;
|
|
1432
|
+
case "stale-running": return t.error;
|
|
1402
1433
|
case "completed": return t.success;
|
|
1403
1434
|
case "error": return t.error;
|
|
1404
1435
|
default: return t.textMuted;
|
|
@@ -1406,6 +1437,7 @@ function DelegationRow(props) {
|
|
|
1406
1437
|
});
|
|
1407
1438
|
const marker = createMemo(() => {
|
|
1408
1439
|
if (props.job.state === "running") return `${delegationSpinnerFrame(props.animationNow)} `;
|
|
1440
|
+
if (props.job.state === "stale-running") return "⚠ ";
|
|
1409
1441
|
switch (props.job.state) {
|
|
1410
1442
|
case "completed": return "✓ ";
|
|
1411
1443
|
case "error": return "✕ ";
|
|
@@ -1842,6 +1874,6 @@ const plugin = {
|
|
|
1842
1874
|
tui
|
|
1843
1875
|
};
|
|
1844
1876
|
//#endregion
|
|
1845
|
-
export { buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
|
|
1877
|
+
export { IDLE_SILENCE_MS, STALE_RUNNING_THRESHOLD_MS, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
|
|
1846
1878
|
|
|
1847
1879
|
//# sourceMappingURL=tui.js.map
|
|
@@ -154,25 +154,29 @@ async function resolvePresetForTui(
|
|
|
154
154
|
* Se falhar, retorna null e a View omite a versão.
|
|
155
155
|
*
|
|
156
156
|
* Try order:
|
|
157
|
+
* 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
|
|
158
|
+
* 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.tsx → 4 níveis)
|
|
157
159
|
* 1. api.client.file.read package.json
|
|
158
160
|
* 2. git describe --tags
|
|
159
161
|
* 3. opencode --version
|
|
160
162
|
* 4. null */
|
|
161
163
|
|
|
162
164
|
async function detectVersion(api: TuiPluginApi): Promise<string | null> {
|
|
163
|
-
// Try
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
// Try 0a: installed location (2 levels: dist/tui.js → pantheon-tui/package.json)
|
|
166
|
+
try {
|
|
167
|
+
const pkgUrl = new URL('../../package.json', import.meta.url)
|
|
168
|
+
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
169
|
+
const pkg = JSON.parse(pkgContent)
|
|
170
|
+
if (pkg.version) return pkg.version
|
|
171
|
+
} catch {}
|
|
172
|
+
|
|
173
|
+
// Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.tsx → package.json)
|
|
168
174
|
try {
|
|
169
175
|
const pkgUrl = new URL('../../../../package.json', import.meta.url)
|
|
170
176
|
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
} catch {
|
|
174
|
-
/* fall through */
|
|
175
|
-
}
|
|
177
|
+
const pkg = JSON.parse(pkgContent)
|
|
178
|
+
if (pkg.version) return pkg.version
|
|
179
|
+
} catch {}
|
|
176
180
|
|
|
177
181
|
// Try 1: package.json do projeto (via worktree path)
|
|
178
182
|
try {
|
|
@@ -942,7 +946,14 @@ export type DelegationEntry = {
|
|
|
942
946
|
taskID?: string
|
|
943
947
|
/** Agent name, e.g. "apollo". */
|
|
944
948
|
agent: string
|
|
945
|
-
state:
|
|
949
|
+
state:
|
|
950
|
+
| 'running'
|
|
951
|
+
| 'completed'
|
|
952
|
+
| 'error'
|
|
953
|
+
| 'startup_failed'
|
|
954
|
+
| 'startup_unknown'
|
|
955
|
+
| 'cancelled'
|
|
956
|
+
| 'stale-running'
|
|
946
957
|
/** Epoch ms of the `Started` header. */
|
|
947
958
|
startedAt: number
|
|
948
959
|
/** Epoch ms of the `Finalized` header — null while still running. */
|
|
@@ -1048,8 +1059,10 @@ export function parseDelegationMarkdown(
|
|
|
1048
1059
|
const startedAt = started !== undefined ? Date.parse(started) : NaN
|
|
1049
1060
|
if (agent === undefined || state === undefined || Number.isNaN(startedAt)) return null
|
|
1050
1061
|
const normalized = state.toLowerCase()
|
|
1062
|
+
// Running state should only come from live channels, never from persisted
|
|
1063
|
+
// reports. A stale "running" MD artifact contaminates the panel — skip it.
|
|
1064
|
+
if (normalized === 'running') return null
|
|
1051
1065
|
if (
|
|
1052
|
-
normalized !== 'running' &&
|
|
1053
1066
|
normalized !== 'completed' &&
|
|
1054
1067
|
normalized !== 'error' &&
|
|
1055
1068
|
normalized !== 'startup_failed' &&
|
|
@@ -1171,8 +1184,8 @@ export function tuiLogPath(projectRoot: string): string {
|
|
|
1171
1184
|
* falling back to startedAt, descending). Shared by the md reader and
|
|
1172
1185
|
* mergeDelegationSources. */
|
|
1173
1186
|
export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
|
|
1174
|
-
const aRun = a.state === 'running' ? 1 : 0
|
|
1175
|
-
const bRun = b.state === 'running' ? 1 : 0
|
|
1187
|
+
const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
|
|
1188
|
+
const bRun = b.state === 'running' || b.state === 'stale-running' ? 1 : 0
|
|
1176
1189
|
if (aRun !== bRun) return bRun - aRun
|
|
1177
1190
|
return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
|
|
1178
1191
|
}
|
|
@@ -1184,12 +1197,45 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
|
|
|
1184
1197
|
export function visibleDelegationList(
|
|
1185
1198
|
all: readonly DelegationEntry[],
|
|
1186
1199
|
maxTerminal = 8,
|
|
1200
|
+
now = Date.now(),
|
|
1201
|
+
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1187
1202
|
): DelegationEntry[] {
|
|
1188
|
-
const running = all
|
|
1203
|
+
const running = all
|
|
1204
|
+
.filter((d) => d.state === 'running')
|
|
1205
|
+
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1189
1206
|
const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
|
|
1190
1207
|
return [...running, ...terminal]
|
|
1191
1208
|
}
|
|
1192
1209
|
|
|
1210
|
+
/** Default stale-running threshold: 30 minutes. */
|
|
1211
|
+
export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
1212
|
+
|
|
1213
|
+
/** Idle silence window: if no updatedAt change in this window, the entry is
|
|
1214
|
+
* considered stale. Combined with the stale-running threshold to produce the
|
|
1215
|
+
* display-only `stale-running` state. */
|
|
1216
|
+
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1220
|
+
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
1221
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
|
|
1222
|
+
*
|
|
1223
|
+
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
1224
|
+
* delegation is still treated as running by the backend.
|
|
1225
|
+
*/
|
|
1226
|
+
export function markStaleIfRunning(
|
|
1227
|
+
entry: DelegationEntry,
|
|
1228
|
+
now: number,
|
|
1229
|
+
thresholdMs = STALE_RUNNING_THRESHOLD_MS,
|
|
1230
|
+
): DelegationEntry {
|
|
1231
|
+
if (entry.state !== 'running') return entry
|
|
1232
|
+
const elapsed = now - entry.startedAt
|
|
1233
|
+
if (elapsed < thresholdMs) return entry
|
|
1234
|
+
// If updatedAt exists and is recent, entry is still active — not stale.
|
|
1235
|
+
if (entry.updatedAt !== null && now - entry.updatedAt < IDLE_SILENCE_MS) return entry
|
|
1236
|
+
return { ...entry, state: 'stale-running' as const }
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1193
1239
|
/** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
|
|
1194
1240
|
* second for running jobs. */
|
|
1195
1241
|
export function fmtElapsed(ms: number): string {
|
|
@@ -1207,7 +1253,8 @@ export function fmtElapsed(ms: number): string {
|
|
|
1207
1253
|
/** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
|
|
1208
1254
|
* → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
|
|
1209
1255
|
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1210
|
-
if (entry.state === 'running'
|
|
1256
|
+
if (entry.state === 'running' || entry.state === 'stale-running')
|
|
1257
|
+
return fmtElapsed(now - entry.startedAt)
|
|
1211
1258
|
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1212
1259
|
}
|
|
1213
1260
|
|
|
@@ -1874,7 +1921,9 @@ export function childrenToDelegationEntries(
|
|
|
1874
1921
|
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
1875
1922
|
state,
|
|
1876
1923
|
startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
|
|
1877
|
-
updatedAt:
|
|
1924
|
+
updatedAt:
|
|
1925
|
+
mdEntry?.updatedAt ??
|
|
1926
|
+
(state === 'running' || state === 'stale-running' ? null : (child.time?.updated ?? null)),
|
|
1878
1927
|
timedOut: mdEntry?.timedOut ?? false,
|
|
1879
1928
|
description:
|
|
1880
1929
|
mdEntry !== undefined && mdEntry.description !== ''
|
|
@@ -2008,6 +2057,8 @@ function DelegationRow(props: {
|
|
|
2008
2057
|
switch (props.job.state) {
|
|
2009
2058
|
case 'running':
|
|
2010
2059
|
return t.warning
|
|
2060
|
+
case 'stale-running':
|
|
2061
|
+
return t.error // warning color for stale delegations
|
|
2011
2062
|
case 'completed':
|
|
2012
2063
|
return t.success
|
|
2013
2064
|
case 'error':
|
|
@@ -2019,6 +2070,7 @@ function DelegationRow(props: {
|
|
|
2019
2070
|
|
|
2020
2071
|
const marker = createMemo(() => {
|
|
2021
2072
|
if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
|
|
2073
|
+
if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
|
|
2022
2074
|
switch (props.job.state) {
|
|
2023
2075
|
case 'completed':
|
|
2024
2076
|
return '\u2713 '
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-tui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.0",
|
|
5
5
|
"description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -154,25 +154,29 @@ async function resolvePresetForTui(
|
|
|
154
154
|
* Se falhar, retorna null e a View omite a versão.
|
|
155
155
|
*
|
|
156
156
|
* Try order:
|
|
157
|
+
* 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
|
|
158
|
+
* 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.tsx → 4 níveis)
|
|
157
159
|
* 1. api.client.file.read package.json
|
|
158
160
|
* 2. git describe --tags
|
|
159
161
|
* 3. opencode --version
|
|
160
162
|
* 4. null */
|
|
161
163
|
|
|
162
164
|
async function detectVersion(api: TuiPluginApi): Promise<string | null> {
|
|
163
|
-
// Try
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
// Try 0a: installed location (2 levels: dist/tui.js → pantheon-tui/package.json)
|
|
166
|
+
try {
|
|
167
|
+
const pkgUrl = new URL('../../package.json', import.meta.url)
|
|
168
|
+
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
169
|
+
const pkg = JSON.parse(pkgContent)
|
|
170
|
+
if (pkg.version) return pkg.version
|
|
171
|
+
} catch {}
|
|
172
|
+
|
|
173
|
+
// Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.tsx → package.json)
|
|
168
174
|
try {
|
|
169
175
|
const pkgUrl = new URL('../../../../package.json', import.meta.url)
|
|
170
176
|
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
} catch {
|
|
174
|
-
/* fall through */
|
|
175
|
-
}
|
|
177
|
+
const pkg = JSON.parse(pkgContent)
|
|
178
|
+
if (pkg.version) return pkg.version
|
|
179
|
+
} catch {}
|
|
176
180
|
|
|
177
181
|
// Try 1: package.json do projeto (via worktree path)
|
|
178
182
|
try {
|
|
@@ -45,3 +45,10 @@ Generate a hierarchical map of the codebase to understand its structure, key mod
|
|
|
45
45
|
- Focus on structure, not implementation details
|
|
46
46
|
- Max 3 levels deep unless a module is critical
|
|
47
47
|
- Return to agent: "Codemap generated. Send to @athena for planning."
|
|
48
|
+
|
|
49
|
+
## Integração @apollo
|
|
50
|
+
Antes de glob/grep manual, tente `code_query`:
|
|
51
|
+
- `code_query("MyClass")` → busca FTS5 por entidade
|
|
52
|
+
- Fallback para glob/grep se não indexado ou query vazia
|
|
53
|
+
- `code_index("src/")` para indexação incremental (hash-based, skip se não mudou, force=true para re-index)
|
|
54
|
+
- `code_neighbors(entity_id, depth=2)` para grafo de imports/calls
|
|
@@ -859,3 +859,42 @@ When `01-active-context.md` exceeds 100 lines:
|
|
|
859
859
|
- `skills/wisdom-accumulation/SKILL.md` — learning extraction
|
|
860
860
|
- `skills/memory-bank/SKILL.md` — memory bank maintenance
|
|
861
861
|
- `instructions/backend-standards.instructions.md` — zeus scoring reference
|
|
862
|
+
|
|
863
|
+
---
|
|
864
|
+
|
|
865
|
+
## 19. Tool Output Sandboxing (v2 P0 — Plugin Hook, 0 MCPs)
|
|
866
|
+
|
|
867
|
+
Camada P0 do Deepwork Plan "Context Window Optimization" (council 2026-08-25, 3/3 High): truncamento simples via `tool.execute.after`, sem tiktoken/LLM em v1 (YAGNI).
|
|
868
|
+
|
|
869
|
+
**Módulo:** `src/pantheon/context-sandbox.ts` (~120 LOC) — `sandboxOutput(tool, output)` + `createContextSandbox(config)`
|
|
870
|
+
|
|
871
|
+
**Limites (defaults sensatos, cobrem 80% do problema):**
|
|
872
|
+
|
|
873
|
+
| Tool | Limite | Ação no excesso |
|
|
874
|
+
|------|--------|-----------------|
|
|
875
|
+
| read | 200 linhas | Primeiras 50 + `[TRUNCATED: N lines]` + últimas 10 |
|
|
876
|
+
| grep | 20 resultados | Top 10 + `[Showing 10 of N matches]` |
|
|
877
|
+
| glob | 50 arquivos | Top 20 + `[showing 20 of N files]` |
|
|
878
|
+
| webfetch | 5000 chars | Primeiros 2000 + `[Content truncated]` |
|
|
879
|
+
|
|
880
|
+
**Integração (ordem fixa — 0-overhead extra):** `tool.execute.after` → `sandboxHandler` (trunca) → `readEnhancer` (hashline tags). Sandbox sempre antes de compression: outputs já chegam enxutos ao context window; compress-inline (L1) só atua sobre o que sobrou. Custo <1ms por call, 0 novos processos.
|
|
881
|
+
|
|
882
|
+
**Config opcional em `opencode.json` (fail-open — defaults quando ausente):**
|
|
883
|
+
```json
|
|
884
|
+
{
|
|
885
|
+
"context_sandbox": {
|
|
886
|
+
"enabled": true,
|
|
887
|
+
"limits": {
|
|
888
|
+
"read": { "maxLines": 200, "keepHead": 50, "keepTail": 10 },
|
|
889
|
+
"grep": { "maxResults": 20, "keepTop": 10 },
|
|
890
|
+
"glob": { "maxFiles": 50, "keepTop": 20 },
|
|
891
|
+
"webfetch": { "maxChars": 5000, "keepHead": 2000 }
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
Desabilitar: `"enabled": false` (plugin vira no-op, sem throw).
|
|
897
|
+
|
|
898
|
+
**Session Memory (P1 — já existe, só conectado):** `pantheon-persistence` KV com TTL 4h já fornece `context_save`/`context_get`. Auto-save antes de compactar (`experimental.session.compacting` → `buildCompactionContext` + `todoPreserver.capture`) e recovery pós-compactação (`session.compacted` → `todoPreserver.onCompacted` + `reassertAfterCompaction`) preservam estado sem novo MCP. Testado em `delegation-compaction.test.ts` + `todo-preserve.test.ts`.
|
|
899
|
+
|
|
900
|
+
**Fora de escopo v1 (YAGNI):** tiktoken counting, LLM summarization, budget dashboard — só se medição provar necessidade.
|