Skip to content
252 changes: 156 additions & 96 deletions apps/memos-local-plugin/adapters/openclaw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";

import { createOpenClawBridge, type BridgeHandle } from "./bridge.js";
import {
acquireOpenClawRuntimeLock,
DuplicateOpenClawRuntimeError,
type OpenClawRuntimeLockHandle,
} from "./runtime-lock.js";
import { registerOpenClawTools } from "./tools.js";
import type {
DefinedPluginEntry,
Expand All @@ -37,6 +42,7 @@ import type {
} 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";
Expand Down Expand Up @@ -75,10 +81,9 @@ interface PluginRuntime {
core: MemoryCore;
bridge: BridgeHandle;
/**
* The viewer HTTP server. May be `null` if the configured port was
* already in use at boot — in that case OpenClaw runs headless
* (memory still works, just no UI). We don't retry: the user can
* free the port and restart the gateway.
* 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<void>;
Expand Down Expand Up @@ -125,119 +130,172 @@ function resolveViewerStaticRoot(): string | undefined {
}
}

async function createRuntime(api: OpenClawPluginApi): Promise<PluginRuntime> {
const OPENCLAW_VIEWER_PORT = 18799;

async function createRuntime(
api: OpenClawPluginApi,
runtimeLock: OpenClawRuntimeLockHandle,
): Promise<PluginRuntime> {
const log = rootLogger.child({ channel: "adapters.openclaw" });
log.info("plugin.bootstrap", { version: PLUGIN_VERSION });

// Bootstrap core — returns `{ core, home, config }` so we know which
// viewer port to bind.
const { core, config, home } = await bootstrapMemoryCoreFull({
agent: "openclaw",
namespace: { agentKind: "openclaw", profileId: "main" },
pkgVersion: PLUGIN_VERSION,
});
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<typeof Telemetry>) => void }
).bindTelemetry?.(telemetry);
telemetry.trackPluginStarted("openclaw");

const bridge = createOpenClawBridge({
agent: "openclaw",
core,
log: api.logger,
});

// 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.
const OPENCLAW_VIEWER_PORT = 18799;
let core: MemoryCore | null = null;
let viewer: ServerHandle | null = null;

try {
viewer = await startHttpServer(
{
core,
home,
logTail: () => memoryBuffer().tail({ limit: 200 }),
telemetry,
},
{
port: OPENCLAW_VIEWER_PORT,
host: config.viewer.bindHost,
staticRoot: resolveViewerStaticRoot(),
agent: "openclaw",
},
// 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(),
);
api.logger.info(`memos-local: viewer live at ${viewer.url}`);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e?.code === "EADDRINUSE") {
api.logger.warn(
`memos-local: viewer port :${OPENCLAW_VIEWER_PORT} is already in use — ` +
`running headless. Free the port and restart the gateway to expose it.`,
(
core as { bindTelemetry?: (t: InstanceType<typeof Telemetry>) => void }
).bindTelemetry?.(telemetry);
telemetry.trackPluginStarted("openclaw");

const bridge = createOpenClawBridge({
agent: "openclaw",
core,
log: api.logger,
});

// 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",
},
);
} else {
api.logger.error("memos-local: viewer failed to start", {
err: e?.message ?? String(err),
});
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;
}
}

return {
core,
bridge,
viewer,
async shutdown() {
if (viewer) {
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 viewer.close();
await runtimeCore.shutdown();
} catch (err) {
api.logger.warn("memos-local: viewer close error", {
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 (err) {
api.logger.warn("memos-local: shutdown error", {
err: err instanceof Error ? err.message : String(err),
});
} catch {
/* best-effort cleanup after failed bootstrap */
}
},
};
}
runtimeLock.release();
throw err;
}
}

async function closeViewerAfterFailedBootstrap(
viewer: ServerHandle | null,
): Promise<void> {
if (!viewer) return;
try {
await viewer.close();
} catch {
/* best-effort cleanup after failed bootstrap */
}
}

// ─── Registration ──────────────────────────────────────────────────────────

function register(api: OpenClawPluginApi): void {
let runtimeLock: OpenClawRuntimeLockHandle;
try {
runtimeLock = acquireOpenClawRuntimeLock({
home: resolveHome("openclaw"),
pluginId: PLUGIN_ID,
version: PLUGIN_VERSION,
viewerPort: OPENCLAW_VIEWER_PORT,
});
} 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;
}

// 1. Memory capability (prompt prelude) — register synchronously so the
// host immediately knows who owns the memory slot, even if bootstrap
// fails later.
Expand Down Expand Up @@ -295,15 +353,17 @@ function register(api: OpenClawPluginApi): void {
// 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)
const bootstrapPromise = createRuntime(api, runtimeLock)
.then((r) => {
runtime = r;
api.logger.info("memos-local: plugin ready");
})
.catch((err) => {
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,
});
});

Expand Down
Loading