Skip to content

Commit c0f3d6e

Browse files
authored
Openclaw local plugin 20260319 (#1328)
### ## Description Please include a summary of the change, the problem it solves, the implementation approach, and relevant context. List any dependencies required for this change. Related Issue (Required): Fixes #issue_number ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Refactor (does not change functionality, e.g. code style improvements, linting) - [ ] Documentation update ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration - [ ] Unit Test - [ ] Test Script Or Test Steps (please provide) - [ ] Pipeline Automated API Test (please provide) ## Checklist - [ ] I have performed a self-review of my own code | 我已自行检查了自己的代码 - [ ] I have commented my code in hard-to-understand areas | 我已在难以理解的地方对代码进行了注释 - [ ] I have added tests that prove my fix is effective or that my feature works | 我已添加测试以证明我的修复有效或功能正常 - [ ] I have created related documentation issue/PR in [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) (if applicable) | 我已在 [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) 中创建了相关的文档 issue/PR(如果适用) - [ ] I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR(如果适用) - [ ] I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人 ## Reviewer Checklist - [ ] closes #xxxx (Replace xxxx with the GitHub issue number) - [ ] Made sure Checks passed - [ ] Tests have been provided
2 parents eda86d8 + 4a5ed6a commit c0f3d6e

File tree

7 files changed

+236
-161
lines changed

7 files changed

+236
-161
lines changed

apps/memos-local-openclaw/index.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
99
import { Type } from "@sinclair/typebox";
1010
import * as fs from "fs";
1111
import * as path from "path";
12+
import { createRequire } from "node:module";
1213
import { fileURLToPath } from "url";
1314
import { buildContext } from "./src/config";
1415
import type { HostModelsConfig } from "./src/openclaw-api";
@@ -83,25 +84,56 @@ const memosLocalPlugin = {
8384
configSchema: pluginConfigSchema,
8485

8586
register(api: OpenClawPluginApi) {
86-
// ─── Ensure better-sqlite3 native module is available ───
87-
const pluginDir = path.dirname(fileURLToPath(import.meta.url));
87+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
88+
const localRequire = createRequire(import.meta.url);
89+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
90+
91+
function detectPluginDir(startDir: string): string {
92+
let cur = startDir;
93+
for (let i = 0; i < 6; i++) {
94+
const pkg = path.join(cur, "package.json");
95+
if (fs.existsSync(pkg)) return cur;
96+
const parent = path.dirname(cur);
97+
if (parent === cur) break;
98+
cur = parent;
99+
}
100+
return startDir;
101+
}
102+
103+
const pluginDir = detectPluginDir(moduleDir);
88104

89105
function normalizeFsPath(p: string): string {
90-
return path.resolve(p).replace(/\\/g, "/").toLowerCase();
106+
return path.resolve(p).replace(/^\\\\\?\\/, "").toLowerCase();
107+
}
108+
109+
function isPathInside(baseDir: string, targetPath: string): boolean {
110+
const baseNorm = normalizeFsPath(baseDir);
111+
const targetNorm = normalizeFsPath(targetPath);
112+
const rel = path.relative(baseNorm, targetNorm);
113+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
114+
}
115+
116+
function runNpm(args: string[]) {
117+
const { spawnSync } = localRequire("child_process") as typeof import("node:child_process");
118+
return spawnSync(npmCmd, args, {
119+
cwd: pluginDir,
120+
stdio: "pipe",
121+
shell: false,
122+
timeout: 120_000,
123+
});
91124
}
92125

93126
let sqliteReady = false;
94127

95128
function trySqliteLoad(): boolean {
96129
try {
97-
const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
98-
const resolvedNorm = normalizeFsPath(resolved);
99-
const pluginNorm = normalizeFsPath(pluginDir);
100-
if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
130+
const resolved = localRequire.resolve("better-sqlite3", { paths: [pluginDir] });
131+
const resolvedReal = fs.existsSync(resolved) ? fs.realpathSync.native(resolved) : resolved;
132+
if (!isPathInside(pluginDir, resolvedReal)) {
101133
api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
102134
return false;
103135
}
104-
require(resolved);
136+
localRequire(resolvedReal);
105137
return true;
106138
} catch {
107139
return false;
@@ -114,23 +146,17 @@ const memosLocalPlugin = {
114146
api.logger.warn(`memos-local: better-sqlite3 not found in ${pluginDir}, attempting auto-rebuild ...`);
115147

116148
try {
117-
const { spawnSync } = require("child_process");
118-
const rebuildResult = spawnSync("npm", ["rebuild", "better-sqlite3"], {
119-
cwd: pluginDir,
120-
stdio: "pipe",
121-
shell: true,
122-
timeout: 120_000,
123-
});
149+
const rebuildResult = runNpm(["rebuild", "better-sqlite3"]);
124150

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

130156
if (rebuildResult.status === 0) {
131-
Object.keys(require.cache)
157+
Object.keys(localRequire.cache)
132158
.filter(k => k.includes("better-sqlite3") || k.includes("better_sqlite3"))
133-
.forEach(k => delete require.cache[k]);
159+
.forEach(k => delete localRequire.cache[k]);
134160
sqliteReady = trySqliteLoad();
135161
if (sqliteReady) {
136162
api.logger.info("memos-local: better-sqlite3 auto-rebuild succeeded!");
@@ -222,7 +248,7 @@ const memosLocalPlugin = {
222248

223249
let pluginVersion = "0.0.0";
224250
try {
225-
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
251+
const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf-8"));
226252
pluginVersion = pkg.version ?? pluginVersion;
227253
} catch {}
228254
const telemetry = new Telemetry(ctx.config.telemetry ?? {}, stateDir, pluginVersion, ctx.log, pluginDir);

apps/memos-local-openclaw/scripts/postinstall.cjs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ function phase(n, title) {
2323
}
2424

2525
const pluginDir = path.resolve(__dirname, "..");
26+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
27+
28+
function normalizePathForMatch(p) {
29+
return path.resolve(p).replace(/^\\\\\?\\/, "").replace(/\\/g, "/").toLowerCase();
30+
}
2631

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

4449
function cleanStaleArtifacts() {
45-
const isExtensionsDir = pluginDir.includes(path.join(".openclaw", "extensions"));
50+
const pluginDirNorm = normalizePathForMatch(pluginDir);
51+
const isExtensionsDir = pluginDirNorm.includes("/.openclaw/extensions/");
4652
if (!isExtensionsDir) return;
4753

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

135141
const startMs = Date.now();
136-
const result = spawnSync("npm", ["install", "--omit=dev"], {
142+
const result = spawnSync(npmCmd, ["install", "--omit=dev"], {
137143
cwd: pluginDir,
138144
stdio: "pipe",
139-
shell: true,
145+
shell: false,
140146
timeout: 120_000,
141147
});
142148
const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
@@ -223,8 +229,8 @@ function cleanupLegacy() {
223229
newEntry.source = oldSource
224230
.replace(/memos-lite-openclaw-plugin/g, "memos-local-openclaw-plugin")
225231
.replace(/memos-lite/g, "memos-local-openclaw-plugin")
226-
.replace(/\/memos-local\//g, "/memos-local-openclaw-plugin/")
227-
.replace(/\/memos-local$/g, "/memos-local-openclaw-plugin");
232+
.replace(/[\\/]memos-local[\\/]/g, `${path.sep}memos-local-openclaw-plugin${path.sep}`)
233+
.replace(/[\\/]memos-local$/g, `${path.sep}memos-local-openclaw-plugin`);
228234
if (newEntry.source !== oldSource) {
229235
log(`Updated source path: ${DIM}${oldSource}${RESET}${GREEN}${newEntry.source}${RESET}`);
230236
cfgChanged = true;
@@ -384,6 +390,16 @@ if (sqliteBindingsExist()) {
384390
warn("better-sqlite3 native bindings not found in plugin dir.");
385391
log(`Searched in: ${DIM}${sqliteModulePath}/build/${RESET}`);
386392
log("Running: npm rebuild better-sqlite3 (may take 30-60s)...");
393+
}
394+
395+
const startMs = Date.now();
396+
397+
const result = spawnSync(npmCmd, ["rebuild", "better-sqlite3"], {
398+
cwd: pluginDir,
399+
stdio: "pipe",
400+
shell: false,
401+
timeout: 180_000,
402+
});
387403

388404
const startMs = Date.now();
389405
const result = spawnSync("npm", ["rebuild", "better-sqlite3"], {

apps/memos-local-openclaw/src/ingest/providers/index.ts

Lines changed: 14 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as fs from "fs";
22
import * as path from "path";
3-
import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types";
4-
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai";
3+
import type { SummarizerConfig, SummaryProvider, Logger } from "../../types";
4+
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI } from "./openai";
55
import type { FilterResult, DedupResult } from "./openai";
66
export type { FilterResult, DedupResult } from "./openai";
77
import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic";
@@ -466,6 +466,8 @@ function callSummarize(cfg: SummarizerConfig, text: string, log: Logger): Promis
466466
case "azure_openai":
467467
case "zhipu":
468468
case "siliconflow":
469+
case "deepseek":
470+
case "moonshot":
469471
case "bailian":
470472
case "cohere":
471473
case "mistral":
@@ -489,6 +491,8 @@ function callSummarizeTask(cfg: SummarizerConfig, text: string, log: Logger): Pr
489491
case "azure_openai":
490492
case "zhipu":
491493
case "siliconflow":
494+
case "deepseek":
495+
case "moonshot":
492496
case "bailian":
493497
case "cohere":
494498
case "mistral":
@@ -512,6 +516,8 @@ function callGenerateTaskTitle(cfg: SummarizerConfig, text: string, log: Logger)
512516
case "azure_openai":
513517
case "zhipu":
514518
case "siliconflow":
519+
case "deepseek":
520+
case "moonshot":
515521
case "bailian":
516522
case "cohere":
517523
case "mistral":
@@ -535,6 +541,8 @@ function callTopicJudge(cfg: SummarizerConfig, currentContext: string, newMessag
535541
case "azure_openai":
536542
case "zhipu":
537543
case "siliconflow":
544+
case "deepseek":
545+
case "moonshot":
538546
case "bailian":
539547
case "cohere":
540548
case "mistral":
@@ -558,6 +566,8 @@ function callFilterRelevant(cfg: SummarizerConfig, query: string, candidates: Ar
558566
case "azure_openai":
559567
case "zhipu":
560568
case "siliconflow":
569+
case "deepseek":
570+
case "moonshot":
561571
case "bailian":
562572
case "cohere":
563573
case "mistral":
@@ -581,6 +591,8 @@ function callJudgeDedup(cfg: SummarizerConfig, newSummary: string, candidates: A
581591
case "azure_openai":
582592
case "zhipu":
583593
case "siliconflow":
594+
case "deepseek":
595+
case "moonshot":
584596
case "bailian":
585597
case "cohere":
586598
case "mistral":
@@ -629,91 +641,3 @@ function wordCount(text: string): number {
629641
if (noCjk) count += noCjk.split(/\s+/).filter(Boolean).length;
630642
return count;
631643
}
632-
633-
// ─── OpenClaw Prompt Templates ───
634-
635-
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.
636-
637-
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.
638-
639-
Output EXACTLY this structure:
640-
641-
📌 Title
642-
A short, descriptive title (10-30 characters). Like a chat group name.
643-
644-
🎯 Goal
645-
One sentence: what the user wanted to accomplish.
646-
647-
📋 Key Steps
648-
- Describe each meaningful step in detail
649-
- Include the ACTUAL content produced: code snippets, commands, config blocks, formulas, key paragraphs
650-
- For code: include the function signature and core logic (up to ~30 lines per block), use fenced code blocks
651-
- For configs: include the actual config values and structure
652-
- For lists/instructions: include the actual items, not just "provided a list"
653-
- Merge only truly trivial back-and-forth (like "ok" / "sure")
654-
- Do NOT over-summarize: "provided a function" is BAD; show the actual function
655-
656-
✅ Result
657-
What was the final outcome? Include the final version of any code/config/content produced.
658-
659-
💡 Key Details
660-
- Decisions made, trade-offs discussed, caveats noted, alternative approaches mentioned
661-
- Specific values: numbers, versions, thresholds, URLs, file paths, model names
662-
- Omit this section only if there truly are no noteworthy details
663-
664-
RULES:
665-
- This summary is a KNOWLEDGE BASE ENTRY, not a brief note. Be thorough.
666-
- PRESERVE verbatim: code, commands, URLs, file paths, error messages, config values, version numbers, names, amounts
667-
- DISCARD only: greetings, filler, the assistant explaining what it will do before doing it
668-
- Replace secrets (API keys, tokens, passwords) with [REDACTED]
669-
- Target length: 30-50% of the original conversation length. Longer conversations need longer summaries.
670-
- Output summary only, no preamble.`;
671-
672-
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.
673-
674-
Answer ONLY "NEW" or "SAME".
675-
676-
Rules:
677-
- "NEW" = the new message is about a completely different subject, project, or task
678-
- "SAME" = the new message continues, follows up on, or is closely related to the current topic
679-
- Follow-up questions, clarifications, refinements, bug fixes, or next steps on the same task = SAME
680-
- Greetings or meta-questions like "你好" or "谢谢" without new substance = SAME
681-
- A clearly unrelated request (e.g., current topic is deployment, new message asks about cooking) = NEW
682-
683-
Output exactly one word: NEW or SAME`;
684-
685-
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:
686-
687-
1. Select ALL candidates that could be useful for answering the query. When in doubt, INCLUDE the candidate.
688-
- For questions about lists, history, or "what/where/who" across multiple items, include ALL matching items.
689-
- For factual lookups, a single direct answer is enough.
690-
2. Judge whether the selected memories are SUFFICIENT to fully answer the query WITHOUT fetching additional context.
691-
692-
IMPORTANT for "sufficient" judgment:
693-
- sufficient=true ONLY when the memories contain a concrete ANSWER, fact, decision, or actionable information that directly addresses the query.
694-
- sufficient=false when the memories only repeat the question, show related topics but lack the specific detail, or contain partial information.
695-
696-
Output a JSON object with exactly two fields:
697-
{"relevant":[1,3,5],"sufficient":true}
698-
699-
- "relevant": array of candidate numbers that are useful. Empty array [] if none are relevant.
700-
- "sufficient": true ONLY if the memories contain a direct answer; false otherwise.
701-
702-
Output ONLY the JSON object, nothing else.`;
703-
704-
const OPENCLAW_DEDUP_JUDGE_PROMPT = `You are a memory deduplication system. Given a NEW memory summary and several EXISTING memory summaries, determine the relationship.
705-
706-
For each EXISTING memory, the NEW memory is either:
707-
- "DUPLICATE": NEW is fully covered by an EXISTING memory — no new information at all
708-
- "UPDATE": NEW contains information that supplements or updates an EXISTING memory (new data, status change, additional detail)
709-
- "NEW": NEW is a different topic/event despite surface similarity
710-
711-
Pick the BEST match among all candidates. If none match well, choose "NEW".
712-
713-
Output a single JSON object:
714-
- If DUPLICATE: {"action":"DUPLICATE","targetIndex":2,"reason":"..."}
715-
- If UPDATE: {"action":"UPDATE","targetIndex":3,"reason":"...","mergedSummary":"a combined summary preserving all info from both old and new, same language as input"}
716-
- If NEW: {"action":"NEW","reason":"..."}
717-
718-
CRITICAL: mergedSummary must use the SAME language as the input. Output ONLY the JSON object.`;
719-

apps/memos-local-openclaw/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ export type SummaryProvider =
150150
| "bedrock"
151151
| "zhipu"
152152
| "siliconflow"
153+
| "deepseek"
154+
| "moonshot"
153155
| "bailian"
154156
| "cohere"
155157
| "mistral"

apps/memos-local-openclaw/src/viewer/server.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import http from "node:http";
22
import os from "node:os";
33
import crypto from "node:crypto";
4-
import { execSync, exec } from "node:child_process";
4+
import { execSync, exec, execFile } from "node:child_process";
55
import fs from "node:fs";
66
import path from "node:path";
77
import readline from "node:readline";
@@ -3248,33 +3248,30 @@ export class ViewerServer {
32483248

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

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

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

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

32803277
// Read new version

0 commit comments

Comments
 (0)