From fa122d86c7dddff702f810bf1902038837c2eaea Mon Sep 17 00:00:00 2001 From: ChaseLiao Date: Thu, 12 Mar 2026 00:10:04 +0800 Subject: [PATCH 01/33] fix: improve Node.js v25 compatibility for better-sqlite3 - Update better-sqlite3 to v12.6.3 for better compatibility - Add Node version upper bound (<25.0.0) in engines field - Add Node v25 detection warning in postinstall script - Improve error messages for v25+ users with fallback options --- apps/memos-local-openclaw/package.json | 4 +- .../scripts/postinstall.cjs | 57 +++++++++++++------ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/apps/memos-local-openclaw/package.json b/apps/memos-local-openclaw/package.json index 7ee152e49..d7d2d6041 100644 --- a/apps/memos-local-openclaw/package.json +++ b/apps/memos-local-openclaw/package.json @@ -40,12 +40,12 @@ ], "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18.0.0 <25.0.0" }, "dependencies": { "@huggingface/transformers": "^3.8.0", "@sinclair/typebox": "^0.34.48", - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^12.6.3", "posthog-node": "^5.28.0", "puppeteer": "^24.38.0", "uuid": "^10.0.0" diff --git a/apps/memos-local-openclaw/scripts/postinstall.cjs b/apps/memos-local-openclaw/scripts/postinstall.cjs index b6593f9ea..b2b6ba2e0 100644 --- a/apps/memos-local-openclaw/scripts/postinstall.cjs +++ b/apps/memos-local-openclaw/scripts/postinstall.cjs @@ -24,6 +24,9 @@ function phase(n, title) { const pluginDir = path.resolve(__dirname, ".."); +const nodeVersion = process.version; +const nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0], 10); + console.log(` ${CYAN}${BOLD}┌──────────────────────────────────────────────────┐ │ MemOS Local Memory — postinstall setup │ @@ -31,7 +34,13 @@ ${CYAN}${BOLD}┌───────────────────── `); log(`Plugin dir: ${DIM}${pluginDir}${RESET}`); -log(`Node: ${process.version} Platform: ${process.platform}-${process.arch}`); +log(`Node: ${GREEN}${nodeVersion}${RESET} Platform: ${process.platform}-${process.arch}`); + +if (nodeMajor >= 25) { + warn(`Node.js ${nodeVersion} detected. This version may have compatibility issues with native modules.`); + log(`Recommended: Use Node.js LTS (v20 or v22) for best compatibility.`); + log(`You can use nvm to switch versions: ${CYAN}nvm use 22${RESET}`); +} /* ═══════════════════════════════════════════════════════════ * Phase 0: Ensure all dependencies are installed @@ -281,23 +290,39 @@ ${GREEN}${BOLD} ┌──────────────────── console.log(` ${YELLOW}${BOLD} ╔══════════════════════════════════════════════════════════════╗ - ║ ✖ better-sqlite3 native module build failed ║ + ║ ✖ better-sqlite3 native module build failed ║ ╠══════════════════════════════════════════════════════════════╣${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} This plugin requires C/C++ build tools to compile ${YELLOW}║${RESET} -${YELLOW} ║${RESET} the SQLite native module on first install. ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${BOLD}Install build tools:${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${CYAN}macOS:${RESET} xcode-select --install ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${CYAN}Ubuntu:${RESET} sudo apt install build-essential python3 ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${CYAN}Windows:${RESET} npm install -g windows-build-tools ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${BOLD}Then retry:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} This plugin requires C/C++ build tools to compile ${YELLOW}║${RESET} +${YELLOW} ║${RESET} the SQLite native module on first install. ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${BOLD}Install build tools:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}macOS:${RESET} xcode-select --install ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}Ubuntu:${RESET} sudo apt install build-essential python3 ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}Windows:${RESET} npm install -g windows-build-tools ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET}`); + +if (nodeMajor >= 25) { + console.log(`${YELLOW} ║${RESET} ${BOLD}${RED}Node.js v25+ compatibility issue detected:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} better-sqlite3 may not have prebuilt binaries for Node 25. ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${BOLD}Recommended solutions:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} 1. Use Node.js LTS (v20 or v22): ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${GREEN}nvm install 22 && nvm use 22${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} 2. Or use MemOS Cloud version instead: ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}https://github.com/MemTensor/MemOS/tree/main/apps/memos-cloud${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET}`); +} + +console.log(`${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${BOLD}Then retry:${RESET} ${YELLOW}║${RESET} ${YELLOW} ║${RESET} ${GREEN}cd ${pluginDir}${RESET} -${YELLOW} ║${RESET} ${GREEN}npm rebuild better-sqlite3${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${GREEN}openclaw gateway stop && openclaw gateway start${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${GREEN}npm rebuild better-sqlite3${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${GREEN}openclaw gateway stop && openclaw gateway start${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} ${YELLOW}${BOLD} ╚══════════════════════════════════════════════════════════════╝${RESET} `); From 3474379a1a0c21f599a35fd64cb5efce9c23a811 Mon Sep 17 00:00:00 2001 From: Orion Date: Sat, 14 Mar 2026 22:09:24 +0800 Subject: [PATCH 02/33] fix(memory): normalize auto-recall query for /new startup prompts --- apps/memos-local-openclaw/index.ts | 48 +++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index d84d94dcd..13827ecd7 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -44,6 +44,45 @@ function deduplicateHits(hits: T[]): T[] { return kept; } +const NEW_SESSION_PROMPT_RE = /A new session was started via \/new or \/reset\./i; +const INTERNAL_CONTEXT_RE = /OpenClaw runtime context \(internal\):[\s\S]*/i; +const CONTINUE_PROMPT_RE = /^Continue where you left off\.[\s\S]*/i; + +function normalizeAutoRecallQuery(rawPrompt: string): string { + let query = rawPrompt.trim(); + + const senderTag = "Sender (untrusted metadata):"; + const senderPos = query.indexOf(senderTag); + if (senderPos !== -1) { + const afterSender = query.slice(senderPos); + const fenceStart = afterSender.indexOf("```json"); + const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1; + if (fenceEnd > 0) { + query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim(); + } else { + const firstDblNl = afterSender.indexOf("\n\n"); + if (firstDblNl > 0) { + query = afterSender.slice(firstDblNl + 2).trim(); + } + } + } + + query = stripInboundMetadata(query); + query = query.replace(/<[^>]+>/g, "").trim(); + + if (NEW_SESSION_PROMPT_RE.test(query)) { + query = query.replace(NEW_SESSION_PROMPT_RE, "").trim(); + query = query.replace(/^(Execute|Run) your Session Startup sequence[^\n]*\n?/im, "").trim(); + query = query.replace(/^Current time:[^\n]*(\n|$)/im, "").trim(); + } + + query = query.replace(INTERNAL_CONTEXT_RE, "").trim(); + query = query.replace(CONTINUE_PROMPT_RE, "").trim(); + + return query; +} + + const pluginConfigSchema = { type: "object" as const, additionalProperties: true, @@ -874,14 +913,7 @@ const memosLocalPlugin = { const rawPrompt = event.prompt; ctx.log.debug(`auto-recall: rawPrompt="${rawPrompt.slice(0, 300)}"`); - let query = rawPrompt; - const lastDoubleNewline = rawPrompt.lastIndexOf("\n\n"); - if (lastDoubleNewline > 0 && lastDoubleNewline < rawPrompt.length - 3) { - const tail = rawPrompt.slice(lastDoubleNewline + 2).trim(); - if (tail.length >= 2) query = tail; - } - query = stripInboundMetadata(query); - query = query.replace(/<[^>]+>/g, "").trim(); + const query = normalizeAutoRecallQuery(rawPrompt); recallQuery = query; if (query.length < 2) { From 659e9bf902ff311ffb25c43c4cc9b8ceee8be8da Mon Sep 17 00:00:00 2001 From: jiang Date: Tue, 31 Mar 2026 16:35:24 +0800 Subject: [PATCH 03/33] fix(openclaw-plugin): add memory search logging to viewer and fix install reliability - Add store.recordApiLog("memory_search") calls in Context Engine assemble() so auto-recall results appear in Memory Viewer's log panel - Fix postinstall.cjs: don't delete node_modules on fresh install; track plugin version + Node.js major version in .installed-version fingerprint; only clean on environment change (plugin upgrade or Node.js version change) - Add post-install verification in install.sh and install.ps1: detect missing node_modules and better-sqlite3, auto-recover with --ignore-scripts reinstall - Bump version to 1.0.7-beta.2 Made-with: Cursor --- apps/memos-local-openclaw/index.ts | 48 +++++++++++++++++++ apps/memos-local-openclaw/install.ps1 | 32 +++++++++++++ apps/memos-local-openclaw/install.sh | 22 +++++++++ apps/memos-local-openclaw/package.json | 2 +- .../scripts/postinstall.cjs | 27 +++++++---- 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index c4ee84bba..cf20a2c39 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -393,12 +393,31 @@ const memosLocalPlugin = { const recallOwner = [`agent:${currentAgentId}`, "public"]; const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwner }); + const rawCandidates = result.hits.map((h: SearchHit) => ({ + score: h.score, + summary: h.summary, + content: h.original_excerpt ?? h.summary, + original_excerpt: h.original_excerpt ?? "", + role: h.source?.role ?? "user", + origin: h.origin ?? "local", + ownerName: h.ownerName ?? "", + })); const filteredHits = ceDeduplicateHits( result.hits.filter((h: SearchHit) => h.score >= 0.5), ); if (filteredHits.length === 0) { ctx.log.debug("context-engine assemble: no memory hits"); + try { + const dur = performance.now() - recallT0; + store.recordApiLog( + "memory_search", + { type: "auto_recall", query }, + JSON.stringify({ candidates: rawCandidates, hubCandidates: [], filtered: [] }), + dur, + true, + ); + } catch { /* best-effort */ } return { messages, estimatedTokens: 0 }; } @@ -415,6 +434,16 @@ const memosLocalPlugin = { const sk = sessionKey ?? sessionId; + const filteredCandidates = filteredHits.map((h: SearchHit) => ({ + score: h.score, + summary: h.summary, + content: h.original_excerpt ?? h.summary, + original_excerpt: h.original_excerpt ?? "", + role: h.source?.role ?? "user", + origin: h.origin ?? "local", + ownerName: h.ownerName ?? "", + })); + if (lastAssistantIdx < 0) { const syntheticAssistant: CEAgentMessage = { role: "assistant", @@ -424,7 +453,17 @@ const memosLocalPlugin = { usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, }; pendingInjection = { sessionKey: sk, memoryBlock, isSynthetic: true }; + const dur = performance.now() - recallT0; ctx.log.info(`context-engine assemble: first turn, injecting synthetic assistant (${filteredHits.length} memories)`); + try { + store.recordApiLog( + "memory_search", + { type: "auto_recall", query }, + JSON.stringify({ candidates: rawCandidates, hubCandidates: [], filtered: filteredCandidates }), + dur, + true, + ); + } catch { /* best-effort */ } return { messages: [...cloned, syntheticAssistant], estimatedTokens: 0 }; } @@ -434,6 +473,15 @@ const memosLocalPlugin = { const dur = performance.now() - recallT0; ctx.log.info(`context-engine assemble: injected ${filteredHits.length} memories into assistant[${lastAssistantIdx}] (${dur.toFixed(0)}ms)`); + try { + store.recordApiLog( + "memory_search", + { type: "auto_recall", query }, + JSON.stringify({ candidates: rawCandidates, hubCandidates: [], filtered: filteredCandidates }), + dur, + true, + ); + } catch { /* best-effort */ } return { messages: cloned, estimatedTokens: 0 }; } catch (err) { ctx.log.warn(`context-engine assemble failed: ${err}`); diff --git a/apps/memos-local-openclaw/install.ps1 b/apps/memos-local-openclaw/install.ps1 index 449831756..26301f440 100644 --- a/apps/memos-local-openclaw/install.ps1 +++ b/apps/memos-local-openclaw/install.ps1 @@ -303,6 +303,38 @@ if (-not (Test-Path $ExtensionDir)) { exit 1 } +$NodeModulesDir = Join-Path $ExtensionDir "node_modules" +if (-not (Test-Path $NodeModulesDir)) { + Write-Warn "node_modules missing after install (postinstall may have cleaned it). Reinstalling..." + Push-Location $ExtensionDir + try { + & npm install --omit=dev --no-fund --no-audit --ignore-scripts --loglevel=error 2>&1 + } + finally { + Pop-Location + } +} + +$SqliteDir = Join-Path $ExtensionDir "node_modules\better-sqlite3" +if (-not (Test-Path $SqliteDir)) { + Write-Warn "better-sqlite3 missing, attempting rebuild..." + Push-Location $ExtensionDir + try { + & npm rebuild better-sqlite3 2>&1 + } + catch { + Write-Warn "better-sqlite3 rebuild returned an error. Continuing..." + } + finally { + Pop-Location + } +} + +if (-not (Test-Path $NodeModulesDir)) { + Write-Err "Dependencies installation failed. Run manually: cd $ExtensionDir && npm install --omit=dev" + exit 1 +} + Update-OpenClawConfig -OpenClawHome $OpenClawHome -ConfigPath $OpenClawConfigPath -PluginId $PluginId Write-Success "Restarting OpenClaw Gateway..." diff --git a/apps/memos-local-openclaw/install.sh b/apps/memos-local-openclaw/install.sh index b8229d17a..5ab6c0208 100644 --- a/apps/memos-local-openclaw/install.sh +++ b/apps/memos-local-openclaw/install.sh @@ -310,6 +310,28 @@ if [[ ! -d "$EXTENSION_DIR" ]]; then exit 1 fi +if [[ ! -d "${EXTENSION_DIR}/node_modules" ]]; then + warn "node_modules missing after install (postinstall may have cleaned it), 安装后 node_modules 缺失,正在重新安装..." + ( + cd "${EXTENSION_DIR}" + npm install --omit=dev --no-fund --no-audit --ignore-scripts --loglevel=error 2>&1 + ) +fi + +if [[ ! -d "${EXTENSION_DIR}/node_modules/better-sqlite3" ]]; then + warn "better-sqlite3 missing, attempting rebuild, better-sqlite3 缺失,尝试重新编译..." + ( + cd "${EXTENSION_DIR}" + npm rebuild better-sqlite3 2>&1 || true + ) +fi + +if [[ ! -d "${EXTENSION_DIR}/node_modules" ]]; then + error "Dependencies installation failed. Run manually: cd ${EXTENSION_DIR} && npm install --omit=dev" + error "依赖安装失败,请手动运行: cd ${EXTENSION_DIR} && npm install --omit=dev" + exit 1 +fi + update_openclaw_config success "Restart OpenClaw Gateway, 重启 OpenClaw Gateway..." diff --git a/apps/memos-local-openclaw/package.json b/apps/memos-local-openclaw/package.json index cd450d472..19f6f6680 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.7-beta.1", + "version": "1.0.7-beta.2", "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval", "type": "module", "main": "index.ts", diff --git a/apps/memos-local-openclaw/scripts/postinstall.cjs b/apps/memos-local-openclaw/scripts/postinstall.cjs index 96e073bce..419e3ee4f 100644 --- a/apps/memos-local-openclaw/scripts/postinstall.cjs +++ b/apps/memos-local-openclaw/scripts/postinstall.cjs @@ -61,21 +61,30 @@ function cleanStaleArtifacts() { installedVer = pkg.version || "unknown"; } catch { /* ignore */ } + const nodeMajor = process.versions.node.split(".")[0]; + const currentFingerprint = `${installedVer}+node${nodeMajor}`; + const markerPath = path.join(pluginDir, ".installed-version"); - let prevVer = ""; - try { prevVer = fs.readFileSync(markerPath, "utf-8").trim(); } catch { /* first install */ } + let prevFingerprint = ""; + try { prevFingerprint = fs.readFileSync(markerPath, "utf-8").trim(); } catch { /* first install */ } + + const writeMarker = () => { + try { fs.writeFileSync(markerPath, currentFingerprint + "\n", "utf-8"); } catch { /* ignore */ } + }; - if (prevVer === installedVer) { - log(`Version unchanged (${installedVer}), skipping artifact cleanup.`); + if (prevFingerprint === currentFingerprint) { + log(`Version unchanged (${currentFingerprint}), skipping artifact cleanup.`); return; } - if (prevVer) { - log(`Upgrade detected: ${DIM}${prevVer}${RESET} → ${GREEN}${installedVer}${RESET}`); - } else { - log(`Fresh install: ${GREEN}${installedVer}${RESET}`); + if (!prevFingerprint) { + log(`Fresh install: ${GREEN}${currentFingerprint}${RESET}`); + writeMarker(); + return; } + log(`Environment changed: ${DIM}${prevFingerprint}${RESET} → ${GREEN}${currentFingerprint}${RESET}`); + const dirsToClean = ["dist", "node_modules"]; let cleaned = 0; for (const dir of dirsToClean) { @@ -99,7 +108,7 @@ function cleanStaleArtifacts() { } } - try { fs.writeFileSync(markerPath, installedVer + "\n", "utf-8"); } catch { /* ignore */ } + writeMarker(); if (cleaned > 0) { ok(`Cleaned ${cleaned} stale artifact(s). Fresh install will follow.`); From d04a21f104e5c177e6fd8343f083551e3b59ec73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=81=E5=9C=A8=E5=90=B5=E7=9D=80=E5=90=83=E7=B3=96?= Date: Tue, 31 Mar 2026 15:24:13 +0800 Subject: [PATCH 04/33] Fix multi-agent memory display issue --- apps/memos-local-openclaw/index.ts | 14 +++++++---- apps/memos-local-openclaw/src/viewer/html.ts | 25 ++++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index c4ee84bba..40d19b3d3 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -358,8 +358,11 @@ const memosLocalPlugin = { tokenBudget?: number; model?: string; prompt?: string; + agentId?: string; + profileId?: string; }) { const { messages, prompt, sessionId, sessionKey } = params; + const assembleAgentId = params.agentId ?? params.profileId ?? currentAgentId; if (!allowPromptInjection || !prompt || prompt.length < 3) { return { messages, estimatedTokens: 0 }; @@ -391,7 +394,7 @@ const memosLocalPlugin = { ctx.log.debug(`context-engine assemble: query="${query.slice(0, 80)}"`); - const recallOwner = [`agent:${currentAgentId}`, "public"]; + const recallOwner = [`agent:${assembleAgentId}`, "public"]; const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwner }); const filteredHits = ceDeduplicateHits( result.hits.filter((h: SearchHit) => h.score >= 0.5), @@ -1524,10 +1527,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, "or access their stored memories, or asks where the memory dashboard is. " + "Returns the URL the user can open in their browser.", parameters: Type.Object({}), - execute: trackTool("memory_viewer", async () => { + execute: trackTool("memory_viewer", async (_toolCallId: any, params: any, context?: any) => { ctx.log.debug(`memory_viewer called`); telemetry.trackViewerOpened(); - const url = `http://127.0.0.1:${viewerPort}`; + const agentId = context?.agentId ?? context?.profileId ?? currentAgentId; + const url = `http://127.0.0.1:${viewerPort}?agentId=${encodeURIComponent(agentId)}`; return { content: [ { @@ -2001,7 +2005,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, if (!allowPromptInjection) return {}; if (!event.prompt || event.prompt.length < 3) return; - const recallAgentId = hookCtx?.agentId ?? "main"; + const recallAgentId = hookCtx?.agentId ?? (event as any)?.agentId ?? (event as any)?.profileId ?? "main"; currentAgentId = recallAgentId; const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall; @@ -2083,7 +2087,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, if (!event.success || !event.messages || event.messages.length === 0) return; try { - const captureAgentId = hookCtx?.agentId ?? "main"; + const captureAgentId = hookCtx?.agentId ?? event?.agentId ?? event?.profileId ?? "main"; currentAgentId = captureAgentId; const captureOwner = `agent:${captureAgentId}`; const sessionKey = hookCtx?.sessionKey ?? "default"; diff --git a/apps/memos-local-openclaw/src/viewer/html.ts b/apps/memos-local-openclaw/src/viewer/html.ts index 14aecc6fc..2dbd16497 100644 --- a/apps/memos-local-openclaw/src/viewer/html.ts +++ b/apps/memos-local-openclaw/src/viewer/html.ts @@ -2034,6 +2034,14 @@ let memorySearchScope='local',skillSearchScope='local',taskSearchScope='local'; let _lastMemoriesFingerprint='',_lastTasksFingerprint='',_lastSkillsFingerprint=''; let _embeddingWarningShown=false; let _currentAgentOwner='agent:main'; +try { + const urlParams = new URLSearchParams(window.location.search); + const agentId = urlParams.get('agentId'); + if (agentId) { + _currentAgentOwner = 'agent:' + agentId; + } +} catch(e) {} + /* ─── i18n ─── */ const I18N={ @@ -3796,7 +3804,7 @@ function onMemoryScopeChange(){ var ownerSel=document.getElementById('filterOwner'); var filterBar=document.getElementById('filterBar'); var dateFilter=document.querySelector('.date-filter'); - if(ownerSel){ownerSel.style.display=(isHub||isLocal)?'none':'';if(isHub||isLocal)ownerSel.value='';} + if(ownerSel){ownerSel.style.display=isHub?'none':'';if(isHub)ownerSel.value='';} if(filterBar) filterBar.style.display=isHub?'none':''; if(dateFilter) dateFilter.style.display=isHub?'none':''; if(document.getElementById('searchInput').value.trim()) doSearch(document.getElementById('searchInput').value); @@ -7738,7 +7746,7 @@ async function loadStats(ownerFilter){ d=await r.json(); }catch(e){ d={}; } if(!d||typeof d!=='object') d={}; - if(d.currentAgentOwner) _currentAgentOwner=d.currentAgentOwner; + if(d.currentAgentOwner && !new URLSearchParams(window.location.search).get('agentId')) _currentAgentOwner=d.currentAgentOwner; const tm=d.totalMemories||0; const dedupB=d.dedupBreakdown||{}; const activeCount=dedupB.active||tm; @@ -7846,11 +7854,14 @@ function getFilterParams(){ const sort=document.getElementById('filterSort').value; if(sort==='oldest') p.set('sort','oldest'); const scope=memorySearchScope||'local'; - if(scope==='local'){ - p.set('owner',_currentAgentOwner); - }else if(scope==='allLocal'){ - const owner=document.getElementById('filterOwner').value; - if(owner) p.set('owner',owner); + if(scope==='local' || scope==='allLocal'){ + const owner=document.getElementById('filterOwner').value; + if(owner) { + p.set('owner',owner); + _currentAgentOwner = owner; + } else if(scope==='local') { + p.set('owner',_currentAgentOwner); + } } return p; } From f11911398062207065a03d5d086fbe30c5c6f300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=81=E5=9C=A8=E5=90=B5=E7=9D=80=E5=90=83=E7=B3=96?= Date: Tue, 31 Mar 2026 17:04:58 +0800 Subject: [PATCH 05/33] Fix overlapping retrieval content in multi-agent mode --- apps/memos-local-openclaw/index.ts | 41 +++++++++---------- .../memos-local-openclaw/src/recall/engine.ts | 2 +- .../src/storage/sqlite.ts | 12 +++++- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index 40d19b3d3..709871155 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -577,7 +577,6 @@ const memosLocalPlugin = { } }; - const getCurrentOwner = () => `agent:${currentAgentId}`; const resolveMemorySearchScope = (scope?: string): "local" | "group" | "all" => scope === "group" || scope === "all" ? scope : "local"; const resolveMemoryShareTarget = (target?: string): "agents" | "hub" | "both" => @@ -668,7 +667,7 @@ const memosLocalPlugin = { // ─── Tool: memory_search ─── api.registerTool( - { + (context) => ({ name: "memory_search", label: "Memory Search", description: @@ -684,7 +683,7 @@ const memosLocalPlugin = { hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })), userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })), }), - execute: trackTool("memory_search", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("memory_search", async (_toolCallId: any, params: any) => { const { query, scope: rawScope, @@ -705,9 +704,6 @@ const memosLocalPlugin = { const role = rawRole === "user" || rawRole === "assistant" || rawRole === "tool" || rawRole === "system" ? rawRole : undefined; const minScore = typeof rawMinScore === "number" ? Math.max(0.35, Math.min(1, rawMinScore)) : undefined; let searchScope = resolveMemorySearchScope(rawScope); - if (searchScope === "local" && ctx.config?.sharing?.enabled) { - searchScope = "all"; - } const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10; const agentId = context?.agentId ?? currentAgentId; @@ -727,7 +723,7 @@ const memosLocalPlugin = { // Split local results: pure-local vs hub-memory (Hub role's hub_memories mixed in by RecallEngine) const localHits = result.hits.filter((h) => h.origin !== "hub-memory"); - const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory"); + const hubLocalHits = searchScope !== "local" ? result.hits.filter((h) => h.origin === "hub-memory") : []; const rawLocalCandidates = localHits.map((h) => ({ chunkId: h.ref.chunkId, @@ -892,14 +888,14 @@ const memosLocalPlugin = { }, }; }), - }, + }), { name: "memory_search" }, ); // ─── Tool: memory_timeline ─── api.registerTool( - { + (context) => ({ name: "memory_timeline", label: "Memory Timeline", description: @@ -909,7 +905,7 @@ const memosLocalPlugin = { chunkId: Type.String({ description: "The chunkId from a memory_search hit" }), window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })), }), - execute: trackTool("memory_timeline", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => { const agentId = context?.agentId ?? currentAgentId; ctx.log.debug(`memory_timeline called (agent=${agentId})`); const { chunkId, window: win } = params as { @@ -953,14 +949,14 @@ const memosLocalPlugin = { details: { entries, anchorRef: { sessionKey: anchorChunk.sessionKey, chunkId, turnId: anchorChunk.turnId, seq: anchorChunk.seq } }, }; }), - }, + }), { name: "memory_timeline" }, ); // ─── Tool: memory_get ─── api.registerTool( - { + (context) => ({ name: "memory_get", label: "Memory Get", description: @@ -971,7 +967,7 @@ const memosLocalPlugin = { Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }), ), }), - execute: trackTool("memory_get", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("memory_get", async (_toolCallId: any, params: any) => { const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number }; const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax); @@ -997,7 +993,7 @@ const memosLocalPlugin = { }, }; }), - }, + }), { name: "memory_get" }, ); @@ -1519,7 +1515,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, const viewerPort = (pluginCfg as any).viewerPort ?? (gatewayPort + 10); api.registerTool( - { + (context) => ({ name: "memory_viewer", label: "Open Memory Viewer", description: @@ -1527,7 +1523,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, "or access their stored memories, or asks where the memory dashboard is. " + "Returns the URL the user can open in their browser.", parameters: Type.Object({}), - execute: trackTool("memory_viewer", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("memory_viewer", async (_toolCallId: any, params: any) => { ctx.log.debug(`memory_viewer called`); telemetry.trackViewerOpened(); const agentId = context?.agentId ?? context?.profileId ?? currentAgentId; @@ -1552,7 +1548,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, details: { viewerUrl: url }, }; }), - }, + }), { name: "memory_viewer" }, ); @@ -1783,7 +1779,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, // ─── Tool: skill_search ─── api.registerTool( - { + (context) => ({ name: "skill_search", label: "Skill Search", description: @@ -1793,10 +1789,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, query: Type.String({ description: "Natural language description of the needed skill" }), scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default), 'self', 'public', 'group', or 'all'." })), }), - execute: trackTool("skill_search", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("skill_search", async (_toolCallId: any, params: any) => { const { query: skillQuery, scope: rawScope } = params as { query: string; scope?: string }; const scope = (rawScope === "self" || rawScope === "public") ? rawScope : "mix"; - const currentOwner = getCurrentOwner(); + const agentId = context?.agentId ?? currentAgentId; + const currentOwner = `agent:${agentId}`; if (rawScope === "group" || rawScope === "all") { const [localHits, hub] = await Promise.all([ @@ -1858,7 +1855,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, details: { query: skillQuery, scope, hits }, }; }), - }, + }), { name: "skill_search" }, ); @@ -2037,7 +2034,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, const skillCandidateMap = new Map(); try { - const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner()); + const directSkillHits = await engine.searchSkills(query, "mix" as any, `agent:${recallAgentId}`); for (const sh of directSkillHits.slice(0, skillLimit + 2)) { if (!skillCandidateMap.has(sh.skillId)) { skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" }); diff --git a/apps/memos-local-openclaw/src/recall/engine.ts b/apps/memos-local-openclaw/src/recall/engine.ts index cca29b8ed..1aab42420 100644 --- a/apps/memos-local-openclaw/src/recall/engine.ts +++ b/apps/memos-local-openclaw/src/recall/engine.ts @@ -77,7 +77,7 @@ export class RecallEngine { } const shortTerms = [...new Set([...spaceSplit, ...cjkBigrams])]; const patternHits = shortTerms.length > 0 - ? this.store.patternSearch(shortTerms, { limit: candidatePool }) + ? this.store.patternSearch(shortTerms, { limit: candidatePool, ownerFilter }) : []; const patternRanked = patternHits.map((h, i) => ({ id: h.chunkId, diff --git a/apps/memos-local-openclaw/src/storage/sqlite.ts b/apps/memos-local-openclaw/src/storage/sqlite.ts index 5bebd07a4..025e009b8 100644 --- a/apps/memos-local-openclaw/src/storage/sqlite.ts +++ b/apps/memos-local-openclaw/src/storage/sqlite.ts @@ -1207,7 +1207,7 @@ export class SqliteStore { // ─── Pattern Search (LIKE-based, for CJK text where FTS tokenization is weak) ─── - patternSearch(patterns: string[], opts: { role?: string; limit?: number } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> { + patternSearch(patterns: string[], opts: { role?: string; limit?: number; ownerFilter?: string[] } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> { if (patterns.length === 0) return []; const limit = opts.limit ?? 10; @@ -1216,13 +1216,21 @@ export class SqliteStore { const roleClause = opts.role ? " AND c.role = ?" : ""; const params: (string | number)[] = patterns.map(p => `%${p}%`); if (opts.role) params.push(opts.role); + + let ownerClause = ""; + if (opts.ownerFilter && opts.ownerFilter.length > 0) { + const placeholders = opts.ownerFilter.map(() => "?").join(","); + ownerClause = ` AND c.owner IN (${placeholders})`; + params.push(...opts.ownerFilter); + } + params.push(limit); try { const rows = this.db.prepare(` SELECT c.id as chunk_id, c.content, c.role, c.created_at FROM chunks c - WHERE (${whereClause})${roleClause} AND c.dedup_status = 'active' + WHERE (${whereClause})${roleClause}${ownerClause} AND c.dedup_status = 'active' ORDER BY c.created_at DESC LIMIT ? `).all(...params) as Array<{ chunk_id: string; content: string; role: string; created_at: number }>; From e6948880e87df681b2e9eb3514dfa5c871ec90d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=81=E5=9C=A8=E5=90=B5=E7=9D=80=E5=90=83=E7=B3=96?= Date: Tue, 31 Mar 2026 17:05:26 +0800 Subject: [PATCH 06/33] Fix compilation errors --- apps/memos-local-openclaw/src/ingest/providers/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/memos-local-openclaw/src/ingest/providers/index.ts b/apps/memos-local-openclaw/src/ingest/providers/index.ts index 99db7b63e..05fa757b8 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/index.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/index.ts @@ -1,7 +1,7 @@ import * as fs from "fs"; import * as path from "path"; -import type { SummarizerConfig, SummaryProvider, Logger } from "../../types"; -import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI } from "./openai"; +import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types"; +import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai"; import type { FilterResult, DedupResult } from "./openai"; export type { FilterResult, DedupResult } from "./openai"; import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic"; From 593106efd3ba3982e251a0a1176ce2fc0d4780c3 Mon Sep 17 00:00:00 2001 From: muwb Date: Wed, 1 Apr 2026 08:22:29 +0800 Subject: [PATCH 07/33] fix: skip tools.allow patching when wildcard '*' is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When tools.allow contains '*' (wildcard), the plugin no longer appends 'group:plugins' to tools.allow. This prevents an OpenClaw core bug where each config change triggers a restart loop: append group:plugins to tools.allow → core syncs to acp.allowedAgents without dedup → config change detected → gateway restart → plugin re-initializes → core appends again → infinite restart loop The wildcard '*' already covers all plugin tools, so the patching is unnecessary in this case. --- apps/memos-local-openclaw/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index c4ee84bba..c02504806 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -290,7 +290,7 @@ const memosLocalPlugin = { const raw = fs.readFileSync(openclawJsonPath, "utf-8"); const cfg = JSON.parse(raw); const allow: string[] | undefined = cfg?.tools?.allow; - if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins")) { + if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins") && !allow.includes("*")) { const lastEntry = JSON.stringify(allow[allow.length - 1]); const patched = raw.replace( new RegExp(`(${lastEntry})(\\s*\\])`), From 9c1e492d3b5398890a19f75e3dfdd130675c5df5 Mon Sep 17 00:00:00 2001 From: jiang Date: Wed, 1 Apr 2026 16:42:41 +0800 Subject: [PATCH 08/33] revert: remove context engine registration, restore before_prompt_build memory injection Reverts PR #1356 (feat: register context engine slot for local plugin). Restores the original before_prompt_build hook with full memory injection via prependContext, including local+hub search, LLM filtering, dedup, and integrated skill auto-recall. - Remove registerContextEngine and registerMemoryPromptSection calls - Delete src/context-engine/ module and tests/context-engine.test.ts - Remove contextEngine slot config from install scripts - Restore complete auto-recall logic in before_prompt_build hook Made-with: Cursor --- apps/memos-local-openclaw/index.ts | 529 ++++++++--------- apps/memos-local-openclaw/install.ps1 | 5 - apps/memos-local-openclaw/install.sh | 5 - .../src/context-engine/index.ts | 321 ----------- .../tests/context-engine.test.ts | 534 ------------------ 5 files changed, 268 insertions(+), 1126 deletions(-) delete mode 100644 apps/memos-local-openclaw/src/context-engine/index.ts delete mode 100644 apps/memos-local-openclaw/tests/context-engine.test.ts diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index c4ee84bba..f5f126519 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -31,18 +31,6 @@ import { SkillInstaller } from "./src/skill/installer"; import { Summarizer } from "./src/ingest/providers"; import { MEMORY_GUIDE_SKILL_MD } from "./src/skill/bundled-memory-guide"; import { Telemetry } from "./src/telemetry"; -import { - type AgentMessage as CEAgentMessage, - type PendingInjection, - deduplicateHits as ceDeduplicateHits, - formatMemoryBlock, - appendMemoryToMessage, - removeExistingMemoryBlock, - messageHasMemoryBlock, - getTextFromMessage, - insertSyntheticAssistantEntry, - findTargetAssistantEntry, -} from "./src/context-engine"; /** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */ @@ -332,214 +320,6 @@ const memosLocalPlugin = { api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled"); } - // ─── Context Engine: inject memories into assistant messages ─── - // Memories are wrapped in tags which OpenClaw's UI - // automatically strips from assistant messages, keeping the chat clean. - // Persisted to the session file so the prompt prefix stays stable for KV cache. - - let pendingInjection: PendingInjection | null = null; - - try { - api.registerContextEngine("memos-local-openclaw-plugin", () => ({ - info: { - id: "memos-local-openclaw-plugin", - name: "MemOS Local Memory Context Engine", - version: "1.0.0", - }, - - async ingest() { - return { ingested: false }; - }, - - async assemble(params: { - sessionId: string; - sessionKey?: string; - messages: CEAgentMessage[]; - tokenBudget?: number; - model?: string; - prompt?: string; - }) { - const { messages, prompt, sessionId, sessionKey } = params; - - if (!allowPromptInjection || !prompt || prompt.length < 3) { - return { messages, estimatedTokens: 0 }; - } - - const recallT0 = performance.now(); - try { - let query = prompt; - const senderTag = "Sender (untrusted metadata):"; - const senderPos = query.indexOf(senderTag); - if (senderPos !== -1) { - const afterSender = query.slice(senderPos); - const fenceStart = afterSender.indexOf("```json"); - const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1; - if (fenceEnd > 0) { - query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim(); - } else { - const firstDblNl = afterSender.indexOf("\n\n"); - if (firstDblNl > 0) { - query = afterSender.slice(firstDblNl + 2).trim(); - } - } - } - query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim(); - - if (query.length < 2) { - return { messages, estimatedTokens: 0 }; - } - - ctx.log.debug(`context-engine assemble: query="${query.slice(0, 80)}"`); - - const recallOwner = [`agent:${currentAgentId}`, "public"]; - const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwner }); - const filteredHits = ceDeduplicateHits( - result.hits.filter((h: SearchHit) => h.score >= 0.5), - ); - - if (filteredHits.length === 0) { - ctx.log.debug("context-engine assemble: no memory hits"); - return { messages, estimatedTokens: 0 }; - } - - const memoryBlock = formatMemoryBlock(filteredHits); - const cloned: CEAgentMessage[] = messages.map((m) => structuredClone(m)); - - let lastAssistantIdx = -1; - for (let i = cloned.length - 1; i >= 0; i--) { - if (cloned[i].role === "assistant") { - lastAssistantIdx = i; - break; - } - } - - const sk = sessionKey ?? sessionId; - - if (lastAssistantIdx < 0) { - const syntheticAssistant: CEAgentMessage = { - role: "assistant", - content: [{ type: "text", text: memoryBlock }], - timestamp: Date.now(), - stopReason: "end_turn", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - }; - pendingInjection = { sessionKey: sk, memoryBlock, isSynthetic: true }; - ctx.log.info(`context-engine assemble: first turn, injecting synthetic assistant (${filteredHits.length} memories)`); - return { messages: [...cloned, syntheticAssistant], estimatedTokens: 0 }; - } - - removeExistingMemoryBlock(cloned[lastAssistantIdx]); - appendMemoryToMessage(cloned[lastAssistantIdx], memoryBlock); - pendingInjection = { sessionKey: sk, memoryBlock, isSynthetic: false }; - - const dur = performance.now() - recallT0; - ctx.log.info(`context-engine assemble: injected ${filteredHits.length} memories into assistant[${lastAssistantIdx}] (${dur.toFixed(0)}ms)`); - return { messages: cloned, estimatedTokens: 0 }; - } catch (err) { - ctx.log.warn(`context-engine assemble failed: ${err}`); - return { messages, estimatedTokens: 0 }; - } - }, - - async afterTurn() {}, - - async compact(params: any) { - try { - const { delegateCompactionToRuntime } = await import("openclaw/plugin-sdk"); - return await delegateCompactionToRuntime(params); - } catch { - return { ok: true, compacted: false, reason: "delegateCompactionToRuntime not available" }; - } - }, - - async maintain(params: { - sessionId: string; - sessionKey?: string; - sessionFile: string; - runtimeContext?: { rewriteTranscriptEntries?: (req: any) => Promise }; - }) { - const noChange = { changed: false, bytesFreed: 0, rewrittenEntries: 0 }; - - if (!pendingInjection) return noChange; - - const sk = params.sessionKey ?? params.sessionId; - if (pendingInjection.sessionKey !== sk) { - pendingInjection = null; - return { ...noChange, reason: "session mismatch" }; - } - - try { - if (pendingInjection.isSynthetic) { - // First turn: INSERT synthetic assistant before existing entries - const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - const sm = SessionManager.open(params.sessionFile); - const ok = insertSyntheticAssistantEntry(sm, pendingInjection.memoryBlock); - pendingInjection = null; - if (ok) { - ctx.log.info("context-engine maintain: persisted synthetic assistant message"); - return { changed: true, bytesFreed: 0, rewrittenEntries: 1 }; - } - return { ...noChange, reason: "empty branch, could not insert synthetic" }; - } - - // Subsequent turns: REPLACE last assistant entry with memory-injected version - if (!params.runtimeContext?.rewriteTranscriptEntries) { - pendingInjection = null; - return { ...noChange, reason: "rewriteTranscriptEntries not available" }; - } - - const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - const sm = SessionManager.open(params.sessionFile); - const branch = sm.getBranch(); - const targetEntry = findTargetAssistantEntry(branch); - - if (!targetEntry) { - pendingInjection = null; - return { ...noChange, reason: "no target assistant entry found" }; - } - - const modifiedMessage = structuredClone(targetEntry.message!); - removeExistingMemoryBlock(modifiedMessage as CEAgentMessage); - appendMemoryToMessage(modifiedMessage as CEAgentMessage, pendingInjection.memoryBlock); - - const result = await params.runtimeContext.rewriteTranscriptEntries({ - replacements: [{ entryId: targetEntry.id, message: modifiedMessage }], - }); - - ctx.log.info(`context-engine maintain: persisted memory to assistant entry ${targetEntry.id}`); - pendingInjection = null; - return result; - } catch (err) { - ctx.log.warn(`context-engine maintain failed: ${err}`); - pendingInjection = null; - return { ...noChange, reason: String(err) }; - } - }, - })); - - ctx.log.info("memos-local: registered context engine 'memos-local-openclaw-plugin'"); - } catch (err) { - ctx.log.warn(`memos-local: context engine registration failed (${err}), memory injection will use before_prompt_build fallback`); - } - - // ─── Memory Prompt Section: static instructions for the LLM ─── - try { - api.registerMemoryPromptSection(() => [ - "## Memory System", - "", - "Assistant messages in this conversation may contain blocks.", - "These are NOT part of the assistant's original response.", - "They contain background knowledge and memories relevant to the next user message,", - "injected by the user's local memory system before each query.", - "Use them as context to better understand and respond to the following user message.", - "Do not mention, quote, or repeat these memory blocks in your replies.", - "", - ]); - ctx.log.info("memos-local: registered memory prompt section"); - } catch (err) { - ctx.log.warn(`memos-local: registerMemoryPromptSection failed: ${err}`); - } - const trackTool = (toolName: string, fn: (...args: any[]) => Promise) => async (...args: any[]) => { const t0 = performance.now(); @@ -1993,9 +1773,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, { name: "network_skill_pull" }, ); - // ─── Skill auto-recall: inject relevant skills before agent starts ─── - // Memory injection is handled by the Context Engine above. - // This hook only handles skill auto-recall via prependContext. + // ─── Auto-recall: inject relevant memories before agent starts ─── api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => { if (!allowPromptInjection) return {}; @@ -2003,18 +1781,21 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, const recallAgentId = hookCtx?.agentId ?? "main"; currentAgentId = recallAgentId; - - const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall; - if (!skillAutoRecall) return; + const recallOwnerFilter = [`agent:${recallAgentId}`, "public"]; + ctx.log.info(`auto-recall: agentId=${recallAgentId} (from hookCtx)`); const recallT0 = performance.now(); + let recallQuery = ""; try { - let query = event.prompt; + const rawPrompt = event.prompt; + ctx.log.debug(`auto-recall: rawPrompt="${rawPrompt.slice(0, 300)}"`); + + let query = rawPrompt; const senderTag = "Sender (untrusted metadata):"; - const senderPos = query.indexOf(senderTag); + const senderPos = rawPrompt.indexOf(senderTag); if (senderPos !== -1) { - const afterSender = query.slice(senderPos); + const afterSender = rawPrompt.slice(senderPos); const fenceStart = afterSender.indexOf("```json"); const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1; if (fenceEnd > 0) { @@ -2026,48 +1807,274 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, } } } - query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim(); - if (query.length < 2) return; + query = stripInboundMetadata(query); + query = query.replace(/<[^>]+>/g, "").trim(); + recallQuery = query; - const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit; - const skillCandidateMap = new Map(); + if (query.length < 2) { + ctx.log.debug("auto-recall: extracted query too short, skipping"); + return; + } + ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`); + + // ── Phase 1: Local search ∥ Hub search (parallel) ── + const arLocalP = engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter }); + const arHubP = ctx.config?.sharing?.enabled + ? hubSearchMemories(store, ctx, { query, maxResults: 10, scope: "all" }) + .catch((err: any) => { ctx.log.debug(`auto-recall: hub search failed (${err})`); return { hits: [] as any[], meta: {} }; }) + : Promise.resolve({ hits: [] as any[], meta: {} }); + + const [result, arHubResult] = await Promise.all([arLocalP, arHubP]); + + const localHits = result.hits.filter((h) => h.origin !== "hub-memory"); + const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory"); + const hubRemoteHits: SearchHit[] = (arHubResult.hits ?? []).map((h: any) => ({ + summary: h.summary, + original_excerpt: h.excerpt || h.summary, + ref: { sessionKey: "", chunkId: h.remoteHitId ?? "", turnId: "", seq: 0 }, + score: 0.9, + taskId: null, + skillId: null, + origin: "hub-remote" as const, + source: { ts: h.source?.ts, role: h.source?.role ?? "assistant", sessionKey: "" }, + ownerName: h.ownerName, + groupName: h.groupName, + })); + const allHubHits = [...hubLocalHits, ...hubRemoteHits]; + + ctx.log.debug(`auto-recall: local=${localHits.length}, hub-memory=${hubLocalHits.length}, hub-remote=${hubRemoteHits.length}`); + + const rawLocalCandidates = localHits.map((h) => ({ + score: h.score, role: h.source.role, summary: h.summary, + content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local", + })); + const rawHubCandidates = allHubHits.map((h) => ({ + score: h.score, role: h.source.role, summary: h.summary, + content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "hub-remote", + ownerName: (h as any).ownerName ?? "", groupName: (h as any).groupName ?? "", + })); + + const allRawHits = [...localHits, ...allHubHits]; + + if (allRawHits.length === 0) { + ctx.log.debug("auto-recall: no memory candidates found"); + const dur = performance.now() - recallT0; + store.recordToolCall("memory_search", dur, true); + store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ + candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [], + }), dur, true); + + const skillAutoRecallEarly = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall; + if (skillAutoRecallEarly) { + try { + const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit; + const skillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner()); + const topSkills = skillHits.slice(0, skillLimit); + if (topSkills.length > 0) { + const skillLines = topSkills.map((sc, i) => { + const manifest = skillInstaller.getCompanionManifest(sc.skillId); + let badge = ""; + if (manifest?.installed) badge = " [installed]"; + else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]"; + else if (manifest?.hasCompanionFiles) badge = " [has companion files]"; + return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → call \`skill_get(skillId="${sc.skillId}")\` for the full guide`; + }); + const skillContext = "## Relevant skills from past experience\n\n" + + "No direct memory matches were found, but these skills from past tasks may help:\n\n" + + skillLines.join("\n\n") + + "\n\nYou SHOULD call `skill_get` to retrieve the full guide before attempting the task."; + ctx.log.info(`auto-recall-skill (no-memory path): injecting ${topSkills.length} skill(s)`); + try { store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(topSkills), dur, true); } catch { /* best-effort */ } + return { prependContext: skillContext }; + } + } catch (err) { + ctx.log.debug(`auto-recall-skill (no-memory path): failed: ${err}`); + } + } - try { - const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner()); - for (const sh of directSkillHits.slice(0, skillLimit + 2)) { - if (!skillCandidateMap.has(sh.skillId)) { - skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" }); + if (query.length > 50) { + const noRecallHint = + "## Memory system — ACTION REQUIRED\n\n" + + "Auto-recall found no results for a long query. " + + "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " + + "Do NOT skip this step. Do NOT answer without searching first."; + return { prependContext: noRecallHint }; + } + return; + } + + // ── Phase 2: Merge all → single LLM filter ── + const mergedForFilter = allRawHits.map((h, i) => ({ + index: i + 1, + role: h.source.role, + content: (h.original_excerpt ?? "").slice(0, 300), + time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "", + })); + + let filteredHits = allRawHits; + let sufficient = false; + + const filterResult = await summarizer.filterRelevant(query, mergedForFilter); + if (filterResult !== null) { + sufficient = filterResult.sufficient; + if (filterResult.relevant.length > 0) { + const indexSet = new Set(filterResult.relevant); + filteredHits = allRawHits.filter((_, i) => indexSet.has(i + 1)); + } else { + const dur = performance.now() - recallT0; + store.recordToolCall("memory_search", dur, true); + store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ + candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [], + }), dur, true); + if (query.length > 50) { + const noRecallHint = + "## Memory system — ACTION REQUIRED\n\n" + + "Auto-recall found no relevant results for a long query. " + + "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " + + "Do NOT skip this step. Do NOT answer without searching first."; + return { prependContext: noRecallHint }; } + return; } - } catch (err) { - ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`); } - const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit); - if (skillCandidates.length === 0) return; - - const skillLines = skillCandidates.map((sc, i) => { - const manifest = skillInstaller.getCompanionManifest(sc.skillId); - let badge = ""; - if (manifest?.installed) badge = " [installed]"; - else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]"; - else if (manifest?.hasCompanionFiles) badge = " [has companion files]"; - const action = `call \`skill_get(skillId="${sc.skillId}")\``; - return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`; + const beforeDedup = filteredHits.length; + filteredHits = deduplicateHits(filteredHits); + ctx.log.debug(`auto-recall: merged ${allRawHits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`); + + const lines = filteredHits.map((h, i) => { + const excerpt = h.original_excerpt; + const oTag = h.origin === "local-shared" ? " [本机共享]" : h.origin === "hub-memory" ? " [团队缓存]" : ""; + const parts: string[] = [`${i + 1}. [${h.source.role}]${oTag}`]; + if (excerpt) parts.push(` ${excerpt}`); + parts.push(` chunkId="${h.ref.chunkId}"`); + if (h.taskId) { + const task = store.getTask(h.taskId); + if (task && task.status !== "skipped") { + parts.push(` task_id="${h.taskId}"`); + } + } + return parts.join("\n"); }); - const skillContext = "## Relevant skills from past experience\n\n" + - "The following skills were distilled from similar previous tasks. " + - "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" + - skillLines.join("\n\n"); - ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`); - try { - store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true); - } catch { /* best-effort */ } + const hasTask = filteredHits.some((h) => { + if (!h.taskId) return false; + const t = store.getTask(h.taskId); + return t && t.status !== "skipped"; + }); + const tips: string[] = []; + if (hasTask) { + tips.push("- A hit has `task_id` → call `task_summary(taskId=\"...\")` to get the full task context (steps, code, results)"); + tips.push("- A task may have a reusable guide → call `skill_get(taskId=\"...\")` to retrieve the experience/skill"); + } + tips.push("- Need more surrounding dialogue → call `memory_timeline(chunkId=\"...\")` to expand context around a hit"); + const tipsText = "\n\nAvailable follow-up tools:\n" + tips.join("\n"); + + const contextParts = [ + "## User's conversation history (from memory system)", + "", + "IMPORTANT: The following are facts from previous conversations with this user.", + "You MUST treat these as established knowledge and use them directly when answering.", + "Do NOT say you don't know or don't have information if the answer is in these memories.", + "", + lines.join("\n\n"), + ]; + if (tipsText) contextParts.push(tipsText); + + // ─── Skill auto-recall ─── + const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall; + const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit; + let skillSection = ""; + + if (skillAutoRecall) { + try { + const skillCandidateMap = new Map(); + + try { + const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner()); + for (const sh of directSkillHits.slice(0, skillLimit + 2)) { + if (!skillCandidateMap.has(sh.skillId)) { + skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" }); + } + } + } catch (err) { + ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`); + } + + const taskIds = new Set(); + for (const h of filteredHits) { + if (h.taskId) { + const t = store.getTask(h.taskId); + if (t && t.status !== "skipped") taskIds.add(h.taskId); + } + } + for (const tid of taskIds) { + const linked = store.getSkillsByTask(tid); + for (const rs of linked) { + if (!skillCandidateMap.has(rs.skill.id)) { + skillCandidateMap.set(rs.skill.id, { name: rs.skill.name, description: rs.skill.description, skillId: rs.skill.id, source: `task:${tid}` }); + } + } + } + + const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit); + + if (skillCandidates.length > 0) { + const skillLines = skillCandidates.map((sc, i) => { + const manifest = skillInstaller.getCompanionManifest(sc.skillId); + let badge = ""; + if (manifest?.installed) badge = " [installed]"; + else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]"; + else if (manifest?.hasCompanionFiles) badge = " [has companion files]"; + const action = `call \`skill_get(skillId="${sc.skillId}")\``; + return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`; + }); + skillSection = "\n\n## Relevant skills from past experience\n\n" + + "The following skills were distilled from similar previous tasks. " + + "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" + + skillLines.join("\n\n"); + + ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`); + try { + store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true); + } catch { /* best-effort */ } + } else { + ctx.log.debug("auto-recall-skill: no matching skills found"); + } + } catch (err) { + ctx.log.debug(`auto-recall-skill: failed: ${err}`); + } + } + + if (skillSection) contextParts.push(skillSection); + const context = contextParts.join("\n"); + + const recallDur = performance.now() - recallT0; + store.recordToolCall("memory_search", recallDur, true); + store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ + candidates: rawLocalCandidates, + hubCandidates: rawHubCandidates, + filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local" })), + }), recallDur, true); + telemetry.trackAutoRecall(filteredHits.length, recallDur); + + ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`); + + if (!sufficient) { + const searchHint = + "\n\nIf these memories don't fully answer the question, " + + "call `memory_search` with a shorter or rephrased query to find more."; + return { prependContext: context + searchHint }; + } - return { prependContext: skillContext }; + return { + prependContext: context, + }; } catch (err) { - ctx.log.warn(`auto-recall-skill failed: ${String(err)}`); + const dur = performance.now() - recallT0; + store.recordToolCall("memory_search", dur, false); + try { store.recordApiLog("memory_search", { type: "auto_recall", query: recallQuery }, `error: ${String(err)}`, dur, false); } catch (_) { /* best-effort */ } + ctx.log.warn(`auto-recall failed: ${String(err)}`); } }); diff --git a/apps/memos-local-openclaw/install.ps1 b/apps/memos-local-openclaw/install.ps1 index 449831756..f48d130f1 100644 --- a/apps/memos-local-openclaw/install.ps1 +++ b/apps/memos-local-openclaw/install.ps1 @@ -184,11 +184,6 @@ if (!config.plugins.allow.includes(pluginId)) { config.plugins.allow.push(pluginId); } -if (!config.plugins.slots || typeof config.plugins.slots !== "object") { - config.plugins.slots = {}; -} -config.plugins.slots.contextEngine = "memos-local-openclaw-plugin"; - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); '@ $nodeScript | & node - $ConfigPath $PluginId diff --git a/apps/memos-local-openclaw/install.sh b/apps/memos-local-openclaw/install.sh index b8229d17a..1294be5f8 100644 --- a/apps/memos-local-openclaw/install.sh +++ b/apps/memos-local-openclaw/install.sh @@ -246,11 +246,6 @@ if (!config.plugins.allow.includes(pluginId)) { config.plugins.allow.push(pluginId); } -if (!config.plugins.slots || typeof config.plugins.slots !== 'object') { - config.plugins.slots = {}; -} -config.plugins.slots.contextEngine = 'memos-local-openclaw-plugin'; - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); NODE success "OpenClaw config updated, OpenClaw 配置已更新: ${OPENCLAW_CONFIG_PATH}" diff --git a/apps/memos-local-openclaw/src/context-engine/index.ts b/apps/memos-local-openclaw/src/context-engine/index.ts deleted file mode 100644 index 5b32f672c..000000000 --- a/apps/memos-local-openclaw/src/context-engine/index.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * MemOS Local Memory — Context Engine - * - * Injects recalled memories into assistant messages wrapped in - * tags. OpenClaw's UI automatically strips these tags from assistant messages, - * keeping the chat clean while providing full context to the LLM. - * - * Memory blocks are persisted into the session file so the prompt prefix remains - * stable across turns, maximizing KV cache reuse. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Minimal AgentMessage shape used by OpenClaw */ -export interface AgentMessage { - role: string; - content: string | ContentBlock[]; - timestamp?: number; - [key: string]: unknown; -} - -export interface ContentBlock { - type: string; - text?: string; - [key: string]: unknown; -} - -export interface SearchHit { - score: number; - summary: string; - original_excerpt?: string; - source: { role: string; ts?: number; sessionKey?: string }; - ref: { chunkId: string; sessionKey?: string; turnId?: string; seq?: number }; - taskId?: string | null; - skillId?: string | null; - origin?: string; - ownerName?: string; - groupName?: string; -} - -export interface RecallSearchResult { - hits: SearchHit[]; -} - -export interface RecallEngineLike { - search(params: { - query: string; - maxResults: number; - minScore: number; - ownerFilter?: string[]; - }): Promise; -} - -export interface PendingInjection { - sessionKey: string; - memoryBlock: string; - isSynthetic: boolean; -} - -export interface ContextEngineLogger { - info(msg: string): void; - warn(msg: string): void; - debug(msg: string): void; -} - -// --------------------------------------------------------------------------- -// Message helpers -// --------------------------------------------------------------------------- - -export function getTextFromMessage(msg: AgentMessage): string { - if (typeof msg.content === "string") return msg.content; - if (Array.isArray(msg.content)) { - return msg.content - .filter((b) => b.type === "text" && typeof b.text === "string") - .map((b) => b.text!) - .join(""); - } - return ""; -} - -export function appendMemoryToMessage(msg: AgentMessage, memoryBlock: string): void { - if (typeof msg.content === "string") { - msg.content = msg.content + memoryBlock; - return; - } - if (Array.isArray(msg.content)) { - const lastText = [...msg.content].reverse().find((b) => b.type === "text"); - if (lastText && typeof lastText.text === "string") { - lastText.text += memoryBlock; - } else { - msg.content.push({ type: "text", text: memoryBlock }); - } - return; - } - msg.content = memoryBlock; -} - -const MEMORY_TAG_RE = /\n?[\s\S]*?<\/relevant-memories>/g; - -export function removeExistingMemoryBlock(msg: AgentMessage): void { - if (typeof msg.content === "string") { - msg.content = msg.content.replace(MEMORY_TAG_RE, ""); - return; - } - if (Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "text" && typeof block.text === "string") { - block.text = block.text.replace(MEMORY_TAG_RE, ""); - } - } - } -} - -export function messageHasMemoryBlock(msg: AgentMessage): boolean { - return getTextFromMessage(msg).includes(""); -} - -// --------------------------------------------------------------------------- -// Memory block formatting -// --------------------------------------------------------------------------- - -export function formatMemoryBlock(hits: SearchHit[]): string { - const lines = hits - .map( - (h, i) => - `${i + 1}. [${h.source.role}] ${(h.original_excerpt ?? h.summary).slice(0, 200)}`, - ) - .join("\n"); - return ( - `\n\n` + - `[Memory context relevant to the next user message — injected by user's memory system, not part of assistant's original reply]\n\n` + - `${lines}\n` + - `` - ); -} - -// --------------------------------------------------------------------------- -// Deduplication (shared with main plugin) -// --------------------------------------------------------------------------- - -export function deduplicateHits(hits: T[]): T[] { - const kept: T[] = []; - for (const hit of hits) { - const dominated = kept.some((k) => { - const a = k.summary.toLowerCase(); - const b = hit.summary.toLowerCase(); - if (a === b) return true; - const wordsA = new Set(a.split(/\s+/).filter((w) => w.length > 1)); - const wordsB = new Set(b.split(/\s+/).filter((w) => w.length > 1)); - if (wordsA.size === 0 || wordsB.size === 0) return false; - let overlap = 0; - for (const w of wordsB) { - if (wordsA.has(w)) overlap++; - } - return overlap / Math.min(wordsA.size, wordsB.size) > 0.7; - }); - if (!dominated) kept.push(hit); - } - return kept; -} - -// --------------------------------------------------------------------------- -// Session manager helpers (for maintain() persistence) -// --------------------------------------------------------------------------- - -interface SessionBranchEntry { - id: string; - type: string; - parentId?: string | null; - message?: AgentMessage; - summary?: string; - firstKeptEntryId?: string; - tokensBefore?: number; - details?: unknown; - fromHook?: unknown; - thinkingLevel?: string; - provider?: string; - modelId?: string; - customType?: string; - data?: unknown; - content?: unknown; - display?: unknown; - name?: string; - targetId?: string; - label?: string; -} - -interface SessionManagerLike { - getBranch(): SessionBranchEntry[]; - branch(parentId: string): void; - resetLeaf(): void; - appendMessage(msg: unknown): string; - appendCompaction( - summary: string, - firstKeptEntryId: string, - tokensBefore: number, - details?: unknown, - fromHook?: unknown, - ): string; - appendThinkingLevelChange(level: string): string; - appendModelChange(provider: string, modelId: string): string; - appendCustomEntry(customType: string, data: unknown): string; - appendCustomMessageEntry( - customType: string, - content: unknown, - display: unknown, - details?: unknown, - ): string; - appendSessionInfo(name: string): string; - branchWithSummary( - parentId: string | null, - summary: string, - details?: unknown, - fromHook?: unknown, - ): string; - appendLabelChange(targetId: string, label: string): string; -} - -/** - * Re-append a branch entry preserving its type. Mirrors the - * `appendBranchEntry` pattern from OpenClaw's transcript-rewrite module. - */ -function reappendEntry(sm: SessionManagerLike, entry: SessionBranchEntry): string { - switch (entry.type) { - case "message": - return sm.appendMessage(entry.message); - case "compaction": - return sm.appendCompaction( - entry.summary ?? "", - entry.firstKeptEntryId ?? "", - entry.tokensBefore ?? 0, - entry.details, - entry.fromHook, - ); - case "thinking_level_change": - return sm.appendThinkingLevelChange(entry.thinkingLevel ?? ""); - case "model_change": - return sm.appendModelChange(entry.provider ?? "", entry.modelId ?? ""); - case "custom": - return sm.appendCustomEntry(entry.customType ?? "", entry.data); - case "custom_message": - return sm.appendCustomMessageEntry( - entry.customType ?? "", - entry.content, - entry.display, - entry.details, - ); - case "session_info": - return sm.appendSessionInfo(entry.name ?? ""); - case "branch_summary": - return sm.branchWithSummary( - entry.parentId ?? null, - entry.summary ?? "", - entry.details, - entry.fromHook, - ); - default: - if (entry.targetId !== undefined && entry.label !== undefined) { - return sm.appendLabelChange(entry.targetId, entry.label); - } - return sm.appendMessage(entry.message); - } -} - -/** - * Insert a synthetic assistant message at the start of the session branch - * (before any existing entries). Uses the branch-and-reappend pattern. - */ -export function insertSyntheticAssistantEntry( - sm: SessionManagerLike, - memoryBlock: string, -): boolean { - const branch = sm.getBranch(); - if (branch.length === 0) return false; - - const firstEntry = branch[0]; - if (firstEntry.parentId) { - sm.branch(firstEntry.parentId); - } else { - sm.resetLeaf(); - } - - sm.appendMessage({ - role: "assistant", - content: [{ type: "text", text: memoryBlock }], - timestamp: Date.now(), - stopReason: "end_turn", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - }); - - for (const entry of branch) { - reappendEntry(sm, entry); - } - return true; -} - -/** - * Find the target assistant entry for memory injection in the session branch. - * Returns the last assistant entry that appears before the last user entry. - */ -export function findTargetAssistantEntry( - branch: SessionBranchEntry[], -): SessionBranchEntry | null { - let lastUserIdx = -1; - for (let i = branch.length - 1; i >= 0; i--) { - if (branch[i].type === "message" && branch[i].message?.role === "user") { - lastUserIdx = i; - break; - } - } - if (lastUserIdx < 0) return null; - - for (let i = lastUserIdx - 1; i >= 0; i--) { - if (branch[i].type === "message" && branch[i].message?.role === "assistant") { - return branch[i]; - } - } - return null; -} diff --git a/apps/memos-local-openclaw/tests/context-engine.test.ts b/apps/memos-local-openclaw/tests/context-engine.test.ts deleted file mode 100644 index 9dba4b143..000000000 --- a/apps/memos-local-openclaw/tests/context-engine.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - type AgentMessage, - type SearchHit, - getTextFromMessage, - appendMemoryToMessage, - removeExistingMemoryBlock, - messageHasMemoryBlock, - formatMemoryBlock, - deduplicateHits, - insertSyntheticAssistantEntry, - findTargetAssistantEntry, -} from "../src/context-engine"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeMsg(role: string, text: string): AgentMessage { - return { role, content: [{ type: "text", text }], timestamp: Date.now() }; -} - -function makeStringMsg(role: string, text: string): AgentMessage { - return { role, content: text, timestamp: Date.now() }; -} - -function makeHit(overrides?: Partial): SearchHit { - return { - score: 0.85, - summary: "test memory summary", - original_excerpt: "test memory excerpt content", - source: { role: "user", ts: Date.now(), sessionKey: "s1" }, - ref: { chunkId: "c1", sessionKey: "s1", turnId: "t1", seq: 0 }, - taskId: null, - skillId: null, - origin: "local", - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// getTextFromMessage -// --------------------------------------------------------------------------- - -describe("getTextFromMessage", () => { - it("extracts text from string content", () => { - const msg = makeStringMsg("assistant", "hello world"); - expect(getTextFromMessage(msg)).toBe("hello world"); - }); - - it("extracts text from content block array", () => { - const msg: AgentMessage = { - role: "assistant", - content: [ - { type: "text", text: "part one" }, - { type: "image", url: "http://..." }, - { type: "text", text: "part two" }, - ], - }; - expect(getTextFromMessage(msg)).toBe("part onepart two"); - }); - - it("returns empty string for non-text content", () => { - const msg: AgentMessage = { - role: "assistant", - content: [{ type: "image", url: "http://..." }], - }; - expect(getTextFromMessage(msg)).toBe(""); - }); - - it("returns empty string for undefined content", () => { - const msg: AgentMessage = { role: "assistant", content: "" }; - expect(getTextFromMessage(msg)).toBe(""); - }); -}); - -// --------------------------------------------------------------------------- -// appendMemoryToMessage -// --------------------------------------------------------------------------- - -describe("appendMemoryToMessage", () => { - it("appends to string content", () => { - const msg = makeStringMsg("assistant", "response text"); - appendMemoryToMessage(msg, "\n\nmem\n"); - expect(msg.content).toBe( - "response text\n\nmem\n", - ); - }); - - it("appends to last text block in content array", () => { - const msg = makeMsg("assistant", "response text"); - appendMemoryToMessage(msg, "\n\nmem\n"); - const blocks = msg.content as Array<{ type: string; text?: string }>; - expect(blocks[0].text).toBe( - "response text\n\nmem\n", - ); - }); - - it("creates a new text block if none exist", () => { - const msg: AgentMessage = { - role: "assistant", - content: [{ type: "image", url: "http://..." }], - }; - appendMemoryToMessage(msg, "\n\nmem\n"); - const blocks = msg.content as Array<{ type: string; text?: string }>; - expect(blocks).toHaveLength(2); - expect(blocks[1].type).toBe("text"); - expect(blocks[1].text).toContain(""); - }); - - it("handles empty content by setting it", () => { - const msg: AgentMessage = { role: "assistant", content: undefined as any }; - appendMemoryToMessage(msg, "memory"); - expect(msg.content).toBe("memory"); - }); -}); - -// --------------------------------------------------------------------------- -// removeExistingMemoryBlock -// --------------------------------------------------------------------------- - -describe("removeExistingMemoryBlock", () => { - it("removes memory block from string content", () => { - const msg = makeStringMsg( - "assistant", - "response text\n\nsome memories\n", - ); - removeExistingMemoryBlock(msg); - expect(msg.content).toBe("response text"); - }); - - it("removes memory block from content block array", () => { - const msg = makeMsg( - "assistant", - "response text\n\nsome memories\n", - ); - removeExistingMemoryBlock(msg); - const blocks = msg.content as Array<{ type: string; text: string }>; - expect(blocks[0].text).toBe("response text"); - }); - - it("handles message without memory block (no-op)", () => { - const msg = makeMsg("assistant", "clean response"); - removeExistingMemoryBlock(msg); - expect(getTextFromMessage(msg)).toBe("clean response"); - }); - - it("removes multiple memory blocks", () => { - const msg = makeStringMsg( - "assistant", - "text\n\nmem1\n\nmore\n\nmem2\n", - ); - removeExistingMemoryBlock(msg); - expect(msg.content).toBe("text\nmore"); - }); -}); - -// --------------------------------------------------------------------------- -// messageHasMemoryBlock -// --------------------------------------------------------------------------- - -describe("messageHasMemoryBlock", () => { - it("returns true when memory block exists", () => { - const msg = makeMsg( - "assistant", - "text\n\nmem\n", - ); - expect(messageHasMemoryBlock(msg)).toBe(true); - }); - - it("returns false when no memory block", () => { - const msg = makeMsg("assistant", "clean text"); - expect(messageHasMemoryBlock(msg)).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// formatMemoryBlock -// --------------------------------------------------------------------------- - -describe("formatMemoryBlock", () => { - it("formats hits into a memory block with tags", () => { - const hits: SearchHit[] = [ - makeHit({ source: { role: "user" }, original_excerpt: "user said hello" }), - makeHit({ source: { role: "assistant" }, original_excerpt: "bot replied hi" }), - ]; - const block = formatMemoryBlock(hits); - expect(block).toContain(""); - expect(block).toContain(""); - expect(block).toContain("1. [user] user said hello"); - expect(block).toContain("2. [assistant] bot replied hi"); - expect(block).toContain("not part of assistant's original reply"); - }); - - it("truncates excerpts at 200 chars", () => { - const longExcerpt = "x".repeat(300); - const hits = [makeHit({ original_excerpt: longExcerpt })]; - const block = formatMemoryBlock(hits); - const match = block.match(/1\. \[user\] (x+)/); - expect(match).toBeTruthy(); - expect(match![1].length).toBe(200); - }); - - it("falls back to summary when excerpt is missing", () => { - const hits = [ - makeHit({ original_excerpt: undefined, summary: "summary fallback" }), - ]; - const block = formatMemoryBlock(hits); - expect(block).toContain("summary fallback"); - }); -}); - -// --------------------------------------------------------------------------- -// deduplicateHits -// --------------------------------------------------------------------------- - -describe("deduplicateHits", () => { - it("removes near-duplicate hits by summary overlap", () => { - const hits = [ - { summary: "the quick brown fox jumps over the lazy dog", score: 0.9 }, - { summary: "the quick brown fox jumps over a lazy dog", score: 0.8 }, - { summary: "completely different summary about cats", score: 0.7 }, - ]; - const result = deduplicateHits(hits); - expect(result).toHaveLength(2); - expect(result[0].score).toBe(0.9); - expect(result[1].summary).toContain("cats"); - }); - - it("keeps all hits when no duplicates", () => { - const hits = [ - { summary: "alpha beta gamma", score: 0.9 }, - { summary: "delta epsilon zeta", score: 0.8 }, - ]; - expect(deduplicateHits(hits)).toHaveLength(2); - }); - - it("handles empty input", () => { - expect(deduplicateHits([])).toHaveLength(0); - }); - - it("handles exact duplicate summaries", () => { - const hits = [ - { summary: "exactly the same", score: 0.9 }, - { summary: "exactly the same", score: 0.7 }, - ]; - expect(deduplicateHits(hits)).toHaveLength(1); - expect(deduplicateHits(hits)[0].score).toBe(0.9); - }); -}); - -// --------------------------------------------------------------------------- -// SessionManager mock for insertSyntheticAssistantEntry and findTargetAssistantEntry -// --------------------------------------------------------------------------- - -class MockSessionManager { - private entries: Array<{ - id: string; - type: string; - parentId?: string | null; - message?: AgentMessage; - [key: string]: unknown; - }> = []; - private nextId = 1; - private branchedFrom: string | null = null; - public appendedMessages: unknown[] = []; - - addEntry(type: string, message?: AgentMessage, parentId?: string | null) { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type, parentId: parentId ?? null, message }); - return id; - } - - getBranch() { - return [...this.entries]; - } - - branch(parentId: string) { - this.branchedFrom = parentId; - this.entries = []; - this.nextId = 100; - } - - resetLeaf() { - this.branchedFrom = "__root__"; - this.entries = []; - this.nextId = 100; - } - - appendMessage(msg: unknown): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ - id, - type: "message", - parentId: null, - message: msg as AgentMessage, - }); - this.appendedMessages.push(msg); - return id; - } - - appendCompaction( - summary: string, - firstKeptEntryId: string, - tokensBefore: number, - details?: unknown, - fromHook?: unknown, - ): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ - id, - type: "compaction", - summary, - firstKeptEntryId, - tokensBefore, - details, - fromHook, - }); - return id; - } - - appendThinkingLevelChange(level: string): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type: "thinking_level_change", thinkingLevel: level }); - return id; - } - - appendModelChange(provider: string, modelId: string): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type: "model_change", provider, modelId }); - return id; - } - - appendCustomEntry(customType: string, data: unknown): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type: "custom", customType, data }); - return id; - } - - appendCustomMessageEntry( - customType: string, - content: unknown, - display: unknown, - details?: unknown, - ): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type: "custom_message", customType, content, display, details }); - return id; - } - - appendSessionInfo(name: string): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type: "session_info", name }); - return id; - } - - branchWithSummary( - parentId: string | null, - summary: string, - details?: unknown, - fromHook?: unknown, - ): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type: "branch_summary", parentId, summary, details, fromHook }); - return id; - } - - appendLabelChange(targetId: string, label: string): string { - const id = `entry-${this.nextId++}`; - this.entries.push({ id, type: "label_change", targetId, label }); - return id; - } - - getBranchedFrom() { - return this.branchedFrom; - } -} - -// --------------------------------------------------------------------------- -// insertSyntheticAssistantEntry -// --------------------------------------------------------------------------- - -describe("insertSyntheticAssistantEntry", () => { - it("inserts synthetic assistant before existing entries", () => { - const sm = new MockSessionManager(); - sm.addEntry("message", makeMsg("user", "hello"), null); - sm.addEntry("message", makeMsg("assistant", "hi there")); - - const memBlock = - "\n\n[Memory context]\n1. test mem\n"; - const ok = insertSyntheticAssistantEntry(sm as any, memBlock); - - expect(ok).toBe(true); - const branch = sm.getBranch(); - expect(branch).toHaveLength(3); - expect(branch[0].type).toBe("message"); - expect(branch[0].message?.role).toBe("assistant"); - expect(getTextFromMessage(branch[0].message!)).toContain(""); - expect(branch[1].message?.role).toBe("user"); - expect(branch[2].message?.role).toBe("assistant"); - }); - - it("returns false for empty branch", () => { - const sm = new MockSessionManager(); - const ok = insertSyntheticAssistantEntry(sm as any, "mem"); - expect(ok).toBe(false); - }); - - it("preserves non-message entries during reappend", () => { - const sm = new MockSessionManager(); - sm.addEntry("message", makeMsg("user", "hello"), "root-id"); - sm.addEntry("thinking_level_change", undefined); - (sm.getBranch()[1] as any).thinkingLevel = "high"; - sm.addEntry("message", makeMsg("assistant", "response")); - - const ok = insertSyntheticAssistantEntry(sm as any, "mem"); - expect(ok).toBe(true); - - const branch = sm.getBranch(); - expect(branch.length).toBeGreaterThanOrEqual(3); - const types = branch.map((e) => e.type); - expect(types[0]).toBe("message"); - expect(types).toContain("thinking_level_change"); - }); - - it("calls resetLeaf when first entry has no parentId", () => { - const sm = new MockSessionManager(); - sm.addEntry("message", makeMsg("user", "hello"), null); - - insertSyntheticAssistantEntry(sm as any, "mem"); - expect(sm.getBranchedFrom()).toBe("__root__"); - }); - - it("calls branch with parentId when first entry has one", () => { - const sm = new MockSessionManager(); - sm.addEntry("message", makeMsg("user", "hello"), "parent-123"); - - insertSyntheticAssistantEntry(sm as any, "mem"); - expect(sm.getBranchedFrom()).toBe("parent-123"); - }); -}); - -// --------------------------------------------------------------------------- -// findTargetAssistantEntry -// --------------------------------------------------------------------------- - -describe("findTargetAssistantEntry", () => { - it("finds last assistant before last user", () => { - const branch = [ - { id: "e1", type: "message", message: makeMsg("user", "q1") }, - { id: "e2", type: "message", message: makeMsg("assistant", "a1") }, - { id: "e3", type: "message", message: makeMsg("user", "q2") }, - ]; - const target = findTargetAssistantEntry(branch); - expect(target).not.toBeNull(); - expect(target!.id).toBe("e2"); - }); - - it("returns null when no assistant before user", () => { - const branch = [ - { id: "e1", type: "message", message: makeMsg("user", "q1") }, - ]; - expect(findTargetAssistantEntry(branch)).toBeNull(); - }); - - it("returns null for empty branch", () => { - expect(findTargetAssistantEntry([])).toBeNull(); - }); - - it("skips non-message entries", () => { - const branch = [ - { id: "e1", type: "message", message: makeMsg("assistant", "a0") }, - { id: "e2", type: "compaction", summary: "compacted" }, - { id: "e3", type: "message", message: makeMsg("user", "q1") }, - { id: "e4", type: "message", message: makeMsg("assistant", "a1") }, - { id: "e5", type: "message", message: makeMsg("user", "q2") }, - ]; - const target = findTargetAssistantEntry(branch); - expect(target!.id).toBe("e4"); - }); - - it("picks the immediate assistant before the last user, not an earlier one", () => { - const branch = [ - { id: "e1", type: "message", message: makeMsg("assistant", "a0") }, - { id: "e2", type: "message", message: makeMsg("user", "q1") }, - { id: "e3", type: "message", message: makeMsg("assistant", "a1") }, - { id: "e4", type: "message", message: makeMsg("assistant", "a2") }, - { id: "e5", type: "message", message: makeMsg("user", "q2") }, - ]; - const target = findTargetAssistantEntry(branch); - expect(target!.id).toBe("e4"); - }); -}); - -// --------------------------------------------------------------------------- -// Integration: full injection + removal cycle -// --------------------------------------------------------------------------- - -describe("injection cycle integration", () => { - it("inject → detect → remove → re-inject produces clean result", () => { - const msg = makeMsg("assistant", "original response"); - - const mem1 = formatMemoryBlock([ - makeHit({ original_excerpt: "user likes cats" }), - ]); - appendMemoryToMessage(msg, mem1); - expect(messageHasMemoryBlock(msg)).toBe(true); - expect(getTextFromMessage(msg)).toContain("user likes cats"); - - removeExistingMemoryBlock(msg); - expect(messageHasMemoryBlock(msg)).toBe(false); - expect(getTextFromMessage(msg)).toBe("original response"); - - const mem2 = formatMemoryBlock([ - makeHit({ original_excerpt: "user likes dogs" }), - ]); - appendMemoryToMessage(msg, mem2); - expect(messageHasMemoryBlock(msg)).toBe(true); - expect(getTextFromMessage(msg)).toContain("user likes dogs"); - expect(getTextFromMessage(msg)).not.toContain("user likes cats"); - }); - - it("works with string content messages", () => { - const msg = makeStringMsg("assistant", "string response"); - const mem = formatMemoryBlock([makeHit({ original_excerpt: "mem1" })]); - - appendMemoryToMessage(msg, mem); - expect(messageHasMemoryBlock(msg)).toBe(true); - - removeExistingMemoryBlock(msg); - expect(msg.content).toBe("string response"); - expect(messageHasMemoryBlock(msg)).toBe(false); - }); -}); From 4c5c4898cd2d8f18cda4b01319f9f42ed3cdca62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=81=E5=9C=A8=E5=90=B5=E7=9D=80=E5=90=83=E7=B3=96?= Date: Wed, 1 Apr 2026 16:53:33 +0800 Subject: [PATCH 09/33] fix viewer agent memory list --- apps/memos-local-openclaw/src/viewer/html.ts | 39 ++++++++------------ 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/apps/memos-local-openclaw/src/viewer/html.ts b/apps/memos-local-openclaw/src/viewer/html.ts index 2dbd16497..95e4da87d 100644 --- a/apps/memos-local-openclaw/src/viewer/html.ts +++ b/apps/memos-local-openclaw/src/viewer/html.ts @@ -1251,7 +1251,6 @@ input,textarea,select{font-family:inherit;font-size:inherit} @@ -1295,7 +1294,6 @@ input,textarea,select{font-family:inherit;font-size:inherit} @@ -1337,7 +1335,6 @@ input,textarea,select{font-family:inherit;font-size:inherit} 🔍 @@ -2030,7 +2027,7 @@ input,textarea,select{font-family:inherit;font-size:inherit}