diff --git a/src/bridge/memory-handlers.ts b/src/bridge/memory-handlers.ts new file mode 100644 index 0000000..af551c9 --- /dev/null +++ b/src/bridge/memory-handlers.ts @@ -0,0 +1,162 @@ +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)); +} diff --git a/src/bridge/server.ts b/src/bridge/server.ts index fdd72a4..7139b10 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -4,6 +4,7 @@ import type { BridgeInboundRequest } from "../types/model.js"; import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js"; import { buildBootstrap } from "./bootstrap.js"; import { dispatchToClaude } from "../claude/sdk-adapter.js"; +import { makeMemorySearchHandler, makeMemoryGetHandler } from "./memory-handlers.js"; import { getSession, putSession, @@ -162,7 +163,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { workspace, resumeSessionId: claudeSessionId ?? undefined, permissionMode, - openclawTools: isFirstTurn ? openclawTools : undefined, + openclawTools, // pass every turn — MCP server exits with the claude process bridgePort: port, bridgeApiKey: apiKey, })) { @@ -255,17 +256,41 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { // MCP proxy tool execution callback. // Called by openclaw-mcp-server.mjs when Claude Code invokes an MCP tool. if (method === "POST" && url === "/mcp/execute") { - let body: { tool?: string; args?: Record }; + let body: { tool?: string; args?: Record; workspace?: string }; try { - body = await parseBodyRaw(req) as { tool?: string; args?: Record }; + body = await parseBodyRaw(req) as { tool?: string; args?: Record; workspace?: string }; } catch { sendJson(res, 400, { error: "invalid_json" }); return; } const toolName = body.tool ?? ""; const toolArgs = body.args ?? {}; - logger.info(`[contractor-bridge] mcp/execute tool=${toolName}`); + const execWorkspace = body.workspace ?? ""; + logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace}`); + // Built-in workspace-aware tool implementations + if (execWorkspace) { + if (toolName === "memory_search") { + try { + const result = await makeMemorySearchHandler(execWorkspace)(toolArgs); + sendJson(res, 200, { result }); + } catch (err) { + sendJson(res, 200, { error: `memory_search failed: ${String(err)}` }); + } + return; + } + if (toolName === "memory_get") { + try { + const result = await makeMemoryGetHandler(execWorkspace)(toolArgs); + sendJson(res, 200, { result }); + } catch (err) { + sendJson(res, 200, { error: `memory_get failed: ${String(err)}` }); + } + return; + } + } + + // Plugin-registered tool handlers (globalThis registry) const registry = (globalThis as Record)[toolRegistryKey]; const handler = registry instanceof Map ? (registry as Map Promise>).get(toolName) : undefined; diff --git a/src/claude/sdk-adapter.ts b/src/claude/sdk-adapter.ts index df23997..4daf746 100644 --- a/src/claude/sdk-adapter.ts +++ b/src/claude/sdk-adapter.ts @@ -42,6 +42,7 @@ function setupMcpConfig( tools: OpenAITool[], bridgePort: number, bridgeApiKey: string, + workspace: string, ): string | null { if (!tools.length) return null; if (!fs.existsSync(MCP_SERVER_SCRIPT)) return null; @@ -63,6 +64,7 @@ function setupMcpConfig( TOOL_DEFS_FILE: toolDefsPath, BRIDGE_EXECUTE_URL: `http://127.0.0.1:${bridgePort}/mcp/execute`, BRIDGE_API_KEY: bridgeApiKey, + WORKSPACE: workspace, }, }, }, @@ -108,12 +110,12 @@ export async function* dispatchToClaude( args.push("--resume", resumeSessionId); } - // If OpenClaw tools provided, set up MCP proxy so Claude sees them. - // Added after prompt so --mcp-config doesn't consume the prompt. + // Set up MCP proxy every turn — the MCP server process exits with each `claude -p` + // invocation, so --resume sessions also need --mcp-config to restart it. + // Put --mcp-config after the prompt so its variadic doesn't consume the prompt. let mcpConfigPath: string | null = null; - if (openclawTools?.length && !resumeSessionId) { - // Only inject MCP config on first turn — resume already has MCP from the session. - mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey); + if (openclawTools?.length) { + mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace); if (mcpConfigPath) { args.push("--mcp-config", mcpConfigPath); } diff --git a/src/mcp/openclaw-mcp-server.mjs b/src/mcp/openclaw-mcp-server.mjs index dfac6ef..0b38a60 100644 --- a/src/mcp/openclaw-mcp-server.mjs +++ b/src/mcp/openclaw-mcp-server.mjs @@ -45,6 +45,7 @@ function sendError(id, code, message) { async function executeTool(name, args) { const url = process.env.BRIDGE_EXECUTE_URL; const apiKey = process.env.BRIDGE_API_KEY ?? ""; + const workspace = process.env.WORKSPACE ?? ""; if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`; try { @@ -54,7 +55,8 @@ async function executeTool(name, args) { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ tool: name, args }), + // Include workspace so bridge can resolve workspace-relative operations (e.g. memory files) + body: JSON.stringify({ tool: name, args, workspace }), }); const data = await resp.json(); if (data.error) return `[tool error] ${data.error}`;