refactor: restructure to plugin/ + services/ layout and add per-turn bootstrap injection
- Migrate src/ → plugin/ (plugin/core/, plugin/web/, plugin/commands/)
and src/mcp/ → services/ per OpenClaw plugin dev spec
- Add Gemini CLI backend (plugin/core/gemini/sdk-adapter.ts) with GEMINI.md
system-prompt injection
- Inject bootstrap as stateless system prompt on every turn instead of
first turn only: Claude via --system-prompt, Gemini via workspace/GEMINI.md;
eliminates isFirstTurn branch, keeps skills in sync with OpenClaw snapshots
- Fix session-map-store defensive parsing (sessions ?? []) to handle bare {}
reset files without crashing on .find()
- Add docs/TEST_FLOW.md with E2E test scenarios and expected outcomes
- Add docs/claude/BRIDGE_MODEL_FINDINGS.md with contractor-probe results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
162
plugin/web/memory-handlers.ts
Normal file
162
plugin/web/memory-handlers.ts
Normal file
@@ -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<string> => {
|
||||
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<string> => {
|
||||
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<string, unknown>;
|
||||
if (!(_G["_contractorToolHandlers"] instanceof Map)) {
|
||||
_G["_contractorToolHandlers"] = new Map<string, (args: unknown) => Promise<string>>();
|
||||
}
|
||||
const registry = _G["_contractorToolHandlers"] as Map<string, (args: unknown) => Promise<string>>;
|
||||
registry.set("memory_search", makeMemorySearchHandler(workspace));
|
||||
registry.set("memory_get", makeMemoryGetHandler(workspace));
|
||||
}
|
||||
Reference in New Issue
Block a user