Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions apps/memos-local-openclaw/src/embedding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class Embedder {
case "zhipu":
case "siliconflow":
case "bailian":
case "novita":
result = await embedOpenAI(texts, cfg!, this.log); break;
case "gemini":
result = await embedGemini(texts, cfg!, this.log); break;
Expand Down
111 changes: 21 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 All @@ -22,6 +22,7 @@ function detectProvider(
return "gemini";
}
if (key.includes("bedrock") || url.includes("bedrock")) return "bedrock";
if (key.includes("novita") || url.includes("api.novita.ai")) return "novita";
return "openai_compatible";
}

Expand Down Expand Up @@ -466,10 +467,13 @@ 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":
case "voyage":
case "novita":
return summarizeOpenAI(text, cfg, log);
case "anthropic":
return summarizeAnthropic(text, cfg, log);
Expand All @@ -489,10 +493,13 @@ 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":
case "voyage":
case "novita":
return summarizeTaskOpenAI(text, cfg, log);
case "anthropic":
return summarizeTaskAnthropic(text, cfg, log);
Expand All @@ -512,10 +519,13 @@ 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":
case "voyage":
case "novita":
return generateTaskTitleOpenAI(text, cfg, log);
case "anthropic":
return generateTaskTitleAnthropic(text, cfg, log);
Expand All @@ -535,10 +545,13 @@ function callTopicJudge(cfg: SummarizerConfig, currentContext: string, newMessag
case "azure_openai":
case "zhipu":
case "siliconflow":
case "deepseek":
case "moonshot":
case "bailian":
case "cohere":
case "mistral":
case "voyage":
case "novita":
return judgeNewTopicOpenAI(currentContext, newMessage, cfg, log);
case "anthropic":
return judgeNewTopicAnthropic(currentContext, newMessage, cfg, log);
Expand All @@ -558,10 +571,13 @@ 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":
case "voyage":
case "novita":
return filterRelevantOpenAI(query, candidates, cfg, log);
case "anthropic":
return filterRelevantAnthropic(query, candidates, cfg, log);
Expand All @@ -581,10 +597,13 @@ 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":
case "voyage":
case "novita":
return judgeDedupOpenAI(newSummary, candidates, cfg, log);
case "anthropic":
return judgeDedupAnthropic(newSummary, candidates, cfg, log);
Expand Down Expand Up @@ -629,91 +648,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.`;

4 changes: 4 additions & 0 deletions apps/memos-local-openclaw/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,13 @@ export type SummaryProvider =
| "bedrock"
| "zhipu"
| "siliconflow"
| "deepseek"
| "moonshot"
| "bailian"
| "cohere"
| "mistral"
| "voyage"
| "novita"
| "openclaw";

export type EmbeddingProvider =
Expand All @@ -164,6 +167,7 @@ export type EmbeddingProvider =
| "cohere"
| "mistral"
| "voyage"
| "novita"
| "local"
| "openclaw";

Expand Down
Loading