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 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.3.6--beta-blue" alt="Version"></a>
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 — no chat injection.** User policy: ZERO delegation
243
- notifications in the chat transcript — no `<task-notification>` block is ever
244
- injected into a `chat.message` output (the old queue + flush channel was
245
- removed). When a job reaches a terminal state (completion observed via
246
- `session.idle`/`session.error` on the child, or the timeout finalize path), the
247
- plugin writes a file-only audit log line (echo opt-in via `PANTHEON_HOOKS_LOG`).
248
- Completion visibility lives in the legitimate channels: the board `[unread]`
249
- marker (`pantheon_delegation_list`), `pantheon_delegation_read`, TUI toasts
250
- (pantheon-hooks, `PANTHEON_TOASTS` gate) and compaction carry-forward. Reconcile
251
- is an acknowledgment, not a completion — it never re-fires the terminal
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 — is re-injected as a `<system-reminder>` into the session's next
342
- message via the shared chat-reminder buffer (`chat-reminders.ts`, the same P0
343
- messageID guard that protects subagent fires). A session with nothing to
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 API-key validation (1.3.4):** `pantheon_delegate` gates the resolved
354
- child model's provider before dispatching (single source of truth:
355
- `routing.yml` preset definitions' `apiKeyEnv` — the same check `applyPreset`
356
- enforces at startup). If an **auto-resolved** model (`options.agentModels` or
357
- the active preset) points to a provider that requires an API key
358
- (`apiKeyEnv`) and the env var is unset, the delegate falls back to
359
- `opencode-go/deepseek-v4-flash` (validated the same way). If the fallback is
360
- also unusable, the tool returns a clear error **text** (never throws) naming
361
- the missing env var — and registers **no job** on the board. An **explicit**
362
- `model` passed by the caller is always respected (warned, not overridden).
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. The idle-flush log entry is
380
- deduplicated: the flush logs a summary (count + aggregated line count) and the
381
- reminder content is logged exactly once, at `chat.message` delivery — no more
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.message reminders):** on OpenCode 1.18.13 the TUI drops `tui.toast.show` events, so delegation signals are injected into the next user message as a single `<system-reminder>` text part (oh-my-openagent fallback pattern). Ask something that dispatches subagents (e.g. *"dispare 2 subagentes apollo em paralelo para listar arquivos e comparar resultados"*) and expect:
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 `<system-reminder>` in the chat with `🚀 apollo em execução` / `✅ apollo concluiu` (or the aggregate `✅ N agentes concluídos (...)` for 3+ agents completing within 6s)
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/reminders at all
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.7",
3
+ "version": "1.4.1",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -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 datetime, timezone
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=timezone.utc)
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
- * Chat Reminders (release-134 Phase 4) — the SHARED <system-reminder> buffer.
2
+ * Retired transcript-notification module.
3
3
  *
4
- * WHY a shared module (not owned by pantheon-hooks.ts):
5
- * 1. opencode's legacy plugin loader (verified in the 1.18.16 source,
6
- * packages/opencode/src/plugin/index.ts getLegacyPlugins) does
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
- }