1/**
2 * Code Core
3 *
4 * Ingests a codebase into a tree. Directories become nodes. Files become notes.
5 * Respects .gitignore. Skips binaries and node_modules.
6 */
7
8import fs from "fs/promises";
9import path from "path";
10import os from "os";
11import log from "../../seed/log.js";
12import Node from "../../seed/models/node.js";
13import Note from "../../seed/models/note.js";
14import { v4 as uuidv4 } from "uuid";
15import { execFile } from "child_process";
16import { promisify } from "util";
17
18const execFileAsync = promisify(execFile);
19
20let _metadata = null;
21export function configure({ metadata }) { _metadata = metadata; }
22
23// Files/dirs to always skip
24const SKIP_DIRS = new Set([
25 "node_modules", ".git", ".svn", ".hg", "__pycache__", ".next", ".nuxt",
26 "dist", "build", ".cache", "coverage", ".nyc_output", ".tox", "venv",
27 "env", ".env", ".venv", "vendor", "target", "out", ".gradle", ".idea",
28 ".vscode", ".DS_Store",
29]);
30
31const SKIP_EXTENSIONS = new Set([
32 ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".svg", ".webp",
33 ".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
34 ".zip", ".tar", ".gz", ".bz2", ".rar", ".7z",
35 ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a",
36 ".woff", ".woff2", ".ttf", ".eot",
37 ".pdf", ".doc", ".docx", ".xls", ".xlsx",
38 ".lock", ".map",
39]);
40
41const MAX_FILE_LINES = 500;
42const MAX_FILE_SIZE = 100000; // 100KB
43const MAX_FILES = 500;
44const MAX_DEPTH = 15;
45
46/**
47 * Parse .gitignore patterns into a simple matcher.
48 */
49async function loadGitignore(repoPath) {
50 const patterns = [];
51 try {
52 const content = await fs.readFile(path.join(repoPath, ".gitignore"), "utf-8");
53 for (const line of content.split("\n")) {
54 const trimmed = line.trim();
55 if (!trimmed || trimmed.startsWith("#")) continue;
56 patterns.push(trimmed);
57 }
58 } catch {}
59 return patterns;
60}
61
62function isIgnored(relativePath, patterns) {
63 const name = path.basename(relativePath);
64 for (const pattern of patterns) {
65 if (pattern === name) return true;
66 if (pattern.endsWith("/") && name === pattern.slice(0, -1)) return true;
67 if (pattern.startsWith("*") && relativePath.endsWith(pattern.slice(1))) return true;
68 }
69 return false;
70}
71
72/**
73 * Detect language from file extension.
74 */
75function detectLanguage(filePath) {
76 const ext = path.extname(filePath).toLowerCase();
77 const map = {
78 ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
79 ".ts": "typescript", ".tsx": "typescript",
80 ".py": "python", ".rb": "ruby", ".go": "go", ".rs": "rust",
81 ".java": "java", ".kt": "kotlin", ".scala": "scala",
82 ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
83 ".cs": "csharp", ".swift": "swift", ".m": "objc",
84 ".php": "php", ".lua": "lua", ".r": "r",
85 ".sh": "shell", ".bash": "shell", ".zsh": "shell",
86 ".sql": "sql", ".graphql": "graphql",
87 ".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
88 ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml",
89 ".xml": "xml", ".md": "markdown", ".txt": "text",
90 ".dockerfile": "docker", ".env": "env",
91 };
92 return map[ext] || null;
93}
94
95/**
96 * Clone a git repository to a temp directory.
97 * Returns the local path. Caller is responsible for cleanup.
98 */
99export async function cloneRepo(url) {
100 const tmpDir = path.join(os.tmpdir(), `treeos-code-${uuidv4().slice(0, 8)}`);
101 await fs.mkdir(tmpDir, { recursive: true });
102
103 try {
104 await execFileAsync("git", ["clone", "--depth", "1", url, tmpDir], {
105 timeout: 120000,
106 maxBuffer: 1024 * 1024,
107 });
108 log.verbose("Code", `Cloned ${url} to ${tmpDir}`);
109 return tmpDir;
110 } catch (err) {
111 // Clean up on failure
112 await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
113 throw new Error(`Clone failed: ${err.message}`);
114 }
115}
116
117/**
118 * Check if a string looks like a git URL.
119 */
120export function isGitUrl(str) {
121 return /^https?:\/\/.+\.git$|^https?:\/\/github\.com\/|^git@/.test(str);
122}
123
124/**
125 * Ingest a directory or git URL into a tree node.
126 * Creates child nodes for directories, notes for files.
127 */
128export async function ingest(parentNodeId, dirPath, userId, opts = {}) {
129 const repoRoot = opts.repoRoot || dirPath;
130 const gitignore = opts.gitignore || await loadGitignore(repoRoot);
131 const depth = opts.depth || 0;
132 const stats = opts.stats || { files: 0, dirs: 0, lines: 0, skipped: 0 };
133
134 if (depth > MAX_DEPTH) return stats;
135 if (stats.files >= MAX_FILES) return stats;
136
137 let entries;
138 try {
139 entries = await fs.readdir(dirPath, { withFileTypes: true });
140 } catch (err) {
141 log.debug("Code", `Cannot read ${dirPath}: ${err.message}`);
142 return stats;
143 }
144
145 // Sort: directories first, then files
146 entries.sort((a, b) => {
147 if (a.isDirectory() && !b.isDirectory()) return -1;
148 if (!a.isDirectory() && b.isDirectory()) return 1;
149 return a.name.localeCompare(b.name);
150 });
151
152 for (const entry of entries) {
153 if (stats.files >= MAX_FILES) break;
154
155 const entryPath = path.join(dirPath, entry.name);
156 const relativePath = path.relative(repoRoot, entryPath);
157
158 // Skip ignored
159 if (SKIP_DIRS.has(entry.name)) { stats.skipped++; continue; }
160 if (isIgnored(relativePath, gitignore)) { stats.skipped++; continue; }
161
162 if (entry.isDirectory()) {
163 // Create a node for the directory
164 const dirNode = await createCodeNode(parentNodeId, entry.name, userId, "directory");
165 stats.dirs++;
166
167 // Recurse
168 await ingest(String(dirNode._id), entryPath, userId, {
169 repoRoot, gitignore, depth: depth + 1, stats,
170 });
171 } else if (entry.isFile()) {
172 const ext = path.extname(entry.name).toLowerCase();
173 if (SKIP_EXTENSIONS.has(ext)) { stats.skipped++; continue; }
174
175 // Read file
176 try {
177 const fileStat = await fs.stat(entryPath);
178 if (fileStat.size > MAX_FILE_SIZE) {
179 stats.skipped++;
180 continue;
181 }
182
183 const content = await fs.readFile(entryPath, "utf-8");
184 const lines = content.split("\n");
185 const language = detectLanguage(entry.name);
186
187 // Truncate large files
188 const truncated = lines.length > MAX_FILE_LINES;
189 const fileContent = truncated
190 ? lines.slice(0, MAX_FILE_LINES).join("\n") + `\n\n... (${lines.length - MAX_FILE_LINES} more lines truncated)`
191 : content;
192
193 // Write file content directly to Note model.
194 // Bypasses createNote's text limit, hooks, and validation.
195 // Code files can be large. No hooks needed for bulk ingestion.
196 await Note.create({
197 _id: uuidv4(),
198 contentType: "text",
199 content: `// ${entry.name}\n${fileContent}`,
200 userId,
201 nodeId: parentNodeId,
202 wasAi: false,
203 metadata: { code: { fileName: entry.name, language, lines: lines.length, truncated } },
204 });
205
206 stats.files++;
207 stats.lines += lines.length;
208 } catch (err) {
209 log.debug("Code", `Cannot read file ${entryPath}: ${err.message}`);
210 stats.skipped++;
211 }
212 }
213 }
214
215 return stats;
216}
217
218/**
219 * Create a child node with code metadata.
220 * Uses the kernel's createNode for proper hook firing and parent linkage.
221 */
222async function createCodeNode(parentId, name, userId, role) {
223 const { createNode: kernelCreateNode } = await import("../../seed/tree/treeManagement.js");
224
225 // Check if already exists (idempotent re-ingestion)
226 const existing = await Node.findOne({ parent: parentId, name }).select("_id").lean();
227 if (existing) return existing;
228
229 const node = await kernelCreateNode({
230 name,
231 parentId,
232 userId,
233 metadata: { code: { role } },
234 });
235 return node;
236}
237
238/**
239 * Search for a pattern across all file notes in a code tree.
240 */
241export async function searchCode(rootId, query, type = "text") {
242 const results = [];
243 const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
244
245 // BFS: collect all nodes in the tree
246 const queue = [rootId];
247 const visited = new Set();
248 const Note = (await import("../../seed/models/note.js")).default;
249
250 while (queue.length > 0 && results.length < 20) {
251 const batch = queue.splice(0, 50);
252 const nodes = await Node.find({ parent: { $in: batch } })
253 .select("_id name children metadata")
254 .lean();
255
256 for (const node of nodes) {
257 const id = String(node._id);
258 if (visited.has(id)) continue;
259 visited.add(id);
260 if (node.children?.length) queue.push(id);
261
262 // Search notes on this node
263 const notes = await Note.find({ nodeId: id, contentType: "text" })
264 .select("content")
265 .lean();
266
267 for (const note of notes) {
268 if (!note.content) continue;
269 const matches = note.content.match(regex);
270 if (!matches) continue;
271
272 // Extract context around first match
273 const idx = note.content.search(regex);
274 const start = Math.max(0, idx - 100);
275 const end = Math.min(note.content.length, idx + query.length + 100);
276 const context = note.content.slice(start, end);
277
278 // Get file name from note content (first line is // filename)
279 const firstLine = note.content.split("\n")[0];
280 const fileName = firstLine.startsWith("// ") ? firstLine.slice(3) : null;
281
282 results.push({
283 nodeId: id,
284 nodeName: node.name,
285 fileName,
286 matchCount: matches.length,
287 context: context.trim(),
288 });
289 }
290
291 if (visited.size > 500) break;
292 }
293 }
294
295 return results;
296}
297
298/**
299 * Get code status for a tree.
300 */
301export async function getStatus(rootId) {
302 const root = await Node.findById(rootId).select("metadata").lean();
303 if (!root) return null;
304 const meta = root.metadata instanceof Map ? root.metadata.get("codebase") : root.metadata?.code;
305 return meta || null;
306}
307
1import log from "../../seed/log.js";
2import { configure } from "./core.js";
3import getTools from "./tools.js";
4
5import analyzeMode from "./modes/analyze.js";
6import browseMode from "./modes/browse.js";
7import editMode from "./modes/edit.js";
8import testMode from "./modes/test.js";
9import reviewMode from "./modes/review.js";
10
11export async function init(core) {
12 configure({ metadata: core.metadata });
13
14 // Register LLM slots: different models for different jobs
15 core.llm.registerRootLlmSlot?.("code-analyze");
16 core.llm.registerRootLlmSlot?.("code-search");
17 core.llm.registerRootLlmSlot?.("code-edit");
18 core.llm.registerRootLlmSlot?.("code-test");
19 core.llm.registerRootLlmSlot?.("code-review");
20
21 // Register modes
22 core.modes.registerMode("tree:code-analyze", analyzeMode, "codebase");
23 core.modes.registerMode("tree:code-browse", browseMode, "codebase");
24 core.modes.registerMode("tree:code-edit", editMode, "codebase");
25 core.modes.registerMode("tree:code-test", testMode, "codebase");
26 core.modes.registerMode("tree:code-review", reviewMode, "codebase");
27
28 // LLM slot assignments: cheap for search/test, quality for edit/review
29 if (core.llm.registerModeAssignment) {
30 core.llm.registerModeAssignment("tree:code-analyze", "code-analyze");
31 core.llm.registerModeAssignment("tree:code-browse", "code-analyze");
32 core.llm.registerModeAssignment("tree:code-edit", "code-edit");
33 core.llm.registerModeAssignment("tree:code-test", "code-test");
34 core.llm.registerModeAssignment("tree:code-review", "code-review");
35 }
36
37 // enrichContext: inject code metadata when at a code node
38 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
39 const codeMeta = meta?.code;
40 if (!codeMeta) return;
41
42 context.code = {
43 role: codeMeta.role || (codeMeta.initialized ? "repository" : null),
44 language: codeMeta.language || null,
45 fileCount: codeMeta.fileCount || null,
46 path: codeMeta.path || null,
47 };
48 }, "codebase");
49
50 // Set modes.respond on code roots so the routing index picks them up
51 // This happens at ingest time via the tool, not here.
52
53 log.info("Code", "Loaded. The tree reads code.");
54
55 return {
56 tools: getTools(),
57 // Inject code tools into converse and librarian modes so code-ingest
58 // is available before a code tree is set up.
59 modeTools: [
60 { modeKey: "tree:converse", toolNames: ["code-ingest", "code-search", "code-git"] },
61 { modeKey: "tree:librarian", toolNames: ["code-ingest", "code-search"] },
62 ],
63 };
64}
65
1export default {
2 name: "codebase",
3 version: "1.0.2",
4 builtFor: "seed",
5 scope: "confined",
6 description:
7 "A codebase becomes a tree. Directories become nodes. Files become notes. " +
8 "The AI at /myproject/src/components/Button knows Button's code, its children, " +
9 "its siblings, its tests. Navigate somewhere and the AI thinks about that code " +
10 "from that position. " +
11 "\n\n" +
12 "Six modes. Each is a specialist. Chains replace one big loop. " +
13 "code-analyze reads and understands. code-search triangulates across the codebase " +
14 "with five strategies and convergence scoring. code-plan designs the change. " +
15 "code-edit applies diffs. code-test runs and parses tests. code-review checks " +
16 "the work. A cheap model handles search and test. A quality model handles planning " +
17 "and editing. Total cost for a bug fix: less than one large model call. " +
18 "\n\n" +
19 "Once code is in the tree, the intelligence bundle works on it automatically. " +
20 "Understanding compresses modules. Scout searches across files. Explore drills " +
21 "into unfamiliar code. Codebook tracks recurring patterns. Evolve detects gaps. " +
22 "Inner monologue thinks about the codebase on breath cycles. " +
23 "\n\n" +
24 "Automate flows run CI, code review, and documentation generation on schedule. " +
25 "The tree watches its own codebase while you sleep.",
26
27 territory: "source code, programming, bugs, refactoring, repositories",
28 classifierHints: [
29 /\b(code|codebase|repository|repo|source code|source file)\b/i,
30 /\b(function|class|module|import|export|require)\b/i,
31 /\b(bug|fix|refactor|lint|test|compile|build)\b/i,
32 /\b(ingest|analyze|diff|blame|commit)\b/i,
33 ],
34
35 needs: {
36 services: ["hooks", "llm", "metadata", "tree"],
37 models: ["Node", "Note"],
38 },
39
40 optional: {
41 extensions: ["shell", "embed", "understanding", "scout", "explore", "treeos-base"],
42 },
43
44 provides: {
45 models: {},
46 routes: "./routes.js",
47 tools: true,
48 jobs: false,
49 orchestrator: false,
50 energyActions: {},
51 sessionTypes: {},
52 env: [],
53 cli: [
54 {
55 command: "code [action] [args...]",
56 scope: ["tree"],
57 description: "Code operations. Actions: ingest <path>, search <query>, status, git-status, git-diff, git-log.",
58 method: "GET",
59 endpoint: "/code/status?nodeId=:nodeId",
60 },
61 ],
62
63 hooks: {
64 fires: [],
65 listens: ["enrichContext"],
66 },
67 },
68};
69
1export default {
2 name: "tree:code-analyze",
3 emoji: "๐",
4 label: "Code Analyze",
5 bigMode: "tree",
6 hidden: true,
7
8 toolNames: [
9 "get-node-notes",
10 "get-tree-context",
11 "navigate-tree",
12 "code-search",
13 ],
14
15 buildSystemPrompt({ username, currentNodeId }) {
16 return `You are analyzing code for ${username}.
17
18Your job: read the file content (notes on this node) and the directory structure (children).
19Understand what this code does. Identify dependencies, exports, patterns, edge cases.
20
21Output a clear analysis:
22- What this file/module does (one sentence)
23- Key functions/classes and what they do
24- Dependencies (imports from other modules)
25- Potential issues or edge cases
26- How it connects to the broader codebase
27
28Be specific. Reference actual function names, variable names, line patterns.
29Do not give generic advice. Read the code and tell me what it says.`.trim();
30 },
31};
32
1import { getContextForAi } from "../../../seed/tree/treeFetch.js";
2
3export default {
4 name: "tree:code-browse",
5 emoji: "๐",
6 label: "Code Browse",
7 bigMode: "tree",
8
9 maxMessagesBeforeLoop: 20,
10 preserveContextOnLoop: true,
11
12 toolNames: [
13 "navigate-tree",
14 "get-tree-context",
15 "get-node-notes",
16 "code-search",
17 "code-git",
18 "code-ingest",
19 "code-sandbox",
20 "code-test",
21 ],
22
23 async buildSystemPrompt({ username, rootId, currentNodeId, conversationMemory }) {
24 const nodeId = currentNodeId || rootId;
25
26 let context = null;
27 if (nodeId) {
28 try {
29 context = await getContextForAi(nodeId, {
30 includeNotes: true,
31 includeChildren: true,
32 includeParentChain: true,
33 userId: null,
34 });
35 } catch {}
36 }
37
38 const ctx = context || {};
39 const name = ctx.name || "this module";
40
41 const sections = [];
42
43 if (ctx.notes?.length) {
44 const fileContent = ctx.notes
45 .slice(0, 10)
46 .map(n => n.content || "")
47 .filter(c => c.length > 0)
48 .join("\n---\n");
49 if (fileContent) sections.push(`SOURCE CODE:\n${fileContent}`);
50 }
51
52 if (ctx.children?.length) {
53 sections.push(`CONTENTS: ${ctx.children.map(c => c.name).join(", ")}`);
54 }
55
56 if (ctx.parentChain?.length) {
57 sections.push(`PATH: ${ctx.parentChain.map(p => p.name).join("/")}`);
58 }
59
60 if (conversationMemory) {
61 sections.push(`RECENT CONVERSATION:\n${conversationMemory}`);
62 }
63
64 const contextBlock = sections.length > 0
65 ? sections.join("\n\n")
66 : "This module is empty. No files yet.";
67
68 return `You are ${username}'s code assistant at "${name}".
69
70${contextBlock}
71
72You have read the code at this position. You know what's here.
73
74WHAT YOU DO:
75- Explain code. Answer questions about it. Trace logic.
76- Search across the codebase with code-search.
77- Navigate to related files with navigate-tree.
78- Check git state with code-git.
79- Reference specific function names, line patterns, variable names.
80
81HOW YOU SPEAK:
82- You are not a generic assistant. You are a developer who read this code.
83- Be specific. "fetchUser on line 23 returns a Promise that resolves to..." not "this file has some API calls."
84- If you don't know, search. Don't guess.`.trim();
85 },
86};
87
1export default {
2 name: "tree:code-edit",
3 emoji: "โ๏ธ",
4 label: "Code Edit",
5 bigMode: "tree",
6 hidden: true,
7
8 maxMessagesBeforeLoop: 20,
9 preserveContextOnLoop: true,
10
11 toolNames: [
12 "navigate-tree",
13 "get-tree-context",
14 "get-node-notes",
15 "code-search",
16 "create-node-note",
17 "edit-node-note",
18 "code-sandbox",
19 "code-test",
20 "code-run-file",
21 ],
22
23 buildSystemPrompt({ username }) {
24 return `You are editing code for ${username}.
25
26You have a plan from the previous step. Execute it precisely.
27
28RULES:
29- Make targeted edits. Don't rewrite entire files.
30- Use edit-node-note to modify existing file content.
31- Use create-node-note to add new files.
32- After editing, confirm what you changed and why.
33- One edit at a time. Verify before moving to the next.`.trim();
34 },
35};
36
1export default {
2 name: "tree:code-review",
3 emoji: "๐๏ธ",
4 label: "Code Review",
5 bigMode: "tree",
6 hidden: true,
7
8 toolNames: [
9 "code-git",
10 "get-node-notes",
11 "code-search",
12 ],
13
14 buildSystemPrompt({ username }) {
15 return `You are reviewing code changes for ${username}.
16
17Read the diff. Check for:
18- Logic errors or edge cases
19- Missing error handling
20- Breaking changes to public APIs
21- Naming inconsistencies
22- Security concerns (injection, exposure, auth bypass)
23- Performance issues (N+1 queries, unbounded loops, memory leaks)
24
25Be specific. Reference the actual code. "Line 23 catches the error but doesn't log it" not "consider adding error handling."
26
27If the change looks good, say so and explain why. Don't invent problems.`.trim();
28 },
29};
30
1export default {
2 name: "tree:code-test",
3 emoji: "๐งช",
4 label: "Code Test",
5 bigMode: "tree",
6 hidden: true,
7
8 toolNames: [
9 "code-run",
10 "code-test",
11 "code-run-file",
12 "code-sandbox",
13 "code-search",
14 ],
15
16 buildSystemPrompt({ username }) {
17 return `You are running tests for ${username}.
18
19Run the test command. Parse the results.
20
21OUTPUT (structured):
22- Total tests, passed, failed
23- For each failure: test name, file, error message
24- Summary: what broke and likely root cause
25
26If all tests pass, say so briefly.
27If tests fail, identify the failing files so the next step can navigate to them.`.trim();
28 },
29};
30
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { ingest, getStatus, searchCode } from "./core.js";
5
6const router = express.Router();
7
8// POST /code/ingest - ingest a codebase
9router.post("/code/ingest", authenticate, async (req, res) => {
10 try {
11 const { rootId, path: dirPath, ignore } = req.body;
12 if (!rootId || !dirPath) return sendError(res, 400, ERR.INVALID_INPUT, "rootId and path required");
13
14 const stats = await ingest(rootId, dirPath, req.userId, { ignore });
15
16 const Node = (await import("../../seed/models/node.js")).default;
17 await Node.updateOne({ _id: rootId }, {
18 $set: {
19 "metadata.code": {
20 initialized: true,
21 ingestedAt: new Date().toISOString(),
22 path: dirPath,
23 fileCount: stats.files,
24 dirCount: stats.dirs,
25 totalLines: stats.lines,
26 },
27 "metadata.modes.respond": "tree:code-browse",
28 },
29 });
30
31 sendOk(res, { stats });
32 } catch (err) {
33 sendError(res, 500, ERR.INTERNAL, err.message);
34 }
35});
36
37// GET /code/status - code tree status
38router.get("/code/status", authenticate, async (req, res) => {
39 try {
40 const nodeId = req.query.nodeId || req.query.rootId;
41 if (!nodeId) return sendError(res, 400, ERR.INVALID_INPUT, "nodeId required");
42 const status = await getStatus(nodeId);
43 sendOk(res, status || { initialized: false });
44 } catch (err) {
45 sendError(res, 500, ERR.INTERNAL, err.message);
46 }
47});
48
49// GET /code/search - search codebase
50router.get("/code/search", authenticate, async (req, res) => {
51 try {
52 const { rootId, query } = req.query;
53 if (!rootId || !query) return sendError(res, 400, ERR.INVALID_INPUT, "rootId and query required");
54 const results = await searchCode(rootId, query);
55 sendOk(res, { results });
56 } catch (err) {
57 sendError(res, 500, ERR.INTERNAL, err.message);
58 }
59});
60
61export default router;
62
1/**
2 * Code Sandbox
3 *
4 * Runs code in an isolated child process. No network. Memory limited.
5 * Timeout enforced. The AI writes code, the sandbox runs it, results come back.
6 *
7 * Two modes:
8 * 1. Run a file: execute a file in the repo context (tests, scripts)
9 * 2. Run snippet: execute arbitrary code the AI wrote (validation, experiments)
10 */
11
12import { execFile, spawn } from "child_process";
13import { promisify } from "util";
14import fs from "fs/promises";
15import path from "path";
16import os from "os";
17import { v4 as uuidv4 } from "uuid";
18import log from "../../seed/log.js";
19
20const execFileAsync = promisify(execFile);
21
22const TIMEOUT_MS = 30000;
23const MAX_OUTPUT = 16384; // 16KB
24const MAX_MEMORY_MB = 128;
25
26// Blocked patterns in snippets
27const BLOCKED = [
28 /require\s*\(\s*['"]child_process['"]\s*\)/,
29 /require\s*\(\s*['"]fs['"]\s*\)/,
30 /require\s*\(\s*['"]net['"]\s*\)/,
31 /require\s*\(\s*['"]http['"]\s*\)/,
32 /require\s*\(\s*['"]https['"]\s*\)/,
33 /import\s+.*from\s+['"]child_process['"]/,
34 /import\s+.*from\s+['"]net['"]/,
35 /import\s+.*from\s+['"]http['"]/,
36 /import\s+.*from\s+['"]https['"]/,
37 /process\.exit/,
38 /process\.kill/,
39 /process\.env/,
40 /eval\s*\(/,
41 /Function\s*\(/,
42];
43
44/**
45 * Check if a code snippet contains blocked patterns.
46 */
47function validateSnippet(code) {
48 for (const pattern of BLOCKED) {
49 if (pattern.test(code)) {
50 return `Blocked: ${pattern.source}`;
51 }
52 }
53 return null;
54}
55
56/**
57 * Run a code snippet in an isolated child process.
58 * No network, limited memory, timeout enforced.
59 *
60 * @param {string} code - JavaScript code to execute
61 * @param {string} [cwd] - Working directory (repo root)
62 * @returns {{ success: boolean, output: string, error?: string, exitCode: number, durationMs: number }}
63 */
64export async function runSnippet(code, cwd = null) {
65 const violation = validateSnippet(code);
66 if (violation) {
67 return { success: false, output: "", error: violation, exitCode: -1, durationMs: 0 };
68 }
69
70 // Write snippet to a temp file
71 const tmpFile = path.join(os.tmpdir(), `treeos-sandbox-${uuidv4().slice(0, 8)}.mjs`);
72
73 try {
74 await fs.writeFile(tmpFile, code, "utf-8");
75
76 const start = Date.now();
77 const result = await runIsolated(tmpFile, cwd);
78 result.durationMs = Date.now() - start;
79 return result;
80 } finally {
81 await fs.unlink(tmpFile).catch(() => {});
82 }
83}
84
85/**
86 * Run a file from the repo in an isolated child process.
87 *
88 * @param {string} filePath - Absolute path to the file to run
89 * @param {string} [cwd] - Working directory
90 * @returns {{ success: boolean, output: string, error?: string, exitCode: number, durationMs: number }}
91 */
92export async function runFile(filePath, cwd = null) {
93 // Verify file exists
94 try {
95 await fs.access(filePath);
96 } catch {
97 return { success: false, output: "", error: `File not found: ${filePath}`, exitCode: -1, durationMs: 0 };
98 }
99
100 const start = Date.now();
101 const result = await runIsolated(filePath, cwd || path.dirname(filePath));
102 result.durationMs = Date.now() - start;
103 return result;
104}
105
106/**
107 * Run a file in a restricted child process.
108 */
109async function runIsolated(filePath, cwd) {
110 return new Promise((resolve) => {
111 const args = [
112 `--max-old-space-size=${MAX_MEMORY_MB}`,
113 "--no-warnings",
114 filePath,
115 ];
116
117 const env = {
118 // Minimal env: no secrets, no credentials
119 PATH: process.env.PATH,
120 NODE_ENV: "sandbox",
121 HOME: os.tmpdir(),
122 };
123
124 const child = spawn("node", args, {
125 cwd: cwd || os.tmpdir(),
126 env,
127 timeout: TIMEOUT_MS,
128 stdio: ["ignore", "pipe", "pipe"],
129 // No shell. Direct exec. No injection.
130 });
131
132 let stdout = "";
133 let stderr = "";
134
135 child.stdout.on("data", (data) => {
136 if (stdout.length < MAX_OUTPUT) stdout += data.toString();
137 });
138
139 child.stderr.on("data", (data) => {
140 if (stderr.length < MAX_OUTPUT) stderr += data.toString();
141 });
142
143 const timer = setTimeout(() => {
144 child.kill("SIGKILL");
145 }, TIMEOUT_MS);
146
147 child.on("close", (exitCode, signal) => {
148 clearTimeout(timer);
149
150 const timedOut = signal === "SIGKILL";
151 const output = (stdout + (stderr ? "\nSTDERR:\n" + stderr : "")).slice(0, MAX_OUTPUT);
152
153 resolve({
154 success: exitCode === 0 && !timedOut,
155 output: timedOut ? output + "\n(timed out after 30s)" : output,
156 error: timedOut ? "Timed out" : (exitCode !== 0 ? `Exit code ${exitCode}` : null),
157 exitCode: exitCode ?? -1,
158 });
159 });
160
161 child.on("error", (err) => {
162 clearTimeout(timer);
163 resolve({
164 success: false,
165 output: "",
166 error: err.message,
167 exitCode: -1,
168 });
169 });
170 });
171}
172
173/**
174 * Run tests in the repo using the detected test runner.
175 * Tries common test commands in order.
176 *
177 * @param {string} repoPath - Absolute path to the repository
178 * @returns {{ success: boolean, output: string, runner: string, durationMs: number }}
179 */
180export async function runTests(repoPath) {
181 // Detect test runner from package.json
182 let testCommand = null;
183 try {
184 const pkg = JSON.parse(await fs.readFile(path.join(repoPath, "package.json"), "utf-8"));
185 if (pkg.scripts?.test && pkg.scripts.test !== "echo \"Error: no test specified\" && exit 1") {
186 testCommand = "npm test";
187 }
188 } catch {}
189
190 if (!testCommand) {
191 // Try common runners
192 const runners = [
193 { file: "jest.config.js", cmd: "npx jest --no-coverage" },
194 { file: "vitest.config.js", cmd: "npx vitest run" },
195 { file: "pytest.ini", cmd: "pytest" },
196 { file: "Cargo.toml", cmd: "cargo test" },
197 { file: "go.mod", cmd: "go test ./..." },
198 ];
199 for (const r of runners) {
200 try {
201 await fs.access(path.join(repoPath, r.file));
202 testCommand = r.cmd;
203 break;
204 } catch {}
205 }
206 }
207
208 if (!testCommand) {
209 return { success: false, output: "No test runner detected.", runner: null, durationMs: 0 };
210 }
211
212 const parts = testCommand.split(/\s+/);
213 const start = Date.now();
214
215 try {
216 const { stdout, stderr } = await execFileAsync(parts[0], parts.slice(1), {
217 cwd: repoPath,
218 timeout: TIMEOUT_MS * 2, // tests get more time
219 maxBuffer: MAX_OUTPUT * 2,
220 env: { ...process.env, NODE_ENV: "test", CI: "true" },
221 });
222
223 return {
224 success: true,
225 output: (stdout + (stderr ? "\nSTDERR:\n" + stderr : "")).slice(0, MAX_OUTPUT),
226 runner: testCommand,
227 durationMs: Date.now() - start,
228 };
229 } catch (err) {
230 return {
231 success: false,
232 output: ((err.stdout || "") + "\n" + (err.stderr || "")).slice(0, MAX_OUTPUT),
233 runner: testCommand,
234 error: `Exit ${err.code || "error"}`,
235 durationMs: Date.now() - start,
236 };
237 }
238}
239
1import { z } from "zod";
2import { ingest, searchCode, getStatus, cloneRepo, isGitUrl } from "./core.js";
3import { runSnippet, runFile, runTests } from "./sandbox.js";
4import fs from "fs/promises";
5import { execFile } from "child_process";
6import { promisify } from "util";
7
8const execFileAsync = promisify(execFile);
9
10// Safe commands for code-run
11const ALLOWED_COMMANDS = /^(npm test|npm run|npx|node|python|pytest|go test|cargo test|make|gradle|mvn|jest|mocha|vitest|eslint|prettier|tsc|rustfmt|gofmt|black|flake8|rubocop)/;
12const BLOCKED_PATTERNS = /rm\s+-rf|>\s*\/|sudo|chmod|chown|kill|pkill|shutdown|reboot|mkfs|dd\s+if/;
13
14export default function getTools() {
15 return [
16 {
17 name: "code-ingest",
18 description: "Ingest a codebase into the tree. Directories become nodes. Files become notes. Respects .gitignore.",
19 schema: {
20 path: z.string().describe("Local path or git URL (https://github.com/user/repo) to ingest"),
21 },
22 annotations: { readOnlyHint: false },
23 async handler({ path: inputPath, userId, nodeId }) {
24 if (!inputPath) return { content: [{ type: "text", text: "Path or URL required." }] };
25 if (!nodeId) return { content: [{ type: "text", text: "Not in a tree position." }] };
26
27 const Node = (await import("../../seed/models/node.js")).default;
28 const node = await Node.findById(nodeId).select("_id name metadata").lean();
29 if (!node) return { content: [{ type: "text", text: "Node not found." }] };
30
31 let dirPath = inputPath;
32 let clonedDir = null;
33 let source = inputPath;
34
35 // Clone if it's a git URL
36 if (isGitUrl(inputPath)) {
37 try {
38 clonedDir = await cloneRepo(inputPath);
39 dirPath = clonedDir;
40 source = inputPath;
41 } catch (err) {
42 return { content: [{ type: "text", text: `Clone failed: ${err.message}` }] };
43 }
44 }
45
46 try {
47 const stats = await ingest(nodeId, dirPath, userId);
48
49 // Mark root as code-initialized
50 await Node.updateOne({ _id: nodeId }, {
51 $set: {
52 "metadata.codebase": {
53 initialized: true,
54 ingestedAt: new Date().toISOString(),
55 path: dirPath,
56 source,
57 fileCount: stats.files,
58 dirCount: stats.dirs,
59 totalLines: stats.lines,
60 skipped: stats.skipped,
61 },
62 "metadata.modes.respond": "tree:code-browse",
63 },
64 });
65
66 return {
67 content: [{ type: "text", text: `Ingested: ${stats.files} files, ${stats.dirs} directories, ${stats.lines} lines. ${stats.skipped} skipped.` }],
68 };
69 } finally {
70 // Clean up cloned repo
71 if (clonedDir) {
72 await fs.rm(clonedDir, { recursive: true, force: true }).catch(() => {});
73 }
74 }
75 },
76 },
77
78 {
79 name: "code-search",
80 description: "Search for code patterns across the codebase. Returns matching files with context.",
81 schema: {
82 query: z.string().describe("Search query: function name, variable, text pattern"),
83 },
84 annotations: { readOnlyHint: true },
85 async handler({ query, rootId }) {
86 if (!query) return { content: [{ type: "text", text: "Query required." }] };
87 if (!rootId) return { content: [{ type: "text", text: "Not in a tree." }] };
88
89 const results = await searchCode(rootId, query);
90
91 if (results.length === 0) {
92 return { content: [{ type: "text", text: `No matches for "${query}".` }] };
93 }
94
95 const text = results.map(r =>
96 `${r.nodeName}/${r.fileName || "?"} (${r.matchCount} matches)\n ${r.context}`
97 ).join("\n\n");
98
99 return { content: [{ type: "text", text }] };
100 },
101 },
102
103 {
104 name: "code-git",
105 description: "Read git state: status, diff, log, or blame. Read-only.",
106 schema: {
107 action: z.enum(["status", "diff", "log", "blame"]).describe("Git action"),
108 path: z.string().optional().describe("File path for blame, or repo path"),
109 },
110 annotations: { readOnlyHint: true },
111 async handler({ action, path: filePath, rootId }) {
112 // Find the repo path from the code root metadata
113 const Node = (await import("../../seed/models/node.js")).default;
114 const root = await Node.findById(rootId).select("metadata").lean();
115 const meta = root?.metadata instanceof Map ? root.metadata.get("codebase") : root?.metadata?.code;
116 const repoPath = meta?.path;
117 if (!repoPath) return { content: [{ type: "text", text: "No code repository ingested at this root." }] };
118
119 const commands = {
120 status: ["git", ["status", "--short"]],
121 diff: ["git", ["diff", "--stat"]],
122 log: ["git", ["log", "--oneline", "-20"]],
123 blame: filePath ? ["git", ["blame", "--line-porcelain", filePath]] : null,
124 };
125
126 const cmd = commands[action];
127 if (!cmd) return { content: [{ type: "text", text: `Unknown action: ${action}` }] };
128
129 try {
130 const { stdout } = await execFileAsync(cmd[0], cmd[1], {
131 cwd: repoPath,
132 timeout: 15000,
133 maxBuffer: 1024 * 64,
134 });
135 const output = stdout.slice(0, 8000);
136 return { content: [{ type: "text", text: output || "(empty)" }] };
137 } catch (err) {
138 return { content: [{ type: "text", text: `Git error: ${err.message}` }] };
139 }
140 },
141 },
142
143 {
144 name: "code-run",
145 description: "Run build/test/lint commands in the code repository. Sanitized: only allows safe commands.",
146 schema: {
147 command: z.string().describe("Command to run: npm test, eslint src/, etc."),
148 },
149 annotations: { readOnlyHint: false },
150 async handler({ command, rootId }) {
151 if (!command) return { content: [{ type: "text", text: "Command required." }] };
152 if (!ALLOWED_COMMANDS.test(command)) {
153 return { content: [{ type: "text", text: `Command not allowed. Use: npm test, eslint, prettier, etc.` }] };
154 }
155 if (BLOCKED_PATTERNS.test(command)) {
156 return { content: [{ type: "text", text: "Blocked: dangerous pattern detected." }] };
157 }
158
159 const Node = (await import("../../seed/models/node.js")).default;
160 const root = await Node.findById(rootId).select("metadata").lean();
161 const meta = root?.metadata instanceof Map ? root.metadata.get("codebase") : root?.metadata?.code;
162 const repoPath = meta?.path;
163 if (!repoPath) return { content: [{ type: "text", text: "No code repository ingested at this root." }] };
164
165 const parts = command.split(/\s+/);
166 try {
167 const { stdout, stderr } = await execFileAsync(parts[0], parts.slice(1), {
168 cwd: repoPath,
169 timeout: 30000,
170 maxBuffer: 1024 * 64,
171 });
172 const output = (stdout + (stderr ? "\nSTDERR:\n" + stderr : "")).slice(0, 8000);
173 return { content: [{ type: "text", text: output || "(no output)" }] };
174 } catch (err) {
175 const output = ((err.stdout || "") + "\n" + (err.stderr || "")).slice(0, 8000);
176 return { content: [{ type: "text", text: `Exit ${err.code || "error"}:\n${output}` }] };
177 }
178 },
179 },
180
181 {
182 name: "code-sandbox",
183 description: "Run a JavaScript/Node.js code snippet in an isolated sandbox. No network, no filesystem, no secrets. 30s timeout, 128MB memory. Use for validation, experiments, quick tests.",
184 schema: {
185 code: z.string().describe("JavaScript code to execute"),
186 },
187 annotations: { readOnlyHint: false },
188 async handler({ code, rootId }) {
189 if (!code) return { content: [{ type: "text", text: "Code required." }] };
190
191 const Node = (await import("../../seed/models/node.js")).default;
192 const root = rootId ? await Node.findById(rootId).select("metadata").lean() : null;
193 const meta = root?.metadata instanceof Map ? root.metadata.get("codebase") : root?.metadata?.code;
194 const cwd = meta?.path || null;
195
196 const result = await runSnippet(code, cwd);
197
198 const status = result.success ? "OK" : "FAILED";
199 const text = `[${status}] ${result.durationMs}ms\n${result.output}${result.error ? "\nError: " + result.error : ""}`;
200 return { content: [{ type: "text", text }] };
201 },
202 },
203
204 {
205 name: "code-test",
206 description: "Run the repository's test suite. Auto-detects test runner (npm test, jest, vitest, pytest, cargo test, go test). Returns pass/fail results.",
207 schema: {},
208 annotations: { readOnlyHint: true },
209 async handler({ rootId }) {
210 const Node = (await import("../../seed/models/node.js")).default;
211 const root = await Node.findById(rootId).select("metadata").lean();
212 const meta = root?.metadata instanceof Map ? root.metadata.get("codebase") : root?.metadata?.code;
213 const repoPath = meta?.path;
214 if (!repoPath) return { content: [{ type: "text", text: "No code repository ingested at this root." }] };
215
216 const result = await runTests(repoPath);
217
218 const status = result.success ? "PASS" : "FAIL";
219 const runner = result.runner ? ` (${result.runner})` : "";
220 const text = `[${status}]${runner} ${result.durationMs}ms\n${result.output}${result.error ? "\nError: " + result.error : ""}`;
221 return { content: [{ type: "text", text }] };
222 },
223 },
224
225 {
226 name: "code-run-file",
227 description: "Run a specific file in the sandbox. For test files, scripts, or any executable code file.",
228 schema: {
229 file: z.string().describe("Relative path to the file within the repo (e.g. tests/auth.test.js)"),
230 },
231 annotations: { readOnlyHint: false },
232 async handler({ file, rootId }) {
233 if (!file) return { content: [{ type: "text", text: "File path required." }] };
234
235 const Node = (await import("../../seed/models/node.js")).default;
236 const root = await Node.findById(rootId).select("metadata").lean();
237 const meta = root?.metadata instanceof Map ? root.metadata.get("codebase") : root?.metadata?.code;
238 const repoPath = meta?.path;
239 if (!repoPath) return { content: [{ type: "text", text: "No code repository ingested at this root." }] };
240
241 const { default: path } = await import("path");
242 const fullPath = path.resolve(repoPath, file);
243
244 // Prevent path traversal
245 if (!fullPath.startsWith(repoPath)) {
246 return { content: [{ type: "text", text: "Path traversal blocked." }] };
247 }
248
249 const result = await runFile(fullPath, repoPath);
250
251 const status = result.success ? "OK" : "FAILED";
252 const text = `[${status}] ${result.durationMs}ms\n${result.output}${result.error ? "\nError: " + result.error : ""}`;
253 return { content: [{ type: "text", text }] };
254 },
255 },
256 ];
257}
258
Loading comments...