diff --git a/apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts b/apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts index 17618d2c0..a755efb98 100644 --- a/apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts +++ b/apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts @@ -10,6 +10,7 @@ import { type GenerateOptions, type LlmCallConfig, type LlmFailure, + type LlmResolvedModelInfo, type PreparedLlmCall, type StreamChunk, } from "@deepseek-ai/dsh-llm"; @@ -29,6 +30,7 @@ const HOST_LLM_TIMEOUT_CODE = "MEMOS_DSH_HOST_LLM_TIMEOUT"; const HOST_LLM_MESSAGE_SOURCE = "memos-local-memory"; const NO_REASONING_EFFORT = ReasoningEffortId("off"); const UNSUPPORTED_REASONING_EFFORT = "UNSUPPORTED_REASONING_EFFORT"; +const MODEL_CAPABILITY_TTL_MS = 10 * 60 * 1_000; /** Atomic provider/model route captured from the DSH agent that owns a turn. */ export interface DeepSeekHarnessLlmRoute { @@ -40,12 +42,34 @@ export interface DeepSeekHarnessLlmRoute { /** Public subset of DSH's LLM runtime used by this adapter. */ export interface DeepSeekHarnessLlmLike { + resolveModelInfo( + provider: string, + model: string, + signal?: AbortSignal, + ): Promise; prepareCall( config: LlmCallConfig, signal?: AbortSignal, ): Promise; } +export interface DeepSeekHarnessHostLlmBridge extends HostLlmBridge { + /** Drop exact-route capability snapshots after a DSH adapter topology update. */ + invalidateModelCapabilities(): void; +} + +type AuxiliaryReasoningCapability = "off" | "plain"; + +interface CapabilityCacheEntry { + readonly expiresAt: number; + readonly value: Promise; +} + +interface CapabilityCache { + readonly entries: Map; + generation: number; +} + /** * Async route scope for MemOS work spawned by one DSH session. * @@ -95,9 +119,17 @@ export interface CreateDeepSeekHarnessHostLlmBridgeOptions { */ export function createDeepSeekHarnessHostLlmBridge( options: CreateDeepSeekHarnessHostLlmBridgeOptions, -): HostLlmBridge { +): DeepSeekHarnessHostLlmBridge { + const capabilityCache: CapabilityCache = { + entries: new Map(), + generation: 0, + }; return { id: HOST_LLM_BRIDGE_ID, + invalidateModelCapabilities(): void { + capabilityCache.generation++; + capabilityCache.entries.clear(); + }, async complete(input: HostLlmCompleteInput): Promise { const route = options.routes.current(); if (!route) { @@ -122,6 +154,7 @@ export function createDeepSeekHarnessHostLlmBridge( input, route, callDeadline.signal, + capabilityCache, ); const request = createGenerateOptions(input, route, prepared.config); request.signal = callDeadline.signal; @@ -217,20 +250,30 @@ function createGenerateOptions( * * Retrieval filters and JSON extractors intentionally use small output caps. * Reusing a conversation's high reasoning effort can spend that entire cap on - * reasoning and produce no JSON/text. DSH effort ids are adapter-owned, so the - * exact registered adapter validates the branded conventional `off` id. Only - * an explicit unsupported-effort result retries without it, preserving the - * adapter/provider default. prepareCall performs no provider generation I/O - * and binds that validation to the same registration used for dispatch, even - * if HMR replaces the route before the returned stream starts. + * reasoning and produce no JSON/text. Resolve exact-model metadata through + * DSH, then cache whether its adapter advertises the conventional `off` id. + * prepareCall remains the final registration-bound authority: if HMR changes + * the route after metadata resolution, an explicit unsupported-effort result + * refreshes the cache and retries with the adapter/provider default. */ async function prepareAuxiliaryCall( llm: DeepSeekHarnessLlmLike, input: HostLlmCompleteInput, route: DeepSeekHarnessLlmRoute, signal: AbortSignal, + capabilityCache: CapabilityCache, ): Promise { const config = createCallConfig(input, route); + const capability = await resolveAuxiliaryReasoningCapability( + llm, + route, + signal, + capabilityCache, + ); + if (capability === "plain") { + return llm.prepareCall(config, signal); + } + const preparationGeneration = capabilityCache.generation; try { return await llm.prepareCall( { ...config, reasoningEffort: NO_REASONING_EFFORT }, @@ -243,10 +286,64 @@ async function prepareAuxiliaryCall( ) { throw error; } + // The exact adapter may have changed after the metadata lookup. Preserve + // prepareCall's registration-bound validation as the final authority and + // remember the corrected capability only if no newer topology update has + // already invalidated this preparation generation. + if (capabilityCache.generation === preparationGeneration) { + rememberAuxiliaryReasoningCapability(route, "plain", capabilityCache); + } return llm.prepareCall(config, signal); } } +function capabilityRouteKey(route: DeepSeekHarnessLlmRoute): string { + return JSON.stringify([route.provider, route.model]); +} + +function rememberAuxiliaryReasoningCapability( + route: DeepSeekHarnessLlmRoute, + capability: AuxiliaryReasoningCapability, + cache: CapabilityCache, +): void { + cache.entries.set(capabilityRouteKey(route), { + expiresAt: Date.now() + MODEL_CAPABILITY_TTL_MS, + value: Promise.resolve(capability), + }); +} + +async function resolveAuxiliaryReasoningCapability( + llm: DeepSeekHarnessLlmLike, + route: DeepSeekHarnessLlmRoute, + signal: AbortSignal, + cache: CapabilityCache, +): Promise { + const key = capabilityRouteKey(route); + const cached = cache.entries.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + if (cached) cache.entries.delete(key); + + let entry: CapabilityCacheEntry; + const value = llm.resolveModelInfo(route.provider, route.model, signal) + .then((info): AuxiliaryReasoningCapability => ( + info.reasoning?.efforts.some((effort) => effort.id === NO_REASONING_EFFORT) + ? "off" + : "plain" + )) + .catch((error: unknown) => { + if (cache.entries.get(key) === entry) cache.entries.delete(key); + throw error; + }); + entry = { + expiresAt: Date.now() + MODEL_CAPABILITY_TTL_MS, + value, + }; + cache.entries.set(key, entry); + return value; +} + function createCallConfig( input: HostLlmCompleteInput, route: DeepSeekHarnessLlmRoute, diff --git a/apps/memos-local-plugin/adapters/deepseek-harness/index.ts b/apps/memos-local-plugin/adapters/deepseek-harness/index.ts index 5c839f104..bc63ba5d6 100644 --- a/apps/memos-local-plugin/adapters/deepseek-harness/index.ts +++ b/apps/memos-local-plugin/adapters/deepseek-harness/index.ts @@ -36,6 +36,7 @@ import { import { createDeepSeekHarnessHostLlmBridge, DeepSeekHarnessLlmRouteContext, + type DeepSeekHarnessHostLlmBridge, } from "./host-llm.js"; import { registerDeepSeekHarnessTools } from "./tools.js"; @@ -126,6 +127,16 @@ export function configureDeepSeekHarnessHostLlm( }); } +/** Refresh exact-model capability snapshots whenever DSH replaces an adapter. */ +export function registerDeepSeekHarnessHostLlmCapabilityInvalidation( + ctx: Context, + bridge: DeepSeekHarnessHostLlmBridge, +): () => void { + return ctx.on("llm/adapters-updated", () => { + bridge.invalidateModelCapabilities(); + }); +} + /** Autonomous recovery has no owning DSH turn from which to capture a route. */ export function deepSeekHarnessAutoRecoveryEnabled(config: ResolvedConfig): boolean { return config.llm.provider.trim().toLowerCase() !== "host" || @@ -247,6 +258,12 @@ export async function apply( const hostLlmBridge = config.hostLlmEnabled ? createDeepSeekHarnessHostLlmBridge({ llm: ctx.llm, routes }) : null; + if (hostLlmBridge) { + registrations.push(registerDeepSeekHarnessHostLlmCapabilityInvalidation( + ctx, + hostLlmBridge, + )); + } const autoRecoveryEnabled = deepSeekHarnessAutoRecoveryEnabled(memoryConfig); core = await bootstrapMemoryCore({ diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py index e2389e498..244fe027c 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py @@ -1504,12 +1504,12 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) -> str: # type: ignore[override] if not self._bridge: - return json.dumps({"error": "bridge not connected"}) + return json.dumps({"error": "bridge not connected"}, ensure_ascii=False) try: if tool_name == "memos_search": query = (args.get("query") or "").strip() if not query: - return json.dumps({"error": "missing query"}) + return json.dumps({"error": "missing query"}, ensure_ascii=False) max_results = self._int_arg(args, "maxResults", 10, 1, 50) params: dict[str, Any] = { "agent": "hermes", @@ -1528,11 +1528,11 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) params, timeout=_LONG_RPC_TIMEOUT, ) - return json.dumps({"hits": resp.get("hits", [])}) + return json.dumps({"hits": resp.get("hits", [])}, ensure_ascii=False) if tool_name == "memos_get": item_id = (args.get("id") or "").strip() if not item_id: - return json.dumps({"error": "missing id"}) + return json.dumps({"error": "missing id"}, ensure_ascii=False) kind = args.get("kind") or "trace" methods = { "trace": "memory.get_trace", @@ -1541,12 +1541,14 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) } method = methods.get(kind) if method is None: - return json.dumps({"error": f"unknown memory kind: {kind}"}) + return json.dumps({"error": f"unknown memory kind: {kind}"}, ensure_ascii=False) item = self._bridge_request_with_retry( method, {"id": item_id, "namespace": self._runtime_namespace()} ) if not item: - return json.dumps({"found": False, "kind": kind, "id": item_id}) + return json.dumps( + {"found": False, "kind": kind, "id": item_id}, ensure_ascii=False + ) if kind == "trace": body = self._clip(item.get("agentText") or item.get("body")) meta = { @@ -1584,7 +1586,8 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) "id": item.get("id", item_id), "body": body, "meta": meta, - } + }, + ensure_ascii=False, ) if tool_name == "memos_timeline": resp = self._bridge_request_with_retry( @@ -1596,13 +1599,16 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) ) limit = self._int_arg(args, "limit", 20, 1, 100) traces = resp.get("traces", [])[:limit] - return json.dumps({"traces": traces}) + return json.dumps({"traces": traces}, ensure_ascii=False) if tool_name == "memos_skill_list": limit = self._int_arg(args, "limit", 10, 1, 50) params = {"limit": limit, "namespace": self._runtime_namespace()} if args.get("status"): params["status"] = args["status"] - return json.dumps(self._bridge_request_with_retry("skill.list", params)) + return json.dumps( + self._bridge_request_with_retry("skill.list", params), + ensure_ascii=False, + ) if tool_name == "memos_environment": query = (args.get("query") or "").strip() limit = self._int_arg(args, "limit", 5, 1, 30) @@ -1621,7 +1627,8 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) for w in resp.get("worldModels", []) ], "queried": False, - } + }, + ensure_ascii=False, ) resp = self._bridge_request_with_retry( "memory.search", @@ -1651,12 +1658,13 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) for h in hits[:limit] ], "queried": True, - } + }, + ensure_ascii=False, ) if tool_name == "memos_skill_get": skill_id = (args.get("id") or "").strip() if not skill_id: - return json.dumps({"error": "missing id"}) + return json.dumps({"error": "missing id"}, ensure_ascii=False) skill = self._bridge_request_with_retry( "skill.get", { @@ -1667,10 +1675,10 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) "episodeId": self._episode_id or None, }, ) - return json.dumps({"found": bool(skill), "skill": skill}) + return json.dumps({"found": bool(skill), "skill": skill}, ensure_ascii=False) except Exception as err: - return json.dumps({"error": str(err)}) - return json.dumps({"error": f"unknown tool: {tool_name}"}) + return json.dumps({"error": str(err)}, ensure_ascii=False) + return json.dumps({"error": f"unknown tool: {tool_name}"}, ensure_ascii=False) # ─── Config schema (for `hermes memory setup`) ──────────────────────── diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index 132af85f7..f348b8c12 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -29,12 +29,10 @@ const path = require("node:path") as typeof import("node:path"); // eslint-disable-next-line @typescript-eslint/no-require-imports const fs = require("node:fs") as typeof import("node:fs"); // eslint-disable-next-line @typescript-eslint/no-require-imports +const { homedir } = require("node:os") as typeof import("node:os"); // eslint-disable-next-line @typescript-eslint/no-require-imports const url = require("node:url") as typeof import("node:url"); -const BRIDGE_STATUS_HEARTBEAT_MS = 5_000; -const BRIDGE_STATUS_STALE_MS = 20_000; -const BRIDGE_STATUS_FILE = "bridge-status.json"; // If core.shutdown() or waitForShutdown() blocks (e.g. L2/L3 LLM calls // hanging during flush), the bridge process would never exit after stdin // EOF or SIGTERM. Race against this deadline so the process always exits @@ -42,7 +40,13 @@ const BRIDGE_STATUS_FILE = "bridge-status.json"; const SHUTDOWN_TIMEOUT_MS = 20_000; function withShutdownTimeout(p: Promise): Promise { - return Promise.race([p, new Promise((r) => setTimeout(r, SHUTDOWN_TIMEOUT_MS))]); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, SHUTDOWN_TIMEOUT_MS); + }); + return Promise.race([p, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); } interface BridgeArgs { @@ -54,15 +58,6 @@ interface BridgeArgs { runtimeScope?: string; } -type BridgeStatus = "connected" | "reconnecting" | "disconnected" | "unknown"; - -interface BridgeStatusSnapshot { - status: BridgeStatus; - lastOkAt: number | null; - lastErrorAt: number | null; - lastError: string | null; -} - function parseArgs(argv: readonly string[]): BridgeArgs { const args: BridgeArgs = { daemon: false, noViewer: false, agent: "openclaw" }; for (const raw of argv) { @@ -104,7 +99,7 @@ function pidFilePath( if (configuredHome) return path.join(path.resolve(configuredHome), "daemon", filename); const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; return path.join( - process.env.HOME ?? "/tmp", + homedir(), agentHome, "memos-plugin", "daemon", @@ -211,6 +206,13 @@ async function main(): Promise { const { isHermesChatRunning } = (await importEsm( runtimeModule("bridge/hermes-process.ts", "dist/bridge/hermes-process.js") )) as typeof import("./bridge/hermes-process.js"); + const { + BRIDGE_STATUS_FILE, + createBridgeStatusReader, + createBridgeStatusWriter, + } = (await importEsm( + runtimeModule("bridge/status.ts", "dist/bridge/status.js") + )) as typeof import("./bridge/status.js"); const rootDir = pluginRoot(); const pkgVersion = JSON.parse( @@ -332,13 +334,15 @@ async function main(): Promise { (core as { bindTelemetry?: (t: InstanceType) => void }).bindTelemetry?.(telemetry); telemetry.trackPluginStarted(args.agent); + const bridgeStatusFile = path.join(home.root, BRIDGE_STATUS_FILE); + const bridgeStatusWriter = + args.agent === "hermes" && !args.daemon + ? createBridgeStatusWriter(bridgeStatusFile) + : null; const bridgeStatus = args.agent === "hermes" - ? createBridgeStatusTracker( - path.join(home.root, BRIDGE_STATUS_FILE), - args.daemon, - isHermesChatRunning, - ) + ? bridgeStatusWriter ?? + createBridgeStatusReader(bridgeStatusFile, { isHermesChatRunning }) : null; // Process-level error reporting. Without these handlers a crash in @@ -384,7 +388,7 @@ async function main(): Promise { const viewerPort = AGENT_DEFAULT_PORTS[args.agent]; let bridgeHeartbeat: - | ReturnType["startHeartbeat"]> + | ReturnType["startHeartbeat"]> | undefined; // ─── Startup ordering invariant (issue #1747 + host LLM fallback) ─── @@ -419,11 +423,11 @@ async function main(): Promise { // `tests/unit/bridge/bridge-startup-ordering.test.ts`. if (!args.daemon) { stdio = startStdioServer({ core }); - bridgeStatus?.markConnected(); - bridgeHeartbeat = bridgeStatus?.startHeartbeat(); + bridgeStatusWriter?.markConnected(); + bridgeHeartbeat = bridgeStatusWriter?.startHeartbeat(); void stdio.done.then(() => { bridgeHeartbeat?.stop(); - bridgeStatus?.markDisconnected("Hermes chat disconnected"); + bridgeStatusWriter?.markDisconnected("Hermes chat disconnected"); }); } @@ -665,124 +669,6 @@ function classifyErrorCode(err: unknown): string { return "unknown"; } -function createBridgeStatusTracker( - statusFile: string, - daemon: boolean, - isHermesChatRunning: () => boolean, -): { - snapshot(): BridgeStatusSnapshot; - markConnected(): void; - markDisconnected(message: string): void; - startHeartbeat(): { stop(): void }; -} { - let snapshot: BridgeStatusSnapshot = daemon - ? { - status: "disconnected", - lastOkAt: null, - lastErrorAt: Date.now(), - lastError: "Hermes chat is not connected", - } - : { - status: "unknown", - lastOkAt: null, - lastErrorAt: null, - lastError: null, - }; - - function writeStatus(next: BridgeStatusSnapshot): void { - snapshot = next; - try { - fs.mkdirSync(path.dirname(statusFile), { recursive: true }); - fs.writeFileSync(statusFile, JSON.stringify(next), "utf8"); - } catch { - // Status display must never affect chat capture. - } - } - - function readStatus(): BridgeStatusSnapshot | null { - try { - const parsed = JSON.parse(fs.readFileSync(statusFile, "utf8")) as Partial; - if ( - parsed.status === "connected" || - parsed.status === "reconnecting" || - parsed.status === "disconnected" || - parsed.status === "unknown" - ) { - return { - status: parsed.status, - lastOkAt: typeof parsed.lastOkAt === "number" ? parsed.lastOkAt : null, - lastErrorAt: typeof parsed.lastErrorAt === "number" ? parsed.lastErrorAt : null, - lastError: typeof parsed.lastError === "string" ? parsed.lastError : null, - }; - } - } catch { - // Missing or corrupt status files are treated as disconnected. - } - return null; - } - - function applyStaleRule(raw: BridgeStatusSnapshot): BridgeStatusSnapshot { - if (raw.status === "disconnected" && daemon && isHermesChatRunning()) { - return { - status: "reconnecting", - lastOkAt: raw.lastOkAt, - lastErrorAt: raw.lastErrorAt, - lastError: "Hermes chat is running; waiting for memory bridge", - }; - } - if ( - raw.status === "connected" && - raw.lastOkAt != null && - Date.now() - raw.lastOkAt > BRIDGE_STATUS_STALE_MS - ) { - return { - status: "disconnected", - lastOkAt: raw.lastOkAt, - lastErrorAt: Date.now(), - lastError: "Hermes bridge heartbeat is stale", - }; - } - return raw; - } - - function markConnected(): void { - writeStatus({ - status: "connected", - lastOkAt: Date.now(), - lastErrorAt: snapshot.lastErrorAt, - lastError: snapshot.lastError, - }); - } - - function markDisconnected(message: string): void { - writeStatus({ - status: "disconnected", - lastOkAt: snapshot.lastOkAt, - lastErrorAt: Date.now(), - lastError: message, - }); - } - - return { - snapshot() { - return { ...applyStaleRule(readStatus() ?? snapshot) }; - }, - markConnected, - markDisconnected, - startHeartbeat() { - const timer = setInterval(() => { - markConnected(); - }, BRIDGE_STATUS_HEARTBEAT_MS); - (timer as unknown as { unref?: () => void }).unref?.(); - return { - stop() { - clearInterval(timer); - }, - }; - }, - }; -} - void main().catch((err) => { const detail = err instanceof Error ? err.stack ?? err.message : String(err); process.stderr.write( diff --git a/apps/memos-local-plugin/bridge.mts b/apps/memos-local-plugin/bridge.mts index 718172af2..75f7e7c37 100644 --- a/apps/memos-local-plugin/bridge.mts +++ b/apps/memos-local-plugin/bridge.mts @@ -28,17 +28,35 @@ * still work). There's no port-sharing or auto-promotion logic — * each agent has its own bookmarkable URL. */ -import * as childProcess from "node:child_process"; import * as fs from "node:fs"; +import { homedir } from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { isHermesChatRunning } from "./bridge/hermes-process.js"; +import { + BRIDGE_STATUS_FILE, + createBridgeStatusReader, + createBridgeStatusWriter, +} from "./bridge/status.js"; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const BRIDGE_STATUS_HEARTBEAT_MS = 5_000; -const BRIDGE_STATUS_STALE_MS = 20_000; -const BRIDGE_STATUS_FILE = "bridge-status.json"; +// Keep both executable bridge entries within the same process-level budget. +// Core shutdown has its own cooperative recovery cancellation, while this +// outer deadline guarantees a broken provider cannot orphan the bridge. +const SHUTDOWN_TIMEOUT_MS = 20_000; + +function withShutdownTimeout(p: Promise): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, SHUTDOWN_TIMEOUT_MS); + }); + return Promise.race([p, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} interface BridgeArgs { daemon: boolean; @@ -48,15 +66,6 @@ interface BridgeArgs { home?: string; } -type BridgeStatus = "connected" | "reconnecting" | "disconnected" | "unknown"; - -interface BridgeStatusSnapshot { - status: BridgeStatus; - lastOkAt: number | null; - lastErrorAt: number | null; - lastError: string | null; -} - function parseArgs(argv: readonly string[]): BridgeArgs { const args: BridgeArgs = { daemon: false, noViewer: false, agent: "openclaw" }; for (const raw of argv) { @@ -87,7 +96,7 @@ function pidFilePath(agent: string, explicitHome?: string): string { if (configuredHome) return path.join(path.resolve(configuredHome), "daemon", PID_FILENAME); const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; return path.join( - process.env.HOME ?? "/tmp", + homedir(), agentHome, "memos-plugin", "daemon", @@ -267,12 +276,15 @@ async function main(): Promise { (core as { bindTelemetry?: (t: InstanceType) => void }).bindTelemetry?.(telemetry); telemetry.trackPluginStarted(args.agent); + const bridgeStatusFile = path.join(home.root, BRIDGE_STATUS_FILE); + const bridgeStatusWriter = + args.agent === "hermes" && !args.daemon + ? createBridgeStatusWriter(bridgeStatusFile) + : null; const bridgeStatus = args.agent === "hermes" - ? createBridgeStatusTracker( - path.join(home.root, BRIDGE_STATUS_FILE), - args.daemon, - ) + ? bridgeStatusWriter ?? + createBridgeStatusReader(bridgeStatusFile, { isHermesChatRunning }) : null; // Process-level error reporting. Without these handlers a crash in @@ -318,7 +330,7 @@ async function main(): Promise { const viewerPort = AGENT_DEFAULT_PORTS[args.agent]; let bridgeHeartbeat: - | ReturnType["startHeartbeat"]> + | ReturnType["startHeartbeat"]> | undefined; // In stdio mode the host fallback path is a reverse JSON-RPC request @@ -329,11 +341,11 @@ async function main(): Promise { // fallback has a transport instead of tripping the lazy bridge guard. if (!args.daemon) { stdio = startStdioServer({ core }); - bridgeStatus?.markConnected(); - bridgeHeartbeat = bridgeStatus?.startHeartbeat(); + bridgeStatusWriter?.markConnected(); + bridgeHeartbeat = bridgeStatusWriter?.startHeartbeat(); void stdio.done.then(() => { bridgeHeartbeat?.stop(); - bridgeStatus?.markDisconnected("Hermes chat disconnected"); + bridgeStatusWriter?.markDisconnected("Hermes chat disconnected"); }); } @@ -399,13 +411,13 @@ async function main(): Promise { process.stderr.write( `bridge: daemon port :${viewerPort} still in use after ${maxBindAttempts}s — exiting.\n`, ); - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); process.exit(1); } process.stderr.write( `bridge: daemon viewer failed: ${(err as Error)?.message ?? String(err)}\n`, ); - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); process.exit(1); } } @@ -415,7 +427,7 @@ async function main(): Promise { removeOwnedPidFile(); try { await viewer!.close(); } catch { /* best-effort */ } try { - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); } catch { // clear-data already shuts the core down before removing SQLite. // The signal still has to terminate the daemon so the supervisor @@ -492,7 +504,7 @@ async function main(): Promise { /* best-effort */ } } - await waitForShutdown(core, activeStdio); + await withShutdownTimeout(waitForShutdown(core, activeStdio)); process.exit(0); }; @@ -513,7 +525,7 @@ async function main(): Promise { if (viewer!.closed) { clearInterval(keepalive); removeOwnedPidFile(); - void core.shutdown().then(() => process.exit(0)); + void withShutdownTimeout(core.shutdown()).then(() => process.exit(0)); } }, 5_000); (keepalive as unknown as { unref?: () => void }).unref?.(); @@ -522,7 +534,7 @@ async function main(): Promise { // No viewer (headless bridge) — clean exit. removeOwnedPidFile(); - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); process.exit(0); } @@ -559,132 +571,6 @@ function classifyErrorCode(err: unknown): string { return "unknown"; } -function createBridgeStatusTracker(statusFile: string, daemon: boolean): { - snapshot(): BridgeStatusSnapshot; - markConnected(): void; - markDisconnected(message: string): void; - startHeartbeat(): { stop(): void }; -} { - let snapshot: BridgeStatusSnapshot = daemon - ? { - status: "disconnected", - lastOkAt: null, - lastErrorAt: Date.now(), - lastError: "Hermes chat is not connected", - } - : { - status: "unknown", - lastOkAt: null, - lastErrorAt: null, - lastError: null, - }; - - function writeStatus(next: BridgeStatusSnapshot): void { - snapshot = next; - try { - fs.mkdirSync(path.dirname(statusFile), { recursive: true }); - fs.writeFileSync(statusFile, JSON.stringify(next), "utf8"); - } catch { - // Status display must never affect chat capture. - } - } - - function readStatus(): BridgeStatusSnapshot | null { - try { - const parsed = JSON.parse(fs.readFileSync(statusFile, "utf8")) as Partial; - if ( - parsed.status === "connected" || - parsed.status === "reconnecting" || - parsed.status === "disconnected" || - parsed.status === "unknown" - ) { - return { - status: parsed.status, - lastOkAt: typeof parsed.lastOkAt === "number" ? parsed.lastOkAt : null, - lastErrorAt: typeof parsed.lastErrorAt === "number" ? parsed.lastErrorAt : null, - lastError: typeof parsed.lastError === "string" ? parsed.lastError : null, - }; - } - } catch { - // Missing or corrupt status files are treated as disconnected. - } - return null; - } - - function applyStaleRule(raw: BridgeStatusSnapshot): BridgeStatusSnapshot { - if (raw.status === "disconnected" && daemon && isHermesChatRunning()) { - return { - status: "reconnecting", - lastOkAt: raw.lastOkAt, - lastErrorAt: raw.lastErrorAt, - lastError: "Hermes chat is running; waiting for memory bridge", - }; - } - if ( - raw.status === "connected" && - raw.lastOkAt != null && - Date.now() - raw.lastOkAt > BRIDGE_STATUS_STALE_MS - ) { - return { - status: "disconnected", - lastOkAt: raw.lastOkAt, - lastErrorAt: Date.now(), - lastError: "Hermes bridge heartbeat is stale", - }; - } - return raw; - } - - function markConnected(): void { - writeStatus({ - status: "connected", - lastOkAt: Date.now(), - lastErrorAt: snapshot.lastErrorAt, - lastError: snapshot.lastError, - }); - } - - function markDisconnected(message: string): void { - writeStatus({ - status: "disconnected", - lastOkAt: snapshot.lastOkAt, - lastErrorAt: Date.now(), - lastError: message, - }); - } - - return { - snapshot() { - return { ...applyStaleRule(readStatus() ?? snapshot) }; - }, - markConnected, - markDisconnected, - startHeartbeat() { - const timer = setInterval(() => { - markConnected(); - }, BRIDGE_STATUS_HEARTBEAT_MS); - (timer as unknown as { unref?: () => void }).unref?.(); - return { - stop() { - clearInterval(timer); - }, - }; - }, - }; -} - -function isHermesChatRunning(): boolean { - try { - const out = childProcess.execFileSync("pgrep", ["-f", "hermes chat"], { - encoding: "utf8", - timeout: 1000, - }); - return out.trim().length > 0; - } catch { - return false; - } -} - void main().catch((err) => { const detail = err instanceof Error ? err.stack ?? err.message : String(err); process.stderr.write( diff --git a/apps/memos-local-plugin/bridge/hermes-process.ts b/apps/memos-local-plugin/bridge/hermes-process.ts index 6c2384f96..b796e2d90 100644 --- a/apps/memos-local-plugin/bridge/hermes-process.ts +++ b/apps/memos-local-plugin/bridge/hermes-process.ts @@ -18,22 +18,16 @@ * therefore misses any invocation with a global flag (`--skills`, * `-m`, `--provider`, …) between them. * - * The current pattern is `hermes(?:\s+\S+)*\s+chat\b`: + * The command grammar is `hermes ()* chat (|$)`: * - * • `hermes` — the binary basename. - * • `(?:\s+\S+)*` — any complete argv-style tokens between the - * binary and the subcommand. - * • `\s+chat\b` — a standalone `chat` token, so it does *not* - * match `chatter`, `chat-server`, `--chat-log`, or a flag value - * like `--profile=chat`. + * • `hermes` — the binary basename. + * • `()*` — complete argv-style tokens before the subcommand. + * • `chat` — a complete token, not `chatter` or `chat-server`. * - * `pgrep -f` on Linux uses glibc's ERE engine, which supports - * `\s`/`\b` as GNU extensions. JavaScript's `RegExp` supports the same - * tokens natively, so this module also exports - * `matchesHermesChatCommandLine()` for unit tests — exercising the - * pattern as a JS regex is a faithful proxy for the pgrep-side - * behaviour without requiring a real Hermes binary or a fork of the - * pgrep process in CI. + * `pgrep -f` on Linux uses glibc's POSIX ERE engine, so its pattern uses + * POSIX character classes and capturing groups only. JavaScript does not + * implement POSIX character classes, so the test helper declares the same + * grammar with `\s`, `\S`, and non-capturing groups instead. */ // eslint-disable-next-line @typescript-eslint/no-require-imports import * as childProcess from "node:child_process"; @@ -45,7 +39,12 @@ import * as childProcess from "node:child_process"; * string we hand to `pgrep` and confirm we have not silently regressed * back to a literal substring match. */ -export const HERMES_CHAT_PROCESS_PATTERN = "hermes(?:\\s+\\S+)*\\s+chat\\b"; +export const HERMES_CHAT_PROCESS_PATTERN = + "hermes([[:space:]]+[^[:space:]]+)*[[:space:]]+chat([[:space:]]|$)"; + +// Keep this semantically aligned with HERMES_CHAT_PROCESS_PATTERN. POSIX ERE +// has no non-capturing groups, while JavaScript can avoid unused captures. +const HERMES_CHAT_JS_PATTERN = /hermes(?:\s+\S+)*\s+chat(?:\s|$)/; /** * JS-side equivalent of `pgrep -f HERMES_CHAT_PROCESS_PATTERN`. @@ -56,7 +55,7 @@ export const HERMES_CHAT_PROCESS_PATTERN = "hermes(?:\\s+\\S+)*\\s+chat\\b"; * `/proc//cmdline`-style command-line string. */ export function matchesHermesChatCommandLine(commandLine: string): boolean { - return new RegExp(HERMES_CHAT_PROCESS_PATTERN).test(commandLine); + return HERMES_CHAT_JS_PATTERN.test(commandLine); } /** diff --git a/apps/memos-local-plugin/bridge/status.ts b/apps/memos-local-plugin/bridge/status.ts new file mode 100644 index 000000000..f9f5097f8 --- /dev/null +++ b/apps/memos-local-plugin/bridge/status.ts @@ -0,0 +1,186 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export const BRIDGE_STATUS_FILE = "bridge-status.json"; +export const BRIDGE_STATUS_HEARTBEAT_MS = 5_000; +export const BRIDGE_STATUS_STALE_MS = 20_000; + +export type BridgeStatus = + | "connected" + | "reconnecting" + | "disconnected" + | "unknown"; + +export interface BridgeStatusSnapshot { + status: BridgeStatus; + lastOkAt: number | null; + lastErrorAt: number | null; + lastError: string | null; +} + +export interface BridgeStatusReader { + snapshot(): BridgeStatusSnapshot; +} + +export interface BridgeStatusWriter extends BridgeStatusReader { + markConnected(): void; + markDisconnected(message: string): void; + startHeartbeat(): { stop(): void }; +} + +interface ReaderOptions { + isHermesChatRunning: () => boolean; + now?: () => number; + staleMs?: number; +} + +interface WriterOptions { + now?: () => number; + heartbeatMs?: number; +} + +function readStatus(statusFile: string): BridgeStatusSnapshot | null { + try { + const parsed = JSON.parse( + fs.readFileSync(statusFile, "utf8"), + ) as Partial; + if ( + parsed.status === "connected" || + parsed.status === "reconnecting" || + parsed.status === "disconnected" || + parsed.status === "unknown" + ) { + return { + status: parsed.status, + lastOkAt: + typeof parsed.lastOkAt === "number" ? parsed.lastOkAt : null, + lastErrorAt: + typeof parsed.lastErrorAt === "number" ? parsed.lastErrorAt : null, + lastError: + typeof parsed.lastError === "string" ? parsed.lastError : null, + }; + } + } catch { + // Missing and corrupt status files both mean there is no live writer. + } + return null; +} + +function errorAt( + status: BridgeStatusSnapshot | null, + observedAt: number, +): number { + return status?.lastErrorAt ?? status?.lastOkAt ?? observedAt; +} + +/** + * Read-only view used by the standalone Hermes Viewer daemon. + * + * The nested health `bridge` describes the Python provider ↔ Node stdio + * transport. The Viewer daemon is a separate HTTP process, so it must never + * refresh this file or claim that Hermes chat is connected on its own behalf. + */ +export function createBridgeStatusReader( + statusFile: string, + options: ReaderOptions, +): BridgeStatusReader { + const now = options.now ?? Date.now; + const staleMs = options.staleMs ?? BRIDGE_STATUS_STALE_MS; + const firstObservedAt = now(); + + return { + snapshot() { + const status = readStatus(statusFile); + const observedAt = now(); + const freshConnected = + status?.status === "connected" && + status.lastOkAt != null && + observedAt - status.lastOkAt <= staleMs; + + // A fresh heartbeat is stronger evidence than process-name probing, + // which can miss valid Hermes command-line shapes on some platforms. + if (freshConnected) return { ...status }; + + const chatRunning = options.isHermesChatRunning(); + if (chatRunning) { + const heartbeatStale = + status?.status === "connected" && status.lastOkAt != null; + return { + status: "reconnecting", + lastOkAt: status?.lastOkAt ?? null, + lastErrorAt: errorAt(status, firstObservedAt), + lastError: heartbeatStale + ? "Hermes bridge heartbeat is stale" + : "Hermes chat is running; waiting for memory bridge", + }; + } + + if (status?.status === "disconnected") return { ...status }; + + return { + status: "disconnected", + lastOkAt: status?.lastOkAt ?? null, + lastErrorAt: errorAt(status, firstObservedAt), + lastError: "Hermes chat is not connected", + }; + }, + }; +} + +/** The stdio bridge is the sole writer of Hermes transport status. */ +export function createBridgeStatusWriter( + statusFile: string, + options: WriterOptions = {}, +): BridgeStatusWriter { + const now = options.now ?? Date.now; + const heartbeatMs = options.heartbeatMs ?? BRIDGE_STATUS_HEARTBEAT_MS; + let status: BridgeStatusSnapshot = { + status: "unknown", + lastOkAt: null, + lastErrorAt: null, + lastError: null, + }; + + function writeStatus(next: BridgeStatusSnapshot): void { + status = next; + try { + fs.mkdirSync(path.dirname(statusFile), { recursive: true }); + fs.writeFileSync(statusFile, JSON.stringify(next), "utf8"); + } catch { + // Status display must never affect chat capture. + } + } + + function markConnected(): void { + writeStatus({ + status: "connected", + lastOkAt: now(), + lastErrorAt: status.lastErrorAt, + lastError: status.lastError, + }); + } + + return { + snapshot() { + return { ...(readStatus(statusFile) ?? status) }; + }, + markConnected, + markDisconnected(message: string) { + writeStatus({ + status: "disconnected", + lastOkAt: status.lastOkAt, + lastErrorAt: now(), + lastError: message, + }); + }, + startHeartbeat() { + const timer = setInterval(markConnected, heartbeatMs); + timer.unref?.(); + return { + stop() { + clearInterval(timer); + }, + }; + }, + }; +} diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 9360b8802..2575a8405 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -59,6 +59,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 1024, + headers: {}, }, l3Llm: { // Empty by default — falls back to the shared `llm` settings. @@ -75,6 +77,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 4096, + headers: {}, }, skillEvolver: { // Empty by default — falls back to the shared `llm` settings. @@ -89,6 +93,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 4096, + headers: {}, }, storage: { ftsTokenizer: "trigram", @@ -252,6 +258,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { etaDelta: 0.1, archiveEta: 0.1, minEtaForRetrieval: 0.1, + idleArchiveMs: 30 * 24 * 60 * 60 * 1000, }, feedback: { failureThreshold: 3, @@ -355,6 +362,19 @@ export const DEFAULT_CONFIG: ResolvedConfig = { }, }; +/** + * Object-valued config slots whose child keys are user-defined rather than + * fields in `DEFAULT_CONFIG`. Keep this list explicit: treating every empty + * default object as a free-form map would silently disable unknown-key + * warnings for any future structured config section that starts out empty. + */ +export const FREE_FORM_CONFIG_PATHS: readonly string[] = Object.freeze([ + "llm.headers", + "l3Llm.headers", + "skillEvolver.headers", + "logging.channels", +]); + /** * Set of dotted-path field names whose values must never be sent to the * viewer or any non-localhost surface. Used by `server/routes/config.ts`. diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 9e7ccfd84..ae3991017 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -18,7 +18,12 @@ import { MemosError } from "../../agent-contract/errors.js"; import type { ResolvedHome } from "./paths.js"; import { resolveHome } from "./paths.js"; import { ConfigSchema, type ResolvedConfig } from "./schema.js"; -import { DEFAULT_CONFIG, effectiveViewerPort } from "./defaults.js"; +import { + DEFAULT_CONFIG, + FREE_FORM_CONFIG_PATHS, + SECRET_FIELD_PATHS, + effectiveViewerPort, +} from "./defaults.js"; import { migrateHermesViewerPort } from "./migrations.js"; import { parseYaml } from "./yaml.js"; @@ -77,9 +82,45 @@ export async function loadConfig(home: ResolvedHome, agent?: string): Promise use process.env[NAME]. Only allowlisted + // names are expanded (`^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$`); + // anything else emits a warning and is left untouched. + // 2. Value is the mask sentinel `__memos_secret__` or empty string + // -> derive the env var from the field path itself + // (llm.apiKey -> LLM_API_KEY, hub.teamToken -> HUB_TEAM_TOKEN, + // skillEvolver.apiKey -> SKILL_EVOLVER_API_KEY, …). Provider-specific + // OpenCode fallbacks only apply when the primary LLM points at the + // matching opencode.ai endpoint; unrelated providers and dedicated + // config slots never borrow those keys. + // 3. Otherwise leave the value untouched. + // + // A masked sentinel or explicit env reference with no backing variable + // emits a warning. A plain empty string does not: empty keys are valid for + // local/host providers and disabled optional integrations. + // + // The mask itself is never used as a credential, and the on-disk write + // stays masked (security preserved); this is read-side only. + resolveSecretEnv(cleaned, warnings); const merged = deepMerge(DEFAULT_CONFIG as Record, cleaned); stripUnsupportedEmbeddingDimensions(merged); const viewerPort = effectiveViewerPort(agent); @@ -109,6 +150,123 @@ export function resolveConfig(raw: unknown, warnings?: string[], agent?: string) // ─── helpers ──────────────────────────────────────────────────────────────── +/** Env var names accepted in `${NAME}` config references. */ +const ENV_REF_ALLOWLIST = /^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$/; + +/** + * Replace masked / empty / `${VAR}` secret leaves in `cleaned` (a freshly + * built, non-shared object — see `pruneUnknown`) with values from the + * environment. The caller's raw config object is never written to. + */ +function resolveSecretEnv(cleaned: Record, warnings?: string[]): void { + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: unknown = cleaned; + // Explicit flag: `break` alone leaves `cursor` pointing at the last + // valid value, which for paths deeper than 2 levels could accidentally + // pass the `isPlainObject(cursor)` check below and index `leaf` on the + // wrong node. Today every SECRET_FIELD_PATHS entry is only 2 levels + // deep, but keeping the flag makes the intent explicit and future- + // proofs against deeper paths being added. + let traversalOk = true; + for (let i = 0; i < keys.length - 1; i++) { + if (!isPlainObject(cursor)) { + traversalOk = false; + break; + } + cursor = (cursor as Record)[keys[i]!]; + } + if (!traversalOk || !isPlainObject(cursor)) continue; + const leaf = keys[keys.length - 1]!; + const val = (cursor as Record)[leaf]; + if (typeof val !== "string") continue; + + let envName: string | null = null; + let warnIfMissing = false; + if (val.startsWith("${") && val.endsWith("}")) { + const name = val.slice(2, -1); + if (!ENV_REF_ALLOWLIST.test(name)) { + warnings?.push( + `config: leaving '${dotted}' as '${val}' — env name '${name}' is not allowlisted ` + + `(expected ^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$)` + ); + continue; + } + envName = name; + warnIfMissing = true; + } else if (val === "__memos_secret__" || val === "") { + // Derive env var name from the field path itself so every entry + // in SECRET_FIELD_PATHS is resolvable, not just the ones whose + // leaf is `apiKey`: + // embedding.apiKey → EMBEDDING_API_KEY + // llm.apiKey → LLM_API_KEY + // l3Llm.apiKey → L3_LLM_API_KEY + // skillEvolver.apiKey → SKILL_EVOLVER_API_KEY + // hub.teamToken → HUB_TEAM_TOKEN + // hub.userToken → HUB_USER_TOKEN + const parent = keys[keys.length - 2] ?? ""; + envName = `${camelToUpperSnake(parent)}_${camelToUpperSnake(leaf)}`; + warnIfMissing = val === "__memos_secret__"; + } + if (!envName) continue; + + const envVal = process.env[envName] ?? resolveOpenCodeApiKey(cleaned, dotted); + if (envVal) { + (cursor as Record)[leaf] = envVal; + } else if (warnIfMissing) { + warnings?.push( + `config: '${dotted}' references env var '${envName}' but it is not set — ` + + `field left as placeholder and auth will fail on the next call` + ); + } + } +} + +/** + * Resolve the provider-specific OpenCode fallback for the primary LLM only. + * The hostname, provider and endpoint tier must all match so an OpenCode key + * can never be sent to Anthropic, Gemini or another OpenAI-compatible host. + */ +function resolveOpenCodeApiKey( + cleaned: Record, + dotted: string, +): string | undefined { + if (dotted !== "llm.apiKey" || !isPlainObject(cleaned.llm)) return undefined; + if (cleaned.llm.provider !== "openai_compatible") return undefined; + + const endpoint = cleaned.llm.endpoint; + if (typeof endpoint !== "string" || endpoint.length === 0) return undefined; + + try { + const url = new URL(endpoint); + if (url.hostname !== "opencode.ai") return undefined; + if (/^\/zen\/go(?:\/|$)/.test(url.pathname)) { + return process.env.OPENCODE_GO_API_KEY; + } + if (/^\/zen(?:\/|$)/.test(url.pathname)) { + return process.env.OPENCODE_ZEN_API_KEY; + } + } catch { + // Schema validation reports malformed endpoints later. Secret resolution + // must not broaden fallback scope just because parsing failed here. + } + return undefined; +} + +/** + * camelCase → UPPER_SNAKE_CASE for deriving env var names from config + * field paths. Only inserts an underscore at a lowercase/digit → uppercase + * boundary so acronyms and digit runs stay intact: + * apiKey → API_KEY + * teamToken → TEAM_TOKEN + * userToken → USER_TOKEN + * l3Llm → L3_LLM + * skillEvolver → SKILL_EVOLVER + */ +function camelToUpperSnake(s: string): string { + return s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); +} + function formatErr(e: ValueError): string { return `${e.path || ""}: ${e.message}`; } @@ -170,6 +328,13 @@ function pruneUnknown( continue; } if (isPlainObject(v) && isPlainObject((defaults as Record)[k])) { + if (FREE_FORM_CONFIG_PATHS.includes(path)) { + // Explicitly declared free-form maps keep user-defined child keys. + // Other empty default objects remain structured config sections and + // continue to report unknown nested keys. + out[k] = v; + continue; + } out[k] = pruneUnknown(v, (defaults as Record)[k], path, warnings); } else { out[k] = v; diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 56bf27fd8..9cd79b612 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -116,6 +116,10 @@ const LlmSchema = Type.Object({ openRouter: Type.Optional(Bool(false)), /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ reasoning: Type.Optional(ReasoningSchema), + /** Max output tokens per completion (deepseek-v4-flash needs >= 100). */ + maxTokens: NumberInRange(1024, 100, 131072), + /** Extra HTTP headers for the provider request. */ + headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); /** @@ -148,6 +152,10 @@ const SkillEvolverSchema = Type.Object({ openRouter: Type.Optional(Bool(false)), /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ reasoning: Type.Optional(ReasoningSchema), + /** Max output tokens per completion. */ + maxTokens: NumberInRange(1024, 100, 131072), + /** Extra HTTP headers for the provider request. */ + headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); const StorageSchema = Type.Object({ @@ -363,6 +371,12 @@ const AlgorithmSchema = Type.Object({ archiveEta: NumberInRange(0.1, 0, 1), /** Hide Tier-1 skills whose η is below this. Mirrors retrieval.minSkillEta. */ minEtaForRetrieval: NumberInRange(0.1, 0, 1), + /** Archive low-η active skills after this much retrieval inactivity (minimum 1 hour). */ + idleArchiveMs: NumberInRange( + 30 * 24 * 60 * 60 * 1000, + 60 * 60 * 1000, + 365 * 24 * 60 * 60 * 1000, + ), }, { default: {} }), feedback: Type.Object({ /** Raise a burst after this many failures of the same tool in-window. */ diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 4677ece0c..660ea737c 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -142,6 +142,10 @@ type DedicatedLlmConfig = { providerOrder?: string[]; openRouter?: boolean; reasoning?: ReasoningConfig; + /** Max output tokens per completion. */ + maxTokens?: number; + /** Extra HTTP headers for the provider request. */ + headers?: Record; }; export interface BootstrapOptions { @@ -442,6 +446,8 @@ export async function bootstrapMemoryCoreFull( providerOrder: evolver?.providerOrder, openRouter: evolver?.openRouter ?? false, reasoning: evolver?.reasoning, + maxTokens: evolver?.maxTokens, + headers: evolver?.headers, maxRetries: 3, // V7 §0.x — when the user's dedicated skill-evolver model is // down (auth, model name typo, server outage), prefer falling @@ -504,6 +510,8 @@ export async function bootstrapMemoryCoreFull( providerOrder: l3c?.providerOrder, openRouter: l3c?.openRouter ?? false, reasoning: l3c?.reasoning, + maxTokens: l3c?.maxTokens, + headers: l3c?.headers, maxRetries: 3, fallbackToHost: true, onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) => @@ -569,6 +577,8 @@ export interface CreateMemoryCoreOptions { onShutdown?: () => void | Promise; /** Optional telemetry instance for ARMS RUM reporting. */ telemetry?: import("../telemetry/index.js").Telemetry | null; + /** Startup-recovery grace used by shutdown. Defaults to 15 seconds. */ + startupRecoveryShutdownGraceMs?: number; } /** @@ -588,6 +598,10 @@ export function createMemoryCore( const bootAt = Date.now(); const log = rootLogger.child({ channel: "core.pipeline.memory-core" }); const autoRecoveryEnabled = options.autoRecovery ?? true; + const startupRecoveryShutdownGraceMs = Math.max( + 0, + options.startupRecoveryShutdownGraceMs ?? 15_000, + ); let telemetry = options.telemetry ?? null; let initialized = false; let shutDown = false; @@ -710,6 +724,7 @@ export function createMemoryCore( // detach the slow recovery to this promise. `waitForStartupRecovery` // exposes it so tests can opt back into the deterministic semantics. let startupRecoveryPromise: Promise = Promise.resolve(); + let startupRecoveryCancelled = false; let lastStaleScan = 0; let lastDirtyClosedScan = 0; async function autoFinalizeStaleTasks(): Promise { @@ -1509,6 +1524,7 @@ export function createMemoryCore( async function recoverOpenEpisodesAsSessionEnd( orphans: Array }>, ): Promise { + if (startupRecoveryCancelled) return; const endedAt = Date.now(); log.info("init.orphan_episodes.session_end_recover", { count: orphans.length }); debugStartupRecovery("H1", "startup_recovery_scan", { @@ -1539,6 +1555,7 @@ export function createMemoryCore( }); try { for (const ep of orphans) { + if (startupRecoveryCancelled) break; if (isLightweightEpisode(ep)) continue; try { const episodeId = ep.id as EpisodeId; @@ -1592,7 +1609,9 @@ export function createMemoryCore( try { await handle.flush(); + if (startupRecoveryCancelled) return; for (const episodeId of needsRewardFallback) { + if (startupRecoveryCancelled) break; if (captureFailedInBatch.has(episodeId)) { log.warn("init.orphan_recovery.reward_fallback_skipped", { episodeId, @@ -1609,6 +1628,7 @@ export function createMemoryCore( }); } } + if (startupRecoveryCancelled) return; await handle.flush(); debugStartupRecovery("H5", "startup_recovery_flush_done", { recoveredCount: orphans.length, @@ -1641,11 +1661,13 @@ export function createMemoryCore( async function recoverDirtyClosedEpisodes( episodes: Array }>, ): Promise { + if (startupRecoveryCancelled) return; log.info("init.dirty_closed_episodes.rescore", { count: episodes.length }); // Snapshot the prior failure counters so we can increment them later // (after the bus chain settles) without an extra DB read. const priorFailedAttempts = new Map(); for (const ep of episodes) { + if (startupRecoveryCancelled) break; if (isLightweightEpisode(ep)) continue; const episodeId = ep.id as EpisodeId; const endedAt = ep.endedAt ?? Date.now(); @@ -1672,6 +1694,7 @@ export function createMemoryCore( }); } await handle.flush(); + if (startupRecoveryCancelled) return; // After the reward / reflect chain has finished, account for the // outcome: clear `meta.rewardDirty` on episodes that are no longer // dirty (success), bump `failedAttempts + lastFailureAt` on episodes @@ -1958,10 +1981,32 @@ export function createMemoryCore( // wait, a fast `init → shutdown` race during tests or a quick // gateway reload would close SQLite while reflect / reward is // mid-flush, producing `SQLITE_MISUSE` noise on the way down. + let startupRecoveryTimedOut = false; try { - await startupRecoveryPromise; - } catch { - /* already logged inside the recovery promise */ + // Bound the wait: a slow / flaky LLM during startup recovery of a + // large dirty episode must not hold shutdown hostage until the + // systemd kill timer (15 Aug 2026 stop-sigterm wedge: SIGTERM at + // 08:00:23, SIGKILL at 08:10:23). Recovery is resumable — dirty + // episodes carry rewardDirty.failedAttempts and the periodic + // rescore re-runs them — so nothing is lost by proceeding after a + // short grace. Fast init→shutdown races still get their grace. + await withTimeout( + startupRecoveryPromise, + startupRecoveryShutdownGraceMs, + "startup_recovery_shutdown_timeout", + ); + } catch (err) { + if ( + err instanceof Error && + err.message === "startup_recovery_shutdown_timeout" + ) { + startupRecoveryTimedOut = true; + startupRecoveryCancelled = true; + log.warn("startup_recovery.shutdown_timeout", { + timeoutMs: startupRecoveryShutdownGraceMs, + action: "cancel_and_shutdown_pipeline", + }); + } } try { await hubRuntime?.stop(); @@ -1970,7 +2015,10 @@ export function createMemoryCore( err: err instanceof Error ? err.message : String(err), }); } - await handle.shutdown("memory-core.shutdown"); + await handle.shutdown( + "memory-core.shutdown", + startupRecoveryTimedOut ? { flushGraceMs: 0 } : undefined, + ); } finally { disposeTurnStartApiLogSessionListener(); turnStartApiLogBySession.clear(); @@ -2003,8 +2051,9 @@ export function createMemoryCore( // actually been able to talk to the configured upstream. See #1596. const effectiveConfig = diskConfig ?? handle.config; - const llmInfo = llmHealth(handle.llm, latestTraceTs()); - const embedderInfo = embedderHealth(handle.embedder, latestTraceTs()); + const latestTraceTimestamp = latestTraceTs(); + const llmInfo = llmHealth(handle.llm, latestTraceTimestamp); + const embedderInfo = embedderHealth(handle.embedder, latestTraceTimestamp); applyConfiguredModelDisplay(effectiveConfig, llmInfo, embedderInfo); const skillEvolverInfo = resolveSkillEvolver( @@ -2017,7 +2066,7 @@ export function createMemoryCore( // in that case anyway. handle.reflectLlm ?? handle.llm, llmInfo, - latestTraceTs(), + latestTraceTimestamp, ); // NOTE: we deliberately do NOT fall back to `api_logs`-stored @@ -2058,9 +2107,7 @@ export function createMemoryCore( function latestTraceTs(): number | null { try { - const rows = handle.repos.traces.list({ limit: 1 }); - if (rows.length === 0) return null; - return rows[0]?.ts ?? null; + return handle.repos.traces.latestTimestamp(); } catch { return null; } @@ -5026,7 +5073,10 @@ export function createMemoryCore( const existing = handle.repos.skills.getById(id); if (!existing || !ownedByCurrent(existing)) return null; const now = Date.now(); - handle.repos.skills.setStatus(id, "active", now); + handle.db.tx(() => { + handle.repos.skills.setStatus(id, "active", now); + handle.repos.skills.recordUse(id, now); + }); if (existing.status !== "active") { handle.buses.skill.emit({ kind: "skill.status.changed", diff --git a/apps/memos-local-plugin/core/pipeline/orchestrator.ts b/apps/memos-local-plugin/core/pipeline/orchestrator.ts index 9e31467e0..17e444bd0 100644 --- a/apps/memos-local-plugin/core/pipeline/orchestrator.ts +++ b/apps/memos-local-plugin/core/pipeline/orchestrator.ts @@ -56,6 +56,7 @@ import type { PipelineBuses, PipelineDeps, PipelineHandle, + PipelineShutdownOptions, RecordToolOutcomeInput, TurnEndResult, } from "./types.js"; @@ -88,6 +89,7 @@ import { prioritizeEmbedder, } from "../util/foreground-resources.js"; import { createRequestDeadline } from "../util/request-deadline.js"; +import { createSkillLifecycleWorker } from "../skill/lifecycle-worker.js"; function classifyWithTimeout( classifyFn: () => Promise, @@ -307,6 +309,12 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { log, emit: emitCore, }); + const skillLifecycleWorker = createSkillLifecycleWorker({ + runLifecycle: () => subs.skills.lifecycleTick(), + log: log.child({ channel: "core.skill.lifecycle-worker" }), + now: deps.now, + }); + if (!lightweightMode) skillLifecycleWorker.start(); // In-memory index of the open episode per session so we can route // `addTurn` calls without a repo round-trip. @@ -1566,6 +1574,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { toolCalls: result.toolCalls.length, agentChars: result.agentText.length, }); + skillLifecycleWorker.trigger(); // The episode stays OPEN — finalize is deferred to topic end. return { @@ -1638,27 +1647,36 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { await subs.l3.drain(); await nextTick(); await subs.skills.flush(); - await subs.skills.lifecycleTick(); + await skillLifecycleWorker.runNow(); await subs.feedback.flush(); await embeddingRetryWorker.flush(); } - async function shutdown(reason: string = "shutdown"): Promise { + async function shutdown( + reason: string = "shutdown", + options: PipelineShutdownOptions = {}, + ): Promise { log.info("pipeline.shutdown.begin", { reason }); // Stop admitting retry jobs, but preserve a bounded grace period for raw // capture and downstream enrichment. Hermes' bridge owns a 20s outer // shutdown ceiling, so abort before that rather than either hanging or // discarding every single-shot session's enrichment immediately. + skillLifecycleWorker.stop(); embeddingRetryWorker.stop(); + const flushGraceMs = Math.max(0, options.flushGraceMs ?? 15_000); + const abortWaitMs = Math.max(0, options.abortWaitMs ?? 4_000); + if (flushGraceMs === 0) { + foregroundResources.shutdown(reason); + } const flushPromise = flush(); try { - const completed = await settlesWithin(flushPromise, 15_000); + const completed = await settlesWithin(flushPromise, flushGraceMs); if (!completed) { - log.warn("pipeline.flush_timeout", { reason, timeoutMs: 15_000 }); + log.warn("pipeline.flush_timeout", { reason, timeoutMs: flushGraceMs }); foregroundResources.shutdown(reason); - const aborted = await settlesWithin(flushPromise, 4_000); + const aborted = await settlesWithin(flushPromise, abortWaitMs); if (!aborted) { - log.warn("pipeline.flush_abandoned", { reason, abortWaitMs: 4_000 }); + log.warn("pipeline.flush_abandoned", { reason, abortWaitMs }); } } } catch (err) { diff --git a/apps/memos-local-plugin/core/pipeline/types.ts b/apps/memos-local-plugin/core/pipeline/types.ts index b37818344..4e20bfada 100644 --- a/apps/memos-local-plugin/core/pipeline/types.ts +++ b/apps/memos-local-plugin/core/pipeline/types.ts @@ -248,12 +248,19 @@ export interface PipelineHandle { // Imperative helpers. flush(): Promise; - shutdown(reason?: string): Promise; + shutdown(reason?: string, options?: PipelineShutdownOptions): Promise; /** Compose a retrieval-deps instance scoped to this pipeline. Used by tests. */ retrievalDeps(): RetrievalDeps; } +export interface PipelineShutdownOptions { + /** Grace before aborting provider calls and queued background work. */ + flushGraceMs?: number; + /** Final drain window after abort before subscribers are detached. */ + abortWaitMs?: number; +} + export interface PipelineBuses { session: SessionEventBus; capture: CaptureEventBus; diff --git a/apps/memos-local-plugin/core/skill/ALGORITHMS.md b/apps/memos-local-plugin/core/skill/ALGORITHMS.md index 45cabf6ae..e1560d926 100644 --- a/apps/memos-local-plugin/core/skill/ALGORITHMS.md +++ b/apps/memos-local-plugin/core/skill/ALGORITHMS.md @@ -236,6 +236,32 @@ can't take down a well-trialled skill. If the blend drives η under `retireEta` we still retire; the skill can rehab later via positive signals. +### Idle archive scan + +The existing lifecycle tick also archives an active skill when both +conditions hold: + +``` +η < minEtaForRetrieval +now - (lastUsedAt ?? createdAt) >= idleArchiveMs +``` + +Configuration validation enforces a one-hour minimum for `idleArchiveMs` to +prevent an accidental zero value from archiving every low-η active Skill on +the next lifecycle tick. + +`lastUsedAt` is updated by the existing recorded-use path. A never-used +skill falls back to `createdAt`; unrelated metadata updates therefore do +not reset its idle clock. Manual reactivation records a fresh use timestamp, +giving the skill a complete grace period before it can be archived again. + +A single-flight lifecycle worker runs once at startup, at most hourly while +the process remains alive, and opportunistically after turn completion. It +does not drain the capture/reward/L2/L3 chain. Each tick atomically selects +and updates at most ten 500-row batches, yielding between full batches; any +remaining backlog is deferred so a large archive queue cannot monopolize the +event loop. + --- ## 7. Retrieval surface diff --git a/apps/memos-local-plugin/core/skill/README.md b/apps/memos-local-plugin/core/skill/README.md index 51ed11e25..04a2de8bf 100644 --- a/apps/memos-local-plugin/core/skill/README.md +++ b/apps/memos-local-plugin/core/skill/README.md @@ -208,6 +208,11 @@ See `algorithm.skill` in | `etaDelta` | `0.1` | η step per `user.positive`/`user.negative`. | | `retireEta` | `0.25` | η floor; crossing retires. | | `minEtaForRetrieval` | `0.5` | η gate for Tier-1 retrieval + auto-promotion. | +| `idleArchiveMs` | `2592000000` | Archive low-η active skills after 30 days without use (minimum 1 hour). | + +Idle archival is maintained by a single-flight background worker that runs +at startup and at most hourly. Manually reactivating a Skill starts a fresh +idle grace period. ## Logging @@ -232,7 +237,7 @@ log (`logs/audit.jsonl`, never deleted) via the `skill` channel. * `tests/unit/skill/crystallize.test.ts` — LLM draft normalization + failures. * `tests/unit/skill/verifier.test.ts` — coverage + resonance checks. * `tests/unit/skill/packager.test.ts` — row shape, invocation guide, embedder failure. -* `tests/unit/skill/lifecycle.test.ts` — trial counter, thumbs, retire on drift. +* `tests/unit/skill/lifecycle.test.ts` — trial counter, thumbs, reward drift, and idle archive decisions. * `tests/unit/skill/events.test.ts` — bus contract. * `tests/unit/skill/skill.integration.test.ts` — end-to-end against real SQLite. * `tests/unit/skill/subscriber.test.ts` — event-driven trigger + runOnce + flush. diff --git a/apps/memos-local-plugin/core/skill/crystallize.ts b/apps/memos-local-plugin/core/skill/crystallize.ts index c45c5453d..04de81654 100644 --- a/apps/memos-local-plugin/core/skill/crystallize.ts +++ b/apps/memos-local-plugin/core/skill/crystallize.ts @@ -9,7 +9,7 @@ * traces we fail fast with `skipped_reason="no-evidence"`. */ -import type { LlmClient, LlmMessage } from "../llm/types.js"; +import type { LlmClient, LlmJsonCompletion, LlmMessage } from "../llm/types.js"; import { detectModelRefusal } from "../llm/refusal.js"; import { detectDominantLanguage, @@ -24,7 +24,7 @@ import { sanitizeDerivedText, } from "../safety/content.js"; import type { EpisodeId, PolicyRow, SkillRow, TraceRow } from "../types.js"; -import { MemosError } from "../../agent-contract/errors.js"; +import { ERROR_CODES, MemosError } from "../../agent-contract/errors.js"; import { extractToolNames } from "./tool-names.js"; import type { SkillModelRefusalDetails, @@ -69,6 +69,48 @@ export type CrystallizeResult = | { ok: true; draft: SkillCrystallizationDraft } | { ok: false; skippedReason: string; modelRefusal?: SkillModelRefusalDetails }; +interface DraftShapeDiagnostics { + rootType: string; + presentFields: string[]; + unknownFieldCount: number; + summaryType: string; + stepsType: string; + rawStepCount: number | null; + normalisedStepCount: number; +} + +interface PreparedDraft { + draft: SkillCrystallizationDraft; + shape: DraftShapeDiagnostics; + repairedFields: Array<"summary" | "steps">; + repairSources: Partial>; + usedAliases: string[]; +} + +const KNOWN_DRAFT_FIELDS = new Set([ + "name", + "display_title", + "displayTitle", + "summary", + "description", + "parameters", + "preconditions", + "steps", + "procedure", + "examples", + "tags", + "tools", + "decision_guidance", + "decisionGuidance", +]); + +const SKILL_DRAFT_SCHEMA_HINT = `{ + "name": "snake_case string", + "display_title": "string", + "summary": "string", + "steps": [{ "title": "string", "body": "string" }] +}`; + /** * Run one crystallization call and return a normalised draft. */ @@ -122,7 +164,8 @@ export async function crystallizeDraft( op: "skill.crystallize", phase: "skill", episodeId: input.episodeId, - schemaHint: "skill-crystallize.v2", + schemaHint: SKILL_DRAFT_SCHEMA_HINT, + malformedRetries: 0, }, ); const rawRefusal = detectModelRefusal(rsp.raw); @@ -139,7 +182,9 @@ export async function crystallizeDraft( }); return { ok: false, skippedReason: "llm-refusal", modelRefusal }; } - const draft = normaliseDraft(rsp.value, input); + const prepared = prepareDraftFromResponse(rsp, input, log, "initial"); + logDraftShape(prepared, rsp, input, log, "initial"); + const draft = prepared.draft; const draftRefusal = detectModelRefusal(draft); if (draftRefusal) { const modelRefusal = { @@ -154,7 +199,7 @@ export async function crystallizeDraft( }); return { ok: false, skippedReason: "llm-refusal", modelRefusal }; } - if (deps.validate) deps.validate(draft); + validatePreparedDraft(draft, prepared.shape, rsp, deps.validate, log, input, "initial"); return { ok: true, draft }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -202,7 +247,8 @@ The error was: ${message}. Please correct this and generate a valid JSON skill d op: "skill.crystallize", phase: "skill", episodeId: input.episodeId, - schemaHint: "skill-crystallize.v2", + schemaHint: SKILL_DRAFT_SCHEMA_HINT, + malformedRetries: 0, }, ); const retryRawRefusal = detectModelRefusal(rsp.raw); @@ -219,7 +265,9 @@ The error was: ${message}. Please correct this and generate a valid JSON skill d }); return { ok: false, skippedReason: "llm-refusal", modelRefusal }; } - const draft = normaliseDraft(rsp.value, input); + const prepared = prepareDraftFromResponse(rsp, input, log, "retry"); + logDraftShape(prepared, rsp, input, log, "retry"); + const draft = prepared.draft; const draftRefusal = detectModelRefusal(draft); if (draftRefusal) { const modelRefusal = { @@ -234,7 +282,7 @@ The error was: ${message}. Please correct this and generate a valid JSON skill d }); return { ok: false, skippedReason: "llm-refusal", modelRefusal }; } - if (deps.validate) deps.validate(draft); + validatePreparedDraft(draft, prepared.shape, rsp, deps.validate, log, input, "retry"); log.warn("skill.crystallize.retry_succeeded", { policyId: input.policy.id, error: message, @@ -255,6 +303,196 @@ The error was: ${message}. Please correct this and generate a valid JSON skill d } } +function prepareDraftFromResponse( + rsp: LlmJsonCompletion>, + input: CrystallizeInput, + log: Logger, + attempt: "initial" | "retry", +): PreparedDraft { + try { + return prepareDraft(rsp.value, input); + } catch (err) { + const shape = emptyDraftShape(rsp.value); + log.warn("skill.crystallize.shape_invalid", { + policyId: input.policy.id, + provider: rsp.provider, + model: rsp.model, + attempt, + reason: "invalid-root", + shape, + }); + const message = err instanceof Error ? err.message : String(err); + throw new MemosError(ERROR_CODES.LLM_OUTPUT_MALFORMED, message, { + provider: rsp.provider, + rawPreview: rsp.raw.slice(0, 512), + shape, + }); + } +} + +function prepareDraft(raw: unknown, input: CrystallizeInput): PreparedDraft { + if (!isRecord(raw)) { + throw new Error("skill.crystallize.invalid: root must be an object"); + } + + const draft = normaliseDraft(raw, input); + const shape = inspectDraftShape(raw, draft.steps.length); + const repairedFields: PreparedDraft["repairedFields"] = []; + const repairSources: PreparedDraft["repairSources"] = {}; + const usedAliases: string[] = []; + + if (!cleanOptionalText(raw.summary) && cleanOptionalText(raw.description)) { + usedAliases.push("description->summary"); + } + if (!Array.isArray(raw.steps) && raw.procedure !== undefined) { + usedAliases.push("procedure->steps"); + } + if (Array.isArray(raw.steps) && raw.steps.some(stepUsesAlias)) { + usedAliases.push("step-aliases"); + } + + if (!draft.summary) { + const candidates: Array<[string, unknown]> = [ + ["step.body", draft.steps[0]?.body], + ["step.title", draft.steps[0]?.title], + ["displayTitle", draft.displayTitle], + ["name", draft.name], + ]; + const source = candidates.find(([, value]) => sanitizeDerivedText(value)); + if (source) { + draft.summary = sanitizeDerivedText(source[1]).slice(0, 200); + repairedFields.push("summary"); + repairSources.summary = source[0]; + } + } + + if (draft.steps.length === 0) { + const policyBody = sanitizeDerivedMarkdown(input.policy.procedure).slice(0, 2000); + if (policyBody) { + const policyTitle = sanitizeDerivedText(input.policy.title || input.policy.trigger) + .slice(0, 200); + draft.steps = [{ + title: policyTitle || sanitizeDerivedText(policyBody).slice(0, 32), + body: policyBody, + }]; + repairedFields.push("steps"); + repairSources.steps = "policy.procedure"; + } + } + + return { draft, shape, repairedFields, repairSources, usedAliases }; +} + +function logDraftShape( + prepared: PreparedDraft, + rsp: LlmJsonCompletion>, + input: CrystallizeInput, + log: Logger, + attempt: "initial" | "retry", +): void { + const common = { + policyId: input.policy.id, + provider: rsp.provider, + model: rsp.model, + attempt, + shape: prepared.shape, + }; + if (prepared.repairedFields.length > 0) { + log.warn("skill.crystallize.shape_repaired", { + ...common, + repairedFields: prepared.repairedFields, + repairSources: prepared.repairSources, + }); + } else if (prepared.usedAliases.length > 0) { + log.warn("skill.crystallize.shape_normalised", { + ...common, + aliases: prepared.usedAliases, + }); + } +} + +function validatePreparedDraft( + draft: SkillCrystallizationDraft, + shape: DraftShapeDiagnostics, + rsp: LlmJsonCompletion>, + validate: CrystallizeDeps["validate"], + log: Logger, + input: CrystallizeInput, + attempt: "initial" | "retry", +): void { + try { + defaultDraftValidator(draft); + if (validate && validate !== defaultDraftValidator) validate(draft); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn("skill.crystallize.shape_invalid", { + policyId: input.policy.id, + provider: rsp.provider, + model: rsp.model, + attempt, + reason: safeValidationReason(message), + shape, + }); + throw new MemosError(ERROR_CODES.LLM_OUTPUT_MALFORMED, message, { + provider: rsp.provider, + rawPreview: rsp.raw.slice(0, 512), + shape, + }); + } +} + +function safeValidationReason(message: string): string { + if (message.endsWith("missing name")) return "missing-name"; + if (message.endsWith("missing summary")) return "missing-summary"; + if (message.endsWith("missing steps")) return "missing-steps"; + return "validator-rejected"; +} + +function inspectDraftShape( + raw: Record, + normalisedStepCount: number, +): DraftShapeDiagnostics { + const keys = Object.keys(raw); + return { + rootType: "object", + presentFields: keys.filter((key) => KNOWN_DRAFT_FIELDS.has(key)).sort(), + unknownFieldCount: keys.filter((key) => !KNOWN_DRAFT_FIELDS.has(key)).length, + summaryType: valueType(raw.summary), + stepsType: valueType(raw.steps), + rawStepCount: Array.isArray(raw.steps) ? raw.steps.length : null, + normalisedStepCount, + }; +} + +function emptyDraftShape(raw: unknown): DraftShapeDiagnostics { + return { + rootType: valueType(raw), + presentFields: [], + unknownFieldCount: 0, + summaryType: "unavailable", + stepsType: "unavailable", + rawStepCount: null, + normalisedStepCount: 0, + }; +} + +function valueType(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stepUsesAlias(value: unknown): boolean { + if (typeof value === "string") return true; + if (!isRecord(value)) return false; + return value.name !== undefined || value.description !== undefined || + value.content !== undefined || value.instruction !== undefined; +} + function rawPreviewFromError(err: unknown): string | null { if (err instanceof MemosError && typeof err.details?.rawPreview === "string") { return err.details.rawPreview; @@ -353,11 +591,11 @@ function normaliseDraft( const displayTitle = sanitizeDerivedText(raw.display_title ?? raw.displayTitle ?? input.policy.title ?? name) || name; - const summary = sanitizeDerivedText(raw.summary); + const summary = cleanOptionalText(raw.summary) || cleanOptionalText(raw.description); const parameters = asArray(raw.parameters).map(coerceParameter).filter(Boolean) as SkillParameterDraft[]; const preconditions = sanitizeDerivedMarkdownList(asStringArray(raw.preconditions)); - const steps = asArray(raw.steps).map(coerceStep).filter(Boolean) as SkillStepDraft[]; + const steps = selectRawSteps(raw).map(coerceStep).filter(Boolean) as SkillStepDraft[]; const examples = asArray(raw.examples).map(coerceExample).filter(Boolean) as SkillExampleDraft[]; const tags = dedupeLc(sanitizeDerivedList(asStringArray(raw.tags))); // V7 §2.4.6 — coerce both `decision_guidance` (preferred LLM key) @@ -381,6 +619,22 @@ function normaliseDraft( }; } +function selectRawSteps(raw: Record): unknown[] { + if (Array.isArray(raw.steps)) return raw.steps; + if (typeof raw.steps === "string" && raw.steps.trim()) return [raw.steps]; + if (Array.isArray(raw.procedure)) return raw.procedure; + if (typeof raw.procedure === "string" && raw.procedure.trim()) return [raw.procedure]; + return []; +} + +function cleanOptionalText(value: unknown): string { + return typeof value === "string" ? sanitizeDerivedText(value) : ""; +} + +function cleanOptionalMarkdown(value: unknown): string { + return typeof value === "string" ? sanitizeDerivedMarkdown(value) : ""; +} + function coerceDecisionGuidance(raw: unknown): { preference: string[]; antiPattern: string[]; @@ -446,10 +700,18 @@ function coerceParameter(x: unknown): SkillParameterDraft | null { } function coerceStep(x: unknown): SkillStepDraft | null { + if (typeof x === "string") { + const body = sanitizeDerivedMarkdown(x); + if (!body) return null; + return { title: sanitizeDerivedText(body).slice(0, 32), body }; + } if (!x || typeof x !== "object") return null; const o = x as Record; - const title = sanitizeDerivedText(o.title); - const body = sanitizeDerivedMarkdown(o.body); + const title = cleanOptionalText(o.title) || cleanOptionalText(o.name); + const body = cleanOptionalMarkdown(o.body) || + cleanOptionalMarkdown(o.description) || + cleanOptionalMarkdown(o.content) || + cleanOptionalMarkdown(o.instruction); if (!title && !body) return null; return { title: title || body.slice(0, 32), body }; } @@ -469,12 +731,25 @@ function capString(s: string, cap: number): string { } /** - * A sensible default validator used both in production and in tests. - * Throws if the draft is structurally unusable (no name, no steps, no summary). + * A sensible default validator used both in production and in tests. Summary + * can be recovered from the draft itself, but steps must already contain + * actionable material. The runtime normaliser may ground missing steps in the + * source policy; this validator never invents a generic procedure. */ export function defaultDraftValidator(draft: SkillCrystallizationDraft): void { if (!draft.name) throw new Error("skill.crystallize.invalid: missing name"); - if (!draft.summary) throw new Error("skill.crystallize.invalid: missing summary"); - if (draft.steps.length === 0) + if (!draft.summary) { + // Auto-generate a summary from the richest available field. Use `||` not + // `??`: LLM JSON emits empty strings, and `??` only falls through on + // null/undefined. + const autoSummary = + draft.steps?.[0]?.body || + draft.steps?.[0]?.title || + draft.displayTitle || + draft.name; + draft.summary = autoSummary.slice(0, 200); + } + if (!draft.steps || draft.steps.length === 0) { throw new Error("skill.crystallize.invalid: missing steps"); + } } diff --git a/apps/memos-local-plugin/core/skill/lifecycle-worker.ts b/apps/memos-local-plugin/core/skill/lifecycle-worker.ts new file mode 100644 index 000000000..d3daf0f1a --- /dev/null +++ b/apps/memos-local-plugin/core/skill/lifecycle-worker.ts @@ -0,0 +1,82 @@ +import type { Logger } from "../logger/types.js"; + +export const DEFAULT_SKILL_LIFECYCLE_INTERVAL_MS = 60 * 60 * 1000; + +export interface SkillLifecycleWorker { + start(): void; + trigger(): void; + runNow(): Promise; + flush(): Promise; + stop(): void; +} + +export interface SkillLifecycleWorkerDeps { + runLifecycle(): Promise; + log: Logger; + intervalMs?: number; + now?: () => number; +} + +/** + * Periodically runs lightweight Skill lifecycle maintenance without draining + * the full capture/reward/L2/L3 pipeline. Scheduled failures are isolated so + * one bad pass cannot permanently stop future maintenance. + */ +export function createSkillLifecycleWorker( + deps: SkillLifecycleWorkerDeps, +): SkillLifecycleWorker { + const intervalMs = Math.max( + 1, + Math.floor(deps.intervalMs ?? DEFAULT_SKILL_LIFECYCLE_INTERVAL_MS), + ); + const now = deps.now ?? Date.now; + let timer: ReturnType | null = null; + let running: Promise | null = null; + let lastStartedAt = Number.NEGATIVE_INFINITY; + let stopped = true; + + function beginRun(): Promise { + if (running) return running; + lastStartedAt = now(); + const current = Promise.resolve().then(() => deps.runLifecycle()).finally(() => { + if (running === current) running = null; + }); + running = current; + return current; + } + + function trigger(): void { + if (stopped || running || now() - lastStartedAt < intervalMs) return; + void beginRun().catch((err) => { + deps.log.warn("skill.lifecycle_worker.failed", { + err: err instanceof Error ? err.message : String(err), + }); + }); + } + + return { + start(): void { + if (!stopped) return; + stopped = false; + trigger(); + timer = setInterval(trigger, intervalMs); + (timer as unknown as { unref?: () => void }).unref?.(); + }, + + trigger, + + runNow(): Promise { + return beginRun(); + }, + + async flush(): Promise { + if (running) await running; + }, + + stop(): void { + stopped = true; + if (timer) clearInterval(timer); + timer = null; + }, + }; +} diff --git a/apps/memos-local-plugin/core/skill/lifecycle.ts b/apps/memos-local-plugin/core/skill/lifecycle.ts index a4eaee04a..db2ed0a56 100644 --- a/apps/memos-local-plugin/core/skill/lifecycle.ts +++ b/apps/memos-local-plugin/core/skill/lifecycle.ts @@ -196,7 +196,8 @@ export function shouldArchiveIdle( now: number, ): boolean { if (skill.status !== "active") return false; - const age = now - skill.updatedAt; + const idleSince = skill.lastUsedAt ?? skill.createdAt; + const age = now - idleSince; if (age < idleMs) return false; return skill.eta < cfg.minEtaForRetrieval; } diff --git a/apps/memos-local-plugin/core/skill/subscriber.ts b/apps/memos-local-plugin/core/skill/subscriber.ts index 67754c311..6b30d9d5e 100644 --- a/apps/memos-local-plugin/core/skill/subscriber.ts +++ b/apps/memos-local-plugin/core/skill/subscriber.ts @@ -35,6 +35,14 @@ import type { } from "./types.js"; import type { SkillId } from "../types.js"; import { now as nowMs } from "../time.js"; +import { IDLE_ARCHIVE_BATCH_LIMIT } from "../storage/repos/skills.js"; + +/** Bound one lifecycle pass to ten repository-sized archival batches. */ +const IDLE_ARCHIVE_MAX_BATCHES_PER_TICK = 10; + +function yieldToEventLoop(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} export interface SkillSubscriberDeps extends Omit { @@ -210,12 +218,12 @@ export function attachSkillSubscriber( } } - /** Periodic lifecycle pass: promote eligible candidate skills to active. */ + /** Promote eligible candidates and archive stale low-η active skills. */ async function lifecycleTick(): Promise { + const at = nowMs(); const candidates = deps.repos.skills.list({ status: "candidate", limit: 500 }); for (const s of candidates) { if (!shouldPromoteCandidate(s, deps.config)) continue; - const at = nowMs(); deps.repos.skills.setStatus(s.id, "active", at); log.info("skill.auto_promoted", { skillId: s.id, name: s.name, eta: s.eta }); deps.bus.emit({ @@ -227,6 +235,56 @@ export function attachSkillSubscriber( transition: "promoted", }); } + + const cutoff = at - deps.config.idleArchiveMs; + let batchesProcessed = 0; + let archivedTotal = 0; + while (batchesProcessed < IDLE_ARCHIVE_MAX_BATCHES_PER_TICK) { + const archivedSkills = deps.repos.skills.archiveNextIdleBatch({ + minEtaForRetrieval: deps.config.minEtaForRetrieval, + cutoff, + updatedAt: at, + limit: IDLE_ARCHIVE_BATCH_LIMIT, + }); + batchesProcessed += 1; + const archivedThisBatch = archivedSkills.length; + archivedTotal += archivedThisBatch; + for (const s of archivedSkills) { + log.debug("skill.idle_archived", { + skillId: s.id, + name: s.name, + eta: s.eta, + lastUsedAt: s.lastUsedAt ?? null, + idleArchiveMs: deps.config.idleArchiveMs, + }); + deps.bus.emit({ + kind: "skill.status.changed", + at, + skillId: s.id, + previous: "active", + next: "archived", + transition: "archived", + }); + } + if (archivedThisBatch > 0) { + log.info("skill.idle_archive_batch", { + batchCount: batchesProcessed, + archivedCount: archivedThisBatch, + cutoff, + minEtaForRetrieval: deps.config.minEtaForRetrieval, + }); + } + if (archivedThisBatch < IDLE_ARCHIVE_BATCH_LIMIT) break; + if (batchesProcessed === IDLE_ARCHIVE_MAX_BATCHES_PER_TICK) { + log.warn("skill.idle_archive_batch_limit_reached", { + batchCount: batchesProcessed, + archivedCount: archivedTotal, + batchSize: IDLE_ARCHIVE_BATCH_LIMIT, + }); + } else { + await yieldToEventLoop(); + } + } } return { dispose, runOnce, applyFeedback, flush, lifecycleTick }; diff --git a/apps/memos-local-plugin/core/skill/types.ts b/apps/memos-local-plugin/core/skill/types.ts index a2799b061..b04e3bc35 100644 --- a/apps/memos-local-plugin/core/skill/types.ts +++ b/apps/memos-local-plugin/core/skill/types.ts @@ -118,6 +118,8 @@ export interface SkillConfig { archiveEta: number; /** Below this η, skills never surface in Tier-1 — matches retrieval config. */ minEtaForRetrieval: number; + /** Archive a low-η active skill after it has not been retrieved for this long. */ + idleArchiveMs: number; } /** diff --git a/apps/memos-local-plugin/core/storage/migrations/018-traces-ts-index.sql b/apps/memos-local-plugin/core/storage/migrations/018-traces-ts-index.sql new file mode 100644 index 000000000..4a2e099c9 --- /dev/null +++ b/apps/memos-local-plugin/core/storage/migrations/018-traces-ts-index.sql @@ -0,0 +1,26 @@ +-- Speed up unfiltered newest-first trace reads (health endpoint + boot replay). +-- +-- `latestTraceTs()` runs `SELECT ... FROM traces ORDER BY ts DESC, id DESC +-- LIMIT 1` several times per `/api/v1/health` request, and the pipeline's +-- recent-events replay issues the same shape at bootstrap. Every existing +-- `traces` index leads with `owner_*`, `share_scope`, `session_id`, or +-- `episode_id` -- none is usable for an UNFILTERED newest-first read, so each +-- call degenerated into a full table scan plus a temp B-tree sort +-- (`SCAN traces` / `USE TEMP B-TREE FOR ORDER BY`). Because better-sqlite3 +-- executes statements synchronously on the JS event loop, that scan blocks +-- the whole daemon: HTTP connections were accepted by the kernel backlog but +-- never answered while it ran. +-- +-- Observed impact on an install whose `traces` table reached ~30k rows +-- (~235 MB with embedding blobs and tool-call JSON): boot took 40-60s of +-- near-100% CPU inside the scan, and every liveness probe timed out before +-- the daemon could answer -- so a 60s watchdog restarted the daemon roughly +-- every 3 minutes, forever (300+ restarts/day). Each doomed generation +-- re-ran the scan at boot and was killed mid-scan, making the storm +-- self-sustaining. +-- +-- A bare `(ts DESC, id DESC)` index turns the lookup into a single index +-- seek: ~0.7ms warm instead of ~700ms warm / tens-of-seconds cold. Build cost +-- is ~1s per 100k rows and `IF NOT EXISTS` keeps re-application free. + +CREATE INDEX IF NOT EXISTS idx_traces_ts ON traces(ts DESC, id DESC); diff --git a/apps/memos-local-plugin/core/storage/migrator.ts b/apps/memos-local-plugin/core/storage/migrator.ts index 47e42807c..b858f7aa3 100644 --- a/apps/memos-local-plugin/core/storage/migrator.ts +++ b/apps/memos-local-plugin/core/storage/migrator.ts @@ -202,6 +202,14 @@ function applyMigration(db: StorageDb, file: MigrationFile): void { } return; } + if (file.version === 18 && file.name === "traces-ts-index") { + // Same guard as 012: some test harnesses build partial schemas without a + // `traces` table; the index is meaningless there and must not fail boot. + 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/core/storage/repos/skills.ts b/apps/memos-local-plugin/core/storage/repos/skills.ts index 2fef2d71e..8a04d8e70 100644 --- a/apps/memos-local-plugin/core/storage/repos/skills.ts +++ b/apps/memos-local-plugin/core/storage/repos/skills.ts @@ -14,6 +14,11 @@ import { toJsonText, } from "./_helpers.js"; +export const IDLE_ARCHIVE_BATCH_LIMIT = 500; + +const IDLE_ARCHIVE_PREDICATE = + "status = 'active' AND eta < @min_eta AND COALESCE(last_used_at, created_at) <= @cutoff"; + const COLUMNS = [ "id", "owner_agent_kind", @@ -87,6 +92,42 @@ export function makeSkillsRepo(db: StorageDb) { updateStatus.run({ id, status, updated_at: updatedAt }); }, + /** + * Atomically select and archive one oldest-first batch. Keeping candidate + * selection and the conditional transition in one SQLite statement + * removes the read/update race and avoids hundreds of UPDATE round-trips. + */ + archiveNextIdleBatch(input: { + minEtaForRetrieval: number; + cutoff: number; + updatedAt: number; + limit?: number; + }): SkillRow[] { + const requestedLimit = Number.isFinite(input.limit) + ? Math.floor(input.limit!) + : IDLE_ARCHIVE_BATCH_LIMIT; + const params = { + min_eta: input.minEtaForRetrieval, + cutoff: input.cutoff, + updated_at: input.updatedAt, + limit: Math.max(1, Math.min(IDLE_ARCHIVE_BATCH_LIMIT, requestedLimit)), + }; + const sql = ` + WITH candidates AS ( + SELECT id + FROM skills + WHERE ${IDLE_ARCHIVE_PREDICATE} + ORDER BY COALESCE(last_used_at, created_at) ASC + LIMIT @limit + ) + UPDATE skills + SET status = 'archived', updated_at = @updated_at + WHERE id IN (SELECT id FROM candidates) + AND ${IDLE_ARCHIVE_PREDICATE} + RETURNING ${COLUMNS.join(", ")}`; + return db.prepare(sql).all(params).map(mapRow); + }, + bumpTrial( id: SkillId, passed: boolean, diff --git a/apps/memos-local-plugin/core/storage/repos/traces.ts b/apps/memos-local-plugin/core/storage/repos/traces.ts index d78094872..d8bcea5e3 100644 --- a/apps/memos-local-plugin/core/storage/repos/traces.ts +++ b/apps/memos-local-plugin/core/storage/repos/traces.ts @@ -106,6 +106,9 @@ export function makeTracesRepo(db: StorageDb) { const selectById = db.prepare<{ id: string }, RawTraceRow>( `SELECT ${COLUMNS.join(", ")} FROM traces WHERE id=@id`, ); + const selectLatestTimestamp = db.prepare( + `SELECT ts FROM traces ORDER BY ts DESC, id DESC LIMIT 1`, + ); return { insert(row: TraceRow): void { @@ -135,6 +138,10 @@ export function makeTracesRepo(db: StorageDb) { return mapRow(r); }, + latestTimestamp(): number | null { + return selectLatestTimestamp.get()?.ts ?? null; + }, + getManyByIds(ids: readonly TraceId[]): TraceRow[] { if (ids.length === 0) return []; const placeholders = buildInClause(ids.length); diff --git a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md index 8cfe175e9..82e03230d 100644 --- a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md +++ b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md @@ -156,6 +156,7 @@ algorithm: etaDelta: 0.1 # η step per user.positive/user.negative thumbs archiveEta: 0.25 # η floor; crossing archives minEtaForRetrieval: 0.5 # η gate for Tier-1 retrieval + auto-promotion + idleArchiveMs: 2592000000 # archive low-η skills after 30d without retrieval (minimum 1h) feedback: failureThreshold: 3 # failures in `failureWindow` that trigger a burst (V7 §6.3) failureWindow: 5 # rolling tool-call window per (toolId, context) diff --git a/apps/memos-local-plugin/install.ps1 b/apps/memos-local-plugin/install.ps1 index 7afbabb65..be5fc85c7 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -48,6 +48,30 @@ function Invoke-NativeChecked { } } +function Invoke-OpenClawGatewayChecked { + param( + [ValidateSet("start", "stop")] + [string]$Action + ) + # PowerShell 5.1 can promote a native process' stderr to an ErrorRecord. + # Capture it without turning it into a terminating PowerShell error, then + # use the native exit code as the authoritative result. + $PreviousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $GatewayOutput = @(& cmd.exe /d /c "openclaw gateway $Action" 2>&1) + $ExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $PreviousErrorActionPreference + } + foreach ($Line in $GatewayOutput) { + Write-Host "$Line" + } + if ($ExitCode -ne 0) { + throw "openclaw gateway $Action failed (exit code $ExitCode)" + } +} + function Test-BetterSqlite3 { param([string]$NodeBin, [string]$Prefix) $SmokeScript = "const Database=require('better-sqlite3');const db=new Database(':memory:');db.exec('SELECT 1');db.close();" @@ -157,6 +181,7 @@ if ($AgentSelection -eq "auto") { # Resolve tarball $StageDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([guid]::NewGuid().ToString())) -Force +try { $SourceKind = "npm" $SourceSpec = $NpmPackage $BuiltTarball = "" @@ -482,18 +507,21 @@ function Install-OpenClaw { $ConfigPath = Join-Path $env:USERPROFILE ".openclaw\openclaw.json" $OcBin = Get-Command "openclaw" -ErrorAction SilentlyContinue - if ($OcBin) { - Write-Info "Stopping OpenClaw gateway" - cmd /c "openclaw gateway stop" - Start-Sleep -Seconds 1 - } + $GatewayRecoveryState = "inactive" + try { + if ($OcBin) { + Write-Info "Stopping OpenClaw gateway" + Invoke-OpenClawGatewayChecked -Action "stop" + $GatewayRecoveryState = "needs_recovery" + Start-Sleep -Seconds 1 + } - Deploy-Tarball -Prefix $Prefix + Deploy-Tarball -Prefix $Prefix - $RuntimeEntry = "./dist/adapters/openclaw/index.js" - if (-not (Test-Path (Join-Path $Prefix "dist\adapters\openclaw\index.js"))) { - Stop-Die "OpenClaw runtime entry missing." - } + $RuntimeEntry = "./dist/adapters/openclaw/index.js" + if (-not (Test-Path (Join-Path $Prefix "dist\adapters\openclaw\index.js"))) { + throw "OpenClaw runtime entry missing." + } Ensure-RuntimeHome -Agent "openclaw" -HomeDir $HomeDir -Prefix $Prefix @@ -628,21 +656,40 @@ config.plugins.entries[pluginId].hooks.allowConversationAccess = true; fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); "@ - $NodeScriptPath = Join-Path $env:TEMP "patch_openclaw.js" - Set-Content -Path $NodeScriptPath -Value $NodeScript -Encoding UTF8 - node $NodeScriptPath - Write-Success "openclaw.json patched" + $NodeScriptPath = Join-Path $env:TEMP "patch_openclaw.js" + Set-Content -Path $NodeScriptPath -Value $NodeScript -Encoding UTF8 + Invoke-NativeChecked -Command "node" -Arguments @($NodeScriptPath) -FailureMessage "Failed to patch openclaw.json" + Write-Success "openclaw.json patched" - if ($OcBin) { - Write-Info "Starting OpenClaw gateway" - cmd /c "openclaw gateway start" - if (Wait-ForViewer -Port $OpenClawPort) { - Write-Success "OpenClaw install complete" + if ($OcBin) { + Write-Info "Starting OpenClaw gateway" + try { + Invoke-OpenClawGatewayChecked -Action "start" + } catch { + # The regular final start already ran. Do not invoke it a + # second time from recovery cleanup and hide the real failure. + $GatewayRecoveryState = "final_failed" + throw + } + $GatewayRecoveryState = "inactive" + if (Wait-ForViewer -Port $OpenClawPort) { + Write-Success "OpenClaw install complete" + } else { + Write-Warn "Memory Viewer did not respond." + } } else { - Write-Warn "Memory Viewer did not respond." + Write-Warn "openclaw CLI not found. Start gateway manually." + } + } finally { + if ($GatewayRecoveryState -eq "needs_recovery") { + Write-Warn "Install failed after stopping OpenClaw; restarting the gateway." + try { + Invoke-OpenClawGatewayChecked -Action "start" + Write-Success "OpenClaw gateway recovered" + } catch { + Write-Warn "OpenClaw gateway recovery failed: $($_.Exception.Message)" + } } - } else { - Write-Warn "openclaw CLI not found. Start gateway manually." } } @@ -786,3 +833,8 @@ if ($AgentSelection -eq "hermes" -or $AgentSelection -eq "all") { Install-Hermes Write-Host "`n ==================================================" -ForegroundColor Green Write-Host " Install finished successfully! " -ForegroundColor Green Write-Host " ==================================================`n" -ForegroundColor Green +} finally { + if ($StageDir -and (Test-Path $StageDir)) { + Remove-Item -Recurse -Force $StageDir -ErrorAction SilentlyContinue + } +} diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index bd972c8f1..5f2c8184e 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -299,8 +299,20 @@ STAGE_DIR="" DSH_PNPM_TEMP_DIR="" SOURCE_KIND="" # "path" for a local file, "npm" otherwise SOURCE_SPEC="" - -cleanup_install_temp_dirs() { +GATEWAY_RECOVERY_BIN="" +GATEWAY_RECOVERY_STATE="inactive" + +cleanup_install_state() { + if [[ "${GATEWAY_RECOVERY_STATE:-inactive}" == "needs_recovery" \ + && -n "${GATEWAY_RECOVERY_BIN:-}" ]]; then + local recovery_out="" + if ! recovery_out="$("${GATEWAY_RECOVERY_BIN}" gateway start 2>&1)"; then + warn "OpenClaw gateway recovery failed; the gateway may still be stopped." + if [[ -n "${recovery_out}" ]]; then + printf '%s\n' "${recovery_out}" | sed 's/^/ /' >&2 + fi + fi + fi if [[ -n "${STAGE_DIR}" && -d "${STAGE_DIR}" ]]; then rm -rf -- "${STAGE_DIR}" fi @@ -308,7 +320,7 @@ cleanup_install_temp_dirs() { rm -rf -- "${DSH_PNPM_TEMP_DIR}" fi } -trap cleanup_install_temp_dirs EXIT +trap cleanup_install_state EXIT resolve_source_spec() { if [[ -n "${VERSION_ARG}" && -f "${VERSION_ARG}" ]]; then @@ -484,11 +496,16 @@ install_openclaw() { mkdir -p "${HOME}/.openclaw" local oc_bin="" + # These remain global because the EXIT trap runs after this function returns. + GATEWAY_RECOVERY_BIN="" + GATEWAY_RECOVERY_STATE="inactive" if oc_bin="$(find_openclaw_cli)"; then step "Stopping OpenClaw gateway" "${oc_bin}" gateway stop >/dev/null 2>&1 || true sleep 1 success "Gateway stopped" + GATEWAY_RECOVERY_BIN="${oc_bin}" + GATEWAY_RECOVERY_STATE="needs_recovery" fi deploy_tarball_to_prefix "${prefix}" @@ -663,6 +680,9 @@ NODE || (command -v lsof >/dev/null 2>&1 && lsof -i ":18789" -t >/dev/null 2>&1); then success "OpenClaw gateway already running" else + # The intended final start already ran and failed; do not repeat the same + # command from the EXIT trap. + GATEWAY_RECOVERY_STATE="final_failed" error "openclaw gateway start failed:" echo "${start_out}" | sed 's/^/ /' >&2 warn "Inspect ~/.openclaw/logs/gateway.err.log for the full reason." @@ -671,6 +691,10 @@ NODE else success "OpenClaw gateway started" fi + # The service started (or was already running), so later viewer fallback + # failures must not trigger another service start from the EXIT trap. + GATEWAY_RECOVERY_STATE="inactive" + GATEWAY_RECOVERY_BIN="" step "Waiting for Memory Viewer" if wait_for_viewer "${OPENCLAW_PORT}"; then diff --git a/apps/memos-local-plugin/templates/config.demo.yaml b/apps/memos-local-plugin/templates/config.demo.yaml index ccaca7012..7359f38bc 100644 --- a/apps/memos-local-plugin/templates/config.demo.yaml +++ b/apps/memos-local-plugin/templates/config.demo.yaml @@ -62,3 +62,4 @@ algorithm: minGain: 0.0 candidateTrials: 1 cooldownMs: 0 + idleArchiveMs: 2592000000 # 30 days; minimum 1 hour diff --git a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts index 6ec654173..fa12b9189 100644 --- a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts +++ b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts @@ -55,7 +55,7 @@ import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; import { fakeLlm, type FakeLlmScript } from "../../helpers/fake-llm.js"; import type { LlmClient } from "../../../core/llm/types.js"; import type { EmbedInput, EmbedStats, Embedder } from "../../../core/embedding/types.js"; -import type { EmbeddingVector } from "../../../core/types.js"; +import type { EmbeddingVector, SkillId, SkillRow } from "../../../core/types.js"; import type { AgentKind } from "../../../agent-contract/dto.js"; // ─── Helpers ───────────────────────────────────────────────────────────── @@ -641,4 +641,50 @@ describe("OpenClaw adapter integration — multi-session full V7 chain", () => { JSON.stringify(snapshot, null, 2), ); }); + + it("archives a stale low-η skill when OpenClaw closes its session", async () => { + const thirtyOneDaysMs = 31 * 24 * 60 * 60 * 1_000; + const stale: SkillRow = { + id: "sk_openclaw_idle_archive" as SkillId, + name: "openclaw_idle_archive", + status: "active", + invocationGuide: "# OpenClaw idle archive integration fixture", + procedureJson: null, + eta: 0.05, + support: 3, + gain: 0.05, + trialsAttempted: 0, + trialsPassed: 0, + sourcePolicyIds: [], + sourceWorldModelIds: [], + evidenceAnchors: [], + vec: unitFromSeed("skill:openclaw_idle_archive") as unknown as EmbeddingVector, + createdAt: (NOW - thirtyOneDaysMs) as SkillRow["createdAt"], + updatedAt: NOW as SkillRow["updatedAt"], + lastUsedAt: (NOW - thirtyOneDaysMs) as SkillRow["lastUsedAt"], + version: 1, + }; + db!.repos.skills.upsert(stale); + const bridge = createOpenClawBridge({ + agent: AGENT, + core: core!, + log: { + trace: (_m: string, _c?: unknown) => undefined, + info: (_m: string, _c?: unknown) => undefined, + warn: (_m: string, _c?: unknown) => undefined, + error: (_m: string, _c?: unknown) => undefined, + debug: (_m: string, _c?: unknown) => undefined, + }, + now: () => NOW, + }); + const session = new OpenClawSimulator({ bridge, sessionKey: "s-idle-archive" }); + + await session.turn( + "用 Python 返回字符串 hello", + '```python\ndef hello() -> str:\n return "hello"\n```', + ); + await session.close(); + + expect(db!.repos.skills.getById(stale.id)?.status).toBe("archived"); + }); }); diff --git a/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py b/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py index ecd0e1ae1..ea6818ca9 100644 --- a/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py +++ b/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py @@ -931,5 +931,295 @@ def test_post_llm_call_orders_backfilled_tools_before_later_tool_results(self) - self.assertEqual(turn_end["toolCalls"][1]["thinkingBefore"], "先列计划,再查机票。") +class ChineseToolResultBridge(FakeBridge): + """Fake bridge whose read-path responses embed Chinese characters. + + Used by ``HandleToolCallEnsureAsciiTests`` to prove that every + ``json.dumps`` inside ``handle_tool_call`` passes + ``ensure_ascii=False`` so Chinese memory content is returned to the + host LLM as readable UTF-8 rather than ``\\uXXXX`` escapes (#2255). + """ + + _CH_REFLECTION = "用户提供了 Tushare KEY,需要在后续查询中携带。" + _CH_SNIPPET = "记忆命中:北京晚高峰的地铁调度策略。" + _CH_POLICY_TITLE = "策略:夜间批任务错峰" + _CH_POLICY_BODY = "在凌晨 02:00 之后触发全量导入,避开在线读写高峰。" + _CH_WORLD_TITLE = "世界模型:城市晚高峰" + _CH_WORLD_BODY = "工作日 17:30-19:30 主干道车流密集,通勤需绕行。" + _CH_SKILL_TITLE = "技能:中文摘要" + _CH_SKILL_PROCEDURE = "先分段抽取关键句,再融合成 3 句摘要。" + + def request(self, method: str, params: dict | None = None, **_kwargs: object) -> dict: + payload = params or {} + self.calls.append((method, payload)) + if method == "session.open": + return {"sessionId": payload.get("sessionId") or "hermes:test-session"} + if method == "core.health": + return {"ok": True} + if method == "memory.search": + return { + "hits": [ + { + "id": "trace-cn-1", + "refId": "trace-cn-1", + "refKind": "trace", + "tier": 1, + "score": 0.87, + "snippet": self._CH_SNIPPET, + "reflection": self._CH_REFLECTION, + }, + { + "id": "world-cn-1", + "refId": "world-cn-1", + "refKind": "world_model", + "tier": 3, + "score": 0.71, + "snippet": f"{self._CH_WORLD_TITLE}\n{self._CH_WORLD_BODY}", + }, + ] + } + if method == "memory.get_trace": + return { + "id": payload.get("id"), + "episodeId": "ep-cn-1", + "agentText": "已按用户请求完成中文摘要生成。", + "userText": "帮我把上面的中文材料压缩成 3 句摘要。", + "reflection": self._CH_REFLECTION, + "ts": "2026-08-16T02:50:22Z", + "toolCalls": [], + "value": 0.9, + } + if method == "memory.get_policy": + return { + "id": payload.get("id"), + "title": self._CH_POLICY_TITLE, + "procedure": self._CH_POLICY_BODY, + "trigger": "夜间空闲窗口", + "verification": "首屏读取 P95 无回退", + "boundary": "仅离线批任务", + "gain": "峰值 QPS 下降 30%", + "support": 12, + "status": "active", + } + if method == "memory.get_world": + return { + "id": payload.get("id"), + "title": self._CH_WORLD_TITLE, + "body": self._CH_WORLD_BODY, + "policyIds": ["policy-cn-1"], + } + if method == "memory.timeline": + return { + "traces": [ + { + "id": "trace-cn-1", + "snippet": self._CH_SNIPPET, + "reflection": self._CH_REFLECTION, + } + ] + } + if method == "memory.list_world_models": + return { + "worldModels": [ + { + "id": "world-cn-1", + "title": self._CH_WORLD_TITLE, + "body": self._CH_WORLD_BODY, + "policyIds": ["policy-cn-1"], + } + ] + } + if method == "skill.list": + return { + "skills": [ + { + "id": "skill-cn-1", + "title": self._CH_SKILL_TITLE, + "summary": self._CH_SKILL_PROCEDURE, + } + ] + } + if method == "skill.get": + return { + "id": payload.get("id"), + "title": self._CH_SKILL_TITLE, + "procedure": self._CH_SKILL_PROCEDURE, + } + if method in {"episode.close", "session.close", "subagent.record"}: + return {"ok": True} + raise AssertionError(f"unexpected bridge method: {method}") + + +class HandleToolCallEnsureAsciiTests(unittest.TestCase): + """Regression guard for #2255. + + Every ``json.dumps`` in ``MemTensorProvider.handle_tool_call`` must + pass ``ensure_ascii=False`` so Chinese (and other non-ASCII) memory + content reaches the host LLM as readable UTF-8. Without the flag, + Python's default serialization escapes each non-ASCII code point to + ``\\uXXXX``, dramatically increasing token load and making tool + results unreadable for humans debugging the Hermes side. + """ + + def setUp(self) -> None: + memos_provider.SHARED_BRIDGE_REGISTRY.close_all() + self._mode_patch = patch.dict( + "os.environ", + {"MEMOS_HERMES_BRIDGE_MODE": "legacy"}, + ) + self._mode_patch.start() + + def tearDown(self) -> None: + memos_provider.SHARED_BRIDGE_REGISTRY.close_all() + self._mode_patch.stop() + + def _make_provider(self, bridge: ChineseToolResultBridge) -> object: + patches = ( + patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), + patch("memos_provider.MemosBridgeClient", return_value=bridge), + ) + for p in patches: + self.addCleanup(p.stop) + p.start() + provider = memos_provider.MemTensorProvider() + provider.initialize( + "hermes:2255", + hermes_home="/tmp/hermes-2255-home", + platform="cli", + agent_identity="hermes-2255", + ) + self.addCleanup(provider.shutdown) + return provider + + # -- individual tools ------------------------------------------------- + # + # Each test proves ``ensure_ascii=False`` by asserting that the raw + # serialized string contains the Chinese literal verbatim. That + # guarantee is stronger than searching for the two-character sequence + # ``\\u`` in the output: with ``ensure_ascii=True`` Python would emit + # ``\uXXXX`` escapes and the raw Chinese literal would NOT appear, so + # ``assertIn(_CH_..., raw)`` alone catches the regression while + # avoiding false positives on legitimate values that just happen to + # contain a backslash followed by ``u`` (e.g. Windows paths, regex + # patterns, or unrelated escape sequences in future fields). + + def test_memos_search_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_search", {"query": "中文摘要"}) + + self.assertIn(ChineseToolResultBridge._CH_REFLECTION, raw) + parsed = json.loads(raw) + self.assertEqual( + parsed["hits"][0]["reflection"], + ChineseToolResultBridge._CH_REFLECTION, + ) + + def test_memos_get_trace_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_get", {"id": "trace-cn-1", "kind": "trace"}) + + self.assertIn(ChineseToolResultBridge._CH_REFLECTION, raw) + parsed = json.loads(raw) + self.assertTrue(parsed["found"]) + self.assertEqual(parsed["meta"]["reflection"], ChineseToolResultBridge._CH_REFLECTION) + + def test_memos_get_policy_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_get", {"id": "policy-cn-1", "kind": "policy"}) + + self.assertIn(ChineseToolResultBridge._CH_POLICY_TITLE, raw) + parsed = json.loads(raw) + self.assertIn(ChineseToolResultBridge._CH_POLICY_BODY, parsed["body"]) + + def test_memos_get_world_model_returns_utf8_chinese(self) -> None: + """Regression guard for the ``world_model`` branch of ``memos_get``. + + Without this case a future accidental removal of + ``ensure_ascii=False`` from the ``world_model`` branch of + ``memos_get`` (routed via ``memory.get_world``) would go + undetected — the other ``memos_get`` tests only exercise the + ``trace`` and ``policy`` kinds. + """ + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_get", {"id": "world-cn-1", "kind": "world_model"}) + + self.assertIn(ChineseToolResultBridge._CH_WORLD_TITLE, raw) + parsed = json.loads(raw) + self.assertTrue(parsed["found"]) + self.assertEqual(parsed["kind"], "world_model") + self.assertEqual(parsed["meta"]["title"], ChineseToolResultBridge._CH_WORLD_TITLE) + self.assertIn(ChineseToolResultBridge._CH_WORLD_BODY, parsed["body"]) + + def test_memos_timeline_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_timeline", {"episodeId": "ep-cn-1"}) + + self.assertIn(ChineseToolResultBridge._CH_SNIPPET, raw) + parsed = json.loads(raw) + self.assertEqual(parsed["traces"][0]["snippet"], ChineseToolResultBridge._CH_SNIPPET) + + def test_memos_skill_list_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_skill_list", {"limit": 5}) + + self.assertIn(ChineseToolResultBridge._CH_SKILL_TITLE, raw) + parsed = json.loads(raw) + self.assertEqual( + parsed["skills"][0]["title"], + ChineseToolResultBridge._CH_SKILL_TITLE, + ) + + def test_memos_environment_list_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_environment", {"limit": 5}) + + self.assertIn(ChineseToolResultBridge._CH_WORLD_BODY, raw) + parsed = json.loads(raw) + self.assertFalse(parsed["queried"]) + self.assertEqual( + parsed["worldModels"][0]["title"], + ChineseToolResultBridge._CH_WORLD_TITLE, + ) + + def test_memos_environment_query_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_environment", {"query": "晚高峰", "limit": 5}) + + self.assertIn(ChineseToolResultBridge._CH_WORLD_TITLE, raw) + parsed = json.loads(raw) + self.assertTrue(parsed["queried"]) + + def test_memos_skill_get_returns_utf8_chinese(self) -> None: + bridge = ChineseToolResultBridge() + provider = self._make_provider(bridge) + + raw = provider.handle_tool_call("memos_skill_get", {"id": "skill-cn-1"}) + + self.assertIn(ChineseToolResultBridge._CH_SKILL_PROCEDURE, raw) + parsed = json.loads(raw) + self.assertTrue(parsed["found"]) + self.assertEqual( + parsed["skill"]["procedure"], + ChineseToolResultBridge._CH_SKILL_PROCEDURE, + ) + + if __name__ == "__main__": unittest.main() diff --git a/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-host-llm.test.ts b/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-host-llm.test.ts index 87b826afc..493fba9e7 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-host-llm.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-host-llm.test.ts @@ -34,8 +34,27 @@ function streamFrom( config: LlmCallConfig, signal?: AbortSignal, ) => void, + observeResolution?: (provider: string, model: string) => void, ): DeepSeekHarnessLlmLike { return { + async resolveModelInfo(provider, model) { + observeResolution?.(provider, model); + return { + provider, + id: model, + name: model, + ...(reasoningEfforts.length === 0 + ? {} + : { + reasoning: { + efforts: reasoningEfforts.map((effort) => ({ + id: ReasoningEffortId(effort), + name: effort, + })), + }, + }), + }; + }, async prepareCall(config, signal) { observePreparation?.(config, signal); if ( @@ -192,6 +211,7 @@ describe("DeepSeek Harness host LLM bridge", () => { it("does not invent an off effort when the exact model does not advertise it", async () => { let request: GenerateOptions | undefined; const preparations: LlmCallConfig[] = []; + let resolutions = 0; const routes = new DeepSeekHarnessLlmRouteContext(); const bridge = createDeepSeekHarnessHostLlmBridge({ llm: streamFrom([ @@ -201,14 +221,18 @@ describe("DeepSeek Harness host LLM bridge", () => { request = options; }, [], (config) => { preparations.push(config); + }, () => { + resolutions++; }), routes, }); - const result = await routes.run( + const complete = () => routes.run( { provider: "openai", model: "gpt-test", reasoningEffort: "high" }, () => bridge.complete({ messages: [{ role: "user", content: "hello" }] }), ); + const result = await complete(); + await complete(); expect(request).not.toHaveProperty("system"); expect(request).not.toHaveProperty("reasoningEffort"); @@ -216,24 +240,197 @@ describe("DeepSeek Harness host LLM bridge", () => { expect(request).not.toHaveProperty("temperature"); expect(request).not.toHaveProperty("maxTokens"); expect(preparations).toEqual([ - { - provider: "openai", - model: "gpt-test", - reasoningEffort: ReasoningEffortId("off"), - }, + { provider: "openai", model: "gpt-test" }, { provider: "openai", model: "gpt-test" }, ]); + expect(resolutions).toBe(1); expect(result).not.toHaveProperty("usage"); expect(result.text).toBe("ok"); }); + it("invalidates resolved effort capabilities when the DSH adapter changes", async () => { + let supportsOff = false; + let resolutions = 0; + const preparations: string[] = []; + const routes = new DeepSeekHarnessLlmRouteContext(); + const bridge = createDeepSeekHarnessHostLlmBridge({ + llm: { + async resolveModelInfo(provider, model) { + resolutions++; + return { + provider, + id: model, + name: model, + ...(supportsOff + ? { + reasoning: { + efforts: [{ id: ReasoningEffortId("off"), name: "Off" }], + }, + } + : {}), + }; + }, + async prepareCall(config) { + preparations.push(config.reasoningEffort ?? "plain"); + if (config.reasoningEffort !== undefined && !supportsOff) { + throw new LlmError("unsupported", "UNSUPPORTED_REASONING_EFFORT"); + } + return preparedCall(config, () => (async function* () { + yield { type: "text-delta", index: 0, text: "ok" } as StreamChunk; + yield { type: "finish", reason: { kind: "stop" } } as StreamChunk; + })()); + }, + }, + routes, + }); + const complete = () => routes.run( + { provider: "openai", model: "gpt-test" }, + () => bridge.complete({ messages: [{ role: "user", content: "hello" }] }), + ); + + await complete(); + supportsOff = true; + await complete(); + bridge.invalidateModelCapabilities(); + await complete(); + + expect(preparations).toEqual(["plain", "plain", "off"]); + expect(resolutions).toBe(2); + }); + + it("coalesces concurrent capability lookups for one route", async () => { + let resolutions = 0; + const routes = new DeepSeekHarnessLlmRouteContext(); + const llm = streamFrom([ + { type: "text-delta", index: 0, text: "ok" }, + { type: "finish", reason: { kind: "stop" } }, + ], undefined, [], undefined, () => { + resolutions++; + }); + const bridge = createDeepSeekHarnessHostLlmBridge({ llm, routes }); + const complete = () => routes.run( + { provider: "openai", model: "gpt-test" }, + () => bridge.complete({ messages: [{ role: "user", content: "hello" }] }), + ); + + await Promise.all([complete(), complete()]); + + expect(resolutions).toBe(1); + }); + + it("expires cached capabilities so silent model updates are eventually observed", async () => { + let now = 1_000; + let supportsOff = false; + let resolutions = 0; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const routes = new DeepSeekHarnessLlmRouteContext(); + const preparations: string[] = []; + const bridge = createDeepSeekHarnessHostLlmBridge({ + llm: { + async resolveModelInfo(provider, model) { + resolutions++; + return { + provider, + id: model, + name: model, + ...(supportsOff + ? { + reasoning: { + efforts: [{ id: ReasoningEffortId("off"), name: "Off" }], + }, + } + : {}), + }; + }, + async prepareCall(config) { + preparations.push(config.reasoningEffort ?? "plain"); + return preparedCall(config, () => (async function* () { + yield { type: "text-delta", index: 0, text: "ok" } as StreamChunk; + yield { type: "finish", reason: { kind: "stop" } } as StreamChunk; + })()); + }, + }, + routes, + }); + const complete = () => routes.run( + { provider: "openai", model: "gpt-test" }, + () => bridge.complete({ messages: [{ role: "user", content: "hello" }] }), + ); + + try { + await complete(); + supportsOff = true; + now += 10 * 60 * 1_000 + 1; + await complete(); + } finally { + nowSpy.mockRestore(); + } + + expect(preparations).toEqual(["plain", "off"]); + expect(resolutions).toBe(2); + }); + + it("falls back safely when HMR changes capabilities between lookup and preparation", async () => { + const preparations: string[] = []; + let firstOff = true; + let resolutions = 0; + const routes = new DeepSeekHarnessLlmRouteContext(); + const bridge = createDeepSeekHarnessHostLlmBridge({ + llm: { + resolveModelInfo(provider, model) { + resolutions++; + return Promise.resolve({ + provider, + id: model, + name: model, + reasoning: { + efforts: [{ id: ReasoningEffortId("off"), name: "Off" }], + }, + }); + }, + async prepareCall(config) { + preparations.push(config.reasoningEffort ?? "plain"); + if (config.reasoningEffort !== undefined && firstOff) { + firstOff = false; + throw new LlmError("adapter changed", "UNSUPPORTED_REASONING_EFFORT"); + } + return preparedCall(config, () => (async function* () { + yield { type: "text-delta", index: 0, text: "ok" } as StreamChunk; + yield { type: "finish", reason: { kind: "stop" } } as StreamChunk; + })()); + }, + }, + routes, + }); + const complete = () => routes.run( + { provider: "openai", model: "gpt-test" }, + () => bridge.complete({ messages: [{ role: "user", content: "hello" }] }), + ); + + await complete(); + await complete(); + + expect(preparations).toEqual(["off", "plain", "plain"]); + expect(resolutions).toBe(1); + }); + it("does not fall back for errors other than unsupported reasoning effort", async () => { const failure = new LlmError("invalid model metadata", "INVALID_MODEL_REASONING"); const prepareCall = vi.fn(); prepareCall.mockRejectedValue(failure); const routes = new DeepSeekHarnessLlmRouteContext(); const bridge = createDeepSeekHarnessHostLlmBridge({ - llm: { prepareCall }, + llm: { + resolveModelInfo: (provider, model) => Promise.resolve({ + provider, + id: model, + name: model, + reasoning: { + efforts: [{ id: ReasoningEffortId("off"), name: "Off" }], + }, + }), + prepareCall, + }, routes, }); @@ -253,6 +450,9 @@ describe("DeepSeek Harness host LLM bridge", () => { const routes = new DeepSeekHarnessLlmRouteContext(); const bridge = createDeepSeekHarnessHostLlmBridge({ llm: { + resolveModelInfo: (provider, model, signal) => ( + ctx.llm.resolveModelInfo(provider, model, signal) + ), async prepareCall(config, signal) { const prepared = await ctx.llm.prepareCall(config, signal); // Simulate a provider plugin HMR swap in the exact TOCTOU window @@ -452,7 +652,14 @@ describe("DeepSeek Harness host LLM bridge", () => { const prepareCall = vi.fn(); const routes = new DeepSeekHarnessLlmRouteContext(); const bridge = createDeepSeekHarnessHostLlmBridge({ - llm: { prepareCall }, + llm: { + resolveModelInfo: (provider, model) => Promise.resolve({ + provider, + id: model, + name: model, + }), + prepareCall, + }, routes, }); const controller = new AbortController(); @@ -469,6 +676,9 @@ describe("DeepSeek Harness host LLM bridge", () => { it("enforces the MemOS timeout through the fused DSH request signal", async () => { let observedSignal: AbortSignal | undefined; const llm: DeepSeekHarnessLlmLike = { + resolveModelInfo(provider, model) { + return Promise.resolve({ provider, id: model, name: model }); + }, prepareCall(config, signal) { observedSignal = signal; return Promise.resolve(preparedCall(config, (options) => { diff --git a/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-runtime.test.ts b/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-runtime.test.ts index cc79c5045..7d6c515ef 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-runtime.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/deepseek-harness-runtime.test.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import type { Context } from "@deepseek-ai/cordis"; +import { describe, expect, it, vi } from "vitest"; import { configureDeepSeekHarnessHostLlm, @@ -8,7 +9,11 @@ import { deepSeekHarnessMemoryGuidance, defaultDeepSeekHarnessHome, inject, + registerDeepSeekHarnessHostLlmCapabilityInvalidation, } from "../../../adapters/deepseek-harness/index.js"; +import type { + DeepSeekHarnessHostLlmBridge, +} from "../../../adapters/deepseek-harness/host-llm.js"; import { DEFAULT_CONFIG } from "../../../core/config/index.js"; describe("DeepSeek Harness adapter runtime defaults", () => { @@ -80,4 +85,29 @@ describe("DeepSeek Harness adapter runtime defaults", () => { expect(withoutTools).toContain("untrusted historical data"); expect(deepSeekHarnessMemoryGuidance(true)).toContain("memos_search"); }); + + it("invalidates host LLM capabilities when DSH adapters are updated", () => { + let listener: (() => void) | undefined; + const unregister = vi.fn(); + const ctx = { + on(event: string, callback: () => void): () => void { + expect(event).toBe("llm/adapters-updated"); + listener = callback; + return unregister; + }, + } as unknown as Context; + const invalidateModelCapabilities = vi.fn(); + const bridge = { + invalidateModelCapabilities, + } as unknown as DeepSeekHarnessHostLlmBridge; + + const registered = registerDeepSeekHarnessHostLlmCapabilityInvalidation( + ctx, + bridge, + ); + listener?.(); + + expect(registered).toBe(unregister); + expect(invalidateModelCapabilities).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts b/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts index 2f900c7b0..5b5ed7c33 100644 --- a/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts +++ b/apps/memos-local-plugin/tests/unit/bridge/hermes-process.test.ts @@ -7,9 +7,11 @@ * subcommand (`hermes --skills memory-routing chat`) was silently * missed and the viewer was stuck on `"disconnected"`. * - * The pattern under test is `hermes(?:\s+\S+)*\s+chat\b` — these cases - * lock in the exact shape of the fix. + * The pgrep pattern uses POSIX character classes while the JS helper + * uses equivalent `\s` / `\S` tokens. These cases lock in both the + * shared command grammar and the exact wire format passed to pgrep. */ +import { spawnSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; import { @@ -23,8 +25,23 @@ describe("HERMES_CHAT_PROCESS_PATTERN", () => { // If this string ever changes, audit `bridge.cts` callers and the // issue description before adjusting — the constant is the only // surface that fixes the substring-detection bug. - expect(HERMES_CHAT_PROCESS_PATTERN).toBe("hermes(?:\\s+\\S+)*\\s+chat\\b"); + expect(HERMES_CHAT_PROCESS_PATTERN).toBe( + "hermes([[:space:]]+[^[:space:]]+)*[[:space:]]+chat([[:space:]]|$)", + ); }); + + it.skipIf(process.platform !== "linux")( + "compiles under the glibc ERE engine used by pgrep", + () => { + const result = spawnSync("pgrep", ["-f", HERMES_CHAT_PROCESS_PATTERN], { + encoding: "utf8", + timeout: 2000, + }); + + expect(result.error).toBeUndefined(); + expect([0, 1]).toContain(result.status); + }, + ); }); describe("matchesHermesChatCommandLine", () => { @@ -78,6 +95,12 @@ describe("matchesHermesChatCommandLine", () => { ).toBe(false); }); + it("does not match `hermes chat-server` (chat must be a complete token)", () => { + expect( + matchesHermesChatCommandLine("/usr/local/bin/hermes chat-server"), + ).toBe(false); + }); + it("does not match `hermes --chat-log=... status` (chat must be the subcommand token)", () => { expect( matchesHermesChatCommandLine( diff --git a/apps/memos-local-plugin/tests/unit/bridge/pid-file-path.test.ts b/apps/memos-local-plugin/tests/unit/bridge/pid-file-path.test.ts new file mode 100644 index 000000000..cf7f51dfa --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/bridge/pid-file-path.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +describe("bridge PID file path", () => { + for (const entry of ["bridge.cts", "bridge.mts"]) { + it(`${entry} preserves configured homes and falls back to the OS home`, () => { + const source = readFileSync(resolve(entry), "utf8"); + const start = source.indexOf("function pidFilePath"); + const end = source.indexOf("function readPidFile", start); + + expect(start, `${entry}: pidFilePath() not found`).toBeGreaterThanOrEqual(0); + expect(end, `${entry}: readPidFile() not found`).toBeGreaterThan(start); + + const pidFilePathSource = source.slice(start, end); + const configuredHomeGuard = pidFilePathSource.indexOf("if (configuredHome)"); + const osHomeFallback = pidFilePathSource.indexOf("homedir()"); + + expect(configuredHomeGuard, `${entry}: configured home guard missing`).toBeGreaterThanOrEqual( + 0, + ); + expect(osHomeFallback, `${entry}: OS home fallback missing`).toBeGreaterThan( + configuredHomeGuard, + ); + expect(pidFilePathSource).not.toMatch(/process\.env\.HOME|["']\/tmp["']/); + }); + } +}); diff --git a/apps/memos-local-plugin/tests/unit/bridge/status.test.ts b/apps/memos-local-plugin/tests/unit/bridge/status.test.ts new file mode 100644 index 000000000..76aab85e9 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/bridge/status.test.ts @@ -0,0 +1,203 @@ +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createBridgeStatusReader, + createBridgeStatusWriter, + type BridgeStatusSnapshot, +} from "../../../bridge/status.js"; + +const STALE_MS = 20_000; + +describe("Hermes bridge status ownership", () => { + const tempDirs: string[] = []; + + afterEach(() => { + vi.useRealTimers(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function statusFile(): string { + const dir = mkdtempSync(join(tmpdir(), "memos-bridge-status-")); + tempDirs.push(dir); + return join(dir, "bridge-status.json"); + } + + function writeStatus(file: string, status: BridgeStatusSnapshot): void { + writeFileSync(file, JSON.stringify(status), "utf8"); + } + + it("keeps a fresh stdio heartbeat connected even if process detection misses Hermes", () => { + const file = statusFile(); + writeStatus(file, { + status: "connected", + lastOkAt: 90_000, + lastErrorAt: null, + lastError: null, + }); + + const isHermesChatRunning = vi.fn(() => false); + const reader = createBridgeStatusReader(file, { + isHermesChatRunning, + now: () => 100_000, + staleMs: STALE_MS, + }); + + expect(reader.snapshot()).toEqual({ + status: "connected", + lastOkAt: 90_000, + lastErrorAt: null, + lastError: null, + }); + expect(isHermesChatRunning).not.toHaveBeenCalled(); + }); + + it("reports daemon-only startup as not connected without creating a file", () => { + const file = statusFile(); + const reader = createBridgeStatusReader(file, { + isHermesChatRunning: () => false, + now: () => 100_000, + staleMs: STALE_MS, + }); + + expect(reader.snapshot()).toEqual({ + status: "disconnected", + lastOkAt: null, + lastErrorAt: 100_000, + lastError: "Hermes chat is not connected", + }); + expect(() => statSync(file)).toThrow(); + }); + + it("normalizes a stale heartbeat to not connected when Hermes is not running", () => { + const file = statusFile(); + const stale = { + status: "connected" as const, + lastOkAt: 1_000, + lastErrorAt: null, + lastError: null, + }; + writeStatus(file, stale); + const before = statSync(file).mtimeNs; + + const reader = createBridgeStatusReader(file, { + isHermesChatRunning: () => false, + now: () => 100_000, + staleMs: STALE_MS, + }); + + expect(reader.snapshot()).toEqual({ + status: "disconnected", + lastOkAt: 1_000, + lastErrorAt: 1_000, + lastError: "Hermes chat is not connected", + }); + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual(stale); + expect(statSync(file).mtimeNs).toBe(before); + }); + + it("reports reconnecting when Hermes is running but its heartbeat is stale", () => { + const file = statusFile(); + writeStatus(file, { + status: "connected", + lastOkAt: 1_000, + lastErrorAt: null, + lastError: null, + }); + + const reader = createBridgeStatusReader(file, { + isHermesChatRunning: () => true, + now: () => 100_000, + staleMs: STALE_MS, + }); + + expect(reader.snapshot()).toEqual({ + status: "reconnecting", + lastOkAt: 1_000, + lastErrorAt: 1_000, + lastError: "Hermes bridge heartbeat is stale", + }); + }); + + it("reports a stable waiting state without creating a file when Hermes is starting", () => { + const file = statusFile(); + const reader = createBridgeStatusReader(file, { + isHermesChatRunning: () => true, + now: () => 100_000, + staleMs: STALE_MS, + }); + + const expected = { + status: "reconnecting" as const, + lastOkAt: null, + lastErrorAt: 100_000, + lastError: "Hermes chat is running; waiting for memory bridge", + }; + expect(reader.snapshot()).toEqual(expected); + expect(reader.snapshot()).toEqual(expected); + expect(() => statSync(file)).toThrow(); + }); + + it("does not overwrite an explicit stdio disconnect while reporting reconnecting", () => { + const file = statusFile(); + const disconnected = { + status: "disconnected" as const, + lastOkAt: 90_000, + lastErrorAt: 95_000, + lastError: "Hermes chat disconnected", + }; + writeStatus(file, disconnected); + const before = statSync(file).mtimeNs; + const reader = createBridgeStatusReader(file, { + isHermesChatRunning: () => true, + now: () => 100_000, + staleMs: STALE_MS, + }); + + expect(reader.snapshot()).toEqual({ + status: "reconnecting", + lastOkAt: 90_000, + lastErrorAt: 95_000, + lastError: "Hermes chat is running; waiting for memory bridge", + }); + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual(disconnected); + expect(statSync(file).mtimeNs).toBe(before); + }); + + it("advances only the stdio writer heartbeat and stops cleanly", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const file = statusFile(); + const writer = createBridgeStatusWriter(file, { heartbeatMs: 5_000 }); + + writer.markConnected(); + expect(JSON.parse(readFileSync(file, "utf8")).lastOkAt).toBe(1_000); + + const heartbeat = writer.startHeartbeat(); + vi.advanceTimersByTime(5_000); + expect(JSON.parse(readFileSync(file, "utf8")).lastOkAt).toBe(6_000); + + heartbeat.stop(); + vi.advanceTimersByTime(10_000); + expect(JSON.parse(readFileSync(file, "utf8")).lastOkAt).toBe(6_000); + + writer.markDisconnected("Hermes chat disconnected"); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ + status: "disconnected", + lastOkAt: 6_000, + lastErrorAt: 16_000, + lastError: "Hermes chat disconnected", + }); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts new file mode 100644 index 000000000..21dd9de88 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_CONFIG, resolveConfig } from "../../../core/config/index.js"; +import { FREE_FORM_CONFIG_PATHS } from "../../../core/config/defaults.js"; + +describe("resolveConfig llm.maxTokens + llm.headers", () => { + it("uses an explicit allowlist for free-form config maps", () => { + expect(FREE_FORM_CONFIG_PATHS).toEqual([ + "llm.headers", + "l3Llm.headers", + "skillEvolver.headers", + "logging.channels", + ]); + expect(Object.isFrozen(FREE_FORM_CONFIG_PATHS)).toBe(true); + + const warnings: string[] = []; + const cfg = resolveConfig( + { + logging: { channels: { "core.l2.cross-task": "debug" } }, + }, + warnings, + ); + expect(cfg.logging.channels).toEqual({ "core.l2.cross-task": "debug" }); + expect(warnings).toEqual([]); + }); + + it("accepts llm.maxTokens and llm.headers without unknown-key warnings", () => { + const warnings: string[] = []; + const cfg = resolveConfig( + { + llm: { + maxTokens: 2048, + headers: { "User-Agent": "hermes-test", "X-Custom": "v1" }, + }, + }, + warnings, + ); + expect(cfg.llm.maxTokens).toBe(2048); + expect(cfg.llm.headers).toEqual({ "User-Agent": "hermes-test", "X-Custom": "v1" }); + // The free-form-map special case must not warn per header key. + expect(warnings).toEqual([]); + }); + + it("declares llm.maxTokens with a sane default of 1024", () => { + expect(DEFAULT_CONFIG.llm.maxTokens).toBe(1024); + const cfg = resolveConfig({}); + expect(cfg.llm.maxTokens).toBe(1024); + }); + + it("declares llm.headers defaulting to an empty map", () => { + expect(DEFAULT_CONFIG.llm.headers).toEqual({}); + const cfg = resolveConfig({}); + expect(cfg.llm.headers).toEqual({}); + }); + + it("declares skillEvolver.maxTokens (default 4096) for the crystallizer LLM slot", () => { + expect(DEFAULT_CONFIG.skillEvolver.maxTokens).toBe(4096); + const cfg = resolveConfig({ skillEvolver: { maxTokens: 8192 } }); + expect(cfg.skillEvolver.maxTokens).toBe(8192); + }); + + it("declares l3Llm.maxTokens (default 4096) sharing the SkillEvolver schema", () => { + expect(DEFAULT_CONFIG.l3Llm.maxTokens).toBe(4096); + const cfg = resolveConfig({ l3Llm: { maxTokens: 8192 } }); + expect(cfg.l3Llm.maxTokens).toBe(8192); + }); + + it("accepts headers on skillEvolver/l3Llm slots without unknown-key warnings", () => { + const warnings: string[] = []; + const cfg = resolveConfig( + { + skillEvolver: { headers: { "X-Evolver": "v1" } }, + l3Llm: { headers: { "X-L3": "v2" } }, + }, + warnings, + ); + expect(cfg.skillEvolver.headers).toEqual({ "X-Evolver": "v1" }); + expect(cfg.l3Llm.headers).toEqual({ "X-L3": "v2" }); + expect(cfg.l3Llm.maxTokens).toBe(4096); + expect(warnings).toEqual([]); + }); + + it("declares headers defaulting to empty on the dedicated slots", () => { + expect(DEFAULT_CONFIG.skillEvolver.headers).toEqual({}); + expect(DEFAULT_CONFIG.l3Llm.headers).toEqual({}); + }); + + it("rejects out-of-range maxTokens with config_invalid", () => { + expect(() => resolveConfig({ llm: { maxTokens: 50 } })).toThrow(/config failed schema validation/); + }); + + it("rejects non-string header values", () => { + expect(() => resolveConfig({ llm: { headers: { "X-Bad": 42 } } })).toThrow( + /config failed schema validation/, + ); + }); + + it("keeps unrelated llm fields untouched when maxTokens/headers are set", () => { + const cfg = resolveConfig({ + llm: { provider: "openai_compatible", model: "deepseek-v4-flash", maxTokens: 2048 }, + }); + expect(cfg.llm.provider).toBe("openai_compatible"); + expect(cfg.llm.model).toBe("deepseek-v4-flash"); + expect(cfg.llm.temperature).toBe(0); + expect(cfg.llm.fallbackToHost).toBe(true); + expect(cfg.llm.timeoutMs).toBe(45_000); + expect(cfg.llm.maxRetries).toBe(3); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 6bfd158db..2d9f22f81 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -30,6 +30,32 @@ describe("config/loadConfig", () => { expect(cfg.logging.timezone).toBe("America/Los_Angeles"); }); + it("defaults skill idle archival to 30 days and accepts an override", () => { + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + const sixHoursMs = 6 * 60 * 60 * 1000; + expect(resolveConfig({}).algorithm.skill.idleArchiveMs).toBe(thirtyDaysMs); + expect(resolveConfig({ + algorithm: { skill: { idleArchiveMs: sixHoursMs } }, + }).algorithm.skill.idleArchiveMs).toBe(sixHoursMs); + }); + + it("rejects skill idle archival outside the supported one-hour-to-365-day range", () => { + const oneHourMs = 60 * 60 * 1000; + const overOneYearMs = 365 * 24 * 60 * 60 * 1000 + 1; + expect(resolveConfig({ + algorithm: { skill: { idleArchiveMs: oneHourMs } }, + }).algorithm.skill.idleArchiveMs).toBe(oneHourMs); + expect(() => resolveConfig({ + algorithm: { skill: { idleArchiveMs: 0 } }, + })).toThrow(/schema validation/); + expect(() => resolveConfig({ + algorithm: { skill: { idleArchiveMs: oneHourMs - 1 } }, + })).toThrow(/schema validation/); + expect(() => resolveConfig({ + algorithm: { skill: { idleArchiveMs: overOneYearMs } }, + })).toThrow(/schema validation/); + }); + it("rejects invalid logging.timezone with config_invalid", () => { expect(() => resolveConfig({ logging: { timezone: "Not/AZone" } })).toThrow(MemosError); try { diff --git a/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts new file mode 100644 index 000000000..233951231 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { loadConfig, resolveConfig } from "../../../core/config/index.js"; +import { SECRET_FIELD_PATHS } from "../../../core/config/defaults.js"; +import { makeTmpHome } from "../../helpers/tmp-home.js"; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("resolveConfig secret env fallback", () => { + it("expands allowlisted ${ENV_VAR} references in secret fields", () => { + process.env.MY_LLM_API_KEY = "sk-env-expanded"; + const cfg = resolveConfig({ llm: { apiKey: "${MY_LLM_API_KEY}" } }); + expect(cfg.llm.apiKey).toBe("sk-env-expanded"); + }); + + it("resolves the __memos_secret__ mask sentinel from env", () => { + process.env.OPENCODE_GO_API_KEY = "sk-mask-resolved"; + const cfg = resolveConfig({ + llm: { + provider: "openai_compatible", + endpoint: "https://opencode.ai/zen/go/v1", + apiKey: "__memos_secret__", + }, + }); + expect(cfg.llm.apiKey).toBe("sk-mask-resolved"); + }); + + it("resolves empty string secret fields from env", () => { + process.env.OPENCODE_ZEN_API_KEY = "sk-empty-resolved"; + const cfg = resolveConfig({ + llm: { + provider: "openai_compatible", + endpoint: "https://opencode.ai/zen/v1", + apiKey: "", + }, + }); + expect(cfg.llm.apiKey).toBe("sk-empty-resolved"); + }); + + it("does not send an OpenCode key to a different LLM provider", () => { + delete process.env.LLM_API_KEY; + process.env.OPENCODE_GO_API_KEY = "sk-opencode-only"; + process.env.OPENCODE_ZEN_API_KEY = "sk-opencode-zen-only"; + + const cfg = resolveConfig({ + llm: { + provider: "anthropic", + endpoint: "https://api.anthropic.com", + apiKey: "__memos_secret__", + }, + }); + + expect(cfg.llm.apiKey).toBe("__memos_secret__"); + }); + + it("keeps OpenCode Go and Zen endpoint keys isolated", () => { + delete process.env.LLM_API_KEY; + process.env.OPENCODE_GO_API_KEY = "sk-go-only"; + delete process.env.OPENCODE_ZEN_API_KEY; + + const zenConfig = resolveConfig({ + llm: { + provider: "openai_compatible", + endpoint: "https://opencode.ai/zen/v1", + apiKey: "__memos_secret__", + }, + }); + expect(zenConfig.llm.apiKey).toBe("__memos_secret__"); + + delete process.env.OPENCODE_GO_API_KEY; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen-only"; + const goConfig = resolveConfig({ + llm: { + provider: "openai_compatible", + endpoint: "https://opencode.ai/zen/go/v1", + apiKey: "__memos_secret__", + }, + }); + expect(goConfig.llm.apiKey).toBe("__memos_secret__"); + }); + + it("does not warn for intentionally empty optional secrets", () => { + delete process.env.EMBEDDING_API_KEY; + delete process.env.LLM_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_ZEN_API_KEY; + delete process.env.HUB_TEAM_TOKEN; + const warnings: string[] = []; + + resolveConfig( + { + embedding: { provider: "local", apiKey: "" }, + llm: { provider: "host", apiKey: "" }, + hub: { enabled: false, teamToken: "" }, + }, + warnings, + ); + + expect(warnings).toEqual([]); + }); + + it("restores a masked disk secret when config is loaded again", async () => { + process.env.LLM_API_KEY = "sk-restart-restored"; + const ctx = await makeTmpHome({ + agent: "hermes", + configYaml: [ + "version: 1", + "llm:", + " provider: openai_compatible", + " endpoint: https://example.com/v1", + " apiKey: __memos_secret__", + ].join("\n"), + }); + + try { + const restarted = await loadConfig(ctx.home, "hermes"); + expect(restarted.fromDisk).toBe(true); + expect(restarted.config.llm.apiKey).toBe("sk-restart-restored"); + } finally { + await ctx.cleanup(); + } + }); + + it("uses per-path env conventions — every secret path resolves from its own env var", () => { + // Every path has its own environment variable and never silently borrows + // a provider-specific key intended for another config slot. + process.env.LLM_API_KEY = "sk-llm"; + process.env.EMBEDDING_API_KEY = "sk-embed"; + process.env.L3_LLM_API_KEY = "sk-l3"; + process.env.SKILL_EVOLVER_API_KEY = "sk-skill"; + process.env.HUB_TEAM_TOKEN = "sk-team"; + process.env.HUB_USER_TOKEN = "sk-user"; + const raw: Record = {}; + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor = raw; + for (let i = 0; i < keys.length - 1; i++) { + cursor[keys[i]!] = cursor[keys[i]!] ?? {}; + cursor = cursor[keys[i]!] as Record; + } + cursor[keys[keys.length - 1]!] = "__memos_secret__"; + } + const cfg = resolveConfig(raw); + const expected: Record = { + "embedding.apiKey": "sk-embed", + "llm.apiKey": "sk-llm", + "l3Llm.apiKey": "sk-l3", + "skillEvolver.apiKey": "sk-skill", + "hub.teamToken": "sk-team", + "hub.userToken": "sk-user", + }; + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: unknown = cfg; + for (const k of keys) { + cursor = (cursor as Record)[k]; + } + expect(cursor).toBe(expected[dotted]); + } + }); + + it("resolves masked hub tokens from HUB_TEAM_TOKEN / HUB_USER_TOKEN", () => { + // Regression: previously the mask/empty path only ran when `leaf === + // 'apiKey'`, so hub.teamToken / hub.userToken masked by + // maskSecrets() were silently left unresolved and hub auth failed + // exactly like the LLM auth bug in #2245. + process.env.HUB_TEAM_TOKEN = "sk-team-mask"; + process.env.HUB_USER_TOKEN = "sk-user-empty"; + const cfg = resolveConfig({ + hub: { teamToken: "__memos_secret__", userToken: "" }, + }); + expect(cfg.hub.teamToken).toBe("sk-team-mask"); + expect(cfg.hub.userToken).toBe("sk-user-empty"); + }); + + it("does not fall back to OPENCODE_GO/ZEN for l3Llm.apiKey", () => { + // Per-component overrides must not silently inherit the primary + // provider's key: l3-llm and skill-evolver are frequently pointed + // at a different provider than the shared llm settings. + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen"; + delete process.env.L3_LLM_API_KEY; + const cfg = resolveConfig({ l3Llm: { apiKey: "__memos_secret__" } }); + expect(cfg.l3Llm.apiKey).toBe("__memos_secret__"); + }); + + it("does not fall back to OPENCODE_GO/ZEN for skillEvolver.apiKey", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen"; + delete process.env.SKILL_EVOLVER_API_KEY; + const cfg = resolveConfig({ skillEvolver: { apiKey: "__memos_secret__" } }); + expect(cfg.skillEvolver.apiKey).toBe("__memos_secret__"); + }); + + it("warns when an explicit ${VAR} reference cannot be resolved", () => { + // Without a warning the user sees auth failures with no actionable + // hint; the whole point of the read-side resolver is to make config + // → env misconfiguration debuggable. + delete process.env.MISSING_LLM_API_KEY; + const warnings: string[] = []; + const cfg = resolveConfig({ llm: { apiKey: "${MISSING_LLM_API_KEY}" } }, warnings); + expect(cfg.llm.apiKey).toBe("${MISSING_LLM_API_KEY}"); + expect(warnings.some((w) => w.includes("MISSING_LLM_API_KEY") && w.includes("not set"))).toBe( + true, + ); + }); + + it("warns when a masked apiKey has no backing env var", () => { + delete process.env.LLM_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_ZEN_API_KEY; + const warnings: string[] = []; + const cfg = resolveConfig({ llm: { apiKey: "__memos_secret__" } }, warnings); + expect(cfg.llm.apiKey).toBe("__memos_secret__"); + expect(warnings.some((w) => w.includes("llm.apiKey") && w.includes("not set"))).toBe(true); + }); + + it("resolves hub tokens via explicit ${VAR} references", () => { + process.env.HUB_TEAM_TOKEN = "sk-hub-token"; + const cfg = resolveConfig({ hub: { teamToken: "${HUB_TEAM_TOKEN}" } }); + expect(cfg.hub.teamToken).toBe("sk-hub-token"); + }); + + it("does not fall back to generic keys when an explicit ${VAR} is unset", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen"; + const cfg = resolveConfig({ llm: { apiKey: "${MY_LLM_API_KEY}" } }); + expect(cfg.llm.apiKey).toBe("${MY_LLM_API_KEY}"); + }); + + it("warns and skips expansion for non-allowlisted ${VAR} names", () => { + process.env.HOME = "/home/test"; + const warnings: string[] = []; + const cfg = resolveConfig({ llm: { apiKey: "${HOME}" } }, warnings); + expect(cfg.llm.apiKey).toBe("${HOME}"); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("not allowlisted"); + }); + + it("leaves real (non-placeholder) values untouched", () => { + const cfg = resolveConfig({ llm: { apiKey: "sk-real-value" } }); + expect(cfg.llm.apiKey).toBe("sk-real-value"); + }); + + it("leaves placeholders untouched when no env var is set", () => { + delete process.env.LLM_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_ZEN_API_KEY; + const cfg = resolveConfig({ llm: { apiKey: "__memos_secret__" } }); + expect(cfg.llm.apiKey).toBe("__memos_secret__"); + }); + + it("never mutates the caller's raw config object", () => { + process.env.LLM_API_KEY = "sk-llm"; + const raw = { llm: { apiKey: "__memos_secret__" } }; + const cfg = resolveConfig(raw); + expect(cfg.llm.apiKey).toBe("sk-llm"); + expect(raw.llm.apiKey).toBe("__memos_secret__"); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/install/install-gateway-recovery.test.ts b/apps/memos-local-plugin/tests/unit/install/install-gateway-recovery.test.ts new file mode 100644 index 000000000..37dfd651d --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/install/install-gateway-recovery.test.ts @@ -0,0 +1,225 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); +const INSTALLER = path.join(REPO_ROOT, "install.sh"); + +interface InstallerFixture { + root: string; + home: string; + bin: string; + temp: string; + gatewayLog: string; + env: NodeJS.ProcessEnv; +} + +function writeExecutable(file: string, body: string): void { + writeFileSync(file, `#!/usr/bin/env bash\nset -u\n${body}\n`, "utf8"); + chmodSync(file, 0o755); +} + +function createFixture(): InstallerFixture { + const root = mkdtempSync(path.join(tmpdir(), "memos-installer-recovery-")); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const temp = path.join(root, "tmp"); + const gatewayLog = path.join(root, "gateway.log"); + mkdirSync(path.join(home, ".openclaw"), { recursive: true }); + mkdirSync(bin); + mkdirSync(temp); + + writeExecutable( + path.join(bin, "node"), + `if [[ "\${1:-}" == "-v" ]]; then + printf 'v22.0.0\\n' +elif [[ "\${1:-}" == "-p" ]]; then + printf '1.0.0\\n' +fi +exit 0`, + ); + writeExecutable( + path.join(bin, "npm"), + `if [[ "\${1:-}" == "install" ]]; then + mkdir -p node_modules/better-sqlite3 +fi +exit 0`, + ); + writeExecutable( + path.join(bin, "openclaw"), + `printf '%s\\n' "$*" >> "\${FAKE_GATEWAY_LOG:?}" +if [[ "$*" == "gateway start" ]]; then + if [[ "\${FAKE_GATEWAY_START_EXIT:-0}" != "0" ]]; then + printf 'fake gateway start failure\\n' >&2 + fi + exit "\${FAKE_GATEWAY_START_EXIT:-0}" +fi +exit 0`, + ); + writeExecutable(path.join(bin, "sleep"), "exit 0"); + writeExecutable(path.join(bin, "lsof"), "exit 1"); + writeExecutable(path.join(bin, "curl"), 'exit "${FAKE_CURL_EXIT:-0}"'); + + return { + root, + home, + bin, + temp, + gatewayLog, + env: { + ...process.env, + HOME: home, + TMPDIR: temp, + PATH: `${bin}:${process.env.PATH ?? ""}`, + FAKE_GATEWAY_LOG: gatewayLog, + }, + }; +} + +function runInstaller( + fixture: InstallerFixture, + version: string, + extraEnv: NodeJS.ProcessEnv = {}, +) { + return spawnSync( + "bash", + [INSTALLER, "--agent", "openclaw", "--version", version], + { + cwd: fixture.root, + encoding: "utf8", + timeout: 30_000, + env: { ...fixture.env, ...extraEnv }, + }, + ); +} + +function gatewayCalls(fixture: InstallerFixture): string[] { + if (!existsSync(fixture.gatewayLog)) return []; + return readFileSync(fixture.gatewayLog, "utf8") + .trim() + .split("\n") + .filter(Boolean); +} + +function expectTemporaryDirectoriesCleaned(fixture: InstallerFixture): void { + expect(readdirSync(fixture.temp)).toEqual([]); +} + +function createValidPackage(fixture: InstallerFixture): string { + const packageRoot = path.join(fixture.root, "package"); + const runtimeDir = path.join(packageRoot, "dist", "adapters", "openclaw"); + const tarball = path.join(fixture.root, "plugin.tgz"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync( + path.join(packageRoot, "package.json"), + '{"name":"test-plugin","version":"1.0.0"}\n', + "utf8", + ); + writeFileSync(path.join(runtimeDir, "index.js"), "export {};\n", "utf8"); + const result = spawnSync( + "tar", + ["-czf", tarball, "-C", fixture.root, "package"], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + return tarball; +} + +describe.skipIf(process.platform === "win32")( + "unified installer gateway recovery", + () => { + it("restarts the gateway when package extraction fails after it was stopped", () => { + const fixture = createFixture(); + try { + const brokenTarball = path.join(fixture.root, "broken.tgz"); + writeFileSync(brokenTarball, "not a tarball", "utf8"); + + const result = runInstaller(fixture, brokenTarball); + + expect(result.status).toBe(1); + expect(gatewayCalls(fixture)).toEqual([ + "gateway stop", + "gateway start", + ]); + expectTemporaryDirectoriesCleaned(fixture); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("reports a failed recovery start without masking the install failure", () => { + const fixture = createFixture(); + try { + const brokenTarball = path.join(fixture.root, "broken.tgz"); + writeFileSync(brokenTarball, "not a tarball", "utf8"); + + const result = runInstaller(fixture, brokenTarball, { + FAKE_GATEWAY_START_EXIT: "17", + }); + + expect(result.status).toBe(1); + expect(gatewayCalls(fixture)).toEqual([ + "gateway stop", + "gateway start", + ]); + expect(result.stderr).toContain("OpenClaw gateway recovery failed"); + expect(result.stderr).toContain("fake gateway start failure"); + expectTemporaryDirectoriesCleaned(fixture); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("does not retry the normal final gateway start from exit cleanup", () => { + const fixture = createFixture(); + try { + const tarball = createValidPackage(fixture); + + const result = runInstaller(fixture, tarball, { + FAKE_CURL_EXIT: "1", + FAKE_GATEWAY_START_EXIT: "17", + }); + + expect(result.status).not.toBe(0); + expect(gatewayCalls(fixture)).toEqual([ + "gateway stop", + "gateway start", + ]); + expect(result.stderr).toContain("openclaw gateway start failed"); + expectTemporaryDirectoriesCleaned(fixture); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("disarms recovery after the normal gateway start succeeds", () => { + const fixture = createFixture(); + try { + const tarball = createValidPackage(fixture); + + const result = runInstaller(fixture, tarball); + + expect(result.status).toBe(0); + expect(gatewayCalls(fixture)).toEqual([ + "gateway stop", + "gateway start", + ]); + expectTemporaryDirectoriesCleaned(fixture); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } + }); + }, +); diff --git a/apps/memos-local-plugin/tests/unit/install/install-ps1.test.ts b/apps/memos-local-plugin/tests/unit/install/install-ps1.test.ts index 303754579..166a5b8a4 100644 --- a/apps/memos-local-plugin/tests/unit/install/install-ps1.test.ts +++ b/apps/memos-local-plugin/tests/unit/install/install-ps1.test.ts @@ -14,6 +14,59 @@ function extractOpenClawConfigPatch(script: string): string { return match[1]; } +function extractFunction(script: string, name: string, nextName: string): string { + const start = script.indexOf(`function ${name}`); + const end = script.indexOf(`function ${nextName}`, start); + if (start < 0 || end < 0) throw new Error(`${name} function not found`); + return script.slice(start, end); +} + +describe("install.ps1 — gateway recovery", () => { + it("restarts OpenClaw from finally when installation fails after stop", () => { + const script = readFileSync(SCRIPT, "utf8"); + const installOpenClaw = extractFunction( + script, + "Install-OpenClaw", + "Install-Hermes", + ); + + expect(installOpenClaw).toMatch( + /Invoke-OpenClawGatewayChecked\s+-Action\s+"stop"[\s\S]*\$GatewayRecoveryState\s*=\s*"needs_recovery"/, + ); + expect(installOpenClaw).toMatch( + /finally\s*\{[\s\S]*\$GatewayRecoveryState\s+-eq\s+"needs_recovery"[\s\S]*Invoke-OpenClawGatewayChecked\s+-Action\s+"start"/, + ); + }); + + it("checks native gateway failures and does not retry a failed final start", () => { + const script = readFileSync(SCRIPT, "utf8"); + const installOpenClaw = extractFunction( + script, + "Install-OpenClaw", + "Install-Hermes", + ); + const invokeGateway = extractFunction( + script, + "Invoke-OpenClawGatewayChecked", + "Test-BetterSqlite3", + ); + + expect(invokeGateway).toContain("$ExitCode = $LASTEXITCODE"); + expect(invokeGateway).toMatch(/if \(\$ExitCode -ne 0\)[\s\S]*throw/); + expect(installOpenClaw).toMatch( + /catch\s*\{[\s\S]*\$GatewayRecoveryState\s*=\s*"final_failed"[\s\S]*throw/, + ); + }); + + it("cleans the installer staging directory from a top-level finally block", () => { + const script = readFileSync(SCRIPT, "utf8"); + + expect(script).toMatch( + /\}\s*finally\s*\{\s*if \(\$StageDir -and \(Test-Path \$StageDir\)\) \{\s*Remove-Item[^\n]*\$StageDir[\s\S]*\}\s*\}\s*$/, + ); + }); +}); + describe("install.ps1 — OpenClaw config patch", () => { it("preserves existing hook settings and enables conversation access", () => { const script = readFileSync(SCRIPT, "utf8"); diff --git a/apps/memos-local-plugin/tests/unit/logger/signal-ownership.test.ts b/apps/memos-local-plugin/tests/unit/logger/signal-ownership.test.ts index 9e34440c4..c611e6b24 100644 --- a/apps/memos-local-plugin/tests/unit/logger/signal-ownership.test.ts +++ b/apps/memos-local-plugin/tests/unit/logger/signal-ownership.test.ts @@ -17,7 +17,10 @@ describe("process signal ownership", () => { const source = readFileSync(resolve(entry), "utf8"); expect(source).toMatch(/process\.on\("SIGINT"/); expect(source).toMatch(/process\.on\("SIGTERM"/); - expect(source).toMatch(/await (?:withShutdownTimeout\()?core\.shutdown\(\)/); + expect(source).toContain("const SHUTDOWN_TIMEOUT_MS = 20_000"); + expect(source).toContain("function withShutdownTimeout(p: Promise): Promise"); + expect(source).not.toMatch(/(? cfg.model === "l3-model")).toMatchObject({ providerIgnore: ["novita"], providerOrder: ["openai"], openRouter: true, reasoning: { enabled: true, maxTokens: 4_000 }, + maxTokens: 8_192, + headers: { "X-L3": "l3-header" }, }); }); 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 b4c095079..2759ae5bd 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 @@ -171,6 +171,22 @@ describe("MemoryCore façade", () => { expect(h.llm.available).toBe(false); }); + it("reads the latest trace timestamp only once per health snapshot", async () => { + const latestTimestamp = vi.spyOn(db!.repos.traces, "latestTimestamp"); + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test-1.0.0", + ); + await core.init(); + latestTimestamp.mockClear(); + + await core.health(); + + expect(latestTimestamp).toHaveBeenCalledTimes(1); + }); + it("reloads the hub runtime when hub config changes without a process restart", async () => { const home = await makeTmpHome({ agent: "openclaw", @@ -1531,6 +1547,44 @@ describe("MemoryCore façade", () => { name: "local skill", }); }); + + it("gives a manually reactivated skill a fresh idle-archive grace period", async () => { + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + seedCoreSkill("skill-reactivate", "reactivated skill"); + db!.repos.skills.setStatus("skill-reactivate" as SkillId, "archived", 1); + const before = Date.now(); + + await expect(core.reactivateSkill("skill-reactivate" as SkillId)).resolves.toMatchObject({ + status: "active", + }); + expect(db!.repos.skills.getById("skill-reactivate" as SkillId)?.lastUsedAt).toBeGreaterThanOrEqual( + before, + ); + }); + + it("archives idle low-eta skills while the pipeline stays running", async () => { + seedCoreSkill("skill-idle-running", "idle running skill"); + const stale = db!.repos.skills.getById("skill-idle-running" as SkillId)!; + db!.repos.skills.upsert({ + ...stale, + eta: 0.05, + createdAt: 1 as SkillRow["createdAt"], + updatedAt: 1 as SkillRow["updatedAt"], + lastUsedAt: null, + }); + + pipeline = createPipeline(buildDeps(db!)); + await new Promise((resolve) => setImmediate(resolve)); + + expect(db!.repos.skills.getById("skill-idle-running" as SkillId)?.status).toBe("archived"); + }); }); describe("bootstrapMemoryCore", () => { @@ -2642,6 +2696,59 @@ algorithm: await expect(fastCore.shutdown()).resolves.toBeUndefined(); }); + it("cancels stalled startup recovery before shutting down the pipeline (#2252)", async () => { + db!.repos.sessions.upsert({ + id: "se_stalled_recovery", + agent: "openclaw", + ownerAgentKind: "openclaw", + ownerProfileId: "main", + ownerWorkspaceId: null, + startedAt: 1_700_000_000_000, + lastSeenAt: 1_700_000_000_000, + meta: {}, + }); + db!.repos.episodes.insert({ + id: "ep_stalled_recovery", + sessionId: "se_stalled_recovery", + ownerAgentKind: "openclaw", + ownerProfileId: "main", + ownerWorkspaceId: null, + startedAt: 1_700_000_000_000, + endedAt: null, + traceIds: [], + rTask: null, + status: "open", + meta: {}, + }); + + pipeline = createPipeline(buildDeps(db!)); + const originalShutdown = pipeline.shutdown.bind(pipeline); + pipeline.flush = vi.fn(() => new Promise(() => {})); + const shutdownSpy = vi.fn( + async ( + reason?: string, + options?: Parameters[1], + ) => originalShutdown(reason, { ...options, abortWaitMs: 0 }), + ); + pipeline.shutdown = shutdownSpy; + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "issue2252-stalled-recovery", + { startupRecoveryShutdownGraceMs: 10 }, + ); + + await core.init(); + await expect(core.shutdown()).resolves.toBeUndefined(); + expect(shutdownSpy).toHaveBeenCalledWith( + "memory-core.shutdown", + { flushGraceMs: 0 }, + ); + + core = null; + pipeline = null; + }); + it("does not rescore a closed episode whose only mismatch is a ghost trace ID (#1966)", async () => { // Regression guard for https://github.com/MemTensor/MemOS/issues/1966. // A dangling ID in trace_ids_json must not make reward coverage look dirty diff --git a/apps/memos-local-plugin/tests/unit/skill/_helpers.ts b/apps/memos-local-plugin/tests/unit/skill/_helpers.ts index 549a9f477..9cef40506 100644 --- a/apps/memos-local-plugin/tests/unit/skill/_helpers.ts +++ b/apps/memos-local-plugin/tests/unit/skill/_helpers.ts @@ -40,6 +40,7 @@ export function makeSkillConfig(partial: Partial = {}): SkillConfig etaDelta: 0.1, archiveEta: 0.1, minEtaForRetrieval: 0.1, + idleArchiveMs: 30 * 24 * 60 * 60 * 1000, ...partial, }; } @@ -145,7 +146,9 @@ export interface SeedSkillArgs { trialsPassed?: number; sourcePolicyIds?: readonly PolicyId[]; invocationGuide?: string; + createdAt?: EpochMs; updatedAt?: EpochMs; + lastUsedAt?: EpochMs | null; vec?: EmbeddingVector | null; } @@ -165,8 +168,9 @@ export function seedSkill(handle: TmpDbHandle, args: SeedSkillArgs = {}): SkillR sourceWorldModelIds: [], evidenceAnchors: [], vec: args.vec ?? vec([1, 0, 0]), - createdAt: (args.updatedAt ?? NOW) as SkillRow["createdAt"], + createdAt: (args.createdAt ?? args.updatedAt ?? NOW) as SkillRow["createdAt"], updatedAt: (args.updatedAt ?? NOW) as SkillRow["updatedAt"], + lastUsedAt: args.lastUsedAt ?? null, version: 1, }; handle.repos.skills.upsert(row); diff --git a/apps/memos-local-plugin/tests/unit/skill/crystallize-validator.test.ts b/apps/memos-local-plugin/tests/unit/skill/crystallize-validator.test.ts new file mode 100644 index 000000000..54a2d1739 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/skill/crystallize-validator.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { defaultDraftValidator } from "../../../core/skill/crystallize.js"; +import { makeDraft } from "./_helpers.js"; + +describe("defaultDraftValidator", () => { + it("passes a complete draft through unchanged", () => { + const draft = makeDraft(); + expect(() => defaultDraftValidator(draft)).not.toThrow(); + expect(draft.summary).toBe("Ensure system libs exist before pip install on alpine."); + expect(draft.steps).toHaveLength(3); + }); + + it("never throws for a missing summary (issue #2143)", () => { + const draft = makeDraft({ summary: "" }); + expect(() => defaultDraftValidator(draft)).not.toThrow(); + }); + + it("auto-generates summary from the first step body when omitted", () => { + const draft = makeDraft({ summary: "" }); + defaultDraftValidator(draft); + expect(draft.summary).toBe("inspect the pip error for missing .so names"); + }); + + it("falls back through step title, displayTitle, then name for the summary", () => { + const draft = makeDraft({ summary: "", steps: [] }); + expect(() => defaultDraftValidator(draft)).toThrow(/missing steps/); + expect(draft.summary).toBe("Alpine pip install with system deps"); + }); + + it("caps the auto-generated summary at 200 chars", () => { + const longBody = "x".repeat(500); + const draft = makeDraft({ + summary: "", + steps: [{ title: "t", body: longBody }], + }); + defaultDraftValidator(draft); + expect(draft.summary).toBe("x".repeat(200)); + }); + + it("uses || not ?? — an empty-string summary still triggers the fallback", () => { + const draft = makeDraft({ summary: "", steps: [] }); + expect(() => defaultDraftValidator(draft)).toThrow(/missing steps/); + expect(draft.summary).not.toBe(""); + }); + + it("rejects missing steps instead of inventing a generic procedure", () => { + const draft = makeDraft({ steps: [] }); + expect(() => defaultDraftValidator(draft)).toThrow(/missing steps/); + expect(draft.steps).toEqual([]); + }); + + it("still rejects a draft with no name", () => { + const draft = makeDraft({ name: "" }); + expect(() => defaultDraftValidator(draft)).toThrow(/missing name/); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts b/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts index a2e0e63eb..3c6ec0d32 100644 --- a/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts +++ b/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts @@ -5,6 +5,7 @@ import { defaultDraftValidator, } from "../../../core/skill/crystallize.js"; import { rootLogger } from "../../../core/logger/index.js"; +import type { Logger } from "../../../core/logger/types.js"; import type { LlmClient, LlmJsonCompletion } from "../../../core/llm/types.js"; import type { PolicyRow, TraceRow } from "../../../core/types.js"; import { fakeLlm, throwingLlm } from "../../helpers/fake-llm.js"; @@ -59,6 +60,22 @@ function mkTrace(id: string, userText: string): TraceRow { const log = rootLogger.child({ channel: "core.skill.crystallize" }); +function loggerRecordingWarnings( + warnings: Array<{ message: string; data?: Record }>, +): Logger { + return new Proxy(log, { + get(target, prop, receiver) { + if (prop === "warn") { + return (message: string, data?: Record) => { + warnings.push({ message, data }); + }; + } + const value = Reflect.get(target, prop, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + function refusalLlm(raw: string): LlmClient { return { ...fakeLlm(), @@ -156,7 +173,7 @@ describe("skill/crystallize", () => { const r = await crystallizeDraft( { policy, evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, - { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, + { llm, log, config: makeSkillConfig() }, ); expect(r.ok).toBe(true); @@ -230,7 +247,10 @@ describe("skill/crystallize", () => { expect(r.modelRefusal?.content).toContain("I cannot process this request"); }); - it("rejects drafts that the validator flags as invalid", async () => { + it("repairs missing summary and steps from grounded policy fields (issue #2143)", async () => { + // A draft with an empty summary AND no steps used to be rejected with + // skill.crystallize.invalid: missing summary / missing steps. The runtime + // now repairs both from grounded draft/policy fields before validation. const llm = fakeLlm({ completeJson: { "skill.crystallize": makeDraft({ steps: [], summary: "" }) as unknown, @@ -240,6 +260,151 @@ describe("skill/crystallize", () => { { policy: mkPolicy(), evidence: [mkTrace("tr_1", "x")], namingSpace: [] }, { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.draft.summary).not.toBe(""); + expect(r.draft.steps).toEqual([ + { + title: "install system libs before pip", + body: "1. detect 2. apk add 3. retry", + }, + ]); + expect(r.draft.steps[0]!.title).not.toBe("Execute the fix"); + } + }); + + it("normalises only explicit summary and step aliases", async () => { + const llm = fakeLlm({ + completeJson: { + "skill.crystallize": { + ...makeDraft(), + summary: " ", + description: "Install Alpine dependencies before retrying pip.", + steps: [ + "Inspect the pip error", + { + title: " ", + name: "Install packages", + body: " ", + instruction: "Run apk add for the missing libraries", + }, + ], + }, + }, + }); + + const r = await crystallizeDraft( + { policy: mkPolicy(), evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, + ); + + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.draft.summary).toBe("Install Alpine dependencies before retrying pip."); + expect(r.draft.steps).toEqual([ + { title: "Inspect the pip error", body: "Inspect the pip error" }, + { title: "Install packages", body: "Run apk add for the missing libraries" }, + ]); + }); + + it("retries once when the draft has no steps and policy has no grounded procedure", async () => { + let calls = 0; + const llm = fakeLlm({ + completeJson: { + "skill.crystallize": () => { + calls += 1; + return calls === 1 ? makeDraft({ steps: [] }) : makeDraft(); + }, + }, + }); + const policy = { ...mkPolicy(), procedure: "" }; + + const r = await crystallizeDraft( + { policy, evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { llm, log, config: makeSkillConfig() }, + ); + + expect(r.ok).toBe(true); + expect(calls).toBe(2); + }); + + it("retries once when the parsed JSON root has the wrong shape", async () => { + let calls = 0; + const llm = fakeLlm({ + completeJson: { + "skill.crystallize": () => { + calls += 1; + return calls === 1 ? "not-an-object" : makeDraft(); + }, + }, + }); + + const r = await crystallizeDraft( + { policy: mkPolicy(), evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, + ); + + expect(r.ok).toBe(true); + expect(calls).toBe(2); + }); + + it("rejects after one retry when neither response nor policy has procedure steps", async () => { + let calls = 0; + const llm = fakeLlm({ + completeJson: { + "skill.crystallize": () => { + calls += 1; + return makeDraft({ steps: [] }); + }, + }, + }); + const policy = { ...mkPolicy(), procedure: "" }; + + const r = await crystallizeDraft( + { policy, evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, + ); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.skippedReason).toMatch(/missing steps/); + expect(calls).toBe(2); + }); + + it("logs only shape metadata when repairing a malformed draft", async () => { + const warnings: Array<{ message: string; data?: Record }> = []; + const sensitive = "SENSITIVE-CONTENT-MUST-NOT-BE-LOGGED"; + const llm = fakeLlm({ + completeJson: { + "skill.crystallize": { + ...makeDraft({ steps: [] }), + unexpected: sensitive, + }, + }, + }); + + const r = await crystallizeDraft( + { policy: mkPolicy(), evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { + llm, + log: loggerRecordingWarnings(warnings), + config: makeSkillConfig(), + validate: defaultDraftValidator, + }, + ); + + expect(r.ok).toBe(true); + const shapeLog = warnings.find((entry) => entry.message === "skill.crystallize.shape_repaired"); + expect(shapeLog?.data).toMatchObject({ + repairedFields: ["steps"], + shape: { + summaryType: "string", + stepsType: "array", + rawStepCount: 0, + normalisedStepCount: 0, + unknownFieldCount: 1, + }, + }); + expect(JSON.stringify(shapeLog)).not.toContain(sensitive); }); }); diff --git a/apps/memos-local-plugin/tests/unit/skill/lifecycle-worker.test.ts b/apps/memos-local-plugin/tests/unit/skill/lifecycle-worker.test.ts new file mode 100644 index 000000000..08f6fd26b --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/skill/lifecycle-worker.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createSkillLifecycleWorker } from "../../../core/skill/lifecycle-worker.js"; +import { rootLogger } from "../../../core/logger/index.js"; + +describe("skill/lifecycle-worker", () => { + it("runs immediately, stays single-flight, and continues on its interval", async () => { + vi.useFakeTimers(); + try { + let releaseFirst!: () => void; + const firstRun = new Promise((resolve) => { + releaseFirst = resolve; + }); + const runLifecycle = vi + .fn<() => Promise>() + .mockReturnValueOnce(firstRun) + .mockResolvedValue(undefined); + const worker = createSkillLifecycleWorker({ + runLifecycle, + log: rootLogger.child({ channel: "test.skill.lifecycle-worker" }), + intervalMs: 1_000, + }); + + worker.start(); + await vi.advanceTimersByTimeAsync(2_000); + expect(runLifecycle).toHaveBeenCalledTimes(1); + + releaseFirst(); + await worker.flush(); + await vi.advanceTimersByTimeAsync(1_000); + expect(runLifecycle).toHaveBeenCalledTimes(2); + worker.stop(); + } finally { + vi.useRealTimers(); + } + }); + + it("logs scheduled failures and retries on the next interval", async () => { + vi.useFakeTimers(); + try { + const log = rootLogger.child({ channel: "test.skill.lifecycle-worker" }); + const warn = vi.spyOn(log, "warn").mockImplementation(() => undefined); + const runLifecycle = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("scan failed")) + .mockResolvedValue(undefined); + const worker = createSkillLifecycleWorker({ + runLifecycle, + log, + intervalMs: 1_000, + }); + + worker.start(); + await vi.advanceTimersByTimeAsync(0); + expect(warn).toHaveBeenCalledWith("skill.lifecycle_worker.failed", { + err: "scan failed", + }); + + await vi.advanceTimersByTimeAsync(1_000); + expect(runLifecycle).toHaveBeenCalledTimes(2); + worker.stop(); + warn.mockRestore(); + } finally { + vi.useRealTimers(); + } + }); + + it("stops scheduled runs while allowing an explicit final run", async () => { + vi.useFakeTimers(); + try { + const runLifecycle = vi.fn<() => Promise>().mockResolvedValue(undefined); + const worker = createSkillLifecycleWorker({ + runLifecycle, + log: rootLogger.child({ channel: "test.skill.lifecycle-worker" }), + intervalMs: 1_000, + }); + + worker.start(); + await vi.advanceTimersByTimeAsync(0); + worker.stop(); + await vi.advanceTimersByTimeAsync(5_000); + expect(runLifecycle).toHaveBeenCalledTimes(1); + + await worker.runNow(); + expect(runLifecycle).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts b/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts index 86101908e..946cfb663 100644 --- a/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts +++ b/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts @@ -22,6 +22,7 @@ function mkSkill(partial: Partial = {}): SkillRow { vec: null, createdAt: partial.createdAt ?? NOW, updatedAt: partial.updatedAt ?? NOW, + lastUsedAt: partial.lastUsedAt ?? null, version: partial.version ?? 1, }; } @@ -104,9 +105,51 @@ describe("skill/lifecycle", () => { expect(recomputeEta(s, policy, cfg)).toBeCloseTo(0.7, 5); }); - it("shouldArchiveIdle picks up stale active skills with low η", () => { + it("archives a low-η active skill after its last use exceeds idleArchiveMs", () => { + const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6, idleArchiveMs: 1_000 }); + const s = mkSkill({ + status: "active", + eta: 0.4, + lastUsedAt: 1_000 as SkillRow["lastUsedAt"], + }); + expect(shouldArchiveIdle(s, 1_000, cfg, 10_000)).toBe(true); + }); + + it("uses createdAt as the idle baseline for a skill that has never been used", () => { + const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6, idleArchiveMs: 1_000 }); + const s = mkSkill({ + status: "active", + eta: 0.4, + createdAt: 1_000 as SkillRow["createdAt"], + updatedAt: 9_500 as SkillRow["updatedAt"], + lastUsedAt: null, + }); + expect(shouldArchiveIdle(s, 1_000, cfg, 10_000)).toBe(true); + }); + + it("keeps recently used or retrievable active skills", () => { + const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6, idleArchiveMs: 1_000 }); + const recent = mkSkill({ + status: "active", + eta: 0.4, + lastUsedAt: 9_500 as SkillRow["lastUsedAt"], + }); + const retrievable = mkSkill({ + status: "active", + eta: 0.6, + lastUsedAt: 1_000 as SkillRow["lastUsedAt"], + }); + expect(shouldArchiveIdle(recent, 1_000, cfg, 10_000)).toBe(false); + expect(shouldArchiveIdle(retrievable, 1_000, cfg, 10_000)).toBe(false); + }); + + it("archives exactly at the configured idle boundary", () => { const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6 }); - const s = mkSkill({ status: "active", eta: 0.4, updatedAt: 0 as SkillRow["updatedAt"] }); - expect(shouldArchiveIdle(s, 1000, cfg, 10_000)).toBe(true); + const skill = mkSkill({ + status: "active", + eta: 0.4, + lastUsedAt: 9_000 as SkillRow["lastUsedAt"], + }); + expect(shouldArchiveIdle(skill, 1_000, cfg, 10_000)).toBe(true); }); }); diff --git a/apps/memos-local-plugin/tests/unit/skill/skill.integration.test.ts b/apps/memos-local-plugin/tests/unit/skill/skill.integration.test.ts index d2280fdc9..476134abc 100644 --- a/apps/memos-local-plugin/tests/unit/skill/skill.integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/skill/skill.integration.test.ts @@ -107,6 +107,28 @@ describe("skill/runSkill (integration)", () => { expect(all[0]!.sourcePolicyIds).toContain(policyId); }); + it("persists grounded policy steps when the LLM omits its steps", async () => { + const h = open(); + const { policyId } = seedFullCandidate(h); + const policy = h.repos.policies.getById(policyId)!; + const { deps } = makeDeps(h, { + llm: fakeLlm({ + completeJson: { + "skill.crystallize": makeDraft({ steps: [] }), + }, + }), + }); + + const r = await runSkill({ trigger: "manual", policyId }, deps); + + expect(r.crystallized).toBe(1); + const stored = h.repos.skills.list()[0]!; + expect(stored.procedureJson?.steps).toEqual([ + { title: policy.title, body: policy.procedure }, + ]); + expect(stored.invocationGuide).not.toContain("Execute the fix"); + }); + it("rebuilds an existing skill when the policy has drifted", async () => { const h = open(); const { policyId } = seedFullCandidate(h); diff --git a/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts b/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts index dbda7394c..a8c35468a 100644 --- a/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts +++ b/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts @@ -16,6 +16,7 @@ import { makeSkillConfig, seedPolicy, seedSessionOnly, + seedSkill, seedTrace, } from "./_helpers.js"; @@ -154,4 +155,146 @@ describe("skill/subscriber", () => { expect(r.crystallized).toBe(1); sub.dispose(); }); + + it("archives each stale low-η active skill once without regressing candidate promotion", async () => { + handle = makeTmpDb(); + const h = handle; + const l2Bus = createL2EventBus(); + const rewardBus = createRewardEventBus(); + const bus = createSkillEventBus(); + const events: Array<{ + skillId: string; + previous: string; + next: string; + transition: string; + }> = []; + bus.on("skill.status.changed", (event) => { + if (event.kind !== "skill.status.changed") return; + events.push({ + skillId: event.skillId, + previous: event.previous, + next: event.next, + transition: event.transition, + }); + }); + + const stale = seedSkill(h, { + id: "sk_stale" as never, + name: "stale_skill", + status: "active", + eta: 0.05, + createdAt: 1 as never, + updatedAt: 9_000 as never, + lastUsedAt: 1_000 as never, + }); + const candidate = seedSkill(h, { + id: "sk_candidate" as never, + name: "candidate_skill", + status: "candidate", + eta: 0.7, + createdAt: 1 as never, + updatedAt: 1 as never, + }); + + const sub = attachSkillSubscriber({ + l2Bus, + rewardBus, + bus, + repos: h.repos, + embedder: null, + llm: null, + log: rootLogger.child({ channel: "core.skill.subscriber" }), + config: makeSkillConfig({ minEtaForRetrieval: 0.1, idleArchiveMs: 1_000 }), + }); + + await sub.lifecycleTick(); + await sub.lifecycleTick(); + + expect(h.repos.skills.getById(stale.id)?.status).toBe("archived"); + expect(h.repos.skills.getById(candidate.id)?.status).toBe("active"); + expect(events.filter((event) => event.skillId === stale.id)).toEqual([ + { skillId: stale.id, previous: "active", next: "archived", transition: "archived" }, + ]); + expect(events.filter((event) => event.skillId === candidate.id)).toHaveLength(1); + sub.dispose(); + }); + + it("drains more than one 500-skill idle archive batch in one lifecycle tick", async () => { + handle = makeTmpDb(); + const h = handle; + for (let i = 0; i < 501; i++) { + seedSkill(h, { + id: `sk_stale_${i}` as never, + name: `stale_skill_${i}`, + status: "active", + eta: 0.05, + createdAt: 1 as never, + updatedAt: (i + 1) as never, + lastUsedAt: 1 as never, + }); + } + const sub = attachSkillSubscriber({ + l2Bus: createL2EventBus(), + rewardBus: createRewardEventBus(), + bus: createSkillEventBus(), + repos: h.repos, + embedder: null, + llm: null, + log: rootLogger.child({ channel: "core.skill.subscriber" }), + config: makeSkillConfig({ minEtaForRetrieval: 0.1, idleArchiveMs: 1_000 }), + }); + + await sub.lifecycleTick(); + + expect(h.repos.skills.count({ status: "archived" })).toBe(501); + expect(h.repos.skills.count({ status: "active" })).toBe(0); + sub.dispose(); + }); + + it("caps idle archival at ten batches per lifecycle tick", async () => { + handle = makeTmpDb(); + const h = handle; + for (let i = 0; i < 5_001; i++) { + seedSkill(h, { + id: `sk_backlog_${i}` as never, + name: `backlog_skill_${i}`, + status: "active", + eta: 0.05, + createdAt: 1 as never, + updatedAt: (i + 1) as never, + lastUsedAt: 1 as never, + }); + } + const log = rootLogger.child({ channel: "core.skill.subscriber" }); + const infoSpy = vi.spyOn(log, "info").mockImplementation(() => undefined); + const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined); + const sub = attachSkillSubscriber({ + l2Bus: createL2EventBus(), + rewardBus: createRewardEventBus(), + bus: createSkillEventBus(), + repos: h.repos, + embedder: null, + llm: null, + log, + config: makeSkillConfig({ minEtaForRetrieval: 0.1, idleArchiveMs: 1_000 }), + }); + + await sub.lifecycleTick(); + + expect(h.repos.skills.count({ status: "archived" })).toBe(5_000); + expect(h.repos.skills.count({ status: "active" })).toBe(1); + expect(warnSpy).toHaveBeenCalledWith("skill.idle_archive_batch_limit_reached", { + batchCount: 10, + archivedCount: 5_000, + batchSize: 500, + }); + + await sub.lifecycleTick(); + + expect(h.repos.skills.count({ status: "archived" })).toBe(5_001); + expect(h.repos.skills.count({ status: "active" })).toBe(0); + sub.dispose(); + infoSpy.mockRestore(); + warnSpy.mockRestore(); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts b/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts index 94441a273..31df85ebf 100644 --- a/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts +++ b/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts @@ -15,6 +15,14 @@ function initBody(): string { return source.slice(start, end); } +function shutdownBody(): string { + const start = source.indexOf(" async function shutdown(): Promise {"); + expect(start, "shutdown() function should be present").toBeGreaterThanOrEqual(0); + const end = source.indexOf("\n async function health", start + 1); + expect(end, "shutdown() should be followed by health()").toBeGreaterThan(start); + return source.slice(start, end); +} + function stripBackgroundRecoveryCallback(body: string): string { return body.replace( /startupRecoveryPromise = \(async \(\) => \{[\s\S]*?\n\s*\}\)\(\);/g, @@ -32,4 +40,12 @@ describe("memory-core startup recovery", () => { expect(body).toContain("startupRecoveryPromise = (async () => {"); expect(body).not.toContain("await startupRecoveryPromise"); }); + + it("cancels and reports startup recovery that exceeds the shutdown grace", () => { + const body = shutdownBody(); + + expect(body).toContain('log.warn("startup_recovery.shutdown_timeout"'); + expect(body).toContain("startupRecoveryCancelled = true"); + expect(body).toContain("flushGraceMs: 0"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts b/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts index c0e0eb215..933f59977 100644 --- a/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts @@ -205,4 +205,131 @@ describe("storage/migrator", () => { db.close(); } }); + + it("018-traces-ts-index creates the bare-ts index without rewriting historical migrations", () => { + // Regression test for the Aug 2026 restart storm: `latestTraceTs()` runs + // an unfiltered newest-first trace read several times per /api/v1/health + // request. No existing index leads with bare `ts`, so every call was a + // full scan + temp B-tree sort; on a large traces table that blocked the + // synchronous better-sqlite3 event loop long enough that health probes + // timed out and a liveness watchdog restart-looped the daemon forever. + // + // Published and development release trains have already used versions + // 13-17 for unrelated migrations. The new index must therefore use 018 + // and preserve those historical bookkeeping rows verbatim. + const { dbPath, cleanup } = tmpDb(); + cleanups.push(cleanup); + const db = openDb({ filepath: dbPath, agent: "openclaw" }); + try { + // Simulate a database previously migrated by the other release trains. + db.exec(` + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at INTEGER NOT NULL + ) STRICT; + `); + db.exec( + `INSERT INTO schema_migrations (version, name, applied_at) VALUES + (13, 'skill-repair-origin', 1), + (14, 'episode-outcome', 2), + (15, 'policy-merge-family', 3), + (16, 'episode-policy-injections', 4), + (17, 'evolution-jobs', 5)`, + ); + + const result = runMigrations(db); + expect(result.applied).toContainEqual(expect.objectContaining({ + version: 18, + name: "traces-ts-index", + })); + + // The index exists... + const index = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='index' AND name='idx_traces_ts'`, + ) + .get(); + expect(index?.name).toBe("idx_traces_ts"); + + // ...and the plan for newest-first reads uses it instead of a table + // scan plus a temporary sort. + const detail = db + .prepare( + `SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_traces_ts'`, + ) + .get(); + expect(detail?.sql).toContain("ts DESC"); + const plan = db + .prepare( + `EXPLAIN QUERY PLAN SELECT ts FROM traces ORDER BY ts DESC, id DESC LIMIT 1`, + ) + .all() + .map((row) => row.detail) + .join("\n"); + expect(plan).toContain("USING COVERING INDEX idx_traces_ts"); + expect(plan).not.toContain("USE TEMP B-TREE"); + + const historicalRows = db + .prepare( + `SELECT version, name, applied_at FROM schema_migrations + WHERE version BETWEEN 13 AND 17 ORDER BY version`, + ) + .all(); + expect(historicalRows).toEqual([ + { version: 13, name: "skill-repair-origin", applied_at: 1 }, + { version: 14, name: "episode-outcome", applied_at: 2 }, + { version: 15, name: "policy-merge-family", applied_at: 3 }, + { version: 16, name: "episode-policy-injections", applied_at: 4 }, + { version: 17, name: "evolution-jobs", applied_at: 5 }, + ]); + + // Re-running is idempotent: everything counts as skipped. + const again = runMigrations(db); + expect(again.applied).toHaveLength(0); + expect(again.skipped).toBe(again.total); + } finally { + db.close(); + } + }); + + it("keeps skipping a version recorded under a foreign name when that migration is not repairable", () => { + // Conservative path: only migrations in the repairable allowlist may run + // under a version/name collision. Everything else keeps the historical + // behaviour -- the foreign record wins and the file is skipped untouched. + const { dbPath, cleanup } = tmpDb(); + cleanups.push(cleanup); + const db = openDb({ filepath: dbPath, agent: "openclaw" }); + try { + db.exec(` + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at INTEGER NOT NULL + ) STRICT; + `); + db.exec( + `INSERT INTO schema_migrations (version, name, applied_at) VALUES (11, 'their-hub-sharing-renamed', 1)`, + ); + + const result = runMigrations(db); + expect(result.applied.map((m) => m.version)).not.toContain(11); + // The hub-sharing objects were NOT created because 011 was skipped... + const hubTable = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='hub_shared_skills'`, + ) + .get(); + expect(hubTable).toBeUndefined(); + // ...and the foreign bookkeeping row is preserved verbatim. + const row = db + .prepare( + `SELECT name FROM schema_migrations WHERE version = 11`, + ) + .get(); + expect(row?.name).toBe("their-hub-sharing-renamed"); + } finally { + db.close(); + } + }); }); diff --git a/apps/memos-local-plugin/tests/unit/storage/repos.test.ts b/apps/memos-local-plugin/tests/unit/storage/repos.test.ts index 3c859fc19..ea1904901 100644 --- a/apps/memos-local-plugin/tests/unit/storage/repos.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/repos.test.ts @@ -144,6 +144,7 @@ describe("storage/repos — happy paths", () => { const all = repos.traces.list({ sessionId: "s" }); expect(all.length).toBe(3); expect(all[0]!.ts).toBeGreaterThan(all[1]!.ts); // newest first by default + expect(repos.traces.latestTimestamp()).toBe(30); const highAbs = repos.traces.list({ minAbsValue: 0.8 }); expect(highAbs.map((t) => t.id)).toEqual(["t0"]); @@ -314,6 +315,88 @@ describe("storage/repos — happy paths", () => { } }); + it("skills: selects idle archive candidates and excludes a skill after recorded use", () => { + const { db, repos, cleanup } = makeTmpDb(); + try { + const insertSkill = ( + id: string, + status: "active" | "archived", + eta: number, + createdAt: number, + lastUsedAt: number | null, + ) => { + repos.skills.insert({ + id, + name: id, + status, + invocationGuide: "fixture", + procedureJson: null, + eta, + support: 1, + gain: 0, + trialsAttempted: 0, + trialsPassed: 0, + sourcePolicyIds: [], + sourceWorldModelIds: [], + evidenceAnchors: [], + vec: null, + createdAt, + updatedAt: 10_000, + lastUsedAt, + version: 1, + }); + }; + insertSkill("never_used", "active", 0.05, 50, null); + insertSkill("old_used", "active", 0.05, 1, 100); + insertSkill("recent", "active", 0.05, 1, 9_500); + insertSkill("retrievable", "active", 0.1, 1, 100); + insertSkill("already_archived", "archived", 0.05, 1, 100); + + const archived = repos.skills.archiveNextIdleBatch({ + minEtaForRetrieval: 0.1, + cutoff: 9_000, + updatedAt: 10_000, + limit: 500, + }); + expect(archived.map((skill) => skill.id).sort()).toEqual(["never_used", "old_used"]); + expect(repos.skills.getById("never_used")?.status).toBe("archived"); + expect(repos.skills.getById("old_used")?.status).toBe("archived"); + + repos.skills.setStatus("old_used" as never, "active", 10_100); + expect(repos.skills.recordUse("old_used", 9_500)).toBe(true); + expect(repos.skills.getById("old_used")?.lastUsedAt).toBe(9_500); + expect(repos.skills.archiveNextIdleBatch({ + minEtaForRetrieval: 0.1, + cutoff: 9_000, + updatedAt: 10_200, + limit: 500, + })).toEqual([]); + expect(repos.skills.getById("old_used")?.status).toBe("active"); + + insertSkill("rollback_first", "active", 0.05, 1, 100); + insertSkill("rollback_fail", "active", 0.05, 1, 100); + db.exec(` + CREATE TRIGGER reject_idle_archive + BEFORE UPDATE OF status ON skills + WHEN OLD.id = 'rollback_fail' AND NEW.status = 'archived' + BEGIN + SELECT RAISE(ABORT, 'forced archive failure'); + END`); + expect(() => + repos.skills.archiveNextIdleBatch({ + minEtaForRetrieval: 0.1, + cutoff: 9_000, + updatedAt: 10_300, + limit: 500, + }), + ).toThrow(/forced archive failure/); + expect(repos.skills.getById("rollback_first")?.status).toBe("active"); + expect(repos.skills.getById("rollback_fail")?.status).toBe("active"); + } finally { + cleanup(); + } + }); + it("feedback: insert, scoped list, polarity filter", () => { const { repos, cleanup } = makeTmpDb(); try {