@@ -1615,7 +1640,10 @@ const I18N={
'skill.cancel':'Cancel',
'skill.delete.confirm':'Are you sure you want to delete this skill? This will also remove all associated files and cannot be undone.',
'skill.delete.error':'Failed to delete skill: ',
- 'skill.save.error':'Failed to save skill: '
+ 'skill.save.error':'Failed to save skill: ',
+ 'update.available':'New version available',
+ 'update.run':'Run',
+ 'update.dismiss':'Dismiss'
},
zh:{
'title':'OpenClaw 记忆',
@@ -1921,7 +1949,10 @@ const I18N={
'skill.cancel':'取消',
'skill.delete.confirm':'确定要删除此技能吗?关联的文件也会被删除,此操作不可撤销。',
'skill.delete.error':'删除技能失败:',
- 'skill.save.error':'保存技能失败:'
+ 'skill.save.error':'保存技能失败:',
+ 'update.available':'发现新版本',
+ 'update.run':'执行命令',
+ 'update.dismiss':'关闭'
}
};
const LANG_KEY='memos-viewer-lang';
@@ -2059,6 +2090,7 @@ function switchView(view){
} else if(view==='settings'){
settingsView.classList.add('show');
loadConfig();
+ loadModelHealth();
} else if(view==='import'){
migrateView.classList.add('show');
if(!window._migrateRunning) migrateScan();
@@ -2740,6 +2772,93 @@ async function toggleSkillPublic(id,setPublic){
}
}
+/* ─── Model Health Status ─── */
+
+const HEALTH_ROLE_LABELS={
+ 'embedding':'Embedding',
+ 'summarize':'Summarizer',
+ 'filterRelevant':'Memory Filter',
+ 'judgeDedup':'Dedup Judge',
+ 'summarizeTask':'Task Summarizer',
+ 'judgeNewTopic':'Topic Judge'
+};
+
+function classifyError(msg){
+ if(!msg) return '';
+ if(msg.indexOf('\u989D\u5EA6\u5DF2\u7528\u5C3D')>=0||msg.indexOf('quota')>=0||msg.indexOf('RemainQuota')>=0) return 'API quota exhausted';
+ if(msg.indexOf('401')>=0||msg.indexOf('Unauthorized')>=0) return 'Auth failed (401)';
+ if(msg.indexOf('timeout')>=0||msg.indexOf('Timeout')>=0) return 'Request timed out';
+ if(msg.indexOf('429')>=0) return 'Rate limited (429)';
+ if(msg.indexOf('ECONNREFUSED')>=0) return 'Connection refused';
+ if(msg.indexOf('ENOTFOUND')>=0) return 'DNS resolution failed';
+ if(msg.indexOf('403')>=0) return 'Forbidden (403)';
+ return msg.length>50?msg.slice(0,47)+'...':msg;
+}
+
+function shortenModel(s){return s?s.replace('openai_compatible/','').replace('openai/',''):'\u2014';}
+
+async function loadModelHealth(){
+ var bar=document.getElementById('modelHealthBar');
+ if(!bar) return;
+ try{
+ var r=await fetch('/api/model-health');
+ if(!r.ok){bar.innerHTML='
Health data unavailable
';return;}
+ var d=await r.json();
+ var models=d.models||[];
+ if(models.length===0){
+ bar.innerHTML='
No model calls recorded yet
';
+ return;
+ }
+ var order=['embedding','summarize','filterRelevant','judgeDedup','summarizeTask','judgeNewTopic'];
+ models.sort(function(a,b){var ai=order.indexOf(a.role),bi=order.indexOf(b.role);if(ai<0)ai=99;if(bi<0)bi=99;return ai-bi;});
+
+ var h='
';
+ h+='Role Status Model Issue Updated ';
+ h+=' ';
+
+ for(var i=0;i';
+ h+=' ';
+ h+=''+escapeHtml(label)+' ';
+ h+=''+badgeText+' ';
+ h+=''+escapeHtml(shortenModel(m.model))+' ';
+
+ var issue='';
+ if((st==='error'||st==='degraded')&&m.lastErrorMessage){
+ var shortErr=classifyError(m.lastErrorMessage);
+ if(m.failedModel&&m.failedModel!==m.model) issue=shortenModel(m.failedModel)+': ';
+ issue+=shortErr;
+ if(m.consecutiveErrors>1) issue+=' ('+m.consecutiveErrors+'x)';
+ }
+ if(issue) h+=''+escapeHtml(issue)+' ';
+ else h+='\u2014 ';
+
+ h+=''+(ago||'\u2014')+' ';
+ h+='';
+ }
+ h+='
';
+ bar.innerHTML=h;
+ }catch(e){
+ bar.innerHTML='
Failed to load model health
';
+ }
+}
+
+function timeAgo(ts){
+ var diff=Date.now()-ts;
+ if(diff<60000) return 'just now';
+ if(diff<3600000) return Math.floor(diff/60000)+'m ago';
+ if(diff<86400000) return Math.floor(diff/3600000)+'h ago';
+ return Math.floor(diff/86400000)+'d ago';
+}
+
/* ─── Settings / Config ─── */
async function loadConfig(){
try{
@@ -3277,6 +3396,7 @@ async function loadAll(){
await Promise.all([loadStats(),loadMemories()]);
checkMigrateStatus();
connectPPSSE();
+ checkForUpdate();
}
async function loadStats(){
@@ -4213,6 +4333,22 @@ function initViewerTheme(){const s=localStorage.getItem(VIEWER_THEME_KEY);const
function toggleViewerTheme(){const el=document.documentElement;const cur=el.getAttribute('data-theme')||'dark';const next=cur==='dark'?'light':'dark';el.setAttribute('data-theme',next);localStorage.setItem(VIEWER_THEME_KEY,next);}
initViewerTheme();
+/* ─── Update check ─── */
+async function checkForUpdate(){
+ try{
+ const r=await fetch('/api/update-check');
+ if(!r.ok)return;
+ const d=await r.json();
+ if(!d.updateAvailable)return;
+ const banner=document.createElement('div');
+ banner.id='updateBanner';
+ banner.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.25)';
+ banner.innerHTML='
🔔 '+t('update.available')+': v'+esc(d.current)+' → v'+esc(d.latest)+' — '+t('update.run')+': openclaw plugins install '+esc(d.packageName)+' × ';
+ document.body.prepend(banner);
+ document.body.style.paddingTop='48px';
+ }catch(e){}
+}
+
/* ─── Init ─── */
document.getElementById('modalOverlay').addEventListener('click',e=>{if(e.target.id==='modalOverlay')closeModal()});
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Escape'){e.target.value='';loadMemories()}});
diff --git a/apps/memos-local-openclaw/src/viewer/server.ts b/apps/memos-local-openclaw/src/viewer/server.ts
index a1a0e309a..34acb06fb 100644
--- a/apps/memos-local-openclaw/src/viewer/server.ts
+++ b/apps/memos-local-openclaw/src/viewer/server.ts
@@ -6,7 +6,7 @@ import path from "node:path";
import readline from "node:readline";
import type { SqliteStore } from "../storage/sqlite";
import type { Embedder } from "../embedding";
-import { Summarizer } from "../ingest/providers";
+import { Summarizer, modelHealth } from "../ingest/providers";
import { findTopSimilar } from "../ingest/dedup";
import { stripInboundMetadata } from "../capture";
import { vectorSearch } from "../storage/vector";
@@ -17,6 +17,11 @@ import type { Logger, Chunk, PluginContext } from "../types";
import { viewerHTML } from "./html";
import { v4 as uuid } from "uuid";
+function normalizeTimestamp(ts: number): number {
+ if (ts < 1e12) return ts * 1000;
+ return ts;
+}
+
export interface ViewerServerOptions {
store: SqliteStore;
embedder: Embedder;
@@ -93,11 +98,28 @@ export class ViewerServer {
this.server.listen(this.port, "127.0.0.1", () => {
const addr = this.server!.address();
const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
+ this.autoCleanupPolluted();
resolve(`http://127.0.0.1:${actualPort}`);
});
});
}
+ private autoCleanupPolluted(): void {
+ try {
+ const polluted = this.store.findPollutedUserChunks();
+ let deleted = 0;
+ for (const { id } of polluted) {
+ if (this.store.deleteChunk(id)) deleted++;
+ }
+ const fixed = this.store.fixMixedUserChunks();
+ if (deleted > 0 || fixed > 0) {
+ this.log.info(`Auto-cleanup: removed ${deleted} polluted chunks, fixed ${fixed} mixed user+assistant chunks`);
+ }
+ } catch (err) {
+ this.log.warn(`Auto-cleanup failed: ${err}`);
+ }
+ }
+
stop(): void {
this.server?.close();
this.server = null;
@@ -216,8 +238,11 @@ export class ViewerServer {
else if (p === "/api/config" && req.method === "GET") this.serveConfig(res);
else if (p === "/api/config" && req.method === "PUT") this.handleSaveConfig(req, res);
else if (p === "/api/test-model" && req.method === "POST") this.handleTestModel(req, res);
+ else if (p === "/api/model-health" && req.method === "GET") this.serveModelHealth(res);
else if (p === "/api/fallback-model" && req.method === "GET") this.serveFallbackModel(res);
+ else if (p === "/api/update-check" && req.method === "GET") this.handleUpdateCheck(res);
else if (p === "/api/auth/logout" && req.method === "POST") this.handleLogout(req, res);
+ else if (p === "/api/cleanup-polluted" && req.method === "POST") this.handleCleanupPolluted(res);
else if (p === "/api/migrate/scan" && req.method === "GET") this.handleMigrateScan(res);
else if (p === "/api/migrate/start" && req.method === "POST") this.handleMigrateStart(req, res);
else if (p === "/api/migrate/status" && req.method === "GET") this.handleMigrateStatus(res);
@@ -484,7 +509,15 @@ export class ViewerServer {
const total = db.prepare("SELECT COUNT(*) as count FROM chunks").get() as any;
const sessions = db.prepare("SELECT COUNT(DISTINCT session_key) as count FROM chunks").get() as any;
const roles = db.prepare("SELECT role, COUNT(*) as count FROM chunks GROUP BY role").all() as any[];
- const timeRange = db.prepare("SELECT MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks").get() as any;
+ const timeRange = db.prepare("SELECT MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE dedup_status = 'active'").get() as any;
+ const MIN_VALID_TS = 1704067200000; // 2024-01-01
+ if (timeRange.earliest != null && timeRange.earliest < MIN_VALID_TS) {
+ timeRange.earliest = db.prepare("SELECT MIN(created_at) as v FROM chunks WHERE dedup_status = 'active' AND created_at >= ?").get(MIN_VALID_TS) as any;
+ timeRange.earliest = timeRange.earliest?.v ?? null;
+ }
+ if (timeRange.latest != null && timeRange.latest < MIN_VALID_TS) {
+ timeRange.latest = null;
+ }
let embCount = 0;
try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
const kinds = db.prepare("SELECT kind, COUNT(*) as count FROM chunks GROUP BY kind").all() as any[];
@@ -969,11 +1002,13 @@ export class ViewerServer {
const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
const entries = raw?.plugins?.entries ?? {};
const pluginEntry = entries["memos-local-openclaw-plugin"]?.config
+ ?? entries["memos-local"]?.config
?? entries["memos-lite-openclaw-plugin"]?.config
?? entries["memos-lite"]?.config
?? {};
const result: Record
= { ...pluginEntry };
const topEntry = entries["memos-local-openclaw-plugin"]
+ ?? entries["memos-local"]
?? entries["memos-lite-openclaw-plugin"]
?? entries["memos-lite"]
?? {};
@@ -1002,6 +1037,7 @@ export class ViewerServer {
if (!plugins.entries) plugins.entries = {};
const entries = plugins.entries as Record;
const entryKey = entries["memos-local-openclaw-plugin"] ? "memos-local-openclaw-plugin"
+ : entries["memos-local"] ? "memos-local"
: entries["memos-lite-openclaw-plugin"] ? "memos-lite-openclaw-plugin"
: entries["memos-lite"] ? "memos-lite"
: "memos-local-openclaw-plugin";
@@ -1037,8 +1073,8 @@ export class ViewerServer {
return;
}
if (type === "embedding") {
- await this.testEmbeddingModel(provider, model, endpoint, apiKey);
- this.jsonResponse(res, { ok: true, detail: `${provider}/${model}` });
+ const dims = await this.testEmbeddingModel(provider, model, endpoint, apiKey);
+ this.jsonResponse(res, { ok: true, detail: `${provider}/${model}`, dimensions: dims });
} else {
await this.testChatModel(provider, model, endpoint, apiKey);
this.jsonResponse(res, { ok: true, detail: `${provider}/${model}` });
@@ -1051,6 +1087,10 @@ export class ViewerServer {
});
}
+ private serveModelHealth(res: http.ServerResponse): void {
+ this.jsonResponse(res, { models: modelHealth.getAll() });
+ }
+
private serveFallbackModel(res: http.ServerResponse): void {
try {
const cfgPath = this.getOpenClawConfigPath();
@@ -1080,9 +1120,59 @@ export class ViewerServer {
}
}
- private async testEmbeddingModel(provider: string, model: string, endpoint: string, apiKey: string): Promise {
+ private findPluginPackageJson(): string | null {
+ let dir = __dirname;
+ for (let i = 0; i < 6; i++) {
+ const candidate = path.join(dir, "package.json");
+ if (fs.existsSync(candidate)) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8"));
+ if (pkg.name && pkg.name.includes("memos-local")) return candidate;
+ } catch { /* skip */ }
+ }
+ dir = path.dirname(dir);
+ }
+ return null;
+ }
+
+ private async handleUpdateCheck(res: http.ServerResponse): Promise {
+ try {
+ const pkgPath = this.findPluginPackageJson();
+ if (!pkgPath) {
+ this.jsonResponse(res, { updateAvailable: false, error: "package.json not found" });
+ return;
+ }
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
+ const current = pkg.version as string;
+ const name = pkg.name as string;
+ if (!current || !name) {
+ this.jsonResponse(res, { updateAvailable: false, current });
+ return;
+ }
+ const npmResp = await fetch(`https://registry.npmjs.org/${name}/latest`, {
+ signal: AbortSignal.timeout(6_000),
+ });
+ if (!npmResp.ok) {
+ this.jsonResponse(res, { updateAvailable: false, current });
+ return;
+ }
+ const data = await npmResp.json() as { version?: string };
+ const latest = data.version ?? current;
+ this.jsonResponse(res, {
+ updateAvailable: latest !== current,
+ current,
+ latest,
+ packageName: name,
+ });
+ } catch (e) {
+ this.log.warn(`handleUpdateCheck error: ${e}`);
+ this.jsonResponse(res, { updateAvailable: false, error: String(e) });
+ }
+ }
+
+ private async testEmbeddingModel(provider: string, model: string, endpoint: string, apiKey: string): Promise {
if (provider === "local") {
- return;
+ return 384;
}
const baseUrl = (endpoint || "https://api.openai.com/v1").replace(/\/+$/, "");
const embUrl = baseUrl.endsWith("/embeddings") ? baseUrl : `${baseUrl}/embeddings`;
@@ -1095,39 +1185,59 @@ export class ViewerServer {
const resp = await fetch(baseUrl.replace(/\/v\d+.*/, "/v2/embed"), {
method: "POST",
headers,
- body: JSON.stringify({ texts: ["test"], model: model || "embed-english-v3.0", input_type: "search_query", embedding_types: ["float"] }),
+ body: JSON.stringify({ texts: ["test embedding vector"], model: model || "embed-english-v3.0", input_type: "search_query", embedding_types: ["float"] }),
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(`Cohere embed ${resp.status}: ${txt}`);
}
- return;
+ const json = await resp.json() as any;
+ const vecs = json?.embeddings?.float;
+ if (!Array.isArray(vecs) || vecs.length === 0 || !Array.isArray(vecs[0]) || vecs[0].length === 0) {
+ throw new Error("Cohere returned empty embedding vector");
+ }
+ return vecs[0].length;
}
if (provider === "gemini") {
const url = `https://generativelanguage.googleapis.com/v1/models/${model || "text-embedding-004"}:embedContent?key=${apiKey}`;
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ content: { parts: [{ text: "test" }] } }),
+ body: JSON.stringify({ content: { parts: [{ text: "test embedding vector" }] } }),
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(`Gemini embed ${resp.status}: ${txt}`);
}
- return;
+ const json = await resp.json() as any;
+ const vec = json?.embedding?.values;
+ if (!Array.isArray(vec) || vec.length === 0) {
+ throw new Error("Gemini returned empty embedding vector");
+ }
+ return vec.length;
}
const resp = await fetch(embUrl, {
method: "POST",
headers,
- body: JSON.stringify({ input: ["test"], model: model || "text-embedding-3-small" }),
+ body: JSON.stringify({ input: ["test embedding vector"], model: model || "text-embedding-3-small" }),
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(`${resp.status}: ${txt}`);
}
+ const json = await resp.json() as any;
+ const data = json?.data;
+ if (!Array.isArray(data) || data.length === 0) {
+ throw new Error("API returned no embedding data");
+ }
+ const vec = data[0]?.embedding;
+ if (!Array.isArray(vec) || vec.length === 0) {
+ throw new Error(`API returned empty embedding vector (got ${JSON.stringify(vec)?.slice(0, 100)})`);
+ }
+ return vec.length;
}
private async testChatModel(provider: string, model: string, endpoint: string, apiKey: string): Promise {
@@ -1202,6 +1312,28 @@ export class ViewerServer {
return path.join(home, ".openclaw");
}
+ private handleCleanupPolluted(res: http.ServerResponse): void {
+ try {
+ const polluted = this.store.findPollutedUserChunks();
+ let deleted = 0;
+ for (const { id, reason } of polluted) {
+ if (this.store.deleteChunk(id)) {
+ deleted++;
+ this.log.info(`Cleaned polluted chunk ${id}: ${reason}`);
+ }
+ }
+ const fixed = this.store.fixMixedUserChunks();
+ this.log.info(`Cleanup: removed ${deleted} polluted, fixed ${fixed} mixed chunks`);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ deleted, fixed, total: polluted.length }));
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ this.log.error(`handleCleanupPolluted error: ${msg}`);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: msg }));
+ }
+ }
+
private handleMigrateScan(res: http.ServerResponse): void {
try {
const ocHome = this.getOpenClawHome();
@@ -1260,8 +1392,9 @@ export class ViewerServer {
try {
const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
const pluginCfg = raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ??
- raw?.plugins?.entries?.["memos-lite"]?.config ??
- raw?.plugins?.entries?.["memos-lite-openclaw-plugin"]?.config ?? {};
+ raw?.plugins?.entries?.["memos-local"]?.config ??
+ raw?.plugins?.entries?.["memos-lite-openclaw-plugin"]?.config ??
+ raw?.plugins?.entries?.["memos-lite"]?.config ?? {};
const emb = pluginCfg.embedding;
hasEmbedding = !!(emb && emb.provider);
const sum = pluginCfg.summarizer;
@@ -1444,17 +1577,16 @@ export class ViewerServer {
const cfgPath = this.getOpenClawConfigPath();
let summarizerCfg: any;
- let strongCfg: any;
try {
const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
const pluginCfg = raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ??
- raw?.plugins?.entries?.["memos-lite"]?.config ??
- raw?.plugins?.entries?.["memos-lite-openclaw-plugin"]?.config ?? {};
+ raw?.plugins?.entries?.["memos-local"]?.config ??
+ raw?.plugins?.entries?.["memos-lite-openclaw-plugin"]?.config ??
+ raw?.plugins?.entries?.["memos-lite"]?.config ?? {};
summarizerCfg = pluginCfg.summarizer;
- strongCfg = pluginCfg.skillEvolution?.summarizer;
} catch { /* no config */ }
- const summarizer = new Summarizer(summarizerCfg, this.log, strongCfg);
+ const summarizer = new Summarizer(summarizerCfg, this.log);
// Phase 1: Import SQLite memory chunks
if (importSqlite) {
@@ -1580,8 +1712,8 @@ export class ViewerServer {
mergeCount: 0,
lastHitAt: null,
mergeHistory: "[]",
- createdAt: row.updated_at * 1000,
- updatedAt: row.updated_at * 1000,
+ createdAt: normalizeTimestamp(row.updated_at),
+ updatedAt: normalizeTimestamp(row.updated_at),
};
this.store.insertChunk(chunk);
From 009176022cd7dee7eec80b38b6ae9a9b2406c112 Mon Sep 17 00:00:00 2001
From: jiachengzhen
Date: Thu, 12 Mar 2026 10:24:23 +0800
Subject: [PATCH 03/25] fix(memos-local-openclaw): task chunk expand/collapse,
model health section, test result display
Made-with: Cursor
---
apps/memos-local-openclaw/src/viewer/html.ts | 63 ++++++++++++++++----
1 file changed, 53 insertions(+), 10 deletions(-)
diff --git a/apps/memos-local-openclaw/src/viewer/html.ts b/apps/memos-local-openclaw/src/viewer/html.ts
index 64474cfff..fe4adb7f0 100644
--- a/apps/memos-local-openclaw/src/viewer/html.ts
+++ b/apps/memos-local-openclaw/src/viewer/html.ts
@@ -330,8 +330,12 @@ input,textarea,select{font-family:inherit;font-size:inherit}
.task-chunk-role.user{color:var(--pri)}
.task-chunk-role.assistant{color:var(--green)}
.task-chunk-role.tool{color:var(--amber)}
-.task-chunk-bubble{padding:12px 16px;border-radius:16px;white-space:pre-wrap;word-break:break-word;max-height:200px;overflow:hidden;position:relative;transition:all .2s}
-.task-chunk-bubble.expanded{max-height:none}
+.task-chunk-bubble{padding:12px 16px;border-radius:16px;white-space:pre-wrap;word-break:break-word;max-height:none;overflow:hidden;position:relative;transition:all .2s}
+.task-chunk-bubble.collapsed{max-height:200px}
+.task-chunk-expand{display:none;align-items:center;justify-content:center;gap:4px;margin-top:4px;padding:4px 12px;font-size:12px;font-weight:600;color:var(--text-sec);cursor:pointer;user-select:none;border-radius:8px;transition:all .15s}
+.task-chunk-expand:hover{color:var(--pri);background:rgba(99,102,241,.08)}
+.task-chunk-expand .expand-arrow{display:inline-block;font-size:10px;transition:transform .2s}
+.task-chunk-expand.is-expanded .expand-arrow{transform:rotate(180deg)}
.role-user .task-chunk-bubble{background:var(--pri);color:#000;border-bottom-right-radius:4px}
.role-assistant .task-chunk-bubble{background:var(--bg-card);border:1px solid var(--border);color:var(--text-sec);border-bottom-left-radius:4px}
.role-tool .task-chunk-bubble{background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);color:var(--text-sec);border-bottom-left-radius:4px;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:12px}
@@ -962,8 +966,11 @@ input,textarea,select{font-family:inherit;font-size:inherit}
Model Configuration
-
-
Loading model status...
+
+
\u{1F4CA} Model Health
+
+
Loading model status...
+
\u{1F4E1} Embedding Model
@@ -1392,6 +1399,8 @@ const I18N={
'tasks.untitled':'Untitled Task',
'tasks.chunks':'Related Memories',
'tasks.nochunks':'No memories in this task yet.',
+ 'tasks.expand':'Show more',
+ 'tasks.collapse':'Show less',
'tasks.skipped.default':'This conversation was too brief to generate a summary. It will not appear in search results.',
'refresh':'\\u21BB Refresh',
'logout':'Logout',
@@ -1496,6 +1505,7 @@ const I18N={
'tab.import':'\u{1F4E5} Import',
'tab.settings':'\u2699 Settings',
'settings.modelconfig':'Model Configuration',
+ 'settings.modelhealth':'Model Health',
'settings.embedding':'Embedding Model',
'settings.summarizer':'Summarizer Model',
'settings.skill':'Skill Evolution',
@@ -1701,6 +1711,8 @@ const I18N={
'tasks.untitled':'未命名任务',
'tasks.chunks':'关联记忆',
'tasks.nochunks':'此任务暂无关联记忆。',
+ 'tasks.expand':'展开全文',
+ 'tasks.collapse':'收起',
'tasks.skipped.default':'对话内容过少,未生成摘要。该任务不会出现在检索结果中。',
'refresh':'\\u21BB 刷新',
'logout':'退出',
@@ -1805,6 +1817,7 @@ const I18N={
'tab.import':'\u{1F4E5} 导入',
'tab.settings':'\u2699 设置',
'settings.modelconfig':'模型配置',
+ 'settings.modelhealth':'模型健康',
'settings.embedding':'嵌入模型',
'settings.summarizer':'摘要模型',
'settings.skill':'技能进化',
@@ -2448,14 +2461,16 @@ async function openTaskDetail(taskId){
if(task.chunks.length===0){
document.getElementById('taskDetailChunks').innerHTML='
'+t('tasks.nochunks')+'
';
}else{
- document.getElementById('taskDetailChunks').innerHTML=task.chunks.map(c=>{
+ document.getElementById('taskDetailChunks').innerHTML=task.chunks.map(function(c,i){
var roleLabel=c.role==='user'?t('tasks.role.user'):c.role==='assistant'?t('tasks.role.assistant'):c.role.toUpperCase();
return '
'+
'
'+roleLabel+'
'+
- '
'+esc(c.content)+'
'+
+ '
'+esc(c.content)+'
'+
+ '
▼ '+t('tasks.expand')+'
'+
'
'+formatTime(c.createdAt)+'
'+
'
';
}).join('');
+ setTimeout(function(){initChunkExpanders(task.chunks.length)},50);
}
}catch(e){
document.getElementById('taskDetailTitle').textContent=t('tasks.error');
@@ -2519,6 +2534,33 @@ function renderTaskSkillSection(task){
}
}
+function initChunkExpanders(count){
+ for(var i=0;i
b.clientHeight + 4){
+ e.style.display='flex';
+ } else if(b) {
+ b.classList.remove('collapsed');
+ }
+ }
+}
+function toggleChunkExpand(i){
+ var b=document.getElementById('chunk_b_'+i);
+ var e=document.getElementById('chunk_e_'+i);
+ if(!b||!e)return;
+ var expanding=b.classList.contains('collapsed');
+ if(expanding){
+ b.classList.remove('collapsed');
+ e.classList.add('is-expanded');
+ e.querySelector('.expand-label').textContent=t('tasks.collapse');
+ }else{
+ b.classList.add('collapsed');
+ e.classList.remove('is-expanded');
+ e.querySelector('.expand-label').textContent=t('tasks.expand');
+ }
+}
+
function closeTaskDetail(event){
if(event && event.target!==document.getElementById('taskDetailOverlay')) return;
document.getElementById('taskDetailOverlay').classList.remove('show');
@@ -3060,15 +3102,16 @@ async function testModel(type){
var d=await r.json();
if(d.ok){
resultEl.className='test-result ok';
- resultEl.innerHTML='\\u2705 '+t('settings.test.ok')+''+esc(d.detail||'')+'
';
+ resultEl.innerHTML='\\u2705 '+t('settings.test.ok')+(d.detail?''+esc(d.detail)+'
':'');
}else{
- var errMsg=d.error||'Unknown error';
+ var errMsg=(d.error||'Unknown error').replace(/:\s*$/,'').trim();
resultEl.className='test-result fail';
- resultEl.innerHTML='\\u274C '+t('settings.test.fail')+''+esc(errMsg)+'
';
+ resultEl.innerHTML='\\u274C '+t('settings.test.fail')+(errMsg?''+esc(errMsg)+'
':'');
}
}catch(e){
+ var catchMsg=(e.message||'Network error').replace(/:\s*$/,'').trim();
resultEl.className='test-result fail';
- resultEl.innerHTML='\\u274C '+t('settings.test.fail')+''+esc(e.message)+'
';
+ resultEl.innerHTML='\\u274C '+t('settings.test.fail')+(catchMsg?''+esc(catchMsg)+'
':'');
}finally{btn.disabled=false;}
}
From d48e5c10a316f80edf420929cb4a7dff42e670fd Mon Sep 17 00:00:00 2001
From: jiachengzhen
Date: Thu, 12 Mar 2026 10:50:10 +0800
Subject: [PATCH 04/25] fix(memos-local-openclaw): show plugin version badge
(v*) next to logo in viewer
Made-with: Cursor
---
apps/memos-local-openclaw/src/viewer/html.ts | 9 +++++++--
apps/memos-local-openclaw/src/viewer/server.ts | 10 +++++++++-
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/apps/memos-local-openclaw/src/viewer/html.ts b/apps/memos-local-openclaw/src/viewer/html.ts
index fe4adb7f0..ba4d4be3b 100644
--- a/apps/memos-local-openclaw/src/viewer/html.ts
+++ b/apps/memos-local-openclaw/src/viewer/html.ts
@@ -1,4 +1,6 @@
-export const viewerHTML = `
+export function viewerHTML(pluginVersion?: string): string {
+const vBadge = pluginVersion ? `v${pluginVersion} ` : '';
+return `
@@ -110,6 +112,8 @@ input,textarea,select{font-family:inherit;font-size:inherit}
.topbar .brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;color:var(--text);letter-spacing:-.02em;flex-shrink:0}
.topbar .brand .icon{width:32px;height:32px;display:flex;align-items:center;justify-content:center;font-size:22px;background:none;border-radius:0}
.topbar .brand .sub{font-weight:400;color:var(--text-muted);font-size:11px}
+.version-badge{font-size:10px;font-weight:600;color:var(--text-muted);background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.1);padding:1px 7px;border-radius:6px;margin-left:6px;letter-spacing:.02em;user-select:all}
+[data-theme="light"] .version-badge{background:rgba(0,0,0,.05);border-color:rgba(0,0,0,.08);color:var(--text-sec)}
.topbar-center{flex:1;display:flex;justify-content:center}
.topbar .actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
@@ -743,7 +747,7 @@ input,textarea,select{font-family:inherit;font-size:inherit}
-
OpenClaw Memory
+
OpenClaw Memory ${vBadge}
@@ -4412,3 +4416,4 @@ checkAuth();
`;
+}
diff --git a/apps/memos-local-openclaw/src/viewer/server.ts b/apps/memos-local-openclaw/src/viewer/server.ts
index 34acb06fb..a6bd5b8d6 100644
--- a/apps/memos-local-openclaw/src/viewer/server.ts
+++ b/apps/memos-local-openclaw/src/viewer/server.ts
@@ -48,6 +48,14 @@ export class ViewerServer {
private readonly ctx?: PluginContext;
private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
+ private static readonly PLUGIN_VERSION: string = (() => {
+ try {
+ const pkgPath = path.resolve(__dirname, "../../package.json");
+ return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version ?? "unknown";
+ } catch {
+ return "unknown";
+ }
+ })();
private resetToken: string;
private migrationRunning = false;
private migrationAbort = false;
@@ -363,7 +371,7 @@ export class ViewerServer {
private serveViewer(res: http.ServerResponse): void {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache", "Expires": "0" });
- res.end(viewerHTML);
+ res.end(viewerHTML(ViewerServer.PLUGIN_VERSION));
}
// ─── Data APIs ───
From cbfe54b90bb8d22a356a469d720abab244858548 Mon Sep 17 00:00:00 2001
From: tangbo <1502220175@qq.com>
Date: Thu, 12 Mar 2026 15:33:36 +0800
Subject: [PATCH 05/25] =?UTF-8?q?feat(memos-local-openclaw):=20v1.0.2-beta?=
=?UTF-8?q?.5=20=E2=80=94=20multi-agent=20isolation,=20viewer=20UI=20impro?=
=?UTF-8?q?vements,=20topic=20judge=20&=20embedding=20warnings?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Multi-agent data isolation: correctly pass agentId from OpenClaw hooks (ctx),
owner-based filtering in search/FTS/timeline/get tools, skill guide updated
- Viewer UI: sidebar stats always visible on all tabs, analytics page width fix,
session section visibility toggle, tab switch jitter fix, unified content max-width
- Topic judge prompt: rebalanced to correctly detect cross-domain topic shifts
(e.g. cooking vs database config no longer merged)
- Embedding banners: warning when using built-in mini model, error when model fails
- Test infrastructure: accuracy test script (openclaw agent CLI), agent isolation test
Made-with: Cursor
---
apps/memos-local-openclaw/package.json | 3 +-
.../scripts/run-accuracy-test.ts | 778 ++++++++++++++++++
.../scripts/test-agent-isolation.ts | 245 ++++++
.../src/ingest/providers/anthropic.ts | 23 +-
.../src/ingest/providers/bedrock.ts | 23 +-
.../src/ingest/providers/gemini.ts | 23 +-
.../src/ingest/providers/openai.ts | 23 +-
.../src/skill/bundled-memory-guide.ts | 9 +
apps/memos-local-openclaw/src/viewer/html.ts | 146 +++-
.../memos-local-openclaw/src/viewer/server.ts | 57 +-
.../tests/accuracy.test.ts | 571 +++++++++++++
.../tests/bench/README.md | 568 +++++++++++++
apps/memos-local-openclaw/vitest.config.ts | 4 +-
13 files changed, 2366 insertions(+), 107 deletions(-)
create mode 100644 apps/memos-local-openclaw/scripts/run-accuracy-test.ts
create mode 100644 apps/memos-local-openclaw/scripts/test-agent-isolation.ts
create mode 100644 apps/memos-local-openclaw/tests/accuracy.test.ts
create mode 100644 apps/memos-local-openclaw/tests/bench/README.md
diff --git a/apps/memos-local-openclaw/package.json b/apps/memos-local-openclaw/package.json
index c8c883b41..c1fb0800b 100644
--- a/apps/memos-local-openclaw/package.json
+++ b/apps/memos-local-openclaw/package.json
@@ -1,6 +1,6 @@
{
"name": "@memtensor/memos-local-openclaw-plugin",
- "version": "1.0.2-beta.4",
+ "version": "1.0.2-beta.5",
"description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
"type": "module",
"main": "index.ts",
@@ -28,6 +28,7 @@
"lint": "eslint src --ext .ts",
"test": "vitest run",
"test:watch": "vitest",
+ "test:accuracy": "tsx scripts/run-accuracy-test.ts",
"postinstall": "node scripts/postinstall.cjs",
"prepublishOnly": "npm run build"
},
diff --git a/apps/memos-local-openclaw/scripts/run-accuracy-test.ts b/apps/memos-local-openclaw/scripts/run-accuracy-test.ts
new file mode 100644
index 000000000..589af7c8d
--- /dev/null
+++ b/apps/memos-local-openclaw/scripts/run-accuracy-test.ts
@@ -0,0 +1,778 @@
+#!/usr/bin/env npx tsx
+/**
+ * MemOS Accuracy Test — sends data through OpenClaw Gateway (real pipeline).
+ *
+ * Ingest uses `openclaw agent` CLI so data flows through the full gateway,
+ * is processed by the memos plugin, and is visible in the Viewer UI.
+ * Search verification uses direct DB access via initPlugin.
+ *
+ * Usage:
+ * npx tsx scripts/run-accuracy-test.ts # quick mode (5 ingest, verify only)
+ * npx tsx scripts/run-accuracy-test.ts --full # full 50+ test cases
+ * npx tsx scripts/run-accuracy-test.ts --workers 3 # concurrent sessions (full mode)
+ * npx tsx scripts/run-accuracy-test.ts --skip-ingest # only run search checks (assumes data exists)
+ *
+ * Add to package.json:
+ * "test:accuracy": "tsx scripts/run-accuracy-test.ts"
+ */
+
+import { execSync } from "child_process";
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import { initPlugin, type MemosLocalPlugin } from "../src/index";
+
+// ─── CLI args ───
+
+const args = process.argv.slice(2);
+const FULL_MODE = args.includes("--full");
+const SKIP_INGEST = args.includes("--skip-ingest");
+const WORKERS = Number(args.find((_, i, a) => a[i - 1] === "--workers") ?? 2);
+const INGEST_DELAY_MS = 3000;
+
+// ─── Config ───
+
+function loadConfig() {
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
+ const cfgPath = path.join(home, ".openclaw", "openclaw.json");
+ if (!fs.existsSync(cfgPath)) {
+ throw new Error(`OpenClaw config not found: ${cfgPath}`);
+ }
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
+ return raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ?? {};
+}
+
+// ─── Test framework ───
+
+interface TestResult {
+ category: string;
+ name: string;
+ pass: boolean;
+ detail: string;
+ durationMs: number;
+}
+
+const results: TestResult[] = [];
+const RUN_ID = Date.now();
+const SESSION_PREFIX = `acc-${RUN_ID}`;
+let sessionSeq = 0;
+
+function mkSession(label: string) {
+ return `${SESSION_PREFIX}-${label}-${++sessionSeq}`;
+}
+
+function log(msg: string) {
+ const t = new Date().toLocaleTimeString("zh-CN", { hour12: false });
+ console.log(`[${t}] ${msg}`);
+}
+
+function hitContains(hits: any[], keyword: string): boolean {
+ return hits.some(
+ (h: any) =>
+ h.original_excerpt?.toLowerCase().includes(keyword.toLowerCase()) ||
+ h.summary?.toLowerCase().includes(keyword.toLowerCase()),
+ );
+}
+
+// ─── Send message through OpenClaw Gateway ───
+
+function sendViaGateway(sessionId: string, message: string): boolean {
+ const tmpFile = path.join(os.tmpdir(), `memos-test-msg-${Date.now()}.txt`);
+ try {
+ fs.writeFileSync(tmpFile, message, "utf-8");
+ execSync(
+ `openclaw agent --session-id "${sessionId}" --message "$(cat '${tmpFile}')" --json`,
+ { timeout: 120_000, stdio: "pipe" },
+ );
+ return true;
+ } catch (e: any) {
+ log(` [WARN] gateway send failed: ${e.message?.slice(0, 200)}`);
+ return false;
+ } finally {
+ try { fs.unlinkSync(tmpFile); } catch {}
+ }
+}
+
+// ─── Test data: realistic, multi-turn, long-form conversations ───
+
+interface ConversationCase {
+ id: string;
+ label: string;
+ sessionId: string;
+ messages: string[];
+ group: "dedup" | "topic" | "search" | "summary" | "cross-lang";
+}
+
+function buildTestCases(): ConversationCase[] {
+ const cases: ConversationCase[] = [];
+
+ // ═══════════════════════════════════════════
+ // Group 1: Dedup — exact / semantic / merge
+ // ═══════════════════════════════════════════
+
+ const dedupSession1 = mkSession("dedup-exact");
+ cases.push({
+ id: "dedup-exact-1",
+ label: "Dedup: exact duplicate (msg 1/3)",
+ sessionId: dedupSession1,
+ group: "dedup",
+ messages: [
+ `我们的线上 Redis 集群配置如下:Redis 版本 6.2.14,部署在 3 台 AWS ElastiCache r6g.xlarge 节点上,组成 3 主 3 从的 Cluster 模式。maxmemory 设置为 12GB,淘汰策略用 allkeys-lru,连接池大小 50,超时时间 3 秒。所有缓存 key 统一加 "prod:" 前缀,TTL 默认 1 小时,热点数据(如用户 session、商品详情)TTL 设为 24 小时。`,
+ ],
+ });
+ cases.push({
+ id: "dedup-exact-2",
+ label: "Dedup: exact duplicate (msg 2/3, same content)",
+ sessionId: dedupSession1,
+ group: "dedup",
+ messages: [
+ `我们的线上 Redis 集群配置如下:Redis 版本 6.2.14,部署在 3 台 AWS ElastiCache r6g.xlarge 节点上,组成 3 主 3 从的 Cluster 模式。maxmemory 设置为 12GB,淘汰策略用 allkeys-lru,连接池大小 50,超时时间 3 秒。所有缓存 key 统一加 "prod:" 前缀,TTL 默认 1 小时,热点数据(如用户 session、商品详情)TTL 设为 24 小时。`,
+ ],
+ });
+ cases.push({
+ id: "dedup-exact-3",
+ label: "Dedup: exact duplicate (msg 3/3, same content again)",
+ sessionId: dedupSession1,
+ group: "dedup",
+ messages: [
+ `我们的线上 Redis 集群配置如下:Redis 版本 6.2.14,部署在 3 台 AWS ElastiCache r6g.xlarge 节点上,组成 3 主 3 从的 Cluster 模式。maxmemory 设置为 12GB,淘汰策略用 allkeys-lru,连接池大小 50,超时时间 3 秒。所有缓存 key 统一加 "prod:" 前缀,TTL 默认 1 小时,热点数据(如用户 session、商品详情)TTL 设为 24 小时。`,
+ ],
+ });
+
+ const dedupSession2 = mkSession("dedup-semantic");
+ cases.push({
+ id: "dedup-sem-1",
+ label: "Dedup: semantic dup (PostgreSQL v1)",
+ sessionId: dedupSession2,
+ group: "dedup",
+ messages: [
+ `主数据库使用 PostgreSQL 16,部署在 AWS RDS 的 db.r6g.2xlarge 实例上。已开启读写分离,1 个 writer 实例 + 2 个 reader 副本做负载均衡。连接池用 PgBouncer,transaction pooling 模式,max_client_conn 设为 200,default_pool_size 设为 25。WAL 日志异步复制,backup 策略是每日自动快照 + 开启 Point-in-Time Recovery(PITR),保留 7 天。`,
+ ],
+ });
+ cases.push({
+ id: "dedup-sem-2",
+ label: "Dedup: semantic dup (PostgreSQL v2 — reworded)",
+ sessionId: dedupSession2,
+ group: "dedup",
+ messages: [
+ `生产环境的核心关系型数据库是 PG 16,跑在 Amazon RDS 上面,机型选的是 db.r6g.2xlarge。数据库做了读写分离——一个主库负责写入,两个只读副本分担查询流量。中间层用 PgBouncer 做连接池管理,采用事务级池化,最大客户端连接数 200,默认池大小 25。日志走 WAL 异步复制,每天自动创建快照备份,还启用了时间点恢复(PITR),保留窗口 7 天。`,
+ ],
+ });
+
+ const dedupSession3 = mkSession("dedup-merge");
+ cases.push({
+ id: "dedup-merge-1",
+ label: "Dedup: merge — old state (React 18 + Vite)",
+ sessionId: dedupSession3,
+ group: "dedup",
+ messages: [
+ `前端项目用 React 18.2 搭配 Vite 5.0 构建,TypeScript 5.3 严格模式。状态管理用 Zustand + React Query v5,UI 组件库用 Ant Design 5.x。打包产物部署到 CloudFront CDN,Gzip + Brotli 双压缩,首屏 LCP 控制在 1.8 秒以内。`,
+ ],
+ });
+ cases.push({
+ id: "dedup-merge-2",
+ label: "Dedup: merge — new state (migrated to Next.js 14)",
+ sessionId: dedupSession3,
+ group: "dedup",
+ messages: [
+ `前端已经从 React 18 + Vite 迁移到了 Next.js 14 App Router,改用 Vercel 部署。状态管理保持 Zustand + React Query 不变,但 UI 组件库换成了 Shadcn/ui + Tailwind CSS。SSR + ISR 混合渲染,Core Web Vitals 全绿,LCP 降到 1.2 秒。`,
+ ],
+ });
+
+ // ═══════════════════════════════════════════
+ // Group 2: Topic boundary detection
+ // ═══════════════════════════════════════════
+
+ const topicSameSession = mkSession("topic-same");
+ cases.push({
+ id: "topic-same-1",
+ label: "Topic: same topic (Nginx config, part 1)",
+ sessionId: topicSameSession,
+ group: "topic",
+ messages: [
+ `帮我配置生产环境的 Nginx 反向代理。需求:监听 443 端口,SSL/TLS 证书放在 /etc/nginx/ssl/ 目录下,upstream 后端是 localhost:3000 的 Node.js 应用。需要配置 worker_processes auto,worker_connections 4096,以及 proxy_set_header 把真实 IP 传到后端。`,
+ ],
+ });
+ cases.push({
+ id: "topic-same-2",
+ label: "Topic: same topic (Nginx config, part 2 — add gzip + cache)",
+ sessionId: topicSameSession,
+ group: "topic",
+ messages: [
+ `Nginx 配置再加几个优化:开启 gzip 压缩(gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1024;),静态资源加浏览器缓存头(location ~* \\.(js|css|png|jpg|svg|woff2)$ { expires 30d; add_header Cache-Control "public, immutable"; }),还要加上 HTTP/2 和 HSTS(add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";)。`,
+ ],
+ });
+
+ const topicSwitchSession = mkSession("topic-switch");
+ cases.push({
+ id: "topic-switch-1",
+ label: "Topic: switch — Docker (tech)",
+ sessionId: topicSwitchSession,
+ group: "topic",
+ messages: [
+ `帮我写一个多阶段 Dockerfile,用于构建 Node.js 20 的生产镜像。第一阶段用 node:20-alpine 作为 builder,安装 pnpm,复制 package.json 和 pnpm-lock.yaml,然后 pnpm install --frozen-lockfile --prod=false,再 pnpm run build。第二阶段用干净的 node:20-alpine,只复制 dist/ 和 node_modules/,暴露 3000 端口,CMD ["node", "dist/server.js"]。同时生成一个 .dockerignore 排除 node_modules、.git、.env、coverage、*.md。`,
+ ],
+ });
+ cases.push({
+ id: "topic-switch-2",
+ label: "Topic: switch — cooking (completely different domain)",
+ sessionId: topicSwitchSession,
+ group: "topic",
+ messages: [
+ `今天想试试做正宗的红烧肉。食材清单:五花肉 500g(切 3cm 方块)、冰糖 30g、生抽 3 勺、老抽 1 勺、料酒 2 勺、八角 2 颗、桂皮 1 小段、香叶 2 片、干辣椒 2 个、生姜 4 片、葱白 3 段。步骤:五花肉冷水下锅焯水 5 分钟,捞出洗净。锅里放少量油,中小火炒冰糖至焦糖色,下五花肉翻炒上色。加料酒、生抽、老抽,放八角桂皮香叶,加没过肉的热水,大火煮开后转小火炖 50 分钟。最后大火收汁,撒葱花出锅。`,
+ ],
+ });
+
+ // ═══════════════════════════════════════════
+ // Group 3: Search precision + recall data
+ // ═══════════════════════════════════════════
+
+ const searchSession = mkSession("search-data");
+ cases.push({
+ id: "search-mysql",
+ label: "Search: MySQL InnoDB MVCC",
+ sessionId: searchSession,
+ group: "search",
+ messages: [
+ `线上 MySQL 8.0 数据库要点总结:存储引擎统一用 InnoDB,默认行级锁,支持 MVCC 多版本并发控制。事务隔离级别设为 REPEATABLE READ(MySQL 默认),innodb_buffer_pool_size 设为物理内存的 70%(当前 28GB / 40GB),innodb_flush_log_at_trx_commit=1 保证事务持久性。慢查询日志开启,long_query_time=2 秒,定期用 pt-query-digest 分析 Top 20 慢查询。索引策略:核心业务表必须有聚簇索引,联合索引遵循最左前缀原则,覆盖索引优先避免回表。`,
+ ],
+ });
+ cases.push({
+ id: "search-k8s",
+ label: "Search: Kubernetes cluster",
+ sessionId: searchSession,
+ group: "search",
+ messages: [
+ `Kubernetes 生产集群规模和配置:3 个 master 节点(etcd 高可用集群)+ 8 个 worker 节点,全部部署在阿里云 ECS ecs.c7.2xlarge(8c16g)上。容器运行时用 containerd 1.7,网络插件 Calico VXLAN 模式。部署方式:核心服务 Deployment + HPA(CPU 60% 触发扩容,最小 2 副本最大 10 副本),有状态服务(MySQL、Redis)用 StatefulSet + PVC。日志用 Fluent Bit DaemonSet 采集到 ES,监控用 Prometheus Operator + kube-state-metrics。`,
+ ],
+ });
+ cases.push({
+ id: "search-review",
+ label: "Search: Code Review process",
+ sessionId: searchSession,
+ group: "search",
+ messages: [
+ `团队 Code Review 流程规范:每周三下午 2-4 点集中做 Code Review Session,其他时间异步 review。GitLab MR 模板包含:变更描述、影响范围、测试情况、截图/录屏。Review 规则:至少 2 人 approve 才能合并,其中 1 人必须是 Tech Lead 或 Senior。自动化检查:CI 跑 lint(ESLint + Prettier)、单元测试(覆盖率门禁 80%)、类型检查、依赖安全扫描(Snyk)。Code Review 重点关注:逻辑正确性 > 性能 > 可读性 > 编码风格。`,
+ ],
+ });
+ cases.push({
+ id: "search-elk",
+ label: "Search: ELK logging stack",
+ sessionId: searchSession,
+ group: "search",
+ messages: [
+ `日志系统架构:ELK 栈。Elasticsearch 7.17 集群(3 节点,每节点 64GB 内存 + 2TB SSD),Logstash 作为日志处理管道(grok 解析 + 字段映射 + 时间戳标准化),Kibana 做可视化和告警。日志分级:应用日志走 Fluent Bit → Kafka(缓冲) → Logstash → ES,系统日志直接 Filebeat → ES。索引策略:按天滚动创建索引(logs-app-YYYY.MM.DD),ILM 策略 hot/warm/cold 三层,hot 7 天 SSD,warm 30 天 HDD,cold 90 天归档到 S3 Glacier。`,
+ ],
+ });
+ cases.push({
+ id: "search-monitoring",
+ label: "Search: Prometheus Grafana monitoring",
+ sessionId: searchSession,
+ group: "search",
+ messages: [
+ `监控告警体系:Prometheus 2.45 + Grafana 10.x + AlertManager。Prometheus 抓取间隔 15 秒,数据保留 30 天。主要 exporter:node_exporter(主机指标)、cadvisor(容器指标)、mysqld_exporter、redis_exporter、blackbox_exporter(HTTP 探测)。Grafana 仪表盘:系统概览、应用 QPS/延迟/错误率、数据库连接池、Redis 命中率。告警规则:CPU > 80% 持续 5 分钟 → 企业微信通知,5xx 错误率 > 1% → 电话告警(PagerDuty),磁盘使用率 > 85% → 邮件通知。`,
+ ],
+ });
+
+ // Recall data — DevOps tools
+ const recallSession = mkSession("recall-devops");
+ cases.push({
+ id: "search-jenkins",
+ label: "Search: Jenkins CI pipeline",
+ sessionId: recallSession,
+ group: "search",
+ messages: [
+ `CI/CD Pipeline 用 Jenkins 2.x,Jenkinsfile 放在项目根目录,采用 declarative pipeline 语法。流水线分 5 个 stage:Checkout → Lint & Type Check → Unit Test(Jest,覆盖率报告上传 SonarQube)→ Build(Docker 多阶段构建)→ Deploy(kubectl apply 到对应环境)。分支策略:feature/* 只跑 lint + test,develop 跑全量 + 部署 staging,main 跑全量 + 部署 production(需要人工审批)。Jenkins 节点用 Kubernetes Pod 作为 agent,按需弹性伸缩。`,
+ ],
+ });
+ cases.push({
+ id: "search-terraform",
+ label: "Search: Terraform IaC",
+ sessionId: recallSession,
+ group: "search",
+ messages: [
+ `基础设施即代码用 Terraform 1.6,state 存在 S3 bucket + DynamoDB 做状态锁,防止并发修改。模块化组织:modules/networking(VPC、子网、安全组)、modules/compute(ECS 实例、Auto Scaling Group)、modules/database(RDS、ElastiCache)、modules/monitoring(CloudWatch、SNS)。环境用 workspace 隔离:dev / staging / production。变量通过 terraform.tfvars 和 CI 环境变量注入。每次变更走 PR,CI 自动执行 terraform plan,输出 diff 到 PR 评论,merge 后自动 terraform apply。`,
+ ],
+ });
+
+ // ═══════════════════════════════════════════
+ // Group 4: Summary quality — long text
+ // ═══════════════════════════════════════════
+
+ const summarySession = mkSession("summary");
+ cases.push({
+ id: "summary-microservices",
+ label: "Summary: complex microservices architecture",
+ sessionId: summarySession,
+ group: "summary",
+ messages: [
+ `微服务架构详细设计方案如下。服务拆分:user-service 负责用户注册登录、OAuth2.0 第三方授权、RBAC 权限管理、用户画像标签;order-service 处理订单创建/取消/退款全生命周期,支持分库分表(按 user_id 取模 16 库 64 表);payment-service 对接支付宝当面付、微信 JSAPI 支付、银联快捷支付,所有支付回调统一走消息队列异步处理;inventory-service 管理商品库存,用 Redis 预扣 + MySQL 最终一致性方案防超卖;notification-service 负责短信(阿里云 SMS)、邮件(SES)、App Push(极光推送)、站内信。所有服务 Kubernetes 部署,Istio 服务网格做流量管理和灰度发布,Jaeger 全链路追踪,SkyWalking 做 APM 性能监控。服务间通信:同步走 gRPC(protobuf 序列化),异步走 RocketMQ 5.0。API Gateway 用 Kong,统一鉴权、限流、日志。`,
+ ],
+ });
+ cases.push({
+ id: "summary-migration",
+ label: "Summary: DB migration plan",
+ sessionId: summarySession,
+ group: "summary",
+ messages: [
+ `数据库迁移三阶段实施方案。Q1(1-3 月):用户表从 MySQL 迁移到 PostgreSQL。第一步搭建 PG 目标库,用 pgloader 做初始全量同步;第二步开启 Maxwell → Kafka → PG 的实时 CDC 增量同步;第三步应用层改为双写模式(先写 MySQL 再写 PG),持续一个月做数据一致性校验(每天凌晨全表 count + 随机抽样 1000 条 hash 比对);第四步灰度切读到 PG(先 10% → 50% → 100%),确认无误后停止双写。Q2(4-6 月):订单表和支付表迁移,用 Debezium CDC 替代 Maxwell(支持 exactly-once delivery),同样双写 + 校验 + 灰度流程。Q3(7-9 月):剩余表迁移完成,停掉旧 MySQL 集群。每个阶段迁移完成后保留旧库只读权限 90 天,作为回滚保险。`,
+ ],
+ });
+
+ // ═══════════════════════════════════════════
+ // Group 5: Cross-language
+ // ═══════════════════════════════════════════
+
+ const crossLangSession = mkSession("cross-lang");
+ cases.push({
+ id: "cross-lang-en",
+ label: "Cross-lang: Docker Compose (English)",
+ sessionId: crossLangSession,
+ group: "cross-lang",
+ messages: [
+ `Our local development setup uses Docker Compose with four services: "api" runs the Node.js backend on port 3000 with hot-reload via nodemon, "web" runs the Next.js frontend on port 3001 with Fast Refresh, "postgres" uses the official PostgreSQL 16 image with a named volume for data persistence, and "redis" uses Redis 7 Alpine for caching. We also have a "mailhog" service for testing email delivery locally. All services share a custom bridge network called "dev-net". Environment variables are injected via a .env file referenced in docker-compose.yml.`,
+ ],
+ });
+ cases.push({
+ id: "cross-lang-zh",
+ label: "Cross-lang: Docker Compose (Chinese, same meaning)",
+ sessionId: crossLangSession,
+ group: "cross-lang",
+ messages: [
+ `本地开发环境用 Docker Compose 编排四个核心服务:api 容器跑 Node.js 后端(端口 3000,nodemon 热更新),web 容器跑 Next.js 前端(端口 3001,Fast Refresh),postgres 容器用官方 PostgreSQL 16 镜像(命名卷持久化数据),redis 容器用 Redis 7 Alpine 做缓存。另外还有一个 mailhog 容器用来本地测试邮件发送。所有容器通过自定义桥接网络 dev-net 互通。环境变量通过 .env 文件注入。`,
+ ],
+ });
+
+ // ═══════════════════════════════════════════
+ // Full mode: additional cases for scale
+ // ═══════════════════════════════════════════
+
+ if (FULL_MODE) {
+ const fullSession = mkSession("full-extra");
+
+ cases.push({
+ id: "full-api-doc",
+ label: "Full: API documentation (Swagger/OpenAPI)",
+ sessionId: fullSession,
+ group: "search",
+ messages: [
+ `API 文档自动化方案:使用 Swagger/OpenAPI 3.0 规范,结合 swagger-jsdoc 从代码注释自动生成 API 文档。每个接口必须标注:summary、description、parameters(含类型和校验规则)、requestBody schema、responses(200/400/401/403/404/500 各场景)。CI 流水线中自动生成 openapi.json,部署到 Swagger UI(内网 /api-docs 路径)。SDK 生成:用 openapi-generator 给前端自动生成 TypeScript axios client,给移动端生成 Swift/Kotlin client。文档变更必须随代码 PR 一起提交,CI 校验 schema 兼容性(不允许破坏性变更,用 oasdiff 检测)。`,
+ ],
+ });
+ cases.push({
+ id: "full-backup",
+ label: "Full: Database backup strategy",
+ sessionId: fullSession,
+ group: "search",
+ messages: [
+ `数据库备份策略。MySQL:每日凌晨 2 点 mysqldump 全量备份(--single-transaction --routines --triggers),每小时 binlog 增量备份,所有备份加密后上传到 S3 Standard-IA,保留 30 天。PostgreSQL:每日 pg_basebackup 全量 + 持续 WAL 归档(archive_command 到 S3),支持 PITR。恢复演练:每月第一个周六做一次恢复演练,从 S3 拉取备份恢复到演练环境,验证数据完整性(行数对比 + 业务关键数据校验)。恢复 RTO 目标 < 1 小时,RPO 目标 < 1 小时。监控:备份任务状态接入 Prometheus,失败立即 PagerDuty 告警。`,
+ ],
+ });
+ cases.push({
+ id: "full-perf",
+ label: "Full: React performance optimization",
+ sessionId: fullSession,
+ group: "search",
+ messages: [
+ `React 前端性能优化记录。代码层面:用 React.lazy + Suspense 做路由级代码分割,首屏 JS 从 1.2MB 降到 380KB;React.memo + useMemo 避免不必要的重渲染,列表组件用 react-window 虚拟化(1 万条数据渲染从 3.2 秒降到 60ms);图片全部用 next/image 自动 WebP 转换 + 懒加载。构建层面:Vite 5 tree-shaking + dynamic import,第三方库用 CDN 外置(React/ReactDOM/Lodash)。Lighthouse 指标:Performance 从 45 提升到 92,FCP 1.1s,LCP 1.8s,CLS 0.02。监控:接入 web-vitals 库实时上报 Core Web Vitals 到 ClickHouse,Grafana 展示 P75/P90/P99 趋势。`,
+ ],
+ });
+
+ const fullSession2 = mkSession("full-devops");
+ cases.push({
+ id: "full-sonarqube",
+ label: "Full: SonarQube quality gate",
+ sessionId: fullSession2,
+ group: "search",
+ messages: [
+ `代码质量门禁用 SonarQube 9.x。Quality Gate 规则:新代码覆盖率 > 80%,整体覆盖率 > 65%,代码重复率 < 3%,无新增 Blocker/Critical 级别的 Bug 和漏洞,Maintainability Rating 必须 A 级。CI 集成:Jenkins pipeline 中在 test stage 之后执行 sonar-scanner,扫描结果推送到 SonarQube Server,Quality Gate 不通过则 pipeline 失败。自定义规则:在默认 Sonar way profile 基础上,新增了 SQL 注入检测、硬编码密钥检测、日志敏感信息检测等自定义规则。每周一生成代码质量周报,邮件发送给团队 Tech Lead。`,
+ ],
+ });
+ cases.push({
+ id: "full-ansible",
+ label: "Full: Ansible server management",
+ sessionId: fullSession2,
+ group: "search",
+ messages: [
+ `服务器配置管理用 Ansible 2.15。Inventory 文件按环境分组:[dev]、[staging]、[production],每个环境有独立的 group_vars。核心 Playbook:server-init.yml(系统初始化:时区/NTP/防火墙/用户/SSH 加固),deploy-app.yml(应用部署:拉取镜像/更新 compose 文件/滚动重启),monitor-setup.yml(安装 node_exporter + fluent-bit)。Ansible Vault 加密所有密钥和密码。执行策略:变更先在 staging 跑一遍(--check 模式预演),确认无误后在 production 执行(每次最多 2 台,serial: 2)。所有 playbook 执行日志记录到 ELK。`,
+ ],
+ });
+
+ const fullSession3 = mkSession("full-unrelated");
+ cases.push({
+ id: "full-company-event",
+ label: "Full: unrelated (company annual party)",
+ sessionId: fullSession3,
+ group: "dedup",
+ messages: [
+ `公司年会安排确定了。时间:12 月 20 日(周六)下午 2 点到晚上 9 点。地点:杭州西湖国宾馆 3 号楼宴会厅,可容纳 300 人。议程:2:00-3:00 CEO 年度总结和明年规划,3:00-4:30 各部门优秀项目展示(每组 10 分钟),4:30-5:00 茶歇,5:00-6:30 年度颁奖(最佳团队、最佳个人、最佳新人、创新奖),6:30-9:00 晚宴 + 文艺表演 + 抽奖。每个部门需要准备至少一个节目,节目清单 12 月 10 日前提交给 HR 小王。预算:人均 500 元。`,
+ ],
+ });
+ cases.push({
+ id: "full-training",
+ label: "Full: unrelated (new employee training)",
+ sessionId: fullSession3,
+ group: "dedup",
+ messages: [
+ `新员工入职培训计划(为期两周)。第一周:Day 1 公司文化和价值观介绍、HR 制度讲解、IT 账号开通;Day 2-3 技术栈总览(架构图、代码仓库结构、本地开发环境搭建);Day 4 编码规范培训(TypeScript 规范、ESLint 规则、命名约定、文件组织);Day 5 Git 工作流培训(Git Flow、分支命名、Commit Message 规范、MR 流程)。第二周:Day 6-7 跟随导师做一个入门任务(小 feature 开发);Day 8-9 Code Review 流程实践(参加 Review Session、自己提交 MR 被 review);Day 10 入职考核(代码 quiz + 流程问答 + 导师评价)。`,
+ ],
+ });
+ }
+
+ return cases;
+}
+
+// ─── Search cases ───
+
+interface SearchCase {
+ query: string;
+ expectKeyword: string;
+ category: "keyword" | "semantic" | "negative" | "recall";
+ topK: number;
+ minScore?: number;
+ shouldFind: boolean;
+}
+
+function buildSearchCases(): SearchCase[] {
+ const cases: SearchCase[] = [
+ { query: "MySQL InnoDB MVCC 行锁 innodb_buffer_pool_size", expectKeyword: "InnoDB", category: "keyword", topK: 5, shouldFind: true },
+ { query: "Kubernetes ECS 阿里云 容器集群 Calico", expectKeyword: "Kubernetes", category: "keyword", topK: 5, shouldFind: true },
+ { query: "Prometheus Grafana AlertManager 监控告警", expectKeyword: "Prometheus", category: "keyword", topK: 5, shouldFind: true },
+ { query: "ELK Elasticsearch Logstash Kibana 日志", expectKeyword: "Elasticsearch", category: "keyword", topK: 5, shouldFind: true },
+
+ { query: "数据库事务隔离级别和并发控制机制", expectKeyword: "MVCC", category: "semantic", topK: 5, shouldFind: true },
+ { query: "容器编排平台和自动扩容策略", expectKeyword: "Kubernetes", category: "semantic", topK: 5, shouldFind: true },
+ { query: "代码质量审查团队协作流程", expectKeyword: "Review", category: "semantic", topK: 5, shouldFind: true },
+ { query: "应用日志集中采集存储和检索", expectKeyword: "ELK", category: "semantic", topK: 5, shouldFind: true },
+
+ { query: "深度学习 PyTorch GPU 训练模型 CUDA 显存", expectKeyword: "MySQL", category: "negative", topK: 5, minScore: 0.65, shouldFind: false },
+ { query: "量化交易策略回测 Alpha 因子挖掘", expectKeyword: "Kubernetes", category: "negative", topK: 5, minScore: 0.65, shouldFind: false },
+
+ { query: "CI/CD 流水线 自动化部署 发布流程", expectKeyword: "Jenkins", category: "recall", topK: 10, shouldFind: true },
+ { query: "基础设施即代码 IaC 云资源管理", expectKeyword: "Terraform", category: "recall", topK: 10, shouldFind: true },
+ { query: "Docker Compose 本地开发环境 容器编排", expectKeyword: "Docker", category: "recall", topK: 5, shouldFind: true },
+ ];
+
+ if (FULL_MODE) {
+ cases.push(
+ { query: "API 接口文档自动生成 Swagger OpenAPI", expectKeyword: "Swagger", category: "keyword", topK: 5, shouldFind: true },
+ { query: "数据库定时备份恢复策略 mysqldump", expectKeyword: "备份", category: "keyword", topK: 5, shouldFind: true },
+ { query: "React 性能优化 Lighthouse 代码分割", expectKeyword: "React", category: "keyword", topK: 5, shouldFind: true },
+ { query: "代码质量门禁覆盖率重复率检测", expectKeyword: "SonarQube", category: "recall", topK: 10, shouldFind: true },
+ { query: "服务器批量配置管理自动化运维 Playbook", expectKeyword: "Ansible", category: "recall", topK: 10, shouldFind: true },
+ );
+ }
+
+ return cases;
+}
+
+// ─── Register sessions into OpenClaw sessions.json so they appear in UI dropdown ───
+
+function registerSessionsInStore(cases: ConversationCase[]) {
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
+ const storePath = path.join(home, ".openclaw", "agents", "main", "sessions", "sessions.json");
+ if (!fs.existsSync(storePath)) {
+ log("[WARN] sessions.json not found, skipping UI registration");
+ return;
+ }
+
+ const store = JSON.parse(fs.readFileSync(storePath, "utf-8"));
+ const sessionsDir = path.dirname(storePath);
+ const seen = new Set();
+ let added = 0;
+
+ for (const c of cases) {
+ if (seen.has(c.sessionId)) continue;
+ seen.add(c.sessionId);
+
+ const storeKey = `agent:main:${c.sessionId}`;
+ if (store[storeKey]) continue;
+
+ const sessionFile = path.join(sessionsDir, `${c.sessionId}.jsonl`);
+ if (!fs.existsSync(sessionFile)) continue;
+
+ // acc-1773286763918-dedup-exact-1 -> dedup-exact
+ const shortName = c.sessionId
+ .replace(/^acc-\d+-/, "")
+ .replace(/-\d+$/, "");
+
+ store[storeKey] = {
+ sessionId: c.sessionId,
+ updatedAt: Date.now(),
+ systemSent: true,
+ abortedLastRun: false,
+ chatType: "direct",
+ label: `[test] ${shortName}`,
+ displayName: `Test: ${shortName}`,
+ origin: {
+ provider: "cli",
+ surface: "cli",
+ chatType: "direct",
+ label: `accuracy-test:${shortName}`,
+ },
+ sessionFile,
+ };
+ added++;
+ }
+
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
+ log(`Registered ${added} test sessions in sessions.json (UI dropdown)`);
+}
+
+// ─── Ingest via Gateway ───
+
+async function ingestPhase(cases: ConversationCase[]) {
+ log(`Sending ${cases.length} test conversations through OpenClaw Gateway...`);
+ log(`(Each message goes through full gateway → plugin pipeline, visible in Viewer)\n`);
+
+ const buckets: ConversationCase[][] = Array.from({ length: WORKERS }, () => []);
+ cases.forEach((c, i) => buckets[i % WORKERS].push(c));
+
+ let successCount = 0;
+ let failCount = 0;
+
+ const workerFn = async (workerId: number, bucket: ConversationCase[]) => {
+ for (const c of bucket) {
+ for (const msg of c.messages) {
+ const ok = sendViaGateway(c.sessionId, msg);
+ if (ok) {
+ successCount++;
+ log(` [worker-${workerId}] OK: ${c.label}`);
+ } else {
+ failCount++;
+ log(` [worker-${workerId}] FAIL: ${c.label}`);
+ }
+ await new Promise((r) => setTimeout(r, INGEST_DELAY_MS));
+ }
+ }
+ };
+
+ const t0 = performance.now();
+ await Promise.all(
+ buckets.map((b, i) => (b.length > 0 ? workerFn(i + 1, b) : Promise.resolve())),
+ );
+ const dur = Math.round(performance.now() - t0);
+
+ log(`\nIngest complete: ${successCount} sent, ${failCount} failed (${(dur / 1000).toFixed(1)}s)\n`);
+
+ log("Waiting 10s for ingest pipeline to process all messages...");
+ await new Promise((r) => setTimeout(r, 10_000));
+
+ registerSessionsInStore(cases);
+
+ return { successCount, failCount };
+}
+
+// ─── Verify phase ───
+
+async function runSearchTests(plugin: MemosLocalPlugin, cases: SearchCase[]) {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+
+ for (const c of cases) {
+ const t0 = performance.now();
+ const result = (await searchTool.handler({
+ query: c.query,
+ maxResults: c.topK,
+ minScore: c.minScore,
+ })) as any;
+ const dur = Math.round(performance.now() - t0);
+ const hits = result.hits ?? [];
+ const found = hitContains(hits, c.expectKeyword);
+
+ if (c.category === "negative") {
+ const pass = !found;
+ results.push({
+ category: "Precision",
+ name: `negative: "${c.query.slice(0, 25)}..."`,
+ pass,
+ detail: `should NOT contain "${c.expectKeyword}": ${pass ? "OK" : "FAIL"} (${hits.length} hits)`,
+ durationMs: dur,
+ });
+ } else if (c.category === "keyword") {
+ results.push({
+ category: "Precision",
+ name: `keyword: ${c.expectKeyword}`,
+ pass: found,
+ detail: `top${c.topK} contains "${c.expectKeyword}": ${found}`,
+ durationMs: dur,
+ });
+ } else if (c.category === "semantic") {
+ results.push({
+ category: "Precision",
+ name: `semantic: ${c.expectKeyword}`,
+ pass: found,
+ detail: `top${c.topK} contains "${c.expectKeyword}": ${found}`,
+ durationMs: dur,
+ });
+ } else if (c.category === "recall") {
+ results.push({
+ category: "Recall",
+ name: `recall: ${c.expectKeyword}`,
+ pass: found,
+ detail: found ? "found" : "missed",
+ durationMs: dur,
+ });
+ }
+ }
+}
+
+async function runDedupChecks(plugin: MemosLocalPlugin) {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+
+ const t0 = performance.now();
+ const r1 = (await searchTool.handler({ query: "Redis ElastiCache 集群 maxmemory allkeys-lru 连接池", maxResults: 10 })) as any;
+ const redisHits = (r1.hits ?? []).filter((h: any) => hitContains([h], "Redis") || hitContains([h], "ElastiCache"));
+ const exactPass = redisHits.length >= 1 && redisHits.length <= 2;
+ results.push({ category: "Dedup", name: "exact dup (Redis x3 → 1-2)", pass: exactPass, detail: `${redisHits.length} active hits (expect 1-2)`, durationMs: Math.round(performance.now() - t0) });
+
+ const t1 = performance.now();
+ const r2 = (await searchTool.handler({ query: "PostgreSQL RDS PgBouncer 读写分离 WAL", maxResults: 10 })) as any;
+ const pgHits = (r2.hits ?? []).filter((h: any) => hitContains([h], "PostgreSQL") || hitContains([h], "PG ") || hitContains([h], "PgBouncer"));
+ const semPass = pgHits.length >= 1 && pgHits.length <= 2;
+ results.push({ category: "Dedup", name: "semantic dup (PG x2 → 1-2)", pass: semPass, detail: `${pgHits.length} active hits (expect 1-2)`, durationMs: Math.round(performance.now() - t1) });
+
+ const t2 = performance.now();
+ const r3 = (await searchTool.handler({ query: "前端技术栈 Next.js Shadcn Tailwind Vercel", maxResults: 10 })) as any;
+ const hasLatest = hitContains(r3.hits ?? [], "Next.js") || hitContains(r3.hits ?? [], "Shadcn");
+ results.push({ category: "Dedup", name: "merge (React/Vite → Next.js/Vercel)", pass: hasLatest, detail: `latest state present: ${hasLatest}`, durationMs: Math.round(performance.now() - t2) });
+}
+
+async function runSummaryChecks(plugin: MemosLocalPlugin) {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+
+ const queries = [
+ { query: "微服务架构 user-service payment-service Istio gRPC", label: "microservices arch" },
+ { query: "数据库迁移 MySQL PostgreSQL Debezium CDC 双写", label: "DB migration plan" },
+ ];
+
+ for (const q of queries) {
+ const t0 = performance.now();
+ const r = (await searchTool.handler({ query: q.query, maxResults: 3 })) as any;
+ const dur = Math.round(performance.now() - t0);
+ if (r.hits?.length > 0) {
+ const h = r.hits[0];
+ const sl = h.summary?.length ?? 0;
+ const cl = h.original_excerpt?.length ?? 999;
+ const pass = sl > 0 && sl < cl;
+ results.push({ category: "Summary", name: q.label, pass, detail: `summary=${sl}chars, content=${cl}chars, shorter=${sl < cl}`, durationMs: dur });
+ } else {
+ results.push({ category: "Summary", name: q.label, pass: false, detail: "no hits found", durationMs: dur });
+ }
+ }
+}
+
+async function runTopicChecks(plugin: MemosLocalPlugin) {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+
+ const t0 = performance.now();
+ const nginxR = (await searchTool.handler({ query: "Nginx 反向代理 SSL gzip HTTP/2 HSTS", maxResults: 10 })) as any;
+ const nginxHits = (nginxR.hits ?? []).filter((h: any) => hitContains([h], "Nginx") || hitContains([h], "gzip") || hitContains([h], "SSL"));
+ results.push({
+ category: "Topic",
+ name: "same topic merge (Nginx parts → 1 chunk)",
+ pass: nginxHits.length >= 1 && nginxHits.length <= 2,
+ detail: `${nginxHits.length} chunks (expect 1-2 merged)`,
+ durationMs: Math.round(performance.now() - t0),
+ });
+
+ const t1 = performance.now();
+ const dockerR = (await searchTool.handler({ query: "Dockerfile 多阶段构建 pnpm node:20-alpine", maxResults: 5 })) as any;
+ const cookR = (await searchTool.handler({ query: "红烧肉 五花肉 冰糖 八角 桂皮", maxResults: 5 })) as any;
+ const dockerFound = hitContains(dockerR.hits ?? [], "Dockerfile") || hitContains(dockerR.hits ?? [], "node");
+ const cookFound = hitContains(cookR.hits ?? [], "五花肉") || hitContains(cookR.hits ?? [], "红烧肉");
+ const switchPass = dockerFound && cookFound;
+ results.push({
+ category: "Topic",
+ name: "topic switch (Docker → cooking)",
+ pass: switchPass,
+ detail: `Docker found=${dockerFound}, cooking found=${cookFound}`,
+ durationMs: Math.round(performance.now() - t1),
+ });
+}
+
+// ─── Report ───
+
+function printReport(totalMs: number, ingestStats?: { successCount: number; failCount: number }) {
+ console.log("\n");
+ console.log("=".repeat(70));
+ console.log(` MemOS Accuracy Test Report`);
+ console.log(` Mode: ${FULL_MODE ? "FULL" : "QUICK"} | Workers: ${WORKERS} | Duration: ${(totalMs / 1000).toFixed(1)}s`);
+ if (ingestStats) {
+ console.log(` Ingest: ${ingestStats.successCount} sent via Gateway, ${ingestStats.failCount} failed`);
+ }
+ console.log("=".repeat(70));
+
+ const categories = [...new Set(results.map((r) => r.category))];
+ let totalPass = 0;
+ let totalCount = 0;
+
+ for (const cat of categories) {
+ const cr = results.filter((r) => r.category === cat);
+ const passed = cr.filter((r) => r.pass).length;
+ totalPass += passed;
+ totalCount += cr.length;
+ const pct = ((passed / cr.length) * 100).toFixed(1);
+ console.log(`\n ${cat.padEnd(20)} ${passed}/${cr.length} (${pct}%)`);
+ for (const r of cr) {
+ const icon = r.pass ? "PASS" : "FAIL";
+ console.log(` [${icon}] ${r.name}: ${r.detail} (${r.durationMs}ms)`);
+ }
+ }
+
+ console.log("\n" + "-".repeat(70));
+ const overallPct = totalCount > 0 ? ((totalPass / totalCount) * 100).toFixed(1) : "0";
+ console.log(` OVERALL: ${totalPass}/${totalCount} (${overallPct}%)`);
+ console.log("=".repeat(70));
+
+ return totalPass === totalCount ? 0 : 1;
+}
+
+// ─── Main ───
+
+async function main() {
+ const t0 = performance.now();
+ log("MemOS Accuracy Test starting...");
+ log(`Mode: ${FULL_MODE ? "FULL (50+ cases)" : "QUICK (15 cases — pass --full for all)"}`);
+
+ log("Loading OpenClaw config...");
+ const config = loadConfig();
+ const stateDir = path.join(process.env.HOME ?? "/tmp", ".openclaw");
+
+ let ingestStats: { successCount: number; failCount: number } | undefined;
+
+ if (!SKIP_INGEST) {
+ const testCases = buildTestCases();
+ const totalMsgs = testCases.reduce((a, c) => a + c.messages.length, 0);
+ log(`Prepared ${testCases.length} conversations (${totalMsgs} messages total)`);
+ ingestStats = await ingestPhase(testCases);
+ } else {
+ log("Skipping ingest (--skip-ingest), running search checks only...");
+ }
+
+ log("Initializing plugin for search verification (direct DB access)...");
+ const plugin = initPlugin({ stateDir, config });
+
+ log("Running dedup checks...");
+ await runDedupChecks(plugin);
+
+ log("Running topic boundary checks...");
+ await runTopicChecks(plugin);
+
+ log("Running search precision & recall tests...");
+ const searchCases = buildSearchCases();
+ await runSearchTests(plugin, searchCases);
+
+ log("Running summary quality checks...");
+ await runSummaryChecks(plugin);
+
+ const totalMs = Math.round(performance.now() - t0);
+ const exitCode = printReport(totalMs, ingestStats);
+
+ await plugin.shutdown();
+ process.exit(exitCode);
+}
+
+main().catch((err) => {
+ console.error("Fatal error:", err);
+ process.exit(2);
+});
diff --git a/apps/memos-local-openclaw/scripts/test-agent-isolation.ts b/apps/memos-local-openclaw/scripts/test-agent-isolation.ts
new file mode 100644
index 000000000..f059cb2d9
--- /dev/null
+++ b/apps/memos-local-openclaw/scripts/test-agent-isolation.ts
@@ -0,0 +1,245 @@
+#!/usr/bin/env npx tsx
+/**
+ * Multi-agent data isolation test.
+ *
+ * Writes data with different owner tags via initPlugin, then creates
+ * a separate RecallEngine to verify search isolation with ownerFilter.
+ *
+ * Usage:
+ * npx tsx scripts/test-agent-isolation.ts
+ */
+
+import * as fs from "fs";
+import * as path from "path";
+import { initPlugin } from "../src/index";
+import { SqliteStore } from "../src/storage/sqlite";
+import { Embedder } from "../src/embedding";
+import { RecallEngine } from "../src/recall/engine";
+import { buildContext } from "../src/config";
+
+const RUN_ID = Date.now();
+const AGENT_A = "iso-test-alpha";
+const AGENT_B = "iso-test-beta";
+
+const UNIQUE_A = `AlphaUniqueKey${RUN_ID}`;
+const UNIQUE_B = `BetaUniqueKey${RUN_ID}`;
+
+const MSG_A1 = `我正在用 ${UNIQUE_A} 部署一个私有 Redis 缓存集群,配置主从复制和哨兵模式,端口 6379。`;
+const MSG_A2 = `${UNIQUE_A} 的 Redis 集群已经部署完成,延迟从 50ms 降到了 3ms,命中率 95%。`;
+
+const MSG_B1 = `帮我设置 ${UNIQUE_B} 的 PostgreSQL 数据库迁移方案,从 v14 升级到 v16,数据量约 500GB。`;
+const MSG_B2 = `${UNIQUE_B} 的 PostgreSQL 迁移完成了,用了 pg_upgrade --link 模式,停机只有 2 分钟。`;
+
+let passed = 0;
+let failed = 0;
+
+function log(msg: string) {
+ const t = new Date().toLocaleTimeString("zh-CN", { hour12: false });
+ console.log(`[${t}] ${msg}`);
+}
+
+function assert(name: string, condition: boolean, detail: string) {
+ if (condition) {
+ passed++;
+ log(` ✅ ${name}`);
+ } else {
+ failed++;
+ log(` ❌ ${name}: ${detail}`);
+ }
+}
+
+const silentLog = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
+
+async function main() {
+ log("═══════════════════════════════════════════════════════");
+ log(" Multi-Agent Data Isolation Test");
+ log("═══════════════════════════════════════════════════════");
+ log(` Agent A: ${AGENT_A} (keyword: ${UNIQUE_A})`);
+ log(` Agent B: ${AGENT_B} (keyword: ${UNIQUE_B})`);
+ log("");
+
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
+ const stateDir = path.join(home, ".openclaw");
+ const cfgPath = path.join(stateDir, "openclaw.json");
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
+ const pluginCfg = raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ?? {};
+
+ // ── Step 1: Ingest data with different owners ──
+ log("── Step 1: Ingesting data with different agent owners ──");
+
+ const plugin = initPlugin({ stateDir, config: pluginCfg, log: silentLog });
+
+ const sessionA = `iso-session-a-${RUN_ID}`;
+ const sessionB = `iso-session-b-${RUN_ID}`;
+
+ plugin.onConversationTurn(
+ [{ role: "user", content: MSG_A1 }, { role: "assistant", content: MSG_A2 }],
+ sessionA,
+ `agent:${AGENT_A}`,
+ );
+ log(` Enqueued 2 messages for agent:${AGENT_A}`);
+
+ plugin.onConversationTurn(
+ [{ role: "user", content: MSG_B1 }, { role: "assistant", content: MSG_B2 }],
+ sessionB,
+ `agent:${AGENT_B}`,
+ );
+ log(` Enqueued 2 messages for agent:${AGENT_B}`);
+
+ log(" Flushing ingest pipeline...");
+ await plugin.flush();
+ log(" Waiting 3s for embedding completion...");
+ await new Promise((r) => setTimeout(r, 3000));
+ await plugin.flush();
+ log(" Done.");
+
+ await plugin.shutdown();
+
+ // ── Step 2: Open a read-only store + engine for verification ──
+ log("\n── Step 2: Verify owner tags in raw DB ──");
+
+ const ctx = buildContext(stateDir, process.cwd(), pluginCfg, silentLog);
+ const store = new SqliteStore(ctx.config.storage!.dbPath!, silentLog);
+ const embedder = new Embedder(ctx.config.embedding, silentLog);
+ const engine = new RecallEngine(store, embedder, ctx);
+
+ const db = (store as any).db;
+
+ const chunksA = db.prepare(
+ `SELECT id, owner, session_key, role, substr(content, 1, 80) as preview
+ FROM chunks WHERE content LIKE ? AND dedup_status = 'active'`
+ ).all(`%${UNIQUE_A}%`) as any[];
+
+ const chunksB = db.prepare(
+ `SELECT id, owner, session_key, role, substr(content, 1, 80) as preview
+ FROM chunks WHERE content LIKE ? AND dedup_status = 'active'`
+ ).all(`%${UNIQUE_B}%`) as any[];
+
+ log(` Chunks with keyword-A: ${chunksA.length}`);
+ for (const c of chunksA) {
+ log(` owner=${c.owner} role=${c.role} preview=${c.preview.slice(0, 50)}...`);
+ }
+
+ log(` Chunks with keyword-B: ${chunksB.length}`);
+ for (const c of chunksB) {
+ log(` owner=${c.owner} role=${c.role} preview=${c.preview.slice(0, 50)}...`);
+ }
+
+ assert("Keyword-A chunks exist", chunksA.length > 0, "No chunks — ingest failed");
+ assert("Keyword-B chunks exist", chunksB.length > 0, "No chunks — ingest failed");
+
+ if (chunksA.length > 0) {
+ const ownersA = new Set(chunksA.map((c: any) => c.owner));
+ assert(
+ "Keyword-A owner = agent:" + AGENT_A,
+ ownersA.size === 1 && ownersA.has(`agent:${AGENT_A}`),
+ `Got: ${[...ownersA].join(", ")}`,
+ );
+ }
+
+ if (chunksB.length > 0) {
+ const ownersB = new Set(chunksB.map((c: any) => c.owner));
+ assert(
+ "Keyword-B owner = agent:" + AGENT_B,
+ ownersB.size === 1 && ownersB.has(`agent:${AGENT_B}`),
+ `Got: ${[...ownersB].join(", ")}`,
+ );
+ }
+
+ // ── Step 3: Search isolation via RecallEngine ──
+ log("\n── Step 3: Search isolation (RecallEngine) ──");
+
+ const search = async (query: string, owner: string) =>
+ engine.search({ query, maxResults: 10, ownerFilter: [`agent:${owner}`, "public"] });
+
+ const allowedOwners = (owner: string) => new Set([`agent:${owner}`, "public"]);
+
+ const checkHitOwners = (hits: any[], allowed: Set): string[] => {
+ const violations: string[] = [];
+ for (const h of hits) {
+ const chunk = store.getChunk(h.ref.chunkId);
+ if (chunk && !allowed.has(chunk.owner)) {
+ violations.push(`chunkId=${h.ref.chunkId} owner=${chunk.owner}`);
+ }
+ }
+ return violations;
+ };
+
+ // 3a. Agent-A searches own keyword — should find own data
+ const resAA = await search(UNIQUE_A, AGENT_A);
+ assert("Agent-A finds own keyword-A", resAA.hits.length > 0, `Got ${resAA.hits.length} hits`);
+
+ // 3b. Agent-A searches keyword-B — results must only contain Agent-A or public data
+ const resAB = await search(UNIQUE_B, AGENT_A);
+ const violationsAB = checkHitOwners(resAB.hits, allowedOwners(AGENT_A));
+ assert(
+ "Agent-A results for keyword-B contain NO agent-B data ← ISOLATION",
+ violationsAB.length === 0,
+ `Found ${violationsAB.length} leaks: ${violationsAB.join("; ")}`,
+ );
+ log(` (Agent-A got ${resAB.hits.length} hits for keyword-B, all from own/public — OK)`);
+
+ // 3c. Agent-B searches own keyword — should find own data
+ const resBB = await search(UNIQUE_B, AGENT_B);
+ assert("Agent-B finds own keyword-B", resBB.hits.length > 0, `Got ${resBB.hits.length} hits`);
+
+ // 3d. Agent-B searches keyword-A — results must only contain Agent-B or public data
+ const resBA = await search(UNIQUE_A, AGENT_B);
+ const violationsBA = checkHitOwners(resBA.hits, allowedOwners(AGENT_B));
+ assert(
+ "Agent-B results for keyword-A contain NO agent-A data ← ISOLATION",
+ violationsBA.length === 0,
+ `Found ${violationsBA.length} leaks: ${violationsBA.join("; ")}`,
+ );
+ log(` (Agent-B got ${resBA.hits.length} hits for keyword-A, all from own/public — OK)`);
+
+ // 3e. agent:main results should not contain iso-test agents' data
+ const resMainA = await search(UNIQUE_A, "main");
+ const violationsMainA = checkHitOwners(resMainA.hits, allowedOwners("main"));
+ assert(
+ "agent:main results contain no iso-test-alpha data",
+ violationsMainA.length === 0,
+ `Found ${violationsMainA.length} leaks: ${violationsMainA.join("; ")}`,
+ );
+
+ const resMainB = await search(UNIQUE_B, "main");
+ const violationsMainB = checkHitOwners(resMainB.hits, allowedOwners("main"));
+ assert(
+ "agent:main results contain no iso-test-beta data",
+ violationsMainB.length === 0,
+ `Found ${violationsMainB.length} leaks: ${violationsMainB.join("; ")}`,
+ );
+
+ // ── Step 4: FTS isolation ──
+ log("\n── Step 4: FTS isolation ──");
+
+ const ftsAA = store.ftsSearch(UNIQUE_A, 10, [`agent:${AGENT_A}`, "public"]);
+ assert("FTS: Agent-A finds keyword-A", ftsAA.length > 0, `Got ${ftsAA.length}`);
+
+ const ftsAB = store.ftsSearch(UNIQUE_B, 10, [`agent:${AGENT_A}`, "public"]);
+ assert("FTS: Agent-A cannot find keyword-B", ftsAB.length === 0, `Got ${ftsAB.length} — BROKEN!`);
+
+ const ftsBB = store.ftsSearch(UNIQUE_B, 10, [`agent:${AGENT_B}`, "public"]);
+ assert("FTS: Agent-B finds keyword-B", ftsBB.length > 0, `Got ${ftsBB.length}`);
+
+ const ftsBA = store.ftsSearch(UNIQUE_A, 10, [`agent:${AGENT_B}`, "public"]);
+ assert("FTS: Agent-B cannot find keyword-A", ftsBA.length === 0, `Got ${ftsBA.length} — BROKEN!`);
+
+ // ── Summary ──
+ log("\n═══════════════════════════════════════════════════════");
+ log(` Results: ${passed} passed, ${failed} failed`);
+ if (failed === 0) {
+ log(" 🎉 All isolation tests passed!");
+ } else {
+ log(" ⚠ Some isolation tests FAILED");
+ }
+ log("═══════════════════════════════════════════════════════");
+
+ store.close();
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+main().catch((err) => {
+ console.error("Fatal error:", err);
+ process.exit(1);
+});
diff --git a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts
index 2c6c709c0..c55d7c53b 100644
--- a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts
+++ b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts
@@ -84,7 +84,7 @@ export async function summarizeTaskAnthropic(
return json.content.find((c) => c.type === "text")?.text?.trim() ?? "";
}
-const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context (may include opening topic + recent exchanges) and a single NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
+const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
Answer ONLY "NEW" or "SAME".
@@ -92,22 +92,21 @@ SAME — the new message:
- Continues, follows up on, refines, or corrects the same subject/project/task
- Asks a clarification or next-step question about what was just discussed
- Reports a result, error, or feedback about the current task
-- Discusses different tools, methods, or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT → via AI tools = all SAME "learning English" task)
-- Mentions a related technology or platform in the context of the current goal
-- Is a short acknowledgment (ok, thanks, 好的, 嗯) in direct response to the current flow
+- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
+- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
NEW — the new message:
-- Introduces a clearly UNRELATED subject with NO logical connection to the current task
-- The topic has ZERO overlap with any aspect of the current conversation (e.g., from "learning English" to "what's the weather tomorrow")
-- Starts a request about a completely different domain or life area
+- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
+- Has NO logical connection to what was being discussed
+- Starts a request about a different project, system, or life area
- Begins with a new greeting/reset followed by a different topic
Key principles:
-- STRONGLY lean toward SAME — only mark NEW for obvious, unambiguous topic shifts
-- Different aspects, tools, or methods related to the same overall goal are SAME
-- If the new message could reasonably be interpreted as part of the ongoing discussion, choose SAME
-- Only choose NEW when there is absolutely no thematic connection to the current task
-- Examples: "学英语" → "用AI工具学英语" = SAME; "学英语" → "明天天气" = NEW
+- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
+- Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
+- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW)
+- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
+- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW
Output exactly one word: NEW or SAME`;
diff --git a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts
index 1c1e10c65..391e4144d 100644
--- a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts
+++ b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts
@@ -85,7 +85,7 @@ export async function summarizeTaskBedrock(
return json.output?.message?.content?.[0]?.text?.trim() ?? "";
}
-const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context (may include opening topic + recent exchanges) and a single NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
+const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
Answer ONLY "NEW" or "SAME".
@@ -93,22 +93,21 @@ SAME — the new message:
- Continues, follows up on, refines, or corrects the same subject/project/task
- Asks a clarification or next-step question about what was just discussed
- Reports a result, error, or feedback about the current task
-- Discusses different tools, methods, or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT → via AI tools = all SAME "learning English" task)
-- Mentions a related technology or platform in the context of the current goal
-- Is a short acknowledgment (ok, thanks, 好的, 嗯) in direct response to the current flow
+- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
+- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
NEW — the new message:
-- Introduces a clearly UNRELATED subject with NO logical connection to the current task
-- The topic has ZERO overlap with any aspect of the current conversation (e.g., from "learning English" to "what's the weather tomorrow")
-- Starts a request about a completely different domain or life area
+- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
+- Has NO logical connection to what was being discussed
+- Starts a request about a different project, system, or life area
- Begins with a new greeting/reset followed by a different topic
Key principles:
-- STRONGLY lean toward SAME — only mark NEW for obvious, unambiguous topic shifts
-- Different aspects, tools, or methods related to the same overall goal are SAME
-- If the new message could reasonably be interpreted as part of the ongoing discussion, choose SAME
-- Only choose NEW when there is absolutely no thematic connection to the current task
-- Examples: "学英语" → "用AI工具学英语" = SAME; "学英语" → "明天天气" = NEW
+- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
+- Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
+- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW)
+- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
+- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW
Output exactly one word: NEW or SAME`;
diff --git a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts
index 0046c9b94..49eb75b12 100644
--- a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts
+++ b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts
@@ -84,7 +84,7 @@ export async function summarizeTaskGemini(
return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? "";
}
-const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context (may include opening topic + recent exchanges) and a single NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
+const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
Answer ONLY "NEW" or "SAME".
@@ -92,22 +92,21 @@ SAME — the new message:
- Continues, follows up on, refines, or corrects the same subject/project/task
- Asks a clarification or next-step question about what was just discussed
- Reports a result, error, or feedback about the current task
-- Discusses different tools, methods, or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT → via AI tools = all SAME "learning English" task)
-- Mentions a related technology or platform in the context of the current goal
-- Is a short acknowledgment (ok, thanks, 好的, 嗯) in direct response to the current flow
+- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
+- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
NEW — the new message:
-- Introduces a clearly UNRELATED subject with NO logical connection to the current task
-- The topic has ZERO overlap with any aspect of the current conversation (e.g., from "learning English" to "what's the weather tomorrow")
-- Starts a request about a completely different domain or life area
+- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
+- Has NO logical connection to what was being discussed
+- Starts a request about a different project, system, or life area
- Begins with a new greeting/reset followed by a different topic
Key principles:
-- STRONGLY lean toward SAME — only mark NEW for obvious, unambiguous topic shifts
-- Different aspects, tools, or methods related to the same overall goal are SAME
-- If the new message could reasonably be interpreted as part of the ongoing discussion, choose SAME
-- Only choose NEW when there is absolutely no thematic connection to the current task
-- Examples: "学英语" → "用AI工具学英语" = SAME; "学英语" → "明天天气" = NEW
+- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
+- Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
+- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW)
+- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
+- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW
Output exactly one word: NEW or SAME`;
diff --git a/apps/memos-local-openclaw/src/ingest/providers/openai.ts b/apps/memos-local-openclaw/src/ingest/providers/openai.ts
index 92a38fbae..4c5062303 100644
--- a/apps/memos-local-openclaw/src/ingest/providers/openai.ts
+++ b/apps/memos-local-openclaw/src/ingest/providers/openai.ts
@@ -123,7 +123,7 @@ export async function summarizeOpenAI(
return json.choices[0]?.message?.content?.trim() ?? "";
}
-const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context (may include opening topic + recent exchanges) and a single NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
+const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
Answer ONLY "NEW" or "SAME".
@@ -131,22 +131,21 @@ SAME — the new message:
- Continues, follows up on, refines, or corrects the same subject/project/task
- Asks a clarification or next-step question about what was just discussed
- Reports a result, error, or feedback about the current task
-- Discusses different tools, methods, or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT → via AI tools = all SAME "learning English" task)
-- Mentions a related technology or platform in the context of the current goal
-- Is a short acknowledgment (ok, thanks, 好的, 嗯) in direct response to the current flow
+- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
+- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
NEW — the new message:
-- Introduces a clearly UNRELATED subject with NO logical connection to the current task
-- The topic has ZERO overlap with any aspect of the current conversation (e.g., from "learning English" to "what's the weather tomorrow")
-- Starts a request about a completely different domain or life area
+- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
+- Has NO logical connection to what was being discussed
+- Starts a request about a different project, system, or life area
- Begins with a new greeting/reset followed by a different topic
Key principles:
-- STRONGLY lean toward SAME — only mark NEW for obvious, unambiguous topic shifts
-- Different aspects, tools, or methods related to the same overall goal are SAME
-- If the new message could reasonably be interpreted as part of the ongoing discussion, choose SAME
-- Only choose NEW when there is absolutely no thematic connection to the current task
-- Examples: "学英语" → "用AI工具学英语" = SAME; "学英语" → "明天天气" = NEW
+- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
+- Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
+- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW)
+- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
+- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW
Output exactly one word: NEW or SAME`;
diff --git a/apps/memos-local-openclaw/src/skill/bundled-memory-guide.ts b/apps/memos-local-openclaw/src/skill/bundled-memory-guide.ts
index 07d6f7c9f..7a11ed766 100644
--- a/apps/memos-local-openclaw/src/skill/bundled-memory-guide.ts
+++ b/apps/memos-local-openclaw/src/skill/bundled-memory-guide.ts
@@ -88,4 +88,13 @@ This skill describes how to use the MemOS memory tools so you can reliably searc
- Use **concrete terms**: names, topics, tools, or decisions (e.g. "preferred editor", "deploy script", "API key setup").
- If the user's message is long, **derive one or two sub-queries** rather than pasting the whole message.
- Use \`role='user'\` when you specifically want to find what the user said (e.g. preferences, past questions).
+
+## Memory ownership and agent isolation
+
+Each memory is tagged with an \`owner\` (e.g. \`agent:main\`, \`agent:sales-bot\`). This is handled **automatically** — you do not need to pass any owner parameter.
+
+- **Your memories:** All tools (\`memory_search\`, \`memory_get\`, \`memory_timeline\`) automatically scope queries to your agent's own memories.
+- **Public memories:** Memories marked as \`public\` are visible to all agents. Use \`memory_write_public\` to write shared knowledge.
+- **Cross-agent isolation:** You cannot see memories owned by other agents (unless they are public).
+- **How it works:** The system identifies your agent ID from the OpenClaw runtime context and applies owner filtering automatically on every search, recall, and retrieval.
`;
diff --git a/apps/memos-local-openclaw/src/viewer/html.ts b/apps/memos-local-openclaw/src/viewer/html.ts
index ba4d4be3b..eb44aaad7 100644
--- a/apps/memos-local-openclaw/src/viewer/html.ts
+++ b/apps/memos-local-openclaw/src/viewer/html.ts
@@ -120,7 +120,7 @@ input,textarea,select{font-family:inherit;font-size:inherit}
.main-content{display:flex;flex:1;max-width:1400px;margin:0 auto;width:100%;padding:28px 32px;gap:28px}
/* ─── Sidebar ─── */
-.sidebar{width:260px;flex-shrink:0}
+.sidebar{width:260px;min-width:260px;flex-shrink:0}
.sidebar .stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:24px}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;transition:all .2s}
.stat-card:hover{border-color:var(--border-glow);background:var(--bg-card-hover)}
@@ -245,6 +245,16 @@ input,textarea,select{font-family:inherit;font-size:inherit}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:28px}
/* ─── Toast ─── */
+.emb-banner{display:flex;align-items:center;gap:10px;padding:12px 20px;font-size:13px;font-weight:500;border-radius:10px;margin:0 32px 0;animation:slideIn .3s ease}
+.emb-banner.warning{background:rgba(245,158,11,.1);color:#d97706;border:1px solid rgba(245,158,11,.25)}
+.emb-banner.error{background:rgba(239,68,68,.1);color:#ef4444;border:1px solid rgba(239,68,68,.25)}
+[data-theme="light"] .emb-banner.warning{background:rgba(245,158,11,.08);color:#b45309}
+[data-theme="light"] .emb-banner.error{background:rgba(239,68,68,.08);color:#dc2626}
+.emb-banner span{flex:1}
+.emb-banner-btn{background:none;border:1px solid currentColor;border-radius:6px;padding:4px 12px;font-size:12px;font-weight:600;color:inherit;cursor:pointer;white-space:nowrap;opacity:.85;transition:opacity .15s}
+.emb-banner-btn:hover{opacity:1}
+.emb-banner-close{background:none;border:none;font-size:18px;color:inherit;cursor:pointer;opacity:.5;padding:0 4px;line-height:1}
+.emb-banner-close:hover{opacity:1}
.toast-container{position:fixed;top:80px;right:24px;z-index:1000;display:flex;flex-direction:column;gap:8px}
.toast{padding:14px 20px;border-radius:10px;font-size:13px;font-weight:500;box-shadow:var(--shadow-lg);animation:slideIn .3s ease;display:flex;align-items:center;gap:10px;max-width:360px;border:1px solid}
.toast.success{background:var(--green-bg);color:var(--green);border-color:rgba(16,185,129,.3)}
@@ -429,6 +439,7 @@ input,textarea,select{font-family:inherit;font-size:inherit}
[data-theme="light"] .nav-tabs .tab.active{background:#fff;border-color:rgba(0,0,0,.1);box-shadow:0 1px 3px rgba(0,0,0,.08);color:var(--text)}
.analytics-view,.settings-view,.logs-view,.migrate-view{display:none;flex:1;min-width:0;flex-direction:column;gap:20px}
.analytics-view.show,.settings-view.show,.logs-view.show,.migrate-view.show{display:flex}
+.feed-wrap,.tasks-view,.skills-view,.analytics-view,.settings-view,.logs-view,.migrate-view{max-width:960px}
/* ─── Logs ─── */
.logs-toolbar{display:flex;align-items:center;justify-content:space-between;padding:8px 0}
@@ -576,18 +587,19 @@ input,textarea,select{font-family:inherit;font-size:inherit}
@keyframes migrateFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
.feed-wrap{flex:1;min-width:0;display:flex;flex-direction:column}
.feed-wrap.hide{display:none}
+.analytics-view{flex-direction:column;gap:20px}
.analytics-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
-.analytics-card{position:relative;overflow:hidden;border-radius:var(--radius-lg);padding:22px 20px;transition:all .2s ease;border:1px solid var(--border);background:var(--bg-card)}
+.analytics-card{position:relative;overflow:hidden;border-radius:var(--radius-lg);padding:18px 16px;transition:all .2s ease;border:1px solid var(--border);background:var(--bg-card)}
.analytics-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:var(--pri);opacity:.5}
.analytics-card::after{display:none}
.analytics-card:hover{transform:translateY(-2px);box-shadow:var(--shadow);border-color:var(--border-glow)}
.analytics-card.green::before{background:var(--green)}
.analytics-card.amber::before{background:var(--amber)}
-.analytics-card .ac-value{font-size:28px;font-weight:700;letter-spacing:-.03em;color:var(--text);line-height:1;-webkit-text-fill-color:unset;background:none}
+.analytics-card .ac-value{font-size:24px;font-weight:700;letter-spacing:-.03em;color:var(--text);line-height:1;-webkit-text-fill-color:unset;background:none}
.analytics-card.green .ac-value{color:var(--green);background:none}
.analytics-card.amber .ac-value{color:var(--amber);background:none}
.analytics-card .ac-label{font-size:11px;color:var(--text-muted);margin-top:6px;font-weight:500;text-transform:uppercase;letter-spacing:.06em}
-.analytics-section{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:22px 24px;position:relative;overflow:hidden}
+.analytics-section{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:18px 20px;position:relative;overflow:hidden}
.analytics-section::before{display:none}
.analytics-section h3{font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.analytics-section h3 .icon{font-size:14px;opacity:.6}
@@ -776,10 +788,12 @@ input,textarea,select{font-family:inherit;font-size:inherit}
-
-
Sessions
-
-
\u{1F5D1} Clear All Data
+
@@ -905,7 +919,7 @@ input,textarea,select{font-family:inherit;font-size:inherit}