pantheon-opencode 1.3.6 → 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 CHANGED
@@ -27,7 +27,8 @@
27
27
  "npx *": "allow",
28
28
  "pytest *": "allow",
29
29
  "ruff *": "allow",
30
- "pip *": "allow"
30
+ "pip *": "allow",
31
+ "python3 *": "allow"
31
32
  }
32
33
  },
33
34
  "mcp": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.6",
3
+ "version": "1.4.0",
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,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-tui.mjs — Postinstall hook: sync TUI plugin to user's OpenCode config
4
+ *
5
+ * Runs automatically after `npm install pantheon-opencode@latest`. Detects the
6
+ * user's OpenCode config directory and, if the TUI plugin was previously
7
+ * installed (via `npx pantheon-opencode init`), copies fresh files from the
8
+ * installed package and refreshes dependencies.
9
+ *
10
+ * If the user hasn't initialized yet → do nothing (silent exit).
11
+ */
12
+ import { execSync } from 'node:child_process'
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
+ const ROOT = join(__dirname, '..')
20
+
21
+ // ── Helpers ────────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Write content to filePath only if it differs from what's on disk.
25
+ * @returns {boolean} true if file was written (changed), false if skipped
26
+ */
27
+ function writeIfChanged(filePath, content) {
28
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null
29
+ if (existing === content) return false
30
+ writeFileSync(filePath, content, 'utf8')
31
+ return true
32
+ }
33
+
34
+ /**
35
+ * Read the `version` field from a package.json, or null on failure.
36
+ */
37
+ function readVersion(pkgPath) {
38
+ try {
39
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || null
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Resolve the user's OpenCode config directory.
47
+ * Priority: $XDG_CONFIG_HOME/opencode → ~/.opencode → null (not initialized)
48
+ */
49
+ function resolveConfigDir() {
50
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
51
+ const xdgDir = join(xdgConfig, 'opencode')
52
+ if (existsSync(xdgDir)) return xdgDir
53
+
54
+ const homeDir = join(homedir(), '.opencode')
55
+ if (existsSync(homeDir)) return homeDir
56
+
57
+ return null
58
+ }
59
+
60
+ /**
61
+ * Copy plugin runtime files (dist/*, package.json, src/index.tsx) from srcDir
62
+ * to dstDir. Skips files that are byte-identical.
63
+ * @returns {{ created: number, skipped: number }}
64
+ */
65
+ function copyPluginFiles(srcDir, dstDir) {
66
+ const result = { created: 0, skipped: 0 }
67
+
68
+ mkdirSync(dstDir, { recursive: true })
69
+ mkdirSync(join(dstDir, 'dist'), { recursive: true })
70
+
71
+ // src/index.tsx → index.tsx
72
+ const srcIdx = join(srcDir, 'src', 'index.tsx')
73
+ if (existsSync(srcIdx)) {
74
+ const content = readFileSync(srcIdx, 'utf8')
75
+ if (writeIfChanged(join(dstDir, 'index.tsx'), content)) result.created++
76
+ else result.skipped++
77
+ }
78
+
79
+ // dist/*
80
+ const distSrc = join(srcDir, 'dist')
81
+ if (existsSync(distSrc)) {
82
+ for (const f of readdirSync(distSrc)) {
83
+ const content = readFileSync(join(distSrc, f), 'utf8')
84
+ if (writeIfChanged(join(dstDir, 'dist', f), content)) result.created++
85
+ else result.skipped++
86
+ }
87
+ }
88
+
89
+ // package.json
90
+ const pkgSrc = join(srcDir, 'package.json')
91
+ if (existsSync(pkgSrc)) {
92
+ const content = readFileSync(pkgSrc, 'utf8')
93
+ if (writeIfChanged(join(dstDir, 'package.json'), content)) result.created++
94
+ else result.skipped++
95
+ }
96
+
97
+ return result
98
+ }
99
+
100
+ // ── Main ───────────────────────────────────────────────────────────────
101
+
102
+ try {
103
+ const configDir = resolveConfigDir()
104
+ if (!configDir) {
105
+ // User hasn't run init yet — silent exit, no error
106
+ process.exit(0)
107
+ }
108
+
109
+ const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
110
+ if (!existsSync(tuiCopyDir)) {
111
+ // TUI plugin not installed — silent exit
112
+ process.exit(0)
113
+ }
114
+
115
+ // Source: TUI plugin inside the installed package
116
+ const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
117
+ if (!existsSync(tuiSrcDir)) {
118
+ // Source not found (shouldn't happen in a proper install)
119
+ process.exit(0)
120
+ }
121
+
122
+ // Compare versions before copy
123
+ const installedVersion = readVersion(join(tuiCopyDir, 'package.json'))
124
+ const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
125
+
126
+ // Copy fresh files
127
+ const { created, skipped } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
128
+
129
+ // Refresh dependencies in the copy
130
+ try {
131
+ execSync('npm install --omit=dev --no-audit', { cwd: tuiCopyDir, stdio: 'pipe' })
132
+ } catch {
133
+ // Non-fatal — deps may already be current
134
+ }
135
+
136
+ // Log result
137
+ if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
138
+ console.log(` TUI plugin already up to date (v${sourceVersion})`)
139
+ } else {
140
+ console.log(
141
+ ` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
142
+ )
143
+ }
144
+ } catch (err) {
145
+ // Postinstall scripts must never break npm install
146
+ console.warn(`⚠️ TUI sync skipped: ${err.message}`)
147
+ process.exit(0)
148
+ }
@@ -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__":
@@ -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
+ }