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 +2 -1
- package/package.json +1 -1
- package/scripts/install/opencode.mjs +2 -0
- package/scripts/sync-tui.mjs +148 -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/pantheon/delegation-notify.ts +97 -0
- package/src/pantheon/delegation.ts +10 -0
- package/src/pantheon/tui-version-check.ts +72 -0
- package/src/plugin.ts +76 -2
- 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 +69 -17
- 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,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
|
|
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
|
+
}
|