Skip to content

Commit 2d85c73

Browse files
authored
Openclaw local plugin 20260408 (#1454)
## Description This PR introduces UX enhancements for model configurations, improves the server shutdown lifecycle, and fixes a critical issue where multiple Viewer instances were being spawned concurrently due to OpenClaw lifecycle mismatch. **Key Changes:** 1. **Multiple Viewer Instances Fix (Fixes #1422, #1425, #1430)**: - **The Problem**: Previously, the plugin's Viewer could self-start multiple times during OpenClaw's initialization or hot-reloads. This led to duplicate instances, port drifting (e.g., 18799 → 18800+), resource waste, and occasionally caused the Memory service to be reported as unavailable despite a fixed port configuration. - **The Solution**: Introduced a global singleton state (`globalRef.__memosLocalPluginActiveService`) to ensure the service startup is idempotent across repeated register/init cycles. The self-start fallback logic was also refactored to conditionally trigger based on `process.argv` arguments (ensuring it only auto-starts for `gateway`, `start`, or `restart` commands), completely preventing orphan instances and port accumulation. 2. **Model Configuration Warnings**: Added visual warning banners in the Viewer UI (Settings and Main view) to alert users when critical models (Embedding, Summarizer, Skill Evolution) are missing or using the built-in mini model. This helps prevent degraded memory retrieval and processing experiences. 3. **Graceful Shutdown**: Refactored `ViewerServer.stop()` to be asynchronous, utilizing `server.closeAllConnections()` to ensure all active connections are properly closed and releasing the singleton lock before shutting down the process. Related Issue (Required): Fixes #1430, Fixes #1425, Fixes #1422 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Refactor (does not change functionality, e.g. code style improvements, linting) - [ ] Documentation update ## How Has This Been Tested? - [x] Unit Test - [x] Test Script Or Test Steps (please provide) - **Multiple Instances Fix**: Started OpenClaw gateway and verified that the Viewer only starts once on the configured port. Triggered OpenClaw hot-reloads and confirmed the singleton lock prevents duplicate Viewer instances and port drift. - **Shutdown Lifecycle**: Started the viewer using `start-viewer.ts` and triggered `SIGINT` to verify the asynchronous graceful shutdown works as expected without leaving hanging processes. - **UI Warnings**: Verified the UI fallback warning banners correctly render and disappear across both English and Chinese locales when model configurations are absent. - Ran integration and unit tests (`pnpm test`) to ensure everything works correctly. - [ ] Pipeline Automated API Test (please provide) ## Checklist - [x] I have performed a self-review of my own code | 我已自行检查了自己的代码 - [x] I have commented my code in hard-to-understand areas | 我已在难以理解的地方对代码进行了注释 - [x] I have added tests that prove my fix is effective or that my feature works | 我已添加测试以证明我的修复有效或功能正常 - [ ] I have created related documentation issue/PR in `https://github.com/MemTensor/MemOS-Docs` (if applicable) | 我已在 `https://github.com/MemTensor/MemOS-Docs` 中创建了相关的文档 issue/PR(如果适用) - [x] I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR(如果适用) - [ ] I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人 ## Reviewer Checklist - [x] closes #1430 - [x] closes #1425 - [x] closes #1422 - [ ] Made sure Checks passed - [ ] Tests have been provided
2 parents abfe2f4 + 0173a4d commit 2d85c73

4 files changed

Lines changed: 129 additions & 22 deletions

File tree

apps/memos-local-openclaw/index.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,15 @@ const memosLocalPlugin = {
389389
let currentAgentId = "main";
390390
const getCurrentOwner = () => `agent:${currentAgentId}`;
391391

392+
// Manage global singleton instance to prevent duplicate startups
393+
// during OpenClaw hot-reloads or deferred re-registrations.
394+
const globalRef = globalThis as any;
395+
396+
if (globalRef.__memosLocalPluginActiveService) {
397+
api.logger.info("memos-local: Plugin is already running. Reusing the existing backend and returning early.");
398+
return;
399+
}
400+
392401
// ─── Check allowPromptInjection policy ───
393402
// When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
394403
// will be stripped by the framework. Skip auto-recall to avoid unnecessary LLM/embedding calls.
@@ -2383,7 +2392,19 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
23832392
let serviceStarted = false;
23842393

23852394
const startServiceCore = async () => {
2395+
if (globalRef.__memosLocalPluginStopPromise) {
2396+
await globalRef.__memosLocalPluginStopPromise;
2397+
globalRef.__memosLocalPluginStopPromise = undefined;
2398+
}
23862399
if (serviceStarted) return;
2400+
2401+
// If another registration has occurred, we are no longer the active service.
2402+
// Abort starting to prevent orphan instances.
2403+
if (globalRef.__memosLocalPluginActiveService && globalRef.__memosLocalPluginActiveService !== service) {
2404+
api.logger.info("memos-local: aborting startServiceCore because a newer plugin instance is active.");
2405+
return;
2406+
}
2407+
23872408
serviceStarted = true;
23882409

23892410
if (hubServer) {
@@ -2425,27 +2446,43 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
24252446
);
24262447
};
24272448

2428-
api.registerService({
2449+
const service = {
24292450
id: "memos-local-openclaw-plugin",
24302451
start: async () => { await startServiceCore(); },
24312452
stop: async () => {
2453+
await viewer.stop();
2454+
if (globalRef.__memosLocalPluginActiveService === service) {
2455+
globalRef.__memosLocalPluginActiveService = undefined;
2456+
}
24322457
await worker.flush();
24332458
await telemetry.shutdown();
24342459
await hubServer?.stop();
2435-
viewer.stop();
24362460
store.close();
24372461
api.logger.info("memos-local: stopped");
24382462
},
2439-
});
2463+
};
2464+
2465+
api.registerService(service);
2466+
globalRef.__memosLocalPluginActiveService = service;
24402467

24412468
// Fallback: OpenClaw may load this plugin via deferred reload after
24422469
// startPluginServices has already run, so service.start() never fires.
2443-
// Start on the next tick instead of waiting several seconds; the
2444-
// serviceStarted guard still prevents duplicate startup if the host calls
2445-
// service.start() immediately after registration.
2446-
const SELF_START_DELAY_MS = 0;
2470+
// Start on a delay instead of next tick so the host has time to call
2471+
// service.start() during normal startup if this is a fresh launch.
2472+
const SELF_START_DELAY_MS = 2000;
24472473
setTimeout(() => {
2448-
if (!serviceStarted) {
2474+
const args = process.argv.map(arg => String(arg || "").toLowerCase());
2475+
const gatewayIndex = args.lastIndexOf("gateway");
2476+
let shouldStart = false;
2477+
2478+
if (gatewayIndex !== -1) {
2479+
const nextArg = args[gatewayIndex + 1];
2480+
if (!nextArg || nextArg.startsWith("-") || nextArg === "start" || nextArg === "restart") {
2481+
shouldStart = true;
2482+
}
2483+
}
2484+
2485+
if (!serviceStarted && shouldStart) {
24492486
api.logger.info("memos-local: service.start() not called by host, self-starting viewer...");
24502487
startServiceCore().catch((err) => {
24512488
api.logger.warn(`memos-local: self-start failed: ${err}`);

apps/memos-local-openclaw/scripts/start-viewer.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ async function main() {
7171
console.log(`\x1b[90m Reset token: ${viewer.getResetToken()}\x1b[0m`);
7272
console.log(`\x1b[90m Press Ctrl+C to stop\x1b[0m`);
7373

74-
process.on("SIGINT", () => {
75-
viewer.stop();
74+
process.on("SIGINT", async () => {
75+
console.log("Shutting down viewer...");
76+
await viewer.stop();
7677
store.close();
7778
process.exit(0);
7879
});

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

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ input,textarea,select{font-family:inherit;font-size:inherit}
124124
.topbar-center{flex:1;display:flex;justify-content:center}
125125
.topbar .actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
126126

127-
.main-content{display:flex;flex:1;max-width:1400px;margin:0 auto;width:100%;padding:28px 32px;gap:28px}
127+
.main-content{display:grid;grid-template-columns:260px 1fr;grid-template-rows:auto 1fr;flex:1;max-width:1400px;margin:0 auto;width:100%;padding:28px 32px;gap:28px}
128128

129129
/* ─── Sidebar ─── */
130130
.sidebar{width:260px;min-width:260px;flex-shrink:0;position:sticky;top:84px;max-height:calc(100vh - 112px);display:flex;flex-direction:column}
@@ -1500,6 +1500,16 @@ input,textarea,select{font-family:inherit;font-size:inherit}
15001500
</div>
15011501
</div>
15021502
<div class="settings-card-body">
1503+
<div class="emb-banner warning" id="settingsWarningBanner" style="margin: 0 0 24px 0; display: block; border-left: 4px solid #f59e0b; position: relative;">
1504+
<button onclick="document.getElementById('settingsWarningBanner').style.display='none'" style="position: absolute; right: 12px; top: 12px; background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 16px; opacity: 0.7; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7" title="关闭" aria-label="关闭">\u2715</button>
1505+
<div style="font-weight: 700; margin-bottom: 8px; font-size: 14px; color: #d97706;">\u{1F514} 模型配置重要提醒</div>
1506+
<ul style="margin: 0; padding-left: 20px; line-height: 1.7; color: var(--text-sec); font-size: 13px;">
1507+
<li><strong>嵌入模型 (Embedding):</strong>插件内置模型规模较小。为获得更精准的记忆检索体验,强烈建议配置 <code>bge-m3</code> 等专业嵌入模型。</li>
1508+
<li><strong>摘要模型 (Summarizer):</strong>此项为<strong>必填项</strong>,否则无法自动提取记忆摘要。建议配置<strong>非思考型</strong>大模型,以保障处理速度和流畅度。</li>
1509+
<li><strong>技能模型 (Skill Evolution):</strong>用于自动提取可复用技能。建议配置<strong>非思考型</strong>大模型,以获得最佳的生成效果和稳定性。</li>
1510+
</ul>
1511+
</div>
1512+
15031513
<!-- Embedding Model section -->
15041514
<div class="settings-card-subtitle">\u{1F4E1} <span data-i18n="settings.embedding">Embedding Model</span></div>
15051515
<div class="field-hint" style="margin-bottom:10px" data-i18n="settings.embedding.desc">Vector embedding model for memory search and retrieval</div>
@@ -2126,6 +2136,7 @@ const I18N={
21262136
'skills.search.placeholder':'Search skills...',
21272137
'skills.search.local':'Local',
21282138
'skills.search.noresult':'No matching skills found',
2139+
21292140
'skills.load.error':'Failed to load skills',
21302141
'skills.hub.title':'\u{1F310} Team Skills',
21312142
'skills.hub.empty':'No extra team skills to list here — either the hub has none yet, or every hub skill already appears in your local list above (same source skill).',
@@ -2273,7 +2284,10 @@ const I18N={
22732284
'confirm.clearall2':'Are you absolutely sure?',
22742285
'embed.on':'Embedding: ',
22752286
'embed.off':'No embedding model',
2276-
'embed.warn.local':'Using built-in mini model (384d). Search quality is limited — configure an embedding model in Settings for best results.',
2287+
'embed.warn.local':'<strong>Embedding</strong>: Using built-in mini model (384d). Search quality is limited. It is highly recommended to configure a dedicated Embedding model (like bge-m3) in Settings for best results.',
2288+
'fallback.banner.sum': '<strong>Summarizer</strong>: Summarizer model is not configured, automatic memory summarization is paused. A fast, non-reasoning model is recommended for best performance.',
2289+
'fallback.banner.skill': '<strong>Skill Evolution</strong>: Skill Evolution model is not configured. A fast, non-reasoning model is recommended for best stability and generation quality.',
2290+
'fallback.banner.goto': 'Configure models',
22772291
'embed.err.fail':'Embedding model error detected. Check Settings → Model Health.',
22782292
'embed.banner.goto':'Go to Settings',
22792293
'lang.switch':'中',
@@ -2899,6 +2913,7 @@ const I18N={
28992913
'skills.search.placeholder':'搜索技能...',
29002914
'skills.search.local':'本地',
29012915
'skills.search.noresult':'未找到匹配的技能',
2916+
29022917
'skills.load.error':'加载技能失败',
29032918
'skills.hub.title':'\u{1F310} 团队共享技能',
29042919
'skills.hub.empty':'下方只列出「Hub 上有、但上方本机列表尚未包含」的技能;若 Hub 条目已与本机同源同步,则只会在上方显示,此处为空属正常。',
@@ -3046,7 +3061,10 @@ const I18N={
30463061
'confirm.clearall2':'你真的确定吗?',
30473062
'embed.on':'嵌入模型:',
30483063
'embed.off':'无嵌入模型',
3049-
'embed.warn.local':'当前使用内置迷你模型(384维),搜索效果有限。强烈建议在「设置」中配置专用 Embedding 模型以获得最佳效果。',
3064+
'embed.warn.local':'<strong>嵌入模型 (Embedding)</strong>:当前使用内置迷你模型(384维),搜索效果有限。强烈建议在「设置」中配置专用 Embedding 模型(如bge-m3等模型)以获得最佳效果。',
3065+
'fallback.banner.sum': '<strong>摘要模型 (Summarizer)</strong>:摘要模型未配置,无法自动提取记忆摘要。建议配置非思考型大模型,以保障处理速度和流畅度。',
3066+
'fallback.banner.skill': '<strong>技能模型 (Skill Evolution)</strong>:技能模型未配置,建议配置非思考型大模型,以获得最佳的生成效果和稳定性。',
3067+
'fallback.banner.goto': '前往配置',
30503068
'embed.err.fail':'Embedding 模型调用异常,请前往「设置 → 模型健康」检查。',
30513069
'embed.banner.goto':'前往设置',
30523070
'lang.switch':'EN',
@@ -3647,7 +3665,15 @@ function applyI18n(){
36473665
const step2=document.getElementById('resetStep2Desc');
36483666
if(step2) step2.innerHTML=t('reset.step2.desc.pre')+'<span style="font-family:monospace;font-size:12px;color:var(--pri)">password reset token: <strong>a1b2c3d4e5f6...</strong></span>'+t('reset.step2.desc.post');
36493667
document.title=t('title')+' - OpenClaw';
3650-
if(typeof loadStats==='function' && document.getElementById('app').style.display==='flex'){loadStats();}
3668+
if(typeof loadStats==='function' && document.getElementById('app').style.display==='flex'){
3669+
_embeddingWarningShown = false;
3670+
_fallbackWarningShown = false;
3671+
_lastStatsFp = '';
3672+
const embB = document.getElementById('embBanner'); if (embB) embB.remove();
3673+
const fSumB = document.getElementById('fallbackBannerSum'); if (fSumB) fSumB.remove();
3674+
const fSkillB = document.getElementById('fallbackBannerSkill'); if (fSkillB) fSkillB.remove();
3675+
loadStats();
3676+
}
36513677
if(document.querySelector('.analytics-view.show') && typeof loadMetrics==='function'){loadMetrics();}
36523678
}
36533679

@@ -8263,6 +8289,7 @@ async function loadAll(){
82638289
}
82648290

82658291
var _lastStatsFp='';
8292+
var _fallbackWarningShown=false;
82668293
async function loadStats(ownerFilter){
82678294
let d;
82688295
try{
@@ -8312,6 +8339,19 @@ async function loadStats(ownerFilter){
83128339
}).catch(()=>{});
83138340
}
83148341

8342+
if(!_fallbackWarningShown){
8343+
_fallbackWarningShown=true;
8344+
var sumWarn = (d.summarizerProvider === 'none') ? t('fallback.banner.sum') : '';
8345+
var skillWarn = (d.skillEvolutionProvider === 'none') ? t('fallback.banner.skill') : '';
8346+
8347+
if (sumWarn) {
8348+
showFallbackBanner('fallbackBannerSum', '<div>'+sumWarn+'</div>', 'warning');
8349+
}
8350+
if (skillWarn) {
8351+
showFallbackBanner('fallbackBannerSkill', '<div>'+skillWarn+'</div>', 'warning');
8352+
}
8353+
}
8354+
83158355
const memorySessions=d.sessions||[];
83168356
const taskSessions=d.taskSessions||[];
83178357
const skillSessions=d.skillSessions||[];
@@ -9623,7 +9663,22 @@ function showEmbeddingBanner(msg,type){
96239663
var el=document.createElement('div');
96249664
el.id='embBanner';
96259665
el.className=cls;
9626-
el.innerHTML=icon+' <span>'+esc(msg)+'</span>'+btn+close;
9666+
el.innerHTML=icon+' <span>'+msg+'</span>'+btn+close;
9667+
var mc=document.querySelector('.main-content');
9668+
if(mc) mc.parentElement.insertBefore(el,mc);
9669+
}
9670+
9671+
/* ─── Fallback Banner ─── */
9672+
function showFallbackBanner(id, msgHtml,type){
9673+
if(document.getElementById(id)) return;
9674+
var cls=type==='error'?'emb-banner error':'emb-banner warning';
9675+
var icon=type==='error'?'\\u274C':'\\u26A0\\uFE0F';
9676+
var btn='<button class="emb-banner-btn" onclick="switchView(\\'settings\\');this.parentElement.remove()" style="margin-left:auto;">'+t('fallback.banner.goto')+'</button>';
9677+
var close='<button class="emb-banner-close" onclick="this.parentElement.remove()">&times;</button>';
9678+
var el=document.createElement('div');
9679+
el.id=id;
9680+
el.className=cls;
9681+
el.innerHTML=icon+' <div style="line-height:1.5;flex:1;">'+msgHtml+'</div>'+btn+close;
96279682
var mc=document.querySelector('.main-content');
96289683
if(mc) mc.parentElement.insertBefore(el,mc);
96299684
}

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,24 @@ export class ViewerServer {
211211
}
212212
}
213213

214-
stop(): void {
215-
this.stopHubHeartbeat();
216-
this.stopNotifPoll();
217-
for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
218-
this.notifSSEClients = [];
219-
this.server?.close();
220-
this.server = null;
214+
stop(): Promise<void> {
215+
return new Promise((resolve) => {
216+
this.stopHubHeartbeat();
217+
this.stopNotifPoll();
218+
for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
219+
this.notifSSEClients = [];
220+
if (this.server) {
221+
if ("closeAllConnections" in this.server) {
222+
(this.server as any).closeAllConnections();
223+
}
224+
this.server.close(() => {
225+
this.server = null;
226+
resolve();
227+
});
228+
} else {
229+
resolve();
230+
}
231+
});
221232
}
222233

223234
getResetToken(): string {
@@ -904,6 +915,9 @@ export class ViewerServer {
904915
totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
905916
totalSkills: skillCount, totalTasks: taskCount,
906917
embeddingProvider: this.embedder.provider,
918+
summarizerProvider: this.ctx?.config?.summarizer?.provider ?? "none",
919+
skillEvolutionProvider: this.ctx?.config?.skillEvolution?.enabled ? (this.ctx?.config?.summarizer?.provider ?? "none") : "none",
920+
isSummarizerDegraded: this.ctx ? !this.hasUsableSummarizerProvider(this.ctx.config) : false,
907921
dedupBreakdown,
908922
timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
909923
sessions: sessionList,

0 commit comments

Comments
 (0)