diff --git a/README.md b/README.md index a1e2e6aa1..c162a7ac8 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Your lobsters and Hermes Agents now have **the best** memory system โ€” choose * | ๐Ÿง  [**memos-local-plugin 2.0**](https://github.com/hijzy/MemOS/tree/main/apps/memos-local-plugin) | | ๐ŸŒ [Website](https://memos-claw.openmem.net/) ยท ๐Ÿ“– [Docs](https://memos-docs.openmem.net/cn/openclaw/local_plugin) ยท ๐Ÿ™ [GitHub](https://github.com/hijzy/MemOS/tree/main/apps/memos-local-plugin) ยท ๐Ÿ“ฆ [NPM](https://www.npmjs.com/package/@memtensor/memos-local-plugin) | | โ˜๏ธ [**OpenClaw Cloud Plugin**](https://x.com/MemOS_dev/status/2019254160919769171?s=20) | | ๐Ÿ–ฅ๏ธ [MemOS Dashboard](https://memos-dashboard.openmem.net/login/) ยท ๐Ÿ“– [Full Tutorial](https://memos-docs.openmem.net/openclaw/guide#_4-update-plugin) | +> **๐Ÿณ Docker Deployment Note**: When running memos-local-plugin in Docker containers, you must specify the config location using `MEMOS_HOME` environment variable or `--home` CLI flag. See [Docker Configuration Guide](./apps/memos-local-plugin/README.md#docker-deployment) for details. +
diff --git a/apps/memos-local-plugin/README.md b/apps/memos-local-plugin/README.md index d4871a34c..6f896abbe 100644 --- a/apps/memos-local-plugin/README.md +++ b/apps/memos-local-plugin/README.md @@ -97,3 +97,75 @@ npm pack bash install.sh --version ./memtensor-memos-local-plugin-1.0.0-beta.1.tgz ``` +## Configuration + +The plugin reads its configuration from `config.yaml` in the runtime directory. The location is resolved in the following priority order: + +1. **`MEMOS_HOME` environment variable** โ€” points to the runtime root directory (e.g., `/opt/data/.hermes/memos-plugin`) +2. **`MEMOS_CONFIG_FILE` environment variable** โ€” points directly to the config file (e.g., `/opt/data/.hermes/memos-plugin/config.yaml`) +3. **`--home` CLI flag** (bridge.cts only) โ€” specifies the runtime root directory +4. **Default path** โ€” `~/.hermes/memos-plugin/` or `~/.openclaw/memos-plugin/` based on the agent + +### Docker Deployment + +When running the daemon in a Docker container, you must explicitly specify the config location if it differs from the default path. There are three ways to do this: + +#### Option 1: Environment Variable (Recommended) + +Set `MEMOS_HOME` to point to the runtime directory: + +```dockerfile +ENV MEMOS_HOME=/opt/data/home/.hermes/memos-plugin +CMD ["node", "bridge.cts", "--agent=hermes", "--daemon"] +``` + +#### Option 2: CLI Flag + +Pass `--home` directly to the bridge command: + +```dockerfile +CMD ["node", "bridge.cts", "--agent=hermes", "--daemon", "--home=/opt/data/home/.hermes/memos-plugin"] +``` + +#### Option 3: Config File Path + +Set `MEMOS_CONFIG_FILE` to point directly to the config file: + +```dockerfile +ENV MEMOS_CONFIG_FILE=/opt/data/home/.hermes/memos-plugin/config.yaml +CMD ["node", "bridge.cts", "--agent=hermes", "--daemon"] +``` + +### Example Docker Deployment + +For the Hermes Agent Docker image: + +```dockerfile +FROM nousresearch/hermes-agent:latest + +# Install memos-local-plugin +RUN bash -c "$(curl -fsSL https://raw.githubusercontent.com/MemTensor/MemOS/main/apps/memos-local-plugin/install.sh)" + +# Set the config location +ENV MEMOS_HOME=/opt/data/.hermes/memos-plugin + +# Start daemon in background, then run Hermes +CMD node /opt/data/.hermes/plugins/memos-local-plugin/bridge.cts --agent=hermes --daemon && hermes chat +``` + +### Troubleshooting + +If you see warnings like: + +``` +config file not found at /opt/data/.hermes/memos-plugin/config.yaml; using defaults +``` + +This means the bridge process is looking in the wrong location. Check: + +1. Verify your `config.yaml` exists: `ls -la ~/.hermes/memos-plugin/config.yaml` +2. Set `MEMOS_HOME` or use `--home` to point to the correct directory +3. Ensure the path matches the location where `install.sh` created the config + +When config is missing, the plugin falls back to defaults (local embedding, no LLM provider), which will break summarization and reflection features. + diff --git a/apps/memos-local-plugin/adapters/openclaw/index.ts b/apps/memos-local-plugin/adapters/openclaw/index.ts index ba56848cb..9c318889a 100644 --- a/apps/memos-local-plugin/adapters/openclaw/index.ts +++ b/apps/memos-local-plugin/adapters/openclaw/index.ts @@ -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, @@ -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"; @@ -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; @@ -125,119 +130,172 @@ function resolveViewerStaticRoot(): string | undefined { } } -async function createRuntime(api: OpenClawPluginApi): Promise { +const OPENCLAW_VIEWER_PORT = 18799; + +async function createRuntime( + api: OpenClawPluginApi, + runtimeLock: OpenClawRuntimeLockHandle, +): Promise { 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) => 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) => 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 { + 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. @@ -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, }); }); diff --git a/apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts b/apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts new file mode 100644 index 000000000..55d2f6e43 --- /dev/null +++ b/apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts @@ -0,0 +1,165 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import type { ResolvedHome } from "../../core/config/index.js"; + +const LOCK_DIRNAME = "openclaw-runtime.lock"; +const OWNER_FILENAME = "owner.json"; +const UNWRITTEN_OWNER_STALE_MS = 30_000; + +export interface OpenClawRuntimeLockOwner { + pluginId: string; + version: string; + pid: number; + token: string; + startedAt: number; + dbFile: string; + viewerPort: number; +} + +export interface OpenClawRuntimeLockHandle { + lockDir: string; + owner: OpenClawRuntimeLockOwner; + release(): void; +} + +export interface AcquireOpenClawRuntimeLockOptions { + home: ResolvedHome; + pluginId: string; + version: string; + viewerPort: number; + pid?: number; + now?: () => number; + unwrittenOwnerStaleMs?: number; +} + +export class DuplicateOpenClawRuntimeError extends Error { + readonly code = "duplicate_instance"; + readonly lockDir: string; + readonly owner: OpenClawRuntimeLockOwner | null; + + constructor(lockDir: string, owner: OpenClawRuntimeLockOwner | null) { + const detail = owner + ? `pid=${owner.pid} startedAt=${new Date(owner.startedAt).toISOString()}` + : "owner=unknown"; + super(`memos-local OpenClaw runtime is already active (${detail})`); + this.name = "DuplicateOpenClawRuntimeError"; + this.lockDir = lockDir; + this.owner = owner; + } +} + +export function openClawRuntimeLockDir(home: ResolvedHome): string { + return path.join(home.daemonDir, LOCK_DIRNAME); +} + +export function acquireOpenClawRuntimeLock( + options: AcquireOpenClawRuntimeLockOptions, +): OpenClawRuntimeLockHandle { + const lockDir = openClawRuntimeLockDir(options.home); + const ownerFile = path.join(lockDir, OWNER_FILENAME); + const now = options.now ?? Date.now; + const pid = options.pid ?? process.pid; + const unwrittenOwnerStaleMs = + options.unwrittenOwnerStaleMs ?? UNWRITTEN_OWNER_STALE_MS; + + fs.mkdirSync(options.home.daemonDir, { recursive: true }); + + for (;;) { + try { + fs.mkdirSync(lockDir); + break; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code !== "EEXIST") throw err; + + const owner = readOwner(ownerFile); + if (owner && pidIsAlive(owner.pid)) { + throw new DuplicateOpenClawRuntimeError(lockDir, owner); + } + if (!owner && !lockLooksStale(lockDir, now(), unwrittenOwnerStaleMs)) { + throw new DuplicateOpenClawRuntimeError(lockDir, null); + } + + fs.rmSync(lockDir, { recursive: true, force: true }); + } + } + + const owner: OpenClawRuntimeLockOwner = { + pluginId: options.pluginId, + version: options.version, + pid, + token: randomUUID(), + startedAt: now(), + dbFile: options.home.dbFile, + viewerPort: options.viewerPort, + }; + + try { + fs.writeFileSync(ownerFile, JSON.stringify(owner, null, 2), "utf8"); + } catch (err) { + fs.rmSync(lockDir, { recursive: true, force: true }); + throw err; + } + + let released = false; + const releaseSync = () => { + if (released) return; + released = true; + const current = readOwner(ownerFile); + if (current?.token !== owner.token) return; + fs.rmSync(lockDir, { recursive: true, force: true }); + }; + const onExit = () => releaseSync(); + process.once("exit", onExit); + + return { + lockDir, + owner, + release() { + releaseSync(); + process.off("exit", onExit); + }, + }; +} + +function readOwner(ownerFile: string): OpenClawRuntimeLockOwner | null { + try { + const parsed = JSON.parse(fs.readFileSync(ownerFile, "utf8")) as Partial; + if ( + typeof parsed.pluginId !== "string" || + typeof parsed.version !== "string" || + typeof parsed.pid !== "number" || + typeof parsed.token !== "string" || + typeof parsed.startedAt !== "number" || + typeof parsed.dbFile !== "string" || + typeof parsed.viewerPort !== "number" + ) { + return null; + } + return parsed as OpenClawRuntimeLockOwner; + } catch { + return null; + } +} + +function pidIsAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + return code === "EPERM"; + } +} + +function lockLooksStale(lockDir: string, now: number, staleMs: number): boolean { + try { + const stat = fs.statSync(lockDir); + return now - stat.mtimeMs >= staleMs; + } catch { + return true; + } +} diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index 4e7ea1ad4..81848acf7 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -42,6 +42,7 @@ interface BridgeArgs { noViewer: boolean; tcpPort?: number; agent: "openclaw" | "hermes"; + home?: string; } type BridgeStatus = "connected" | "reconnecting" | "disconnected" | "unknown"; @@ -61,6 +62,7 @@ function parseArgs(argv: readonly string[]): BridgeArgs { else if (raw.startsWith("--tcp=")) args.tcpPort = Number(raw.slice(6)); else if (raw === "--agent=hermes") args.agent = "hermes"; else if (raw === "--agent=openclaw") args.agent = "openclaw"; + else if (raw.startsWith("--home=")) args.home = raw.slice(7); } return args; } @@ -235,11 +237,21 @@ async function main(): Promise { runtimeModule("core/telemetry/index.ts", "dist/core/telemetry/index.js") )) as typeof import("./core/telemetry/index.js"); + // Resolve home early so we can use resolveHome with explicit defaultHome + const { resolveHome } = (await importEsm( + runtimeModule("core/config/paths.ts", "dist/core/config/paths.js") + )) as typeof import("./core/config/paths.js"); + + const resolvedHome = args.home + ? resolveHome(args.agent, args.home) + : undefined; + const { core, config, home } = await bootstrapMemoryCoreFull({ agent: args.agent, namespace: { agentKind: args.agent, profileId: "default" }, pkgVersion, hostLlmBridge: args.daemon ? null : lazyHostLlmBridge, + home: resolvedHome, }); const telemetry = new Telemetry( diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index a06aec2bb..1466c27ef 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -48,7 +48,11 @@ export async function loadConfig(home: ResolvedHome): Promise } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code === "ENOENT") { - warnings.push(`config file not found at ${home.configFile}; using defaults`); + warnings.push( + `config file not found at ${home.configFile}; using defaults. ` + + `To fix: set MEMOS_HOME or MEMOS_CONFIG_FILE env var, or use --home CLI flag. ` + + `See: https://github.com/MemTensor/MemOS/tree/main/apps/memos-local-plugin#configuration` + ); } else if (MemosError.is(err)) { throw err; } else { diff --git a/apps/memos-local-plugin/core/experience/feedback-builder.ts b/apps/memos-local-plugin/core/experience/feedback-builder.ts index 5de5dad3e..f5a948d40 100644 --- a/apps/memos-local-plugin/core/experience/feedback-builder.ts +++ b/apps/memos-local-plugin/core/experience/feedback-builder.ts @@ -60,6 +60,9 @@ const MIN_SIGNIFICANCE = 0.5; const MERGE_SIMILARITY = 0.72; const MAX_TITLE_CHARS = 120; const MAX_LINE_CHARS = 360; +// Strict scenarios: only full credit counts as a pass (covers {-1,+1} and 0..1 +// reward scales โ€” anything short of 1 means the task was not fully solved). +const FULL_PASS_REWARD = 1; export async function runFeedbackExperience( input: FeedbackExperienceInput, @@ -157,8 +160,15 @@ async function buildDraft(args: { const text = cleanLine(args.text, MAX_LINE_CHARS); const lower = args.text.toLowerCase(); const verifier = extractVerifierMeta(args.feedback.raw, lower); - const pass = isPositiveSignal(args.feedback, lower, args.classified.shape, verifier); - const fail = isNegativeSignal(args.feedback, lower, args.classified.shape, verifier); + // Authoritative success/failure from the verifier payload or episode reward. + // Strict scenarios (coding/math/verifier): ONLY a full pass is positive โ€” a + // partial pass such as 3/4 (or reward 0) is a failure, never a positive exemplar. + const outcome = objectiveOutcome(args.feedback.raw, args.episode?.rTask); + const lexicalPass = isPositiveSignal(args.feedback, lower, args.classified.shape); + const lexicalFail = isNegativeSignal(args.feedback, lower, args.classified.shape); + // Objective outcome dominates; lexical signals only decide when it is unknown. + const pass = outcome === "pass" || (outcome === "unknown" && lexicalPass && !lexicalFail); + const fail = outcome === "fail" || (outcome === "unknown" && lexicalFail); const hasAvoid = /\b(avoid|do not|don't|never|stop|wrong|incorrect|failed|fail)\b/i.test(args.text) || /ไธ่ฆ|ๅˆซ|ไธ่ƒฝ|้”™่ฏฏ|ๅคฑ่ดฅ|ๅไพ‹/.test(args.text); @@ -169,21 +179,22 @@ async function buildDraft(args: { type = "success_pattern"; polarity = "positive"; skillEligible = true; - } else if (fail && hasAvoid) { - type = "failure_avoidance"; + } else if (fail) { + // Objective failure: never a positive exemplar, never skill-eligible. + type = hasAvoid ? "failure_avoidance" : verifier ? "verifier_feedback" : "repair_instruction"; polarity = "negative"; } else if (args.classified.shape === "preference") { type = "preference"; - polarity = fail ? "negative" : "neutral"; + polarity = "neutral"; } else if (hasAvoid) { type = "failure_avoidance"; polarity = "negative"; - } else if (args.classified.shape === "correction" || args.classified.shape === "constraint" || fail) { + } else if (args.classified.shape === "correction" || args.classified.shape === "constraint") { type = "repair_instruction"; - polarity = fail ? "negative" : "neutral"; + polarity = "neutral"; } else if (verifier) { type = "verifier_feedback"; - polarity = pass ? "positive" : fail ? "negative" : "neutral"; + polarity = "neutral"; } else { type = "repair_instruction"; polarity = "neutral"; @@ -437,27 +448,25 @@ function isPositiveSignal( feedback: FeedbackRow, lower: string, shape: string, - verifier: Record | null, ): boolean { if (feedback.polarity === "positive") return true; if (shape === "positive") return true; - if (verifier && lower.includes("pass")) return true; - return /\b(success|succeeded|passed|task succeeded|works well|correct)\b/.test(lower) - || /ๆˆๅŠŸ|้€š่ฟ‡|ๆญฃ็กฎ|ๅคชๅฅฝไบ†|ๅ†™ๅพ—ๅพˆๅฅฝ/.test(lower); + // No substring "pass"/"้€š่ฟ‡" match here: "passed 3/4" is a partial failure, not + // a positive signal. A genuine full pass is decided by objectiveOutcome(). + return /\b(success|succeeded|works well|looks good|lgtm|correct)\b/.test(lower) + || /ๆˆๅŠŸ|ๆญฃ็กฎ|ๅคชๅฅฝไบ†|ๅ†™ๅพ—ๅพˆๅฅฝ/.test(lower); } function isNegativeSignal( feedback: FeedbackRow, lower: string, shape: string, - verifier: Record | null, ): boolean { if (feedback.polarity === "negative") return true; if (shape === "negative") return true; if (shape === "correction") return true; - if (verifier && /\b(fail|failed|counterexample)\b/.test(lower)) return true; - return /\b(fail|failed|wrong|incorrect|counterexample|not acceptable)\b/.test(lower) - || /ๅคฑ่ดฅ|้”™่ฏฏ|ไธๅฏน|ๅไพ‹/.test(lower); + return /\b(fail|failed|wrong|incorrect|counterexample|not acceptable|timeout|time limit exceeded)\b/.test(lower) + || /ๅคฑ่ดฅ|้”™่ฏฏ|ไธๅฏน|ๅไพ‹|่ถ…ๆ—ถ/.test(lower); } function collectTraceIds(input: FeedbackExperienceInput): TraceId[] { @@ -510,26 +519,88 @@ function extractVerifierMeta(raw: unknown, lower: string): Record = { source: "feedback" }; if (looksVerifier) meta.verifier = true; - if (typeof raw === "object" && raw != null) { - const obj = raw as Record; - for (const key of ["verdict", "score", "reward", "passed", "taskId", "family", "reason"]) { - if (obj[key] !== undefined) meta[key] = obj[key]; + if (src) { + // Read from the verifier payload (top-level or nested under `raw.verifier`) + // so the discriminative fields (reward/passed/total) are preserved. + for (const key of ["verdict", "score", "reward", "passed", "total", "taskId", "family", "reason"]) { + if (src[key] !== undefined) meta[key] = src[key]; } } return Object.keys(meta).length > 1 || looksVerifier ? meta : null; } -function verifierScore(raw: unknown): number { - if (typeof raw !== "object" || raw == null) return 0; - const obj = raw as Record; - for (const key of ["score", "reward", "r", "rating"]) { - const n = Number(obj[key]); - if (Number.isFinite(n)) return Math.min(1, Math.abs(n)); +/** + * Return the object that actually holds verifier fields. Benchmark gateways nest + * them under `raw.verifier`; older/manual feedback puts them at the top level. + */ +function verifierContainer(raw: unknown): Record | null { + let obj: unknown = raw; + if (typeof obj === "string") { + try { + obj = JSON.parse(obj); + } catch { + return null; + } } - return 0; + if (typeof obj !== "object" || obj == null) return null; + const rec = obj as Record; + if (rec.verifier && typeof rec.verifier === "object") { + return rec.verifier as Record; + } + return rec; +} + +interface VerifierStats { + reward: number | null; + passed: number | null; + total: number | null; +} + +function verifierStats(raw: unknown): VerifierStats { + const src = verifierContainer(raw); + const num = (v: unknown): number | null => { + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + if (!src) return { reward: null, passed: null, total: null }; + return { + reward: num(src.reward ?? src.score ?? src.r ?? src.rating), + passed: num(src.passed), + total: num(src.total), + }; +} + +type ObjectiveOutcome = "pass" | "fail" | "unknown"; + +/** + * Authoritative success/failure from the verifier payload, falling back to the + * episode reward. Strict scenarios (coding/math/verifier) treat ONLY a full pass + * as positive: a partial pass (passed < total) or reward below full credit is a + * failure, never a positive exemplar. + */ +function objectiveOutcome(raw: unknown, rTask: number | null | undefined): ObjectiveOutcome { + const { reward, passed, total } = verifierStats(raw); + if (passed != null && total != null && total > 0) { + return passed >= total ? "pass" : "fail"; + } + if (reward != null) { + // Epsilon guards against a float full-pass (e.g. 0.9999998) being misread as fail. + return reward >= FULL_PASS_REWARD - 1e-9 ? "pass" : "fail"; + } + if (typeof rTask === "number") { + if (rTask > 0) return "pass"; + if (rTask < 0) return "fail"; + } + return "unknown"; +} + +function verifierScore(raw: unknown): number { + const { reward } = verifierStats(raw); + return reward == null ? 0 : Math.min(1, Math.abs(reward)); } function traceHint(trace: TraceRow): string { diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 4974ee16d..4622a35c3 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -177,14 +177,23 @@ export async function bootstrapMemoryCoreFull( options: BootstrapOptions, ): Promise { const home = options.home ?? resolveHome(options.agent); - const config = - options.config ?? - (await loadConfig(home)).config; + const configResult = options.config + ? { config: options.config, fromDisk: true, warnings: [], source: home.configFile } + : await loadConfig(home); + const config = configResult.config; const log = rootLogger.child({ channel: "core.pipeline.bootstrap", ctx: { agent: options.agent }, }); + + // Log configuration warnings (e.g., missing config file) + if (configResult.warnings.length > 0) { + for (const warning of configResult.warnings) { + log.warn("config.warning", { message: warning }); + } + } + const namespace = normalizeNamespace(options.namespace, options.agent); // 1. Storage. @@ -702,13 +711,6 @@ export function createMemoryCore( config: input.config, }, ); - if (input.config.lightweightMemory && !llmFilterOutcomeSucceeded(filtered.outcome)) { - filtered = { - ...filtered, - kept: [], - dropped: [...filtered.dropped, ...filtered.kept], - }; - } const kept = new Set(filtered.kept); const dropped = new Set(filtered.dropped); return { @@ -810,10 +812,6 @@ export function createMemoryCore( return text.split(/\n+/).map((line) => line.trim()).find(Boolean)?.slice(0, 240) ?? ""; } - function llmFilterOutcomeSucceeded(outcome: string): boolean { - return outcome === "llm_kept_all" || outcome === "llm_filtered"; - } - function logCandidatesFromHits(hits: readonly RetrievalHitDTO[]): Array<{ tier: number; refKind: string; @@ -1725,7 +1723,7 @@ export function createMemoryCore( : localDropped; const stats = packet ? handle.consumeRetrievalStats(packet.packetId) : null; handle.repos.apiLogs.insert({ - toolName: handle.algorithm.lightweightMemory.enabled ? "memory_search" : "memos_search", + toolName: "memos_search", input: { type: "turn_start", agent: turn.agent, @@ -2456,7 +2454,7 @@ export function createMemoryCore( } finally { try { handle.repos.apiLogs.insert({ - toolName: handle.algorithm.lightweightMemory.enabled ? "memory_search" : "memos_search", + toolName: "memos_search", input: { type: "tool_call", agent: query.agent, @@ -2872,7 +2870,7 @@ export function createMemoryCore( offset: input.offset ?? 0, }); return rows - .filter((r: EpisodeRow) => visibleToCurrent(r) && !isLightweightEpisode(r)) + .filter((r: EpisodeRow) => visibleToCurrent(r)) .map((r: EpisodeRow) => r.id as EpisodeId); } @@ -2885,8 +2883,7 @@ export function createMemoryCore( ensureLive(); return handle.repos.episodes.list({ sessionId: input?.sessionId, limit: 100_000 }).filter((r) => (input?.includeAllNamespaces || visibleToCurrent(r)) && - matchesNamespaceFilter(r, input) && - !isLightweightEpisode(r) + matchesNamespaceFilter(r, input) ).length; } @@ -2912,8 +2909,7 @@ export function createMemoryCore( offset: input?.ownerAgentKind || input?.ownerProfileId ? 0 : input?.offset ?? 0, }).filter((r) => (input?.includeAllNamespaces || visibleToCurrent(r)) && - matchesNamespaceFilter(r, input) && - !isLightweightEpisode(r) + matchesNamespaceFilter(r, input) ); const pagedRows = input?.ownerAgentKind || input?.ownerProfileId ? rows.slice(input?.offset ?? 0, (input?.offset ?? 0) + (input?.limit ?? 50)) diff --git a/apps/memos-local-plugin/core/pipeline/orchestrator.ts b/apps/memos-local-plugin/core/pipeline/orchestrator.ts index 8d4f51d20..75dc7e244 100644 --- a/apps/memos-local-plugin/core/pipeline/orchestrator.ts +++ b/apps/memos-local-plugin/core/pipeline/orchestrator.ts @@ -1156,13 +1156,22 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { result.sessionId, result.contextHints, ); - const episodeId = openEpisodeBySession.get(sessionId) ?? result.episodeId; + const explicitEpisode = result.episodeId + ? session.sessionManager.getEpisode(result.episodeId) + : null; + const episodeId = explicitEpisode + ? result.episodeId + : openEpisodeBySession.get(sessionId) ?? result.episodeId; if (!episodeId) { throw new Error( "pipeline.onTurnEnd: no open episode for session " + sessionId, ); } - const episode = session.sessionManager.getEpisode(episodeId); + let episode = explicitEpisode ?? session.sessionManager.getEpisode(episodeId); + const wasClosedBeforeTurnEnd = episode?.status === "closed"; + if (wasClosedBeforeTurnEnd) { + episode = session.sessionManager.reopenEpisode(episodeId, "follow_up"); + } if (!episode || episode.status !== "open") { throw new Error( "pipeline.onTurnEnd: episode " + episodeId + " is not open", @@ -1256,6 +1265,14 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { } } + if (wasClosedBeforeTurnEnd) { + session.sessionManager.finalizeEpisode(episodeId, { + patchMeta: { + delayedAgentEndRecovered: true, + }, + }); + } + // Update the "current open episode" snapshot so the relation // classifier on the NEXT onTurnStart can decide whether the user // changed topic. We mirror the data shape of `lastEpisodeBySession` diff --git a/apps/memos-local-plugin/core/retrieval/retrieve.ts b/apps/memos-local-plugin/core/retrieval/retrieve.ts index fb13b191f..c8f656a1b 100644 --- a/apps/memos-local-plugin/core/retrieval/retrieve.ts +++ b/apps/memos-local-plugin/core/retrieval/retrieve.ts @@ -313,7 +313,9 @@ async function runAll( patternTerms: compiled.patternTerms, includeLowValue: plan.includeLowValue, excludeSessionId: - ctx.reason === "turn_start" && sessionId ? sessionId : undefined, + ctx.reason === "turn_start" && sessionId && !deps.config.lightweightMemory + ? sessionId + : undefined, }, ) : Promise.resolve({ traces: [], episodes: [] }); @@ -383,11 +385,10 @@ async function runAll( // Mechanical retrieval produces high-recall but low-precision // candidates. A small LLM round-trip (see `llm-filter.ts`) prunes // items that share surface keywords with the query but aren't - // actually relevant. Full mode fails open to preserve recall; - // lightweight mode fails closed because it promises summarizer-LLM - // screened raw memories only. - const queryText = - (ctx as { userText?: string }).userText ?? compiled.text ?? ""; + // actually relevant. If the LLM is unavailable, the filter helper + // keeps the mechanical ranking so local lightweight memories remain + // searchable in offline/default installs. + const queryText = (ctx as { userText?: string }).userText ?? compiled.text ?? ""; const filterResult = opts.skipLlmFilter ? { kept: mechanicalRanked, @@ -403,19 +404,10 @@ async function runAll( config: deps.config, }, ); - const filtered = - !opts.skipLlmFilter && - deps.config.lightweightMemory && - !llmFilterSucceeded(filterResult.outcome) - ? { - ...filterResult, - kept: [], - dropped: [...filterResult.dropped, ...filterResult.kept], - } - : filterResult; + const filtered = filterResult; log.debug("llm_filter.done", { outcome: filtered.outcome, - enforced: deps.config.lightweightMemory && filtered !== filterResult, + enforced: false, sufficient: filtered.sufficient, raw: rawCandidateCount, afterThreshold: mechanicalRanked.length, @@ -637,10 +629,6 @@ function round(n: number, d: number): number { return Math.round(n * f) / f; } -function llmFilterSucceeded(outcome: string): boolean { - return outcome === "llm_kept_all" || outcome === "llm_filtered"; -} - /** Thin faรงade so pipelines can `new Retriever(deps)` if they prefer OO. */ export class Retriever { constructor(private readonly deps: RetrievalDeps) {} diff --git a/apps/memos-local-plugin/core/storage/migrator.ts b/apps/memos-local-plugin/core/storage/migrator.ts index da4c3144d..efefe1885 100644 --- a/apps/memos-local-plugin/core/storage/migrator.ts +++ b/apps/memos-local-plugin/core/storage/migrator.ts @@ -164,6 +164,12 @@ function applyMigration(db: StorageDb, file: MigrationFile): void { ensureSkillUsageColumns(db); return; } + if (file.version === 5 && file.name === "skill-trials") { + if (tableExists(db, "skills") && tableExists(db, "episodes") && tableExists(db, "traces")) { + db.exec(fs.readFileSync(file.fullPath, "utf8")); + } + return; + } if (file.version === 6 && file.name === "world-model-version") { if (tableExists(db, "world_model")) { ensureColumn(db, "world_model", "version", "INTEGER NOT NULL DEFAULT 1"); @@ -184,6 +190,18 @@ function applyMigration(db: StorageDb, file: MigrationFile): void { } return; } + if (file.version === 10 && file.name === "trace-policy-links") { + if (tableExists(db, "traces") && tableExists(db, "policies")) { + db.exec(fs.readFileSync(file.fullPath, "utf8")); + } + return; + } + if (file.version === 12 && file.name === "trace-turn-pagination-index") { + if (tableExists(db, "traces")) { + db.exec(fs.readFileSync(file.fullPath, "utf8")); + } + return; + } db.exec(fs.readFileSync(file.fullPath, "utf8")); } diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts index 19bda3bee..335a41756 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts @@ -1064,9 +1064,9 @@ describe("createOpenClawBridge", () => { await (pipeline as PipelineHandle).flush(); const traces = await mc.listTraces({ groupByTurn: true }); - expect(traces).toHaveLength(2); - expect(traces.some((tr) => tr.toolCalls?.[0]?.name === "sh")).toBe(true); - expect(traces.some((tr) => tr.agentText === "done")).toBe(true); + expect(traces).toHaveLength(1); + expect(traces[0]?.toolCalls?.[0]?.name).toBe("sh"); + expect(traces[0]?.agentText).toBe("done"); }); it("handleAgentEnd works even when before_prompt_build was never called (lazy episode open)", async () => { diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts new file mode 100644 index 000000000..bbfa37cda --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { ResolvedHome } from "../../../core/config/index.js"; +import { + acquireOpenClawRuntimeLock, + DuplicateOpenClawRuntimeError, + openClawRuntimeLockDir, +} from "../../../adapters/openclaw/runtime-lock.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function tmpHome(): ResolvedHome { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memos-oc-lock-")); + roots.push(root); + return { + root, + configFile: path.join(root, "config.yaml"), + dataDir: path.join(root, "data"), + dbFile: path.join(root, "data", "memos.db"), + skillsDir: path.join(root, "skills"), + logsDir: path.join(root, "logs"), + daemonDir: path.join(root, "daemon"), + }; +} + +function acquire(home: ResolvedHome, pid = process.pid) { + return acquireOpenClawRuntimeLock({ + home, + pluginId: "memos-local-plugin", + version: "test", + viewerPort: 18799, + pid, + now: () => 1_700_000_000_000, + unwrittenOwnerStaleMs: 0, + }); +} + +describe("OpenClaw runtime lock", () => { + it("creates an owner record and releases the lock directory", () => { + const home = tmpHome(); + const lock = acquire(home); + const ownerPath = path.join(lock.lockDir, "owner.json"); + + expect(fs.existsSync(ownerPath)).toBe(true); + expect(JSON.parse(fs.readFileSync(ownerPath, "utf8"))).toMatchObject({ + pluginId: "memos-local-plugin", + version: "test", + pid: process.pid, + dbFile: home.dbFile, + viewerPort: 18799, + }); + + lock.release(); + expect(fs.existsSync(lock.lockDir)).toBe(false); + }); + + it("rejects a second live owner before another runtime can bootstrap", () => { + const home = tmpHome(); + const lock = acquire(home); + + expect(() => acquire(home)).toThrow(DuplicateOpenClawRuntimeError); + expect(fs.existsSync(path.join(lock.lockDir, "owner.json"))).toBe(true); + + lock.release(); + }); + + it("reclaims a stale owner whose process is gone", () => { + const home = tmpHome(); + const lockDir = openClawRuntimeLockDir(home); + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + JSON.stringify({ + pluginId: "memos-local-plugin", + version: "old", + pid: 99_999_999, + token: "stale-token", + startedAt: 1, + dbFile: home.dbFile, + viewerPort: 18799, + }), + "utf8", + ); + + const lock = acquire(home); + expect(lock.owner.pid).toBe(process.pid); + expect(lock.owner.token).not.toBe("stale-token"); + + lock.release(); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime.test.ts new file mode 100644 index 000000000..19378853a --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime.test.ts @@ -0,0 +1,174 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_CONFIG } from "../../../core/config/defaults.js"; +import { resolveHome, type ResolvedHome } from "../../../core/config/index.js"; +import type { + HostLogger, + OpenClawPluginApi, + ServiceDescriptor, +} from "../../../adapters/openclaw/openclaw-api.js"; + +interface MockApi extends OpenClawPluginApi { + services: ServiceDescriptor[]; + logger: HostLogger & { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; +} + +const tempRoots: string[] = []; +let oldMemosHome: string | undefined; + +afterEach(() => { + if (oldMemosHome === undefined) delete process.env.MEMOS_HOME; + else process.env.MEMOS_HOME = oldMemosHome; + vi.doUnmock("../../../core/pipeline/index.js"); + vi.doUnmock("../../../server/http.js"); + vi.doUnmock("../../../core/telemetry/index.js"); + vi.resetModules(); + vi.restoreAllMocks(); + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function useTempMemosHome(): ResolvedHome { + oldMemosHome = process.env.MEMOS_HOME; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memos-oc-runtime-")); + tempRoots.push(root); + process.env.MEMOS_HOME = root; + return resolveHome("openclaw"); +} + +function makeCore() { + return { + init: vi.fn(async () => {}), + shutdown: vi.fn(async () => {}), + bindTelemetry: vi.fn(), + }; +} + +function makeApi(): MockApi { + const services: ServiceDescriptor[] = []; + const logger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + return { + id: "memos-local-plugin", + name: "MemOS Local", + logger, + services, + registerTool: vi.fn(), + registerMemoryCapability: vi.fn(), + on: vi.fn(), + registerService: vi.fn((svc: ServiceDescriptor) => { + services.push(svc); + }), + }; +} + +async function loadPluginWithMocks( + bootstrapMemoryCoreFull: ReturnType, + startHttpServer: ReturnType, +) { + vi.resetModules(); + vi.doMock("../../../core/pipeline/index.js", () => ({ + bootstrapMemoryCoreFull, + })); + vi.doMock("../../../server/http.js", () => ({ + startHttpServer, + })); + vi.doMock("../../../core/telemetry/index.js", () => ({ + Telemetry: class { + trackPluginStarted = vi.fn(); + shutdown = vi.fn(async () => {}); + }, + })); + const mod = await import("../../../adapters/openclaw/index.js"); + return mod.default; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("OpenClaw adapter runtime lifecycle", () => { + it("blocks a duplicate register before the second runtime bootstraps", async () => { + const home = useTempMemosHome(); + const firstCore = makeCore(); + const boot = deferred<{ core: ReturnType; config: typeof DEFAULT_CONFIG; home: ResolvedHome }>(); + const bootstrapMemoryCoreFull = vi.fn(() => boot.promise); + const startHttpServer = vi.fn(async () => ({ + url: "http://127.0.0.1:18799", + port: 18799, + closed: false, + close: vi.fn(async () => {}), + })); + const plugin = await loadPluginWithMocks(bootstrapMemoryCoreFull, startHttpServer); + + const api1 = makeApi(); + plugin.register(api1); + expect(bootstrapMemoryCoreFull).toHaveBeenCalledTimes(1); + + const api2 = makeApi(); + expect(() => plugin.register(api2)).toThrow(/already active/); + expect(bootstrapMemoryCoreFull).toHaveBeenCalledTimes(1); + expect(api2.registerTool).not.toHaveBeenCalled(); + expect(api2.on).not.toHaveBeenCalled(); + + boot.resolve({ core: firstCore, config: DEFAULT_CONFIG, home }); + await api1.services[0]!.start?.(); + await api1.services[0]!.stop?.(); + + expect(fs.existsSync(path.join(home.daemonDir, "openclaw-runtime.lock"))).toBe(false); + }); + + it("treats viewer EADDRINUSE as fatal and releases core plus lock", async () => { + const home = useTempMemosHome(); + const core = makeCore(); + const bootstrapMemoryCoreFull = vi.fn(async () => ({ + core, + config: DEFAULT_CONFIG, + home, + })); + const inUse = Object.assign(new Error("address already in use"), { + code: "EADDRINUSE", + }); + const startHttpServer = vi.fn(async () => { + throw inUse; + }); + const plugin = await loadPluginWithMocks(bootstrapMemoryCoreFull, startHttpServer); + + const api = makeApi(); + plugin.register(api); + + await expect(api.services[0]!.start?.()).rejects.toMatchObject({ + code: "EADDRINUSE", + }); + + expect(core.init).toHaveBeenCalledTimes(1); + expect(core.shutdown).toHaveBeenCalledTimes(1); + expect(api.logger.error).toHaveBeenCalledWith( + expect.stringContaining("refusing duplicate/headless OpenClaw runtime"), + ); + expect(api.logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining("running headless"), + ); + expect(fs.existsSync(path.join(home.daemonDir, "openclaw-runtime.lock"))).toBe(false); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/experience/feedback-builder.test.ts b/apps/memos-local-plugin/tests/unit/experience/feedback-builder.test.ts index 3613dfcd1..c1a24c5a4 100644 --- a/apps/memos-local-plugin/tests/unit/experience/feedback-builder.test.ts +++ b/apps/memos-local-plugin/tests/unit/experience/feedback-builder.test.ts @@ -128,6 +128,34 @@ describe("feedback experience builder", () => { expect(recalled.map((c) => c.refId)).toContain(result.policyId); }); + it("treats a partial verifier pass (3/4, reward 0) as a failure, not a success_pattern", async () => { + const result = await runFeedbackExperience( + { + feedback: feedback({ + id: "fb_partial" as FeedbackRow["id"], + polarity: "neutral", + // The literal word "passed" appears here and used to be substring-matched + // as a positive signal โ€” even though 3/4 with reward 0 is a failure. + rationale: + "Verifier feedback for the previous attempt. Verifier reward: 0.0. passed: 3, total: 4. TimeoutException(): Time Limit Exceeded. Please briefly reflect on what you would keep and what you would improve next time.", + raw: { + source: "evoagentbench_gateway_manual_feedback", + verifier: { reward: 0, passed: 3, total: 4, results: [1, 1, 1, -3] }, + }, + }), + episode: { id: "ep_feedback" as EpisodeId, traceIds: [trace.id], rTask: -0.51 }, + trace, + }, + { repos: handle.repos, embedder: fakeEmbedder(), namespace, now: () => NOW }, + ); + + expect(result.policyId).toBeTruthy(); + const row = handle.repos.policies.getById(result.policyId!); + expect(row?.experienceType).not.toBe("success_pattern"); + expect(row?.evidencePolarity).toBe("negative"); + expect(row?.skillEligible).toBe(false); + }); + it("merges later avoidance feedback into a success-backed experience without losing skill eligibility", async () => { const ok = await runFeedbackExperience( { diff --git a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts index 75946dc6f..0fb06a3da 100644 --- a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts +++ b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts @@ -80,8 +80,7 @@ describe("install.sh โ€” CLI surface", () => { expect(script).toContain("const MEMOS_TOOL_NAMES = ["); expect(script).toContain("if (!Array.isArray(config.tools.alsoAllow)) config.tools.alsoAllow = []"); expect(script).toContain("config.tools.alsoAllow.push(toolName)"); - expect(script).toContain("delete config.plugins.entries[pluginId].hooks"); - expect(script).not.toContain("config.plugins.entries[pluginId].hooks.allowConversationAccess = true"); + expect(script).toContain("config.plugins.entries[pluginId].hooks.allowConversationAccess = true"); expect(script).not.toContain('"extensions": ["./adapters/openclaw/index.ts"]'); }); diff --git a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts index 88d5cbbd4..e42af3a79 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts @@ -36,12 +36,34 @@ let db: TmpDbHandle | null = null; let pipeline: PipelineHandle | null = null; let core: MemoryCore | null = null; const TEST_EMBED_DIMENSIONS = 384; +const FULL_MEMORY_CONFIG_YAML = ` +version: 1 +algorithm: + lightweightMemory: + enabled: false +`; + +function configWithLightweightMemory(enabled: boolean): typeof DEFAULT_CONFIG { + return { + ...DEFAULT_CONFIG, + algorithm: { + ...DEFAULT_CONFIG.algorithm, + lightweightMemory: { + ...DEFAULT_CONFIG.algorithm.lightweightMemory, + enabled, + }, + }, + }; +} -function buildDeps(h: TmpDbHandle): PipelineDeps { +function buildDeps( + h: TmpDbHandle, + config: typeof DEFAULT_CONFIG = configWithLightweightMemory(false), +): PipelineDeps { return { agent: "openclaw", home: resolveHome("openclaw", "/tmp/memos-mc-test"), - config: DEFAULT_CONFIG, + config, db: h.db, repos: h.repos, llm: null, @@ -258,7 +280,7 @@ describe("MemoryCore faรงade", () => { }); it("does not require action vectors for lightweight memory traces", async () => { - pipeline = createPipeline(buildDeps(db!)); + pipeline = createPipeline(buildDeps(db!, configWithLightweightMemory(true))); core = createMemoryCore( pipeline, resolveHome("openclaw", "/tmp/memos-mc-test"), @@ -332,6 +354,21 @@ describe("MemoryCore faรงade", () => { const row = db!.repos.traces.getById("tr_lightweight" as never); expect(row?.vecSummary?.length).toBe(TEST_EMBED_DIMENSIONS); expect(row?.vecAction).toBeNull(); + + await expect(core.listEpisodes({ limit: 10 })).resolves.toEqual(["ep_lightweight"]); + await expect(core.countEpisodes()).resolves.toBe(1); + const episodeRows = await core.listEpisodeRows({ limit: 10 }); + expect(episodeRows).toHaveLength(1); + expect(episodeRows[0]?.id).toBe("ep_lightweight"); + expect(episodeRows[0]?.preview).toContain("What changed in the repo?"); + + const search = await core.searchMemory({ + agent: "openclaw", + query: "lightweight memory mode", + topK: { tier1: 0, tier2: 5, tier3: 0 }, + }); + expect(search.hits.length).toBeGreaterThan(0); + expect(search.hits.map((hit) => hit.snippet).join("\n")).toContain("lightweight memory mode"); }); it("onTurnStart returns a RetrievalResultDTO with tier latencies", async () => { @@ -1149,7 +1186,10 @@ algorithm: // the crash; only the final status flip was lost). // - Un-scored rows with no traces โ†’ stay open + `topicState` // `interrupted` so they do not show as skipped. - home = await makeTmpHome({ agent: "openclaw" }); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: FULL_MEMORY_CONFIG_YAML, + }); // First bootstrap: lets migrations run + schema exists. Shut it // down cleanly so we can seed orphans into the DB without holding @@ -1233,7 +1273,10 @@ algorithm: }); it("keeps an interrupted topic open across restart and appends the next same-topic turn", async () => { - home = await makeTmpHome({ agent: "openclaw" }); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: FULL_MEMORY_CONFIG_YAML, + }); const first = await bootstrapMemoryCore({ agent: "openclaw", @@ -1275,7 +1318,10 @@ algorithm: }); it("rescoring closed episodes when traces were appended after the last reward", async () => { - home = await makeTmpHome({ agent: "openclaw" }); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: FULL_MEMORY_CONFIG_YAML, + }); const seeder = await bootstrapMemoryCore({ agent: "openclaw", @@ -1369,7 +1415,10 @@ algorithm: }); it("rescoring finalized closed episodes that have traces but no reward metadata", async () => { - home = await makeTmpHome({ agent: "openclaw" }); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: FULL_MEMORY_CONFIG_YAML, + }); const seeder = await bootstrapMemoryCore({ agent: "openclaw", diff --git a/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts index 87b9e6d8f..f4826a6f3 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts @@ -24,6 +24,19 @@ import type { TurnInputDTO, TurnResultDTO } from "../../../agent-contract/dto.js let dbHandle: TmpDbHandle | null = null; let pipeline: PipelineHandle | null = null; +function configWithLightweightMemory(enabled: boolean): typeof DEFAULT_CONFIG { + return { + ...DEFAULT_CONFIG, + algorithm: { + ...DEFAULT_CONFIG.algorithm, + lightweightMemory: { + ...DEFAULT_CONFIG.algorithm.lightweightMemory, + enabled, + }, + }, + }; +} + function buildDeps( h: TmpDbHandle, embedder = fakeEmbedder({ dimensions: 384 }), @@ -31,7 +44,7 @@ function buildDeps( return { agent: "openclaw", home: resolveHome("openclaw", "/tmp/memos-test-home"), - config: DEFAULT_CONFIG, + config: configWithLightweightMemory(false), db: h.db, repos: h.repos, llm: null, diff --git a/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts index fa3eaeee9..3d2fb0049 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts @@ -374,7 +374,7 @@ describe("retrieval/integration", () => { expect(res.stats.llmFilterKept).toBeGreaterThan(0); }); - it("lightweight mode returns no memories when summarizer filter is unavailable", async () => { + it("lightweight mode keeps local memories when the summarizer filter is unavailable", async () => { const res = await turnStartRetrieve( { ...makeDeps(handle), @@ -397,9 +397,9 @@ describe("retrieval/integration", () => { expect(res.stats.tier2Count).toBeGreaterThan(0); expect(res.stats.llmFilterOutcome).toBe("no_llm"); - expect(res.stats.llmFilterKept).toBe(0); - expect(res.packet.snippets).toEqual([]); - expect(res.stats.emptyPacket).toBe(true); + expect(res.stats.llmFilterKept).toBeGreaterThan(0); + expect(res.packet.snippets.length).toBeGreaterThan(0); + expect(res.stats.emptyPacket).toBe(false); }); it("skill_invoke is tier1-heavy", async () => {