From e6deb2002a4ec7373721b18a7002413ce054814f Mon Sep 17 00:00:00 2001 From: MemOS AutoDev Date: Thu, 28 May 2026 16:40:25 +0800 Subject: [PATCH] fix(memos-local-openclaw): start Memory Viewer when host omits api.on (#1639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On OpenClaw runtimes that do not expose `api.on` (or expose one that throws), the call to `api.on("before_prompt_build", ...)` raised partway through `register()` and aborted the rest of plugin setup. The ViewerServer was never instantiated, `api.registerService` never ran, and the `setTimeout(0)` self-start fallback was never scheduled — so port 18799 stayed unbound while individual tools (registered earlier) kept working. This matched the reported behaviour on Node 24: register/activate succeeds, memory tools work, but the viewer HTTP server never listens. Make the plugin defensive against partial host APIs: - Add a `safeOn` helper that checks for `api.on` and wraps the invocation in try/catch; reroute both `before_prompt_build` and `agent_end` through it. - Wrap `api.registerMemoryCapability` and `api.registerService` in try/catch with existence checks, so missing methods log a warning instead of throwing. - Track whether registerService succeeded so the existing setTimeout(0) fallback can log the right reason when it self-starts the viewer. Also add two regression tests in `plugin-openclaw-wiring.test.ts`: one for the `api.on === undefined` case and one for an `api.on` that throws synchronously. Both assert that ViewerServer is still constructed (and, for the missing-on variant, started) during `register()`. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/memos-local-openclaw/index.ts | 76 ++++-- .../tests/plugin-openclaw-wiring.test.ts | 234 ++++++++++++++++++ 2 files changed, 292 insertions(+), 18 deletions(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index 5e2245198..2b0032ed4 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -160,9 +160,32 @@ const memosLocalPlugin = { configSchema: pluginConfigSchema, register(api: OpenClawPluginApi) { - api.registerMemoryCapability({ - promptBuilder: buildMemoryPromptSection, - }); + // Defensive wrappers for optional host APIs. + // OpenClaw runtimes may differ across versions: some omit `api.on` or + // `api.registerService` entirely. Without these guards, a missing method + // throws partway through `register()` and aborts the remainder of setup — + // most importantly the ViewerServer instantiation and self-start fallback, + // leaving port 18799 unbound (issue #1639). + const safeOn = (event: string, handler: (...args: any[]) => any): void => { + const onFn = (api as any).on; + if (typeof onFn !== "function") { + api.logger.warn(`memos-local: api.on() not available on this OpenClaw runtime; "${event}" hook disabled`); + return; + } + try { + onFn.call(api, event, handler); + } catch (err) { + api.logger.warn(`memos-local: api.on("${event}") registration failed: ${String(err)}`); + } + }; + + try { + api.registerMemoryCapability({ + promptBuilder: buildMemoryPromptSection, + }); + } catch (err) { + api.logger.warn(`memos-local: api.registerMemoryCapability failed: ${String(err)}`); + } const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const localRequire = createRequire(import.meta.url); @@ -1863,7 +1886,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, // ─── Auto-recall: inject relevant memories before agent starts ─── - api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => { + safeOn("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => { if (!allowPromptInjection) return {}; if (!event.prompt || event.prompt.length < 3) return; @@ -2158,7 +2181,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, // already processed before the restart) and only capture future increments. const sessionMsgCursor = new Map(); - api.on("agent_end", async (event: any, hookCtx?: { agentId?: string; sessionKey?: string; sessionId?: string }) => { + safeOn("agent_end", async (event: any, hookCtx?: { agentId?: string; sessionKey?: string; sessionId?: string }) => { if (!event.success || !event.messages || event.messages.length === 0) return; try { @@ -2425,28 +2448,45 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, ); }; - api.registerService({ - id: "memos-local-openclaw-plugin", - start: async () => { await startServiceCore(); }, - stop: async () => { - await worker.flush(); - await telemetry.shutdown(); - await hubServer?.stop(); - viewer.stop(); - store.close(); - api.logger.info("memos-local: stopped"); - }, - }); + let serviceRegistered = false; + try { + if (typeof (api as any).registerService === "function") { + api.registerService({ + id: "memos-local-openclaw-plugin", + start: async () => { await startServiceCore(); }, + stop: async () => { + await worker.flush(); + await telemetry.shutdown(); + await hubServer?.stop(); + viewer.stop(); + store.close(); + api.logger.info("memos-local: stopped"); + }, + }); + serviceRegistered = true; + } else { + api.logger.warn("memos-local: api.registerService() not available on this OpenClaw runtime; viewer will be self-started"); + } + } catch (err) { + api.logger.warn(`memos-local: api.registerService failed: ${String(err)}. Viewer will be self-started.`); + } // Fallback: OpenClaw may load this plugin via deferred reload after // startPluginServices has already run, so service.start() never fires. // Start on the next tick instead of waiting several seconds; the // serviceStarted guard still prevents duplicate startup if the host calls // service.start() immediately after registration. + // + // We also use this path when `api.registerService` isn't available on + // the host (older OpenClaw runtimes), so the viewer always comes up. const SELF_START_DELAY_MS = 0; setTimeout(() => { if (!serviceStarted) { - api.logger.info("memos-local: service.start() not called by host, self-starting viewer..."); + if (serviceRegistered) { + api.logger.info("memos-local: service.start() not called by host, self-starting viewer..."); + } else { + api.logger.info("memos-local: self-starting viewer (no service lifecycle hook available)..."); + } startServiceCore().catch((err) => { api.logger.warn(`memos-local: self-start failed: ${err}`); }); diff --git a/apps/memos-local-openclaw/tests/plugin-openclaw-wiring.test.ts b/apps/memos-local-openclaw/tests/plugin-openclaw-wiring.test.ts index 77b95c33f..b1a83fcd5 100644 --- a/apps/memos-local-openclaw/tests/plugin-openclaw-wiring.test.ts +++ b/apps/memos-local-openclaw/tests/plugin-openclaw-wiring.test.ts @@ -120,4 +120,238 @@ describe("plugin-impl OpenClaw wiring", () => { expect(embedderOpenClawArg).toBe(openclawAPI); expect(summarizerOpenClawArg).toBe(openclawAPI); }); + + /** + * Regression for issue #1639: + * On OpenClaw runtimes where `api.on` is unavailable (or throws), the plugin + * must still set up the Memory Viewer (port 18799). Previously, calling + * `api.on(...)` would throw and abort the remainder of `register()`, + * leaving the ViewerServer never instantiated and the port unbound. + */ + it("still starts the Memory Viewer when api.on is missing on the host", async () => { + let viewerConstructed = 0; + let viewerStarted = 0; + + vi.doMock("../src/config", () => ({ + buildContext: () => ({ + stateDir: "/tmp/memos-viewer-regression", + workspaceDir: "/tmp/memos-viewer-regression/workspace", + log: { debug() {}, info() {}, warn() {}, error() {} }, + openclawAPI: { embed: vi.fn(), complete: vi.fn() }, + config: { + storage: { dbPath: "/tmp/memos-viewer-regression/memos.db" }, + capture: { evidenceWrapperTag: "STORED_MEMORY" }, + telemetry: {}, + embedding: { provider: "local" }, + summarizer: { provider: "none" }, + sharing: { enabled: false, role: "client", hub: { port: 18800, teamName: "", teamToken: "" }, client: { hubAddress: "", userToken: "" }, capabilities: {} }, + }, + }), + })); + + vi.doMock("../src/storage/sqlite", () => ({ SqliteStore: class { + recordToolCall() {} + recordApiLog() {} + close() {} + }})); + + vi.doMock("../src/embedding", () => ({ + Embedder: class { provider = "local"; }, + })); + + vi.doMock("../src/ingest/worker", () => ({ IngestWorker: class { + getTaskProcessor() { return { onTaskCompleted() {} }; } + enqueue() {} + async flush() {} + }})); + + vi.doMock("../src/recall/engine", () => ({ RecallEngine: class { + async search() { return { hits: [], meta: {} }; } + async searchSkills() { return []; } + }})); + + vi.doMock("../src/ingest/providers", () => ({ + Summarizer: class { + async filterRelevant() { return null; } + }, + })); + + vi.doMock("../src/viewer/server", () => ({ ViewerServer: class { + constructor() { viewerConstructed++; } + async start() { viewerStarted++; return "http://127.0.0.1:18799"; } + stop() {} + getResetToken() { return "token"; } + }})); + + vi.doMock("../src/hub/server", () => ({ HubServer: class { + async start() { return "http://127.0.0.1:18800"; } + async stop() {} + }})); + + vi.doMock("../src/client/hub", () => ({ + hubGetMemoryDetail: async () => ({}), + hubRequestJson: async () => ({}), + hubSearchMemories: async () => ({ hits: [], meta: {} }), + hubSearchSkills: async () => ({ hits: [] }), + resolveHubClient: async () => ({ hubUrl: "", userToken: "", userId: "" }), + })); + + vi.doMock("../src/client/connector", () => ({ + getHubStatus: async () => ({ connected: false }), + connectToHub: async () => ({ username: "u", userId: "u" }), + })); + vi.doMock("../src/client/skill-sync", () => ({ + fetchHubSkillBundle: async () => ({}), + publishSkillBundleToHub: async () => ({}), + restoreSkillBundleFromHub: () => ({}), + unpublishSkillBundleFromHub: async () => ({}), + buildSkillBundleForHub: async () => ({}), + })); + vi.doMock("../src/skill/evolver", () => ({ SkillEvolver: class { + async onTaskCompleted() {} + async recoverOrphanedTasks() { return 0; } + }})); + vi.doMock("../src/skill/installer", () => ({ SkillInstaller: class { + getCompanionManifest() { return null; } + }})); + vi.doMock("../src/skill/bundled-memory-guide", () => ({ MEMORY_GUIDE_SKILL_MD: "# mock" })); + vi.doMock("../src/telemetry", () => ({ Telemetry: class { + trackToolCalled() {} + trackAutoRecall() {} + trackMemoryIngested() {} + trackSkillInstalled() {} + trackPluginStarted() {} + trackViewerOpened() {} + async shutdown() {} + }})); + + const pluginModule = await import("../plugin-impl"); + + // Critical: api object lacks `on` entirely, simulating an older OpenClaw runtime. + pluginModule.default.register({ + pluginConfig: {}, + config: {}, + resolvePath: () => "/tmp/memos-viewer-regression", + logger: { info() {}, warn() {} }, + registerTool: () => {}, + registerMemoryCapability: () => {}, + registerService: () => {}, + // intentionally no `on` + } as any); + + // ViewerServer must be constructed during register(), regardless of api.on availability. + expect(viewerConstructed).toBe(1); + + // Wait a tick for the setTimeout(0) self-start fallback to fire, then verify start() was called. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(viewerStarted).toBe(1); + }); + + /** + * Regression for issue #1639 (second variant): + * Some hosts have `api.on` but it throws synchronously when invoked. + * The plugin must catch this and continue with ViewerServer setup. + */ + it("still starts the Memory Viewer when api.on() throws", async () => { + let viewerConstructed = 0; + + vi.doMock("../src/config", () => ({ + buildContext: () => ({ + stateDir: "/tmp/memos-viewer-regression-throws", + workspaceDir: "/tmp/memos-viewer-regression-throws/workspace", + log: { debug() {}, info() {}, warn() {}, error() {} }, + openclawAPI: { embed: vi.fn(), complete: vi.fn() }, + config: { + storage: { dbPath: "/tmp/memos-viewer-regression-throws/memos.db" }, + capture: { evidenceWrapperTag: "STORED_MEMORY" }, + telemetry: {}, + embedding: { provider: "local" }, + summarizer: { provider: "none" }, + sharing: { enabled: false, role: "client", hub: { port: 18800, teamName: "", teamToken: "" }, client: { hubAddress: "", userToken: "" }, capabilities: {} }, + }, + }), + })); + + vi.doMock("../src/storage/sqlite", () => ({ SqliteStore: class { + recordToolCall() {} + recordApiLog() {} + close() {} + }})); + + vi.doMock("../src/embedding", () => ({ Embedder: class { provider = "local"; } })); + vi.doMock("../src/ingest/worker", () => ({ IngestWorker: class { + getTaskProcessor() { return { onTaskCompleted() {} }; } + enqueue() {} + async flush() {} + }})); + vi.doMock("../src/recall/engine", () => ({ RecallEngine: class { + async search() { return { hits: [], meta: {} }; } + async searchSkills() { return []; } + }})); + vi.doMock("../src/ingest/providers", () => ({ + Summarizer: class { async filterRelevant() { return null; } }, + })); + vi.doMock("../src/viewer/server", () => ({ ViewerServer: class { + constructor() { viewerConstructed++; } + async start() { return "http://127.0.0.1:18799"; } + stop() {} + getResetToken() { return "token"; } + }})); + vi.doMock("../src/hub/server", () => ({ HubServer: class { + async start() { return "http://127.0.0.1:18800"; } + async stop() {} + }})); + vi.doMock("../src/client/hub", () => ({ + hubGetMemoryDetail: async () => ({}), + hubRequestJson: async () => ({}), + hubSearchMemories: async () => ({ hits: [], meta: {} }), + hubSearchSkills: async () => ({ hits: [] }), + resolveHubClient: async () => ({ hubUrl: "", userToken: "", userId: "" }), + })); + vi.doMock("../src/client/connector", () => ({ + getHubStatus: async () => ({ connected: false }), + connectToHub: async () => ({ username: "u", userId: "u" }), + })); + vi.doMock("../src/client/skill-sync", () => ({ + fetchHubSkillBundle: async () => ({}), + publishSkillBundleToHub: async () => ({}), + restoreSkillBundleFromHub: () => ({}), + unpublishSkillBundleFromHub: async () => ({}), + buildSkillBundleForHub: async () => ({}), + })); + vi.doMock("../src/skill/evolver", () => ({ SkillEvolver: class { + async onTaskCompleted() {} + async recoverOrphanedTasks() { return 0; } + }})); + vi.doMock("../src/skill/installer", () => ({ SkillInstaller: class { + getCompanionManifest() { return null; } + }})); + vi.doMock("../src/skill/bundled-memory-guide", () => ({ MEMORY_GUIDE_SKILL_MD: "# mock" })); + vi.doMock("../src/telemetry", () => ({ Telemetry: class { + trackToolCalled() {} + trackAutoRecall() {} + trackMemoryIngested() {} + trackSkillInstalled() {} + trackPluginStarted() {} + trackViewerOpened() {} + async shutdown() {} + }})); + + const pluginModule = await import("../plugin-impl"); + + // api.on throws synchronously, simulating an incompatible host implementation. + pluginModule.default.register({ + pluginConfig: {}, + config: {}, + resolvePath: () => "/tmp/memos-viewer-regression-throws", + logger: { info() {}, warn() {} }, + registerTool: () => {}, + registerMemoryCapability: () => {}, + registerService: () => {}, + on: () => { throw new Error("api.on is not supported on this host"); }, + } as any); + + // ViewerServer must still have been constructed even though api.on() threw. + expect(viewerConstructed).toBe(1); + }); });