/** * OpenClaw plugin entry point — Reflect2Evolve core. * * Minimal responsibilities (V7 §0.2 + §2.6): * 1. Bootstrap `MemoryCore` (storage, migrations, providers, pipeline) * against the resolved home (`~/.openclaw/memos-plugin/` by default). * 2. Register the memory capability (prompt prelude). * 3. Register memory tools (factory form with trusted plugin context). * 4. Wire every algorithm-relevant hook through the bridge: * • `before_prompt_build` → `onTurnStart` (Tier 1+2+3 retrieval) * • `agent_end` → `onTurnEnd` (capture + reward chain) * • `before_tool_call` → duration tracker * • `after_tool_call` → `recordToolOutcome` (decision-repair) * • `tool_result_persist` → repeated-failure memos_search hint * • `session_start` / `session_end` → core session lifecycle * 5. Register a service so the host can flush + shut down cleanly. * * The plugin owns *no* business logic — everything lives in `core/*`. * * Host-compatibility contract: * - Tested against OpenClaw SDK `api` shape from * `openclaw/src/plugins/types.ts::OpenClawPluginApi` and hook map from * `openclaw/src/plugins/hook-types.ts::PluginHookHandlerMap`. * - We import **types only** from `./openclaw-api.ts`; the real SDK is * injected by the host at load time. */ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createOpenClawBridge, createOpenClawInboundTextStore, type BridgeHandle, type OpenClawInboundTextStore, } from "./bridge.js"; import { acquireOpenClawRuntimeLock, DuplicateOpenClawRuntimeError, type OpenClawRuntimeLockHandle, } from "./runtime-lock.js"; import { registerOpenClawTools } from "./tools.js"; import type { DefinedPluginEntry, DefinePluginEntryOptions, OpenClawPluginApi, } from "./openclaw-api.js"; import { bootstrapMemoryCoreFull } from "../../core/pipeline/index.js"; import { resolveHome } from "../../core/config/index.js"; import { rootLogger, memoryBuffer } from "../../core/logger/index.js"; import type { MemoryCore } from "../../agent-contract/memory-core.js"; import { startHttpServer } from "../../server/http.js"; import type { ServerHandle } from "../../server/types.js"; import { Telemetry } from "../../core/telemetry/index.js"; // ─── Plugin metadata ─────────────────────────────────────────────────────── export const PLUGIN_ID = "memos-local-plugin"; export const PLUGIN_VERSION = readPluginPackageVersion(); function readPluginPackageVersion(): string { try { const thisFile = fileURLToPath(import.meta.url); const adapterDir = path.dirname(thisFile); // .../adapters/openclaw or .../dist/adapters/openclaw const candidates = [ path.resolve(adapterDir, "..", "..", "..", "package.json"), path.resolve(adapterDir, "..", "..", "package.json"), ]; const packageJsonPath = candidates.find((candidate) => existsSync(candidate)); if (!packageJsonPath) return "dev"; const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown; }; return typeof pkg.version === "string" && pkg.version.trim() ? pkg.version : "dev"; } catch { return "dev"; } } // ─── Runtime state (per plugin load) ─────────────────────────────────────── interface PluginRuntime { core: MemoryCore; bridge: BridgeHandle; /** * The viewer HTTP server. OpenClaw must own this port; if binding * fails we abort bootstrap instead of running a second headless * runtime that would still register hooks and write memory. */ viewer: ServerHandle | null; shutdown: () => Promise; } /** * Locate the plugin source root (the directory holding `package.json`, * `bridge.cts`, etc.). Two layouts to support: built tarball * (`/dist/adapters/openclaw`) and source/tests * (`/adapters/openclaw`). Returned path is the one used by * `Telemetry` to find `telemetry.credentials.json` (CI writes it * here pre-publish via `scripts/generate-telemetry-credentials.cjs`). */ function resolvePluginRoot(): string | undefined { try { const thisFile = fileURLToPath(import.meta.url); const adapterDir = path.dirname(thisFile); // .../adapters/openclaw const candidates = [ path.resolve(adapterDir, "..", "..", ".."), path.resolve(adapterDir, "..", ".."), ]; return candidates.find((candidate) => existsSync(path.join(candidate, "package.json")), ); } catch { return undefined; } } /** Locate the bundled viewer static assets relative to the plugin root. */ function resolveViewerStaticRoot(): string | undefined { // Built packages load from `/dist/adapters`; source tests load // from `/adapters`. The viewer bundle remains at `viewer/dist`. try { const thisFile = fileURLToPath(import.meta.url); const adapterDir = path.dirname(thisFile); // .../adapters/openclaw const candidates = [ path.resolve(adapterDir, "..", "..", "..", "viewer", "dist"), path.resolve(adapterDir, "..", "..", "viewer", "dist"), ]; return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]; } catch { return undefined; } } const OPENCLAW_VIEWER_PORT = 18799; async function createRuntime( api: OpenClawPluginApi, runtimeLock: OpenClawRuntimeLockHandle, inboundUserText: OpenClawInboundTextStore, ): Promise { const log = rootLogger.child({ channel: "adapters.openclaw" }); log.info("plugin.bootstrap", { version: PLUGIN_VERSION }); let core: MemoryCore | null = null; let viewer: ServerHandle | null = null; try { // Bootstrap core — returns `{ core, home, config }` so we know which // viewer port to bind. const boot = await bootstrapMemoryCoreFull({ agent: "openclaw", namespace: { agentKind: "openclaw", profileId: "main" }, pkgVersion: PLUGIN_VERSION, }); core = boot.core; const { config, home } = boot; await core.init(); // Anonymous ARMS telemetry. Mirrors `bridge.cts`'s setup so OpenClaw // emits the same `plugin_started` / `daily_active` / `memos_search` // / `memory_ingested` / `feedback_submitted` / `viewer_opened` // events under the same `memos_local_hermes_v2` group as Hermes. // Without this every OpenClaw user was invisible in ARMS — only the // hermes-side `bridge.cts` was emitting events. // // Order matters: // 1. `new Telemetry` reads `config.telemetry` and the credentials // file under the plugin source root. // 2. `bindTelemetry` must run before any turn so that // `memory-core.ts`'s `if (telemetry)` guards see a non-null // instance on the very first `onTurnStart`. // 3. `trackPluginStarted` immediately after also fires // `daily_active` (with persistent dedup; see sender.ts). // `core.shutdown()` flushes telemetry as part of its `finally` // block, so we don't need to await `telemetry.shutdown()` here. const telemetry = new Telemetry( config.telemetry ?? {}, home.root, PLUGIN_VERSION, rootLogger.child({ channel: "core.telemetry" }), resolvePluginRoot(), ); ( core as { bindTelemetry?: (t: InstanceType) => void } ).bindTelemetry?.(telemetry); telemetry.trackPluginStarted("openclaw"); const bridge = createOpenClawBridge({ agent: "openclaw", core, log: api.logger, inboundUserText, }); // OpenClaw's viewer port is fixed at :18799 (hermes uses :18800). // We ignore `config.viewer.port` for the same reason `bridge.cts` // does: old config.yaml files baked in the legacy single-port // :18799 used by both agents, and we don't want hermes to collide // with us because of stale YAML. try { viewer = await startHttpServer( { core, home, logTail: () => memoryBuffer().tail({ limit: 200 }), telemetry, }, { port: OPENCLAW_VIEWER_PORT, host: config.viewer.bindHost, staticRoot: resolveViewerStaticRoot(), agent: "openclaw", }, ); api.logger.info(`memos-local: viewer live at ${viewer.url}`); } catch (err) { const e = err as NodeJS.ErrnoException; if (e?.code === "EADDRINUSE") { api.logger.error( `memos-local: viewer port :${OPENCLAW_VIEWER_PORT} is already in use — ` + `refusing duplicate/headless OpenClaw runtime.`, ); } else { api.logger.error("memos-local: viewer failed to start", { err: e?.message ?? String(err), }); } throw err; } const runtimeCore = core; const runtimeViewer = viewer; return { core: runtimeCore, bridge, viewer: runtimeViewer, async shutdown() { if (runtimeViewer) { try { await runtimeViewer.close(); } catch (err) { api.logger.warn("memos-local: viewer close error", { err: err instanceof Error ? err.message : String(err), }); } } try { await runtimeCore.shutdown(); } catch (err) { api.logger.warn("memos-local: shutdown error", { err: err instanceof Error ? err.message : String(err), }); } runtimeLock.release(); }, }; } catch (err) { await closeViewerAfterFailedBootstrap(viewer); if (core) { try { await core.shutdown(); } catch { /* best-effort cleanup after failed bootstrap */ } } runtimeLock.release(); throw err; } } async function closeViewerAfterFailedBootstrap( viewer: ServerHandle | null, ): Promise { if (!viewer) return; try { await viewer.close(); } catch { /* best-effort cleanup after failed bootstrap */ } } // ─── Registration ────────────────────────────────────────────────────────── /** * Detect if running in diagnostic mode (e.g., `openclaw doctor`). * * Diagnostic processes should skip runtime lock acquisition to avoid * false positive DuplicateOpenClawRuntimeError when the gateway is running. */ function isDiagnosticMode(): boolean { // Check for OPENCLAW_DIAGNOSTIC_MODE environment variable if (process.env.OPENCLAW_DIAGNOSTIC_MODE === "1" || process.env.OPENCLAW_DIAGNOSTIC_MODE === "true") { return true; } // Check if process title or argv contains "doctor" if (process.title?.includes("doctor")) { return true; } if (process.argv.some(arg => arg.includes("doctor"))) { return true; } return false; } function registerRuntimeBindings( api: OpenClawPluginApi, ensureRuntime: () => Promise, inboundUserText: ReturnType, currentRuntime: () => PluginRuntime | null, ): void { // 1. Memory capability (prompt prelude) — register synchronously so the // host immediately knows who owns the memory slot, even if bootstrap // fails later. api.registerMemoryCapability?.({ promptBuilder: ({ availableTools }) => { const hasSearch = availableTools.has("memos_search"); const hasGet = availableTools.has("memos_get"); const hasTimeline = availableTools.has("memos_timeline"); const hasEnv = availableTools.has("memos_environment"); const hasSkillList = availableTools.has("memos_skill_list"); const hasSkillGet = availableTools.has("memos_skill_get"); if (!hasSearch && !hasGet && !hasTimeline && !hasEnv && !hasSkillList && !hasSkillGet) { return []; } const lines: string[] = [ "## Memory (MemOS Local)", "This workspace uses MemOS Local — a self-evolving layered memory (L1/L2/L3 + Skills).", ]; if (hasSearch) { lines.push( "- `memos_search` — search prior traces, policies, world models, and skills.", ); } if (hasEnv) { lines.push( "- `memos_environment` — list / query accumulated environment knowledge " + "(project layout, behavioural rules, constraints). Use before exploring an unfamiliar area.", ); } if (hasGet || hasTimeline) { lines.push( "- `memos_get` / `memos_timeline` — fetch full bodies + episode timelines.", ); } if (hasSkillList) { lines.push( "- `memos_skill_list` — list MemOS-crystallized skills learned from prior runs.", ); } if (hasSkillGet) { lines.push( "- `memos_skill_get` — load the full invocation guide for a MemOS skill.", ); } lines.push( "- Prefer recalled memory over assuming prior context is unavailable.", "", ); return lines; }, }); /** * Helper for **void / fire-and-forget** hooks: dispatch `fn` against the * runtime as soon as bootstrap finishes (already finished → next tick). * Errors are logged at WARN and swallowed — they must not surface to * OpenClaw's hook runner because the listener itself has already * returned synchronously. * * `label` is used solely for log context so a misbehaving hook is * findable in the gateway log. */ const runWhenReady = async ( fn: (r: PluginRuntime) => void | Promise, label: string, ): Promise => { try { const r = await ensureRuntime(); if (!r) return; await fn(r); } catch (err) { api.logger.warn(`memos-local: hook ${label} failed`, { err: err instanceof Error ? err.message : String(err), }); } }; registerOpenClawTools(api, { agent: "openclaw", getCore: async () => (await ensureRuntime())?.core ?? null, log: api.logger, }); // 3. Hooks — every handler matches the upstream `PluginHookHandlerMap` // signature so OpenClaw's type-check passes in a monorepo install. // // Two upstream constraints govern the registration style here: // (a) `tool_result_persist` is a **value-returning sync hook**. // OpenClaw's hook runner inspects the return value with // `isPromiseLike(ret)` and ignores it when the handler returns a // Promise — so declaring this listener `async` silently disables // the "append memos_search hint after repeated tool failures" // feature. We register a **synchronous** wrapper that calls the // (already sync) `bridge.handleToolResultPersist` directly. If // bootstrap hasn't completed yet, the hook is a no-op (matches // the legacy adapter — runtime not ready means no hint to // inject). // (b) `agent_end` (and the other void hooks below) are run by // OpenClaw with a **hard-coded 30 s timeout** // (`DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK.agent_end = 30_000`). // memos's onTurnEnd chain writes SQLite traces + runs L2 // induction + reflection + reward (LLM-bound), which under I/O // pressure can exceed 30 s. Awaiting that chain inside the hook // handler shows up in the gateway log as // `agent_end handler … timed out after 30000ms`. We schedule // the heavy work as a **fire-and-forget** background task and // return immediately. `core.shutdown()` (called from the // service's `stop`) already drains in-flight pipeline work, so // fire-and-forget does not lose data on a clean shutdown. // // `before_prompt_build` stays async-await because it MUST return the // `prependContext` for OpenClaw to inject — it is a value-returning // hook, not a void hook, and OpenClaw is willing to await its result // (the timeout is laxer than `agent_end`'s 30 s budget). api.on("message_received", (event, ctx) => { // Synchronous and intentionally independent of runtime readiness. // `event.content` is OpenClaw's BodyForCommands/RawBody value, while // before_prompt_build.prompt and agent_end.messages are model-facing // and may contain IM-specific sender/message-id decoration. inboundUserText.remember(event, ctx); }); api.on("before_prompt_build", async (event, ctx) => { const r = await ensureRuntime(); if (!r) return; return r.bridge.handleBeforePrompt(event, ctx); }); api.on("agent_end", (event, ctx) => { // Fire-and-forget. Returning synchronously lets OpenClaw's 30s // void-hook budget tick down only on its own bookkeeping; memos // continues writing the trace + running the reflect/reward chain // in the background. void runWhenReady((r) => r.bridge.handleAgentEnd(event, ctx), "agent_end"); }); api.on("before_tool_call", (event, ctx) => { // `handleBeforeToolCall` is sync and cheap (Map.set + timestamp); // we still gate on runtime presence by deferring to ensureRuntime // when bootstrap is in flight. The fire-and-forget wrapper keeps // the listener void-shaped for OpenClaw. void runWhenReady((r) => { r.bridge.handleBeforeToolCall(event, ctx); }, "before_tool_call"); }); api.on("after_tool_call", (event, ctx) => { void runWhenReady((r) => r.bridge.handleAfterToolCall(event, ctx), "after_tool_call"); }); // tool_result_persist is value-returning AND synchronous on // OpenClaw's side — do NOT make this async. Bridge handler is // already sync, so we can invoke it directly when the runtime is // ready and return undefined otherwise. api.on("tool_result_persist", (event, ctx) => { const runtime = currentRuntime(); if (!runtime) return; // bootstrap not finished — nothing to inject return runtime.bridge.handleToolResultPersist(event, ctx); }); api.on("session_start", (event, ctx) => { void runWhenReady((r) => r.bridge.handleSessionStart(event, ctx), "session_start"); }); api.on("session_end", (event, ctx) => { void runWhenReady((r) => r.bridge.handleSessionEnd(event, ctx), "session_end"); }); api.on("subagent_spawned", (event, ctx) => { void runWhenReady((r) => { r.bridge.handleSubagentSpawned(event, ctx); }, "subagent_spawned"); }); api.on("subagent_ended", (event, ctx) => { void runWhenReady((r) => r.bridge.handleSubagentEnded(event, ctx), "subagent_ended"); }); } // OpenClaw may build a separate tool registry in the same process. That // registry borrows the full registration's core and never owns its lifecycle. interface SharedRuntime { ensureRuntime: () => Promise; registerBindings: (api: OpenClawPluginApi) => void; } // Host registries can reload the module. Keep ownership process-wide while // retaining the filesystem lock against genuinely separate gateway processes. const runtimeKey = Symbol.for("memos.openclaw.activeRuntimes.v1"); const processState = globalThis as typeof globalThis & { [runtimeKey]?: Map; }; const activeRuntimes = processState[runtimeKey] ??= new Map(); function register(api: OpenClawPluginApi): void { const home = resolveHome("openclaw"); if (api.registrationMode === "tool-discovery") { registerOpenClawTools(api, { agent: "openclaw", getCore: async () => (await activeRuntimes.get(home.root)?.ensureRuntime())?.core ?? null, log: api.logger, }); return; } const existing = activeRuntimes.get(home.root); if (existing) { existing.registerBindings(api); api.logger.info("memos-local: reused active runtime for host registry"); return; } const diagnosticMode = isDiagnosticMode(); let runtimeLock: OpenClawRuntimeLockHandle; try { runtimeLock = acquireOpenClawRuntimeLock({ home, pluginId: PLUGIN_ID, version: PLUGIN_VERSION, viewerPort: OPENCLAW_VIEWER_PORT, skipLock: diagnosticMode, }); if (diagnosticMode) { api.logger.info("memos-local: running in diagnostic mode (lock acquisition skipped)"); } } catch (err) { const duplicate = err instanceof DuplicateOpenClawRuntimeError; api.logger.error("memos-local: duplicate OpenClaw runtime blocked", { err: err instanceof Error ? err.message : String(err), code: duplicate ? err.code : (err as { code?: unknown }).code, }); throw err; } // OpenClaw publishes its clean, command-facing inbound body before // prompt construction. Keep this store independent of core bootstrap // so early messages are not lost while SQLite/providers initialize. const inboundUserText = createOpenClawInboundTextStore(); // 2. Kick off core bootstrap. OpenClaw only accepts tool / hook // registration during the synchronous `register(api)` window, so // tools register a shell now and wait for runtime inside execute(). let runtime: PluginRuntime | null = null; let bootstrapError: Error | null = null; const bootstrapPromise = createRuntime(api, runtimeLock, inboundUserText) .then((r) => { runtime = r; api.logger.info("memos-local: plugin ready"); }) .catch((err) => { if (activeRuntimes.get(home.root) === sharedRuntime) activeRuntimes.delete(home.root); bootstrapError = err instanceof Error ? err : new Error(String(err)); const duplicate = err instanceof DuplicateOpenClawRuntimeError; api.logger.error("memos-local: bootstrap failed", { err: bootstrapError.message, code: duplicate ? err.code : (err as { code?: unknown }).code, }); }); const ensureRuntime = async (): Promise => { if (runtime) return runtime; await bootstrapPromise; return runtime; }; const sharedRuntime: SharedRuntime = { ensureRuntime, registerBindings: (target) => registerRuntimeBindings(target, ensureRuntime, inboundUserText, () => runtime), }; activeRuntimes.set(home.root, sharedRuntime); sharedRuntime.registerBindings(api); // 4. Service — lets the host flush + wait for ready and shut us down. // // OpenClaw's current loader (≥ 2026.4) keys the service registry by // `service.id` and calls `id.trim()` unconditionally. A missing `id` // field is the classic "TypeError: Cannot read properties of // undefined (reading 'trim')" reported as // [plugins] memos-local-plugin failed during register … // Earlier drafts of the SDK used `name` as the primary field, so we // fill both to stay compatible across versions. api.registerService?.({ id: "memos-local", name: "memos-local", async start() { await bootstrapPromise; if (bootstrapError) throw bootstrapError; }, async stop() { await bootstrapPromise; if (activeRuntimes.get(home.root) === sharedRuntime) activeRuntimes.delete(home.root); if (runtime) await runtime.shutdown(); }, }); } // ─── Default export consumed by the host ────────────────────────────────── /** * Module shape mirrors `openclaw/src/plugin-sdk/plugin-entry.ts:: * DefinedPluginEntry`. When built into the OpenClaw monorepo the host * calls `module.default.register(api)` with a real `OpenClawPluginApi`. */ const plugin: DefinedPluginEntry = { id: PLUGIN_ID, name: "MemOS Local", description: "Reflect2Evolve memory plugin — L1 traces, L2 policies, L3 world models, " + "skill crystallization, three-tier retrieval, decision repair.", register, }; export default plugin; /** Re-export the plain factory for tests / custom hosts. */ export function defineMemosLocalOpenClawPlugin( overrides?: Partial, ): DefinedPluginEntry { return { id: overrides?.id ?? PLUGIN_ID, name: overrides?.name ?? "MemOS Local", description: overrides?.description ?? plugin.description, register: overrides?.register ?? register, }; }