import fs from "node:fs"; import path from "node:path"; /** * Implementations of OpenClaw memory tools for the contractor bridge. * * OpenClaw's real memory_search uses vector/semantic search; we approximate * with keyword matching across workspace memory files. memory_get is a * line-range file read and is implemented exactly. * * These handlers are registered in globalThis._contractorToolHandlers so the * MCP proxy can call them via POST /mcp/execute. */ type MemorySearchArgs = { query?: string; maxResults?: number; minScore?: number; corpus?: "memory" | "wiki" | "all"; }; type MemoryGetArgs = { path?: string; from?: number; lines?: number; corpus?: "memory" | "wiki" | "all"; }; /** Collect all memory files in a workspace. */ function collectMemoryFiles(workspace: string): string[] { const files: string[] = []; const memoryMd = path.join(workspace, "MEMORY.md"); if (fs.existsSync(memoryMd)) files.push(memoryMd); const memoryDir = path.join(workspace, "memory"); if (fs.existsSync(memoryDir)) { for (const f of fs.readdirSync(memoryDir)) { if (f.endsWith(".md")) files.push(path.join(memoryDir, f)); } } return files; } /** Score a file's relevance to a query by counting keyword occurrences. */ function scoreFile(content: string, query: string): { score: number; snippets: string[] } { const words = query.toLowerCase().split(/\s+/).filter(Boolean); const lines = content.split("\n"); const snippets: string[] = []; let score = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i].toLowerCase(); const matches = words.filter((w) => line.includes(w)).length; if (matches > 0) { score += matches; // Include surrounding context (1 line before/after) const start = Math.max(0, i - 1); const end = Math.min(lines.length - 1, i + 1); const snippet = lines.slice(start, end + 1).join("\n").trim(); if (snippet && !snippets.includes(snippet)) { snippets.push(snippet); } } } return { score, snippets }; } export function makeMemorySearchHandler(workspace: string) { return async (args: unknown): Promise => { const { query = "", maxResults = 5, corpus = "memory" } = args as MemorySearchArgs; if (corpus === "wiki") { return JSON.stringify({ disabled: true, reason: "wiki corpus not available in contractor bridge" }); } const files = collectMemoryFiles(workspace); if (files.length === 0) { return JSON.stringify({ results: [], note: "No memory files found in workspace. Memory is empty.", }); } const results: Array<{ file: string; score: number; snippets: string[] }> = []; for (const filePath of files) { try { const content = fs.readFileSync(filePath, "utf8"); const { score, snippets } = scoreFile(content, query); if (score > 0) { results.push({ file: path.relative(workspace, filePath), score, snippets, }); } } catch { // skip unreadable files } } results.sort((a, b) => b.score - a.score); const top = results.slice(0, maxResults); if (top.length === 0) { return JSON.stringify({ results: [], note: `No memory matches found for query: "${query}"`, }); } return JSON.stringify({ results: top }); }; } export function makeMemoryGetHandler(workspace: string) { return async (args: unknown): Promise => { const { path: filePath = "", from, lines, corpus = "memory" } = args as MemoryGetArgs; if (corpus === "wiki") { return "[memory_get] wiki corpus not available in contractor bridge"; } if (!filePath) return "[memory_get] path is required"; // Resolve path relative to workspace (prevent path traversal) const resolved = path.resolve(workspace, filePath); if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) { return `[memory_get] path outside workspace: ${filePath}`; } if (!fs.existsSync(resolved)) { return `[memory_get] file not found: ${filePath}`; } try { const content = fs.readFileSync(resolved, "utf8"); const allLines = content.split("\n"); const startLine = from != null ? Math.max(0, from - 1) : 0; // convert 1-based to 0-based const endLine = lines != null ? startLine + lines : allLines.length; return allLines.slice(startLine, endLine).join("\n"); } catch (err) { return `[memory_get] error reading file: ${String(err)}`; } }; } /** * Register memory tool handlers into the shared globalThis tool registry. * Called by the bridge server on startup. */ export function registerMemoryHandlers(workspace: string): void { const _G = globalThis as Record; if (!(_G["_contractorToolHandlers"] instanceof Map)) { _G["_contractorToolHandlers"] = new Map Promise>(); } const registry = _G["_contractorToolHandlers"] as Map Promise>; registry.set("memory_search", makeMemorySearchHandler(workspace)); registry.set("memory_get", makeMemoryGetHandler(workspace)); }