Skip to content
62 changes: 44 additions & 18 deletions apps/memos-local-openclaw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
import * as fs from "fs";
import * as path from "path";
import { createRequire } from "node:module";
import { fileURLToPath } from "url";
import { buildContext } from "./src/config";
import type { HostModelsConfig } from "./src/openclaw-api";
Expand Down Expand Up @@ -83,25 +84,56 @@ const memosLocalPlugin = {
configSchema: pluginConfigSchema,

register(api: OpenClawPluginApi) {
// ─── Ensure better-sqlite3 native module is available ───
const pluginDir = path.dirname(fileURLToPath(import.meta.url));
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const localRequire = createRequire(import.meta.url);
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";

function detectPluginDir(startDir: string): string {
let cur = startDir;
for (let i = 0; i < 6; i++) {
const pkg = path.join(cur, "package.json");
if (fs.existsSync(pkg)) return cur;
const parent = path.dirname(cur);
if (parent === cur) break;
cur = parent;
}
return startDir;
}

const pluginDir = detectPluginDir(moduleDir);

function normalizeFsPath(p: string): string {
return path.resolve(p).replace(/\\/g, "/").toLowerCase();
return path.resolve(p).replace(/^\\\\\?\\/, "").toLowerCase();
}

function isPathInside(baseDir: string, targetPath: string): boolean {
const baseNorm = normalizeFsPath(baseDir);
const targetNorm = normalizeFsPath(targetPath);
const rel = path.relative(baseNorm, targetNorm);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}

function runNpm(args: string[]) {
const { spawnSync } = localRequire("child_process") as typeof import("node:child_process");
return spawnSync(npmCmd, args, {
cwd: pluginDir,
stdio: "pipe",
shell: false,
timeout: 120_000,
});
}

let sqliteReady = false;

function trySqliteLoad(): boolean {
try {
const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
const resolvedNorm = normalizeFsPath(resolved);
const pluginNorm = normalizeFsPath(pluginDir);
if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
const resolved = localRequire.resolve("better-sqlite3", { paths: [pluginDir] });
const resolvedReal = fs.existsSync(resolved) ? fs.realpathSync.native(resolved) : resolved;
if (!isPathInside(pluginDir, resolvedReal)) {
api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
return false;
}
require(resolved);
localRequire(resolvedReal);
return true;
} catch {
return false;
Expand All @@ -114,23 +146,17 @@ const memosLocalPlugin = {
api.logger.warn(`memos-local: better-sqlite3 not found in ${pluginDir}, attempting auto-rebuild ...`);

try {
const { spawnSync } = require("child_process");
const rebuildResult = spawnSync("npm", ["rebuild", "better-sqlite3"], {
cwd: pluginDir,
stdio: "pipe",
shell: true,
timeout: 120_000,
});
const rebuildResult = runNpm(["rebuild", "better-sqlite3"]);

const stdout = rebuildResult.stdout?.toString() || "";
const stderr = rebuildResult.stderr?.toString() || "";
if (stdout) api.logger.info(`memos-local: rebuild stdout: ${stdout.slice(0, 500)}`);
if (stderr) api.logger.warn(`memos-local: rebuild stderr: ${stderr.slice(0, 500)}`);

if (rebuildResult.status === 0) {
Object.keys(require.cache)
Object.keys(localRequire.cache)
.filter(k => k.includes("better-sqlite3") || k.includes("better_sqlite3"))
.forEach(k => delete require.cache[k]);
.forEach(k => delete localRequire.cache[k]);
sqliteReady = trySqliteLoad();
if (sqliteReady) {
api.logger.info("memos-local: better-sqlite3 auto-rebuild succeeded!");
Expand Down Expand Up @@ -222,7 +248,7 @@ const memosLocalPlugin = {

let pluginVersion = "0.0.0";
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf-8"));
pluginVersion = pkg.version ?? pluginVersion;
} catch {}
const telemetry = new Telemetry(ctx.config.telemetry ?? {}, stateDir, pluginVersion, ctx.log, pluginDir);
Expand Down
26 changes: 21 additions & 5 deletions apps/memos-local-openclaw/scripts/postinstall.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ function phase(n, title) {
}

const pluginDir = path.resolve(__dirname, "..");
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";

function normalizePathForMatch(p) {
return path.resolve(p).replace(/^\\\\\?\\/, "").replace(/\\/g, "/").toLowerCase();
}

console.log(`
${CYAN}${BOLD}┌──────────────────────────────────────────────────┐
Expand All @@ -42,7 +47,8 @@ log(`Node: ${process.version} Platform: ${process.platform}-${process.arch}`);
* ═══════════════════════════════════════════════════════════ */

function cleanStaleArtifacts() {
const isExtensionsDir = pluginDir.includes(path.join(".openclaw", "extensions"));
const pluginDirNorm = normalizePathForMatch(pluginDir);
const isExtensionsDir = pluginDirNorm.includes("/.openclaw/extensions/");
if (!isExtensionsDir) return;

const pkgPath = path.join(pluginDir, "package.json");
Expand Down Expand Up @@ -133,10 +139,10 @@ function ensureDependencies() {
log("Running: npm install --omit=dev ...");

const startMs = Date.now();
const result = spawnSync("npm", ["install", "--omit=dev"], {
const result = spawnSync(npmCmd, ["install", "--omit=dev"], {
cwd: pluginDir,
stdio: "pipe",
shell: true,
shell: false,
timeout: 120_000,
});
const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
Expand Down Expand Up @@ -223,8 +229,8 @@ function cleanupLegacy() {
newEntry.source = oldSource
.replace(/memos-lite-openclaw-plugin/g, "memos-local-openclaw-plugin")
.replace(/memos-lite/g, "memos-local-openclaw-plugin")
.replace(/\/memos-local\//g, "/memos-local-openclaw-plugin/")
.replace(/\/memos-local$/g, "/memos-local-openclaw-plugin");
.replace(/[\\/]memos-local[\\/]/g, `${path.sep}memos-local-openclaw-plugin${path.sep}`)
.replace(/[\\/]memos-local$/g, `${path.sep}memos-local-openclaw-plugin`);
if (newEntry.source !== oldSource) {
log(`Updated source path: ${DIM}${oldSource}${RESET} → ${GREEN}${newEntry.source}${RESET}`);
cfgChanged = true;
Expand Down Expand Up @@ -384,6 +390,16 @@ if (sqliteBindingsExist()) {
warn("better-sqlite3 native bindings not found in plugin dir.");
log(`Searched in: ${DIM}${sqliteModulePath}/build/${RESET}`);
log("Running: npm rebuild better-sqlite3 (may take 30-60s)...");
}

const startMs = Date.now();

const result = spawnSync(npmCmd, ["rebuild", "better-sqlite3"], {
cwd: pluginDir,
stdio: "pipe",
shell: false,
timeout: 180_000,
});

const startMs = Date.now();
const result = spawnSync("npm", ["rebuild", "better-sqlite3"], {
Expand Down
104 changes: 14 additions & 90 deletions apps/memos-local-openclaw/src/ingest/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from "fs";
import * as path from "path";
import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types";
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai";
import type { SummarizerConfig, SummaryProvider, Logger } from "../../types";
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI } from "./openai";
import type { FilterResult, DedupResult } from "./openai";
export type { FilterResult, DedupResult } from "./openai";
import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic";
Expand Down Expand Up @@ -466,6 +466,8 @@ function callSummarize(cfg: SummarizerConfig, text: string, log: Logger): Promis
case "azure_openai":
case "zhipu":
case "siliconflow":
case "deepseek":
case "moonshot":
case "bailian":
case "cohere":
case "mistral":
Expand All @@ -489,6 +491,8 @@ function callSummarizeTask(cfg: SummarizerConfig, text: string, log: Logger): Pr
case "azure_openai":
case "zhipu":
case "siliconflow":
case "deepseek":
case "moonshot":
case "bailian":
case "cohere":
case "mistral":
Expand All @@ -512,6 +516,8 @@ function callGenerateTaskTitle(cfg: SummarizerConfig, text: string, log: Logger)
case "azure_openai":
case "zhipu":
case "siliconflow":
case "deepseek":
case "moonshot":
case "bailian":
case "cohere":
case "mistral":
Expand All @@ -535,6 +541,8 @@ function callTopicJudge(cfg: SummarizerConfig, currentContext: string, newMessag
case "azure_openai":
case "zhipu":
case "siliconflow":
case "deepseek":
case "moonshot":
case "bailian":
case "cohere":
case "mistral":
Expand All @@ -558,6 +566,8 @@ function callFilterRelevant(cfg: SummarizerConfig, query: string, candidates: Ar
case "azure_openai":
case "zhipu":
case "siliconflow":
case "deepseek":
case "moonshot":
case "bailian":
case "cohere":
case "mistral":
Expand All @@ -581,6 +591,8 @@ function callJudgeDedup(cfg: SummarizerConfig, newSummary: string, candidates: A
case "azure_openai":
case "zhipu":
case "siliconflow":
case "deepseek":
case "moonshot":
case "bailian":
case "cohere":
case "mistral":
Expand Down Expand Up @@ -629,91 +641,3 @@ function wordCount(text: string): number {
if (noCjk) count += noCjk.split(/\s+/).filter(Boolean).length;
return count;
}

// ─── OpenClaw Prompt Templates ───

const OPENCLAW_TASK_SUMMARY_PROMPT = `You create a DETAILED task summary from a multi-turn conversation. This summary will be the ONLY record of this conversation, so it must preserve ALL important information.

CRITICAL LANGUAGE RULE: You MUST write in the SAME language as the user's messages. Chinese input → Chinese output. English input → English output. NEVER mix languages.

Output EXACTLY this structure:

📌 Title
A short, descriptive title (10-30 characters). Like a chat group name.

🎯 Goal
One sentence: what the user wanted to accomplish.

📋 Key Steps
- Describe each meaningful step in detail
- Include the ACTUAL content produced: code snippets, commands, config blocks, formulas, key paragraphs
- For code: include the function signature and core logic (up to ~30 lines per block), use fenced code blocks
- For configs: include the actual config values and structure
- For lists/instructions: include the actual items, not just "provided a list"
- Merge only truly trivial back-and-forth (like "ok" / "sure")
- Do NOT over-summarize: "provided a function" is BAD; show the actual function

✅ Result
What was the final outcome? Include the final version of any code/config/content produced.

💡 Key Details
- Decisions made, trade-offs discussed, caveats noted, alternative approaches mentioned
- Specific values: numbers, versions, thresholds, URLs, file paths, model names
- Omit this section only if there truly are no noteworthy details

RULES:
- This summary is a KNOWLEDGE BASE ENTRY, not a brief note. Be thorough.
- PRESERVE verbatim: code, commands, URLs, file paths, error messages, config values, version numbers, names, amounts
- DISCARD only: greetings, filler, the assistant explaining what it will do before doing it
- Replace secrets (API keys, tokens, passwords) with [REDACTED]
- Target length: 30-50% of the original conversation length. Longer conversations need longer summaries.
- Output summary only, no preamble.`;

const OPENCLAW_TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given a summary of the CURRENT conversation and a NEW user message, determine if the new message starts a DIFFERENT topic/task.

Answer ONLY "NEW" or "SAME".

Rules:
- "NEW" = the new message is about a completely different subject, project, or task
- "SAME" = the new message continues, follows up on, or is closely related to the current topic
- Follow-up questions, clarifications, refinements, bug fixes, or next steps on the same task = SAME
- Greetings or meta-questions like "你好" or "谢谢" without new substance = SAME
- A clearly unrelated request (e.g., current topic is deployment, new message asks about cooking) = NEW

Output exactly one word: NEW or SAME`;

const OPENCLAW_FILTER_RELEVANT_PROMPT = `You are a memory relevance judge. Given a user's QUERY and a list of CANDIDATE memory summaries, do two things:

1. Select ALL candidates that could be useful for answering the query. When in doubt, INCLUDE the candidate.
- For questions about lists, history, or "what/where/who" across multiple items, include ALL matching items.
- For factual lookups, a single direct answer is enough.
2. Judge whether the selected memories are SUFFICIENT to fully answer the query WITHOUT fetching additional context.

IMPORTANT for "sufficient" judgment:
- sufficient=true ONLY when the memories contain a concrete ANSWER, fact, decision, or actionable information that directly addresses the query.
- sufficient=false when the memories only repeat the question, show related topics but lack the specific detail, or contain partial information.

Output a JSON object with exactly two fields:
{"relevant":[1,3,5],"sufficient":true}

- "relevant": array of candidate numbers that are useful. Empty array [] if none are relevant.
- "sufficient": true ONLY if the memories contain a direct answer; false otherwise.

Output ONLY the JSON object, nothing else.`;

const OPENCLAW_DEDUP_JUDGE_PROMPT = `You are a memory deduplication system. Given a NEW memory summary and several EXISTING memory summaries, determine the relationship.

For each EXISTING memory, the NEW memory is either:
- "DUPLICATE": NEW is fully covered by an EXISTING memory — no new information at all
- "UPDATE": NEW contains information that supplements or updates an EXISTING memory (new data, status change, additional detail)
- "NEW": NEW is a different topic/event despite surface similarity

Pick the BEST match among all candidates. If none match well, choose "NEW".

Output a single JSON object:
- If DUPLICATE: {"action":"DUPLICATE","targetIndex":2,"reason":"..."}
- If UPDATE: {"action":"UPDATE","targetIndex":3,"reason":"...","mergedSummary":"a combined summary preserving all info from both old and new, same language as input"}
- If NEW: {"action":"NEW","reason":"..."}

CRITICAL: mergedSummary must use the SAME language as the input. Output ONLY the JSON object.`;

2 changes: 2 additions & 0 deletions apps/memos-local-openclaw/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export type SummaryProvider =
| "bedrock"
| "zhipu"
| "siliconflow"
| "deepseek"
| "moonshot"
| "bailian"
| "cohere"
| "mistral"
Expand Down
13 changes: 5 additions & 8 deletions apps/memos-local-openclaw/src/viewer/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import http from "node:http";
import os from "node:os";
import crypto from "node:crypto";
import { execSync, exec } from "node:child_process";
import { execSync, exec, execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
Expand Down Expand Up @@ -3248,33 +3248,30 @@ export class ViewerServer {

// Install dependencies
this.log.info(`update-install: installing dependencies...`);
exec(`cd ${extDir} && npm install --omit=dev --ignore-scripts`, { timeout: 120_000 }, (npmErr, npmOut, npmStderr) => {
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
execFile(npmCmd, ["install", "--omit=dev", "--ignore-scripts"], { cwd: extDir, timeout: 120_000 }, (npmErr, npmOut, npmStderr) => {
if (npmErr) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
this.log.warn(`update-install: npm install failed: ${npmErr.message}`);
this.jsonResponse(res, { ok: false, error: `Dependency install failed: ${npmStderr || npmErr.message}` });
return;
}

// Rebuild native modules (do not swallow errors)
exec(`cd ${extDir} && npm rebuild better-sqlite3`, { timeout: 60_000 }, (rebuildErr, rebuildOut, rebuildStderr) => {
execFile(npmCmd, ["rebuild", "better-sqlite3"], { cwd: extDir, timeout: 60_000 }, (rebuildErr, rebuildOut, rebuildStderr) => {
if (rebuildErr) {
this.log.warn(`update-install: better-sqlite3 rebuild failed: ${rebuildErr.message}`);
const stderr = String(rebuildStderr || "").trim();
if (stderr) this.log.warn(`update-install: rebuild stderr: ${stderr.slice(0, 500)}`);
// Continue so postinstall.cjs can run (it will try rebuild again and show user guidance)
}

// Run postinstall.cjs: legacy cleanup, skill install, version marker, and optional sqlite re-check
this.log.info(`update-install: running postinstall...`);
exec(`cd ${extDir} && node scripts/postinstall.cjs`, { timeout: 180_000 }, (postErr, postOut, postStderr) => {
execFile(process.execPath, ["scripts/postinstall.cjs"], { cwd: extDir, timeout: 180_000 }, (postErr, postOut, postStderr) => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}

if (postErr) {
this.log.warn(`update-install: postinstall failed: ${postErr.message}`);
const postStderrStr = String(postStderr || "").trim();
if (postStderrStr) this.log.warn(`update-install: postinstall stderr: ${postStderrStr.slice(0, 500)}`);
// Still report success; plugin is updated, user can run postinstall manually if needed
}

// Read new version
Expand Down
Loading
Loading