import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import * as path from "node:path";
import * as fs from "node:fs";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
import { ogMemoryConfigSchema, type ParsedOgMemoryConfig } from "./config.js";
const COMMAND_TIMEOUT_MS = 30_000;
const CACHE_TTL_MS = 60_000;
interface CacheEntry {
content: string;
timestamp: number;
}
const recentMemoriesCache = new Map<string, CacheEntry>();
function getProjectRoot(): string {
const envRoot = process.env.OG_MEMORY_PROJECT_ROOT;
if (envRoot && fs.existsSync(path.join(envRoot, "ogmemory_cli.py"))) {
return path.resolve(envRoot);
}
const currentFile = fileURLToPath(import.meta.url);
const dir = path.dirname(currentFile);
const defaultRoot = path.resolve(dir, "..");
if (fs.existsSync(path.join(defaultRoot, "ogmemory_cli.py"))) {
return defaultRoot;
}
const dockerRoot = "/workspace/oG-Memory";
if (fs.existsSync(path.join(dockerRoot, "ogmemory_cli.py"))) {
return dockerRoot;
}
return defaultRoot;
}
function getPythonPathSeparator(): string {
return process.platform === "win32" ? ";" : ":";
}
function getCacheKey(): string {
return process.cwd();
}
function getCachedMemory(): CacheEntry | null {
const key = getCacheKey();
const entry = recentMemoriesCache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
recentMemoriesCache.delete(key);
return null;
}
return entry;
}
function setCachedMemory(content: string): void {
recentMemoriesCache.set(getCacheKey(), { content, timestamp: Date.now() });
}
function clearCache(): void {
recentMemoriesCache.clear();
}
function buildEnv(cfg: ParsedOgMemoryConfig, projectRoot: string): Record<string, string | undefined> {
const pathSep = getPythonPathSeparator();
return {
...process.env,
PYTHONPATH: `${projectRoot}${pathSep}${process.env.PYTHONPATH || ""}`,
...(cfg.dbHost && { OG_DB_HOST: cfg.dbHost }),
OG_DB_PORT: String(cfg.dbPort),
...(cfg.dbName && { OG_DB_NAME: cfg.dbName }),
...(cfg.dbUser && { OG_DB_USER: cfg.dbUser }),
...(cfg.dbPassword && { OG_DB_PASSWORD: cfg.dbPassword }),
...(cfg.embeddingProvider && { OG_EMBEDDING_PROVIDER: cfg.embeddingProvider }),
...(cfg.embeddingModel && { OG_EMBEDDING_MODEL: cfg.embeddingModel }),
...(cfg.openaiApiKey && { OPENAI_API_KEY: cfg.openaiApiKey }),
...(cfg.openaiBaseUrl && { OPENAI_BASE_URL: cfg.openaiBaseUrl }),
OG_CHUNK_SIZE: String(cfg.chunkSize),
OG_CHUNK_OVERLAP: String(cfg.chunkOverlap),
};
}
function validateConfig(cfg: ParsedOgMemoryConfig): boolean {
if (!cfg.openaiApiKey && !process.env.OPENAI_API_KEY) {
return false;
}
return true;
}
async function runOgMemoryCommand(
projectRoot: string,
cfg: ParsedOgMemoryConfig,
args: string[],
logger: OpenClawPluginApi["logger"],
): Promise<string> {
if (!validateConfig(cfg)) {
throw new Error("Invalid configuration: OPENAI_API_KEY required");
}
const pythonPath = process.env.PYTHON_PATH || "python3";
const cliPath = path.join(projectRoot, "ogmemory_cli.py");
const env = buildEnv(cfg, projectRoot);
logger.debug?.(`ogmemory: executing ${pythonPath} ${cliPath} ${args.join(" ")}`);
return new Promise((resolve, reject) => {
const child = spawn(pythonPath, [cliPath, ...args], {
cwd: projectRoot,
env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk);
const s = chunk.toString().trim();
if (s) logger.debug?.(`[ogmemory] ${s}`);
});
const timeout = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error(`ogmemory command timed out after ${COMMAND_TIMEOUT_MS}ms`));
}, COMMAND_TIMEOUT_MS);
child.on("error", (err: Error) => {
clearTimeout(timeout);
reject(err);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
const stderrStr = Buffer.concat(stderrChunks).toString().trim();
if (stderrStr) logger.warn?.(`ogmemory stderr: ${stderrStr}`);
if (code === 0) {
resolve(Buffer.concat(stdoutChunks).toString());
} else {
const errMsg = stderrStr
? `ogmemory command failed (code=${code}): ${stderrStr.slice(-500)}`
: `ogmemory command failed (code=${code}${signal ? `, signal=${signal}` : ""})`;
reject(new Error(errMsg));
}
});
});
}
async function checkPythonExecutable(logger: OpenClawPluginApi["logger"]): Promise<boolean> {
const pythonPath = process.env.PYTHON_PATH || "python3";
return new Promise((resolve) => {
const child = spawn(pythonPath, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
const timeout = setTimeout(() => {
child.kill("SIGTERM");
resolve(false);
}, 5000);
child.on("error", () => {
clearTimeout(timeout);
resolve(false);
});
child.on("close", (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
});
}
const ogMemoryPlugin = {
id: "ogmemory",
name: "oG-Memory",
description: "openGauss-based semantic memory for OpenClaw",
kind: "memory" as const,
configSchema: ogMemoryConfigSchema,
register(api: OpenClawPluginApi) {
const cfg = ogMemoryConfigSchema.parse(api.pluginConfig);
const projectRoot = getProjectRoot();
if (!validateConfig(cfg)) {
api.logger.warn?.("ogmemory: OPENAI_API_KEY not configured; tools may fail.");
}
api.registerTool(
{
name: "ogmemory_search",
label: "oG-Memory Search",
description: "Search semantic memory using openGauss vector database.",
parameters: Type.Object({
query: Type.String({ description: "Search query text" }),
topK: Type.Optional(
Type.Number({ description: "Number of results to return (default: 5)" }),
),
hybrid: Type.Optional(
Type.Boolean({ description: "Use hybrid search vector + BM25 (default: true)" }),
),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const { query } = params as { query: string };
const topK =
typeof (params as { topK?: number }).topK === "number"
? Math.max(1, Math.min(50, Math.floor((params as { topK: number }).topK)))
: 5;
const hybrid = (params as { hybrid?: boolean }).hybrid !== false;
const args = ["search", query, "--json-output", "--top-k", String(topK)];
if (hybrid) args.push("--hybrid");
try {
const result = await runOgMemoryCommand(projectRoot, cfg, args, api.logger);
const parsed = JSON.parse(result) as Array<{ id?: string; text?: string; score?: number; source?: string }>;
const lines =
Array.isArray(parsed) && parsed.length > 0
? parsed
.map(
(r, i) =>
`${i + 1}. [${typeof r.score === "number" ? r.score.toFixed(4) : "—"}] ${(r.text ?? "").slice(0, 200)}${(r.text?.length ?? 0) > 200 ? "..." : ""}`,
)
.join("\n")
: "No results.";
return {
content: [{ type: "text" as const, text: `oG-Memory search results:\n${lines}` }],
details: { count: Array.isArray(parsed) ? parsed.length : 0, raw: parsed },
};
} catch (err) {
api.logger.warn?.(`ogmemory_search failed: ${String(err)}`);
return {
content: [
{
type: "text" as const,
text: `Search failed or no results: ${err instanceof Error ? err.message : String(err)}`,
},
],
details: { error: String(err) },
};
}
},
},
{ name: "ogmemory_search" },
);
api.registerTool(
{
name: "ogmemory_add",
label: "oG-Memory Add",
description: "Add a memory to openGauss vector database.",
parameters: Type.Object({
text: Type.String({ description: "Memory text to add" }),
metadata: Type.Optional(
Type.Record(Type.String(), Type.Union([Type.String(), Type.Number(), Type.Boolean()]), {
description: "Optional metadata key-value pairs",
}),
),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const { text } = params as { text: string };
const metadata = (params as { metadata?: Record<string, string | number | boolean> }).metadata;
const args = ["add", text];
if (metadata && typeof metadata === "object") {
for (const [k, v] of Object.entries(metadata)) {
args.push("--metadata", `${k}=${String(v)}`);
}
}
const result = await runOgMemoryCommand(projectRoot, cfg, args, api.logger);
return {
content: [{ type: "text" as const, text: result.trim() || "Memory added." }],
details: { action: "add" },
};
},
},
{ name: "ogmemory_add" },
);
api.registerTool(
{
name: "ogmemory_stats",
label: "oG-Memory Stats",
description: "Get statistics about the memory database.",
parameters: Type.Object({}),
async execute() {
const result = await runOgMemoryCommand(projectRoot, cfg, ["stats"], api.logger);
return {
content: [{ type: "text" as const, text: result.trim() }],
details: { action: "stats" },
};
},
},
{ name: "ogmemory_stats" },
);
api.registerTool(
{
name: "ogmemory_clear_cache",
label: "oG-Memory Clear Cache",
description: "Clear the in-process memory cache used for file-based context.",
parameters: Type.Object({}),
async execute() {
clearCache();
return {
content: [{ type: "text" as const, text: "Cache cleared." }],
details: { action: "clear_cache" },
};
},
},
{ name: "ogmemory_clear_cache" },
);
if (typeof api.registerCli === "function") {
api.registerCli(
(arg: { program: unknown }) => {
const program = arg.program as {
command: (n: string, d?: string) => {
description: (d: string) => {
command: (s: string, d?: string) => { option: (...a: unknown[]) => unknown; action: (fn: (...a: unknown[]) => void) => unknown };
option: (...a: unknown[]) => unknown;
action: (fn: (...a: unknown[]) => void) => unknown;
};
};
};
type SubCmd = { option: (...a: unknown[]) => SubCmd; action: (fn: (...a: unknown[]) => void) => SubCmd };
type CliCmd = { command: (s: string, d?: string) => SubCmd };
const ogmemoryCmd = program.command("ogmemory").description("oG-Memory commands") as CliCmd;
ogmemoryCmd
.command("search <query>", "Search memories")
.option("-k, --top-k <n>", "Number of results", "5")
.option("--hybrid", "Use hybrid search")
.action(async (...a: unknown[]) => {
const query = String(a[0] ?? "");
const options = (a[1] ?? {}) as { topK?: string; hybrid?: boolean };
const args = ["search", query];
if (options?.topK) args.push("--top-k", String(options.topK));
if (options?.hybrid) args.push("--hybrid");
console.log(await runOgMemoryCommand(projectRoot, cfg, args, api.logger));
});
ogmemoryCmd
.command("index <path>", "Index markdown files")
.action(async (...a: unknown[]) => {
const filePath = String(a[0] ?? "");
console.log(await runOgMemoryCommand(projectRoot, cfg, ["index", filePath], api.logger));
});
ogmemoryCmd
.command("stats", "Show statistics")
.action(async () => {
console.log(await runOgMemoryCommand(projectRoot, cfg, ["stats"], api.logger));
});
ogmemoryCmd
.command("add <text>", "Add a memory")
.option("-m, --metadata <items>", "Metadata as key=value pairs")
.action(async (...a: unknown[]) => {
const text = String(a[0] ?? "");
const options = (a[1] ?? {}) as { metadata?: string[] };
const args = ["add", text];
if (options?.metadata?.length) {
options.metadata.forEach((m: string) => args.push("--metadata", m));
}
console.log(await runOgMemoryCommand(projectRoot, cfg, args, api.logger));
});
ogmemoryCmd
.command("reset", "Reset all data")
.option("--yes", "Confirm reset")
.action(async (...a: unknown[]) => {
const options = (a[0] ?? {}) as { yes?: boolean };
if (!options?.yes) {
console.log("Error: --yes flag is required to reset");
return;
}
console.log(await runOgMemoryCommand(projectRoot, cfg, ["reset", "--yes"], api.logger));
});
ogmemoryCmd
.command("clear-cache", "Clear memory cache")
.action(() => {
clearCache();
console.log("Cache cleared");
});
},
{ commands: ["ogmemory"] },
);
}
if (typeof api.registerCommand === "function") {
api.registerCommand({
name: "memory",
description: "Search oG-Memory for relevant context",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx: { args?: string }) => {
const query = ctx.args?.trim();
if (!query) return { text: "Usage: /memory <query>" };
try {
const result = await runOgMemoryCommand(
projectRoot,
cfg,
["search", query, "--top-k", "3"],
api.logger,
);
return { text: result };
} catch (err) {
api.logger.error?.(`Memory search failed: ${String(err)}`);
return { text: `Memory search failed: ${String(err)}` };
}
},
});
}
api.on("before_agent_start", async (event?: unknown) => {
const cached = getCachedMemory();
if (cached?.content) {
api.logger.debug?.("ogmemory: using cached file-based memories");
return { prependContext: cached.content };
}
try {
const memoryPath = path.resolve(process.cwd(), cfg.memoryDir);
if (!fs.existsSync(memoryPath)) {
setCachedMemory("");
return {};
}
const allFiles = fs.readdirSync(memoryPath).filter((f) => f.endsWith(".md"));
if (allFiles.length === 0) {
setCachedMemory("");
return {};
}
const recentFiles = allFiles
.map((f) => ({
name: f,
mtime: fs.statSync(path.join(memoryPath, f)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime)
.slice(0, 2)
.map((f) => f.name);
let recentMemories = "";
for (const file of recentFiles) {
const filePath = path.join(memoryPath, file);
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n").slice(-30);
recentMemories += `\n\nRecent memories from ${file}:\n${lines.join("\n")}`;
}
setCachedMemory(recentMemories);
if (recentMemories) {
api.logger.debug?.(`ogmemory: loaded ${recentFiles.length} memory files`);
return { prependContext: recentMemories };
}
} catch (err) {
api.logger.error?.(`ogmemory: failed to load file memories: ${String(err)}`);
setCachedMemory("");
}
return {};
});
api.on("shutdown", () => {
api.logger.info?.("ogmemory: cleaning up");
clearCache();
});
checkPythonExecutable(api.logger)
.then((ok) => {
if (ok) api.logger.info?.("ogmemory: plugin loaded (Python available)");
else api.logger.warn?.("ogmemory: plugin loaded but Python not found; tools may fail.");
})
.catch((err) => {
api.logger.warn?.(`ogmemory: Python check failed: ${String(err)}`);
});
},
};
export default ogMemoryPlugin;