Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 58 additions & 18 deletions apps/memos-local-openclaw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<string, number>();

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 {
Expand Down Expand Up @@ -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}`);
});
Expand Down
234 changes: 234 additions & 0 deletions apps/memos-local-openclaw/tests/plugin-openclaw-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});