/** * memory-tdai v3: Four-layer memory system plugin for OpenClaw. * * Provides: * - L0: Automatic conversation recording (local JSONL) * - L1: Structured memory extraction (LLM + dedup) * - L2: Scene block management (LLM scene extraction) * - L3: Persona generation (LLM persona synthesis) * * All processing is local, zero external API dependencies. */ import path from "node:path"; import { createRequire } from "node:module"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { parseConfig } from "./src/config.js"; import type { MemoryTdaiConfig } from "./src/config.js"; import { performAutoRecall } from "./src/hooks/auto-recall.js"; import { performAutoCapture } from "./src/hooks/auto-capture.js"; import { MemoryPipelineManager } from "./src/utils/pipeline-manager.js"; import { CheckpointManager } from "./src/utils/checkpoint.js"; import { prewarmEmbeddedAgent, setPreferredEmbeddedAgentRuntime, } from "./src/utils/clean-context-runner.js"; import { SessionFilter } from "./src/utils/session-filter.js"; import type { IMemoryStore } from "./src/store/types.js"; import type { EmbeddingService } from "./src/store/embedding.js"; import { executeMemorySearch, formatSearchResponse } from "./src/tools/memory-search.js"; import { executeConversationSearch, formatConversationSearchResponse } from "./src/tools/conversation-search.js"; import { LocalMemoryCleaner } from "./src/utils/memory-cleaner.js"; import { registerMemoryTdaiCli } from "./src/cli/index.js"; import { initDataDirectories, initStores, resetStores, createPipelineManager, createL1Runner, createPersister, createL2Runner, createL3Runner, } from "./src/utils/pipeline-factory.js"; import { getOrCreateInstanceId, initReporter, report, resetReporter } from "./src/report/reporter.js"; import { ensureL2L3Local } from "./src/profile/profile-sync.js"; const TAG = "[memory-tdai]"; /** * Epoch ms when the plugin was registered (cold-start timestamp). * Used as a fallback cursor in performAutoCapture when no checkpoint * exists yet — prevents the first agent_end from dumping the entire * session history into L0. */ let pluginStartTimestamp = 0; /** * Cache original user prompts and message counts across hooks. * - text: clean user prompt before prependContext injection * - ts: cache creation time (for TTL sweep) * - messageCount: session message count at before_prompt_build time, * used as fallback slice offset if timestamp cursor is unreliable */ const pendingOriginalPrompts = new Map(); const PROMPT_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes const PROMPT_CACHE_MAX_SIZE = 10_000; // Hard limit to prevent unbounded growth in high-concurrency scenarios /** * Cache recall results (L1 memories + L3 Persona) from before_prompt_build * for retrieval at agent_end, enabling the agent_turn metric event. * * Keyed by sessionKey — same correlation pattern as pendingOriginalPrompts. */ const pendingRecallCache = new Map; l3Persona: string | null; strategy: string; durationMs: number; ts: number; }>(); /** * Cache recall completion timestamps per session. * Used in agent_end to estimate LLM reasoning time: * llmEstimatedMs ≈ agent_end_start - recall_end_ts * Entries are cleaned up in agent_end after use; stale entries swept alongside prompt cache. */ const pendingRecallEndTimestamps = new Map(); // 进程级单例,避免同一进程重复启动清理器导致并发清理竞态 let sharedMemoryCleaner: LocalMemoryCleaner | undefined; /** * Sweep both pendingOriginalPrompts and pendingRecallCache for stale entries. * Unified from the original sweepStalePromptCache() to cover both Maps * with identical TTL + hard-cap logic. */ function sweepStaleCaches(): void { const now = Date.now(); // Clean pendingOriginalPrompts for (const [key, entry] of pendingOriginalPrompts) { if (now - entry.ts > PROMPT_CACHE_TTL_MS) { pendingOriginalPrompts.delete(key); pendingRecallEndTimestamps.delete(key); } } // Clean pendingRecallCache for (const [key, entry] of pendingRecallCache) { if (now - entry.ts > PROMPT_CACHE_TTL_MS) { pendingRecallCache.delete(key); } } // Hard limit: evict oldest entries if either Map exceeds cap if (pendingOriginalPrompts.size > PROMPT_CACHE_MAX_SIZE) { const entries = [...pendingOriginalPrompts.entries()].sort((a, b) => a[1].ts - b[1].ts); const toEvict = entries.slice(0, entries.length - PROMPT_CACHE_MAX_SIZE); for (const [key] of toEvict) { pendingOriginalPrompts.delete(key); pendingRecallEndTimestamps.delete(key); } } if (pendingRecallCache.size > PROMPT_CACHE_MAX_SIZE) { const entries = [...pendingRecallCache.entries()].sort((a, b) => a[1].ts - b[1].ts); const toEvict = entries.slice(0, entries.length - PROMPT_CACHE_MAX_SIZE); for (const [key] of toEvict) { pendingRecallCache.delete(key); } } } export default function register(api: OpenClawPluginApi) { pluginStartTimestamp = Date.now(); setPreferredEmbeddedAgentRuntime(api.runtime.agent); // Reset reporter singleton so config changes take effect on hot-reload. resetReporter(); const _require = createRequire(import.meta.url); const pluginVersion = (() => { try { return (_require("./package.json") as { version?: string }).version ?? "unknown"; } catch { return "unknown"; } })(); api.logger.debug?.( `${TAG} Registering plugin ... ` + `startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`, ); let cfg: MemoryTdaiConfig; try { cfg = parseConfig(api.pluginConfig as Record | undefined); api.logger.debug?.( `${TAG} Config parsed: ` + `capture=${cfg.capture.enabled}, ` + `recall=${cfg.recall.enabled}(maxResults=${cfg.recall.maxResults}), ` + `extraction=${cfg.extraction.enabled}(dedup=${cfg.extraction.enableDedup}, maxMem=${cfg.extraction.maxMemoriesPerSession}), ` + `pipeline=(everyN=${cfg.pipeline.everyNConversations}, warmup=${cfg.pipeline.enableWarmup}, l1Idle=${cfg.pipeline.l1IdleTimeoutSeconds}s, l2DelayAfterL1=${cfg.pipeline.l2DelayAfterL1Seconds}s, l2Min=${cfg.pipeline.l2MinIntervalSeconds}s, l2Max=${cfg.pipeline.l2MaxIntervalSeconds}s, activeWindow=${cfg.pipeline.sessionActiveWindowHours}h), ` + `persona(triggerEvery=${cfg.persona.triggerEveryN}, backupCount=${cfg.persona.backupCount}, sceneBackupCount=${cfg.persona.sceneBackupCount}), ` + `memoryCleanup(enabled=${cfg.memoryCleanup.enabled}, retentionDays=${cfg.memoryCleanup.retentionDays ?? "(disabled)"}, cleanTime=${cfg.memoryCleanup.cleanTime})`, ); } catch (err) { api.logger.error(`${TAG} Config parsing failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } // If remote embedding config is incomplete, log a prominent error so the user knows if (cfg.embedding.configError) { api.logger.error(`${TAG} [EMBEDDING CONFIG ERROR] ${cfg.embedding.configError}`); } // Resolve plugin data directory via runtime API (avoid importing internal paths directly) const pluginDataDir = path.join(api.runtime.state.resolveStateDir(), "memory-tdai"); initDataDirectories(pluginDataDir); api.logger.debug?.(`${TAG} Data dir: ${pluginDataDir} (all subdirectories initialized)`); // Kick off instanceId resolution immediately after data dir is ready. // getOrCreateInstanceId only reads/writes a small UUID file and caches the // result — starting it here means it will almost certainly be settled before // the first L1 runner fires, avoiding the need to defer metric reporting. let instanceId: string | undefined; getOrCreateInstanceId(pluginDataDir).then((id) => { instanceId = id; // initReporter is guarded by a "already initialised" check, so calling it // here is safe even if the registration-complete call below fires first. initReporter({ enabled: cfg.report.enabled, type: cfg.report.type, logger: api.logger, instanceId: id, pluginVersion }); }).catch((err) => { api.logger.warn(`${TAG} Failed to initialize instanceId for metrics: ${err instanceof Error ? err.message : String(err)}`); }); // Unified session/agent filter: combines internal-session detection + user-configured excludeAgents const sessionFilter = new SessionFilter(cfg.capture.excludeAgents); if (cfg.capture.excludeAgents.length > 0) { api.logger.debug?.(`${TAG} Agent exclude patterns: ${cfg.capture.excludeAgents.join(", ")}`); } // Daily local JSONL cleaner (L0/L1), enabled only when retentionDays is configured. let memoryCleaner: LocalMemoryCleaner | undefined; if (cfg.memoryCleanup.enabled && cfg.memoryCleanup.retentionDays != null) { if (!sharedMemoryCleaner) { sharedMemoryCleaner = new LocalMemoryCleaner({ baseDir: pluginDataDir, retentionDays: cfg.memoryCleanup.retentionDays, cleanTime: cfg.memoryCleanup.cleanTime, logger: api.logger, }); sharedMemoryCleaner.start(); api.logger.debug?.(`${TAG} Memory cleaner started (singleton)`); } else { api.logger.debug?.(`${TAG} Memory cleaner already started in this process, reusing existing instance`); } memoryCleaner = sharedMemoryCleaner; } else { api.logger.debug?.(`${TAG} Memory cleaner disabled (retentionDays not configured)`); } // Hardcoded actor ID (legacy, to be removed) const ACTOR_ID = "default_user"; const resolveSessionKey = (sessionKey?: string): string | undefined => { if (sessionKey) return sessionKey; api.logger.warn(`${TAG} sessionKey is empty, skipping capture/recall to avoid unstable fallback key`); return undefined; }; // ============================ // Tool registration // ============================ // Shared references for tools (populated when extraction scheduler creates them) let sharedVectorStore: IMemoryStore | undefined; let sharedEmbeddingService: EmbeddingService | undefined; /** * Whether the local embedding service warmup has been triggered at least once. * Tracked separately from schedulerStarted because warmup should also * be triggered from before_prompt_build (recall), not only agent_end. */ let embeddingWarmupTriggered = false; /** * Trigger local embedding model warmup (download + load) on first use. * Safe to call multiple times — delegates idempotency to startWarmup() itself. * * IMPORTANT: If a previous warmup attempt FAILED (e.g. model download * network error), this will re-trigger startWarmup() so the service can * retry. startWarmup() internally checks its state machine: * - "ready" / "initializing" → no-op (already done or in progress) * - "idle" / "failed" → starts a new initialization attempt * * This avoids triggering model download during short-lived CLI commands * like `gateway stop` or `agents list` (warmup is still deferred until * the first real conversation). */ const ensureEmbeddingWarmup = (): void => { if (!sharedEmbeddingService) return; if (!embeddingWarmupTriggered) { embeddingWarmupTriggered = true; api.logger.debug?.(`${TAG} Triggering lazy embedding warmup on first conversation`); sharedEmbeddingService.startWarmup(); return; } // After first trigger: re-invoke startWarmup() only if the service // is not yet ready (covers the "failed" → retry path). // startWarmup() is idempotent for "ready" and "initializing" states. if (!sharedEmbeddingService.isReady()) { api.logger.debug?.(`${TAG} Embedding not ready, re-triggering warmup (retry)`); sharedEmbeddingService.startWarmup(); } }; // tdai_memory_search — Agent-callable L1 memory search tool // TODO: implement hard per-turn call limit via before_tool_call hook + execute early-return (方案 D) api.registerTool( { name: "tdai_memory_search", label: "Memory Search", description: "Search through the user's long-term memories. Use this when you need to recall specific information about the user's preferences, past events, instructions, or context from previous conversations. Returns relevant memory records ranked by relevance. " + "Limit: tdai_memory_search and tdai_conversation_search share a combined limit of 3 calls per turn. Stop searching after 3 total attempts.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query describing what you want to recall about the user", }, limit: { type: "number", description: "Maximum number of results to return (default: 5, max: 20)", }, type: { type: "string", enum: ["persona", "episodic", "instruction"], description: "Optional filter by memory type: persona (identity/preferences), episodic (events/activities), instruction (user rules/commands)", }, scene: { type: "string", description: "Optional filter by scene name", }, }, required: ["query"], }, async execute(_toolCallId: string, params: Record) { const startMs = Date.now(); const query = String(params.query ?? ""); const limit = Math.min(Math.max(Number(params.limit) || 5, 1), 20); const typeFilter = typeof params.type === "string" ? params.type : undefined; const sceneFilter = typeof params.scene === "string" ? params.scene : undefined; api.logger.debug?.( `${TAG} [tool] tdai_memory_search called: ` + `query="${query.length > 80 ? query.slice(0, 80) + "…" : query}", ` + `limit=${limit}, type=${typeFilter ?? "(all)"}, scene=${sceneFilter ?? "(all)"}`, ); try { const result = await executeMemorySearch({ query, limit, type: typeFilter, scene: sceneFilter, vectorStore: sharedVectorStore, embeddingService: sharedEmbeddingService, logger: api.logger, }); const elapsedMs = Date.now() - startMs; const responseText = formatSearchResponse(result); api.logger.debug?.( `${TAG} [tool] tdai_memory_search completed (${elapsedMs}ms): ` + `total=${result.total}, strategy=${result.strategy}, ` + `responseLength=${responseText.length} chars`, ); report("tool_call", { tool: "tdai_memory_search", query, limit, typeFilter, sceneFilter, resultCount: result.total, strategy: result.strategy, results: result.results, durationMs: elapsedMs, success: true, }); return { content: [{ type: "text" as const, text: responseText }], details: { count: result.total, strategy: result.strategy }, }; } catch (err) { const elapsedMs = Date.now() - startMs; const errMsg = err instanceof Error ? err.message : String(err); api.logger.error(`${TAG} [tool] tdai_memory_search failed (${elapsedMs}ms): ${errMsg}`); report("tool_call", { tool: "tdai_memory_search", query, limit, typeFilter, sceneFilter, durationMs: elapsedMs, success: false, error: errMsg, }); return { content: [{ type: "text" as const, text: `Memory search failed: ${errMsg}` }], details: { error: errMsg }, }; } }, }, { name: "tdai_memory_search" }, ); // tdai_conversation_search — Agent-callable L0 conversation search tool // TODO: implement hard per-turn call limit via before_tool_call hook + execute early-return (方案 D) api.registerTool( { name: "tdai_conversation_search", label: "Conversation Search", description: "Search through past conversation history (raw dialogue records). " + "Use this when tdai_memory_search (structured memories) doesn't have the information you need, " + "or when you want to find specific past conversations, dialogue context, or exact words " + "the user said before. Returns relevant individual messages ranked by relevance. " + "Limit: tdai_memory_search and tdai_conversation_search share a combined limit of 3 calls per turn. Stop searching after 3 total attempts.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query describing what conversation content you want to find", }, limit: { type: "number", description: "Maximum number of messages to return (default: 5, max: 20)", }, session_key: { type: "string", description: "Optional: filter results to a specific session", }, }, required: ["query"], }, async execute(_toolCallId: string, params: Record) { const startMs = Date.now(); const query = String(params.query ?? ""); const limit = Math.min(Math.max(Number(params.limit) || 5, 1), 20); const sessionKeyFilter = typeof params.session_key === "string" ? params.session_key : undefined; api.logger.debug?.( `${TAG} [tool] tdai_conversation_search called: ` + `query="${query.length > 80 ? query.slice(0, 80) + "…" : query}", ` + `limit=${limit}, session_key=${sessionKeyFilter ?? "(all)"}`, ); try { const result = await executeConversationSearch({ query, limit, sessionKey: sessionKeyFilter, vectorStore: sharedVectorStore, embeddingService: sharedEmbeddingService, logger: api.logger, }); const elapsedMs = Date.now() - startMs; const responseText = formatConversationSearchResponse(result); api.logger.debug?.( `${TAG} [tool] tdai_conversation_search completed (${elapsedMs}ms): ` + `total=${result.total}, responseLength=${responseText.length} chars`, ); report("tool_call", { tool: "tdai_conversation_search", query, limit, sessionKeyFilter, resultCount: result.total, strategy: result.strategy, results: result.results, durationMs: elapsedMs, success: true, }); return { content: [{ type: "text" as const, text: responseText }], details: { count: result.total }, }; } catch (err) { const elapsedMs = Date.now() - startMs; const errMsg = err instanceof Error ? err.message : String(err); api.logger.error(`${TAG} [tool] tdai_conversation_search failed (${elapsedMs}ms): ${errMsg}`); report("tool_call", { tool: "tdai_conversation_search", query, limit, sessionKeyFilter, durationMs: elapsedMs, success: false, error: errMsg, }); return { content: [{ type: "text" as const, text: `Conversation search failed: ${errMsg}` }], details: { error: errMsg }, }; } }, }, { name: "tdai_conversation_search" }, ); // ============================ // Lifecycle hooks // ============================ // Before prompt build: auto-recall relevant memories // (migrated from legacy before_agent_start to before_prompt_build so that // event.messages is guaranteed to be available — session is already loaded) if (cfg.recall.enabled) { api.logger.debug?.(`${TAG} Registering before_prompt_build hook (auto-recall)`); api.on("before_prompt_build", async (event, ctx) => { const startMs = Date.now(); api.logger.debug?.(`${TAG} [before_prompt_build] Hook triggered`); const sessionKey = ctx.sessionKey; if (sessionFilter.shouldSkipCtx(ctx)) { api.logger.debug?.(`${TAG} [before_prompt_build] Skipping filtered session`); return; } // Trigger embedding warmup on first real conversation (lazy init). // This is the earliest point where a real user message arrives, // so we start the model download here rather than in register() // to avoid triggering it during short-lived CLI commands. ensureEmbeddingWarmup(); // Cache original user prompt for agent_end const rawPrompt = event.prompt; const messages = Array.isArray(event.messages) ? event.messages : undefined; if (sessionKey && rawPrompt) { const messageCount = messages?.length ?? 0; pendingOriginalPrompts.set(sessionKey, { text: rawPrompt, ts: Date.now(), messageCount }); api.logger.debug?.(`${TAG} [before_prompt_build] Cached original prompt (${rawPrompt.length} chars, msgCount=${messageCount})`); } sweepStaleCaches(); const userText = rawPrompt; api.logger.debug?.(`${TAG} [before_prompt_build] userText length: ${userText?.length}`); if (!userText) { api.logger.debug?.(`${TAG} [before_prompt_build] No user text found, skipping recall`); return; } const resolvedSessionKey = resolveSessionKey(sessionKey); if (!resolvedSessionKey) { return; } try { const recallStartMs = Date.now(); const result = await performAutoRecall({ userText, actorId: ACTOR_ID, sessionKey: resolvedSessionKey, cfg, pluginDataDir, logger: api.logger, vectorStore: sharedVectorStore, embeddingService: sharedEmbeddingService, }); const elapsedMs = Date.now() - startMs; const recallDurationMs = Date.now() - recallStartMs; // Cache recall results for agent_turn metric (retrieved at agent_end) if (sessionKey && result) { pendingRecallCache.set(sessionKey, { l1Memories: result.recalledL1Memories ?? [], l3Persona: result.recalledL3Persona ?? null, strategy: result.recallStrategy ?? "unknown", durationMs: recallDurationMs, ts: Date.now(), }); } // Record recall completion timestamp for LLM timing estimation in agent_end if (resolvedSessionKey) { pendingRecallEndTimestamps.set(resolvedSessionKey, Date.now()); } if (result?.appendSystemContext) { api.logger.info( `${TAG} [before_prompt_build] Recall complete (${elapsedMs}ms), ` + `appendSystemContext=${result.appendSystemContext.length} chars`, ); } else { api.logger.info(`${TAG} [before_prompt_build] Recall complete (${elapsedMs}ms), no context to inject`); } return result; } catch (err) { const elapsedMs = Date.now() - startMs; api.logger.error(`${TAG} [before_prompt_build] Auto-recall failed after ${elapsedMs}ms: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); // ── error_degradation metric ── if (instanceId) { report("error_degradation", { module: "auto-recall", action: "performAutoRecall", errorType: "exception", errorMessage: err instanceof Error ? err.message : String(err), degradedTo: "no_recall", impact: "non-blocking", }); } } }); } // After agent end: auto-capture + L0 record + L1/L2/L3 schedule if (cfg.capture.enabled) { // ============================ // Create the MemoryPipelineManager (L1→L2→L3 architecture) // ============================ let scheduler: MemoryPipelineManager | undefined; // ============================ // Lazy scheduler startup (Solution C): // Defer scheduler.start() until the first agent_end event. This way, // short-lived CLI management commands (agents add/list/delete, etc.) // never start the scheduler, never recover pending sessions, and // therefore never trigger the L1→L2→L3 flush chain on destroy(). // ============================ let schedulerStarted = false; /** * Lazily start the scheduler on first conversation. * Reads checkpoint, restores session states, and pre-warms the * embedded agent. Subsequent calls are no-ops. * No-op when scheduler is undefined (extraction disabled). */ const ensureSchedulerStarted = async (): Promise => { if (schedulerStarted || !scheduler) return; schedulerStarted = true; // Propagate instanceId to scheduler for pipeline metrics if (instanceId) { scheduler.instanceId = instanceId; } // Trigger embedding warmup alongside scheduler start — both are // deferred until the first real conversation to avoid downloading // models during short-lived CLI commands. ensureEmbeddingWarmup(); try { const initCheckpoint = new CheckpointManager(pluginDataDir, api.logger); const cp = await initCheckpoint.read(); scheduler.start(initCheckpoint.getAllPipelineStates(cp)); api.logger.info( `${TAG} Scheduler lazy-started on first agent_end ` + `(everyN=${cfg.pipeline.everyNConversations}, ` + `l1Idle=${cfg.pipeline.l1IdleTimeoutSeconds}s, ` + `l2DelayAfterL1=${cfg.pipeline.l2DelayAfterL1Seconds}s, ` + `l2MinInterval=${cfg.pipeline.l2MinIntervalSeconds}s, ` + `l2MaxInterval=${cfg.pipeline.l2MaxIntervalSeconds}s, ` + `sessionActiveWindow=${cfg.pipeline.sessionActiveWindowHours}h)`, ); } catch (err) { api.logger.error( `${TAG} Failed to restore checkpoint for scheduler: ${err instanceof Error ? err.message : String(err)}`, ); // Start with empty state as fallback scheduler.start({}); } // Pre-warm the embedded agent entrypoint. When runtime already exposes // runEmbeddedPiAgent this becomes a no-op; otherwise it still preloads // the legacy dist bridge to reduce first-run cold start. prewarmEmbeddedAgent(api.logger, api.runtime.agent); }; if (cfg.extraction.enabled) { // === Store + scheduler initialization (async, runs eagerly) === // Wrapped in an async IIFE because register() is synchronous. // initStores() is once-async: the first call creates the store, // subsequent calls (e.g. from seed CLI) reuse the cached result. let vectorStore: IMemoryStore | undefined; let embeddingService: EmbeddingService | undefined; const storeReady = (async () => { const stores = await initStores(cfg, pluginDataDir, api.logger); vectorStore = stores.vectorStore; embeddingService = stores.embeddingService; // Share with tools immediately sharedVectorStore = vectorStore; sharedEmbeddingService = embeddingService; // Keep cleaner's SQLite handle updated (singleton cleaner may start earlier). memoryCleaner?.setVectorStore(vectorStore); if (vectorStore?.pullProfiles) { try { await ensureL2L3Local(pluginDataDir, vectorStore, api.logger); } catch (err) { api.logger.warn(`${TAG} Startup L2/L3 pull failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`); } } // If embedding provider/model/dimensions changed, re-embed all existing texts if (stores.needsReindex && embeddingService && vectorStore) { const svc = embeddingService; const vs = vectorStore; api.logger.info( `${TAG} Embedding config changed (${stores.reindexReason}). ` + `Starting background re-embed of all stored texts...`, ); vs.reindexAll( (text) => svc.embed(text), (done, total, layer) => { if (done === total || done % 50 === 0) { api.logger.debug?.(`${TAG} Re-embed progress: ${layer} ${done}/${total}`); } }, ).then(({ l1Count, l0Count }) => { api.logger.info( `${TAG} Re-embed complete: L1=${l1Count} records, L0=${l0Count} messages`, ); }).catch((err) => { api.logger.error( `${TAG} Re-embed failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`, ); }); } })(); // === Create pipeline manager (sync — does not need store) === scheduler = createPipelineManager(cfg, api.logger, sessionFilter); // Wire runners after store is ready storeReady.then(() => { // L1 runner via shared factory scheduler!.setL1Runner(createL1Runner({ pluginDataDir, cfg, openclawConfig: api.config, vectorStore, embeddingService, logger: api.logger, getInstanceId: () => instanceId, })); // Persister via shared factory scheduler!.setPersister(createPersister(pluginDataDir, api.logger)); // L2 runner: read L1 records (incremental) → SceneExtractor scheduler!.setL2Runner(async (sessionKey: string, cursor?: string) => { try { const l2Runner = createL2Runner({ pluginDataDir, cfg, openclawConfig: api.config, vectorStore, logger: api.logger, instanceId, }); return await l2Runner(sessionKey, cursor); } catch (err) { api.logger.error(`${TAG} [pipeline-l2] L2 failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); throw err; } }); // L3 runner: persona trigger + generation scheduler!.setL3Runner(async () => { try { const l3Runner = createL3Runner({ pluginDataDir, cfg, openclawConfig: api.config, vectorStore, logger: api.logger, instanceId, }); await l3Runner(); } catch (err) { api.logger.error(`${TAG} [pipeline-l3] Failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); } }); }).catch((err) => { api.logger.error( `${TAG} Store init failed; vector/FTS recall and dedup will be unavailable: ${err instanceof Error ? err.message : String(err)}`, ); }); // Register a SINGLE gateway_stop hook for ordered shutdown. // Order: memoryCleaner → scheduler → vectorStore → embeddingService → resetStores // (memoryCleaner may use VectorStore during cleanup, so it must stop first) // // The entire hook is wrapped with a 3 s timeout to guarantee we never // block the gateway shutdown path — even if a pipeline flush or DB // close hangs. Each step is individually timed for observability. api.on("gateway_stop", async () => { const GATEWAY_STOP_TIMEOUT_MS = 3_000; const hookStartMs = Date.now(); // Ensure store init has completed before tearing down await storeReady.catch(() => {}); const doCleanup = async (): Promise => { // 1. Stop the memory cleaner first (it may be running deleteL1ExpiredByUpdatedTime) if (memoryCleaner) { try { memoryCleaner.destroy(); if (sharedMemoryCleaner === memoryCleaner) { sharedMemoryCleaner = undefined; } } catch (error) { api.logger.error(`${TAG} [gateway_stop] memoryCleaner error: ${error instanceof Error ? error.message : String(error)}`); } } // 2. Destroy scheduler (potentially heavy — flushes pending L1/L2/L3) if (scheduler && schedulerStarted) { const t = Date.now(); await scheduler.destroy(); api.logger.info(`${TAG} [gateway_stop] Scheduler destroyed (${Date.now() - t}ms)`); } else { api.logger.info(`${TAG} [gateway_stop] Scheduler was never started, skipping destroy`); } // 3. Close VectorStore last (after all consumers are done) if (vectorStore) { api.logger.info(`${TAG} [gateway_stop] Closing VectorStore`); vectorStore.close(); } // 4. Release embedding service resources (model memory, GPU, etc.) if (embeddingService?.close) { try { api.logger.info(`${TAG} [gateway_stop] Closing EmbeddingService`); await embeddingService.close(); } catch (err) { api.logger.warn(`${TAG} [gateway_stop] EmbeddingService close error: ${err instanceof Error ? err.message : String(err)}`); } } }; // Race cleanup against a hard timeout so we never block gateway exit. let timeoutId: ReturnType | undefined; try { await Promise.race([ doCleanup(), new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error("timeout")), GATEWAY_STOP_TIMEOUT_MS, ); }), ]); } catch (err) { api.logger.warn( `${TAG} [gateway_stop] Aborted (${Date.now() - hookStartMs}ms): ${err instanceof Error ? err.message : String(err)}. ` + `Pending work will recover on next startup.`, ); } finally { if (timeoutId !== undefined) clearTimeout(timeoutId); } // 5. Reset store singleton cache so hot-restart can re-initialize resetStores(); api.logger.info(`${TAG} [gateway_stop] Cleanup finished, all resources released (${Date.now() - hookStartMs}ms)`); }); } api.logger.debug?.(`${TAG} Registering agent_end hook (auto-capture)`); api.on("agent_end", async (event, ctx) => { const startMs = Date.now(); api.logger.debug?.(`${TAG} [agent_end] Hook triggered`); const e = event as Record; if (!e.success) { api.logger.info(`${TAG} [agent_end] Agent did not succeed, skipping capture`); return; } const sessionKey = ctx.sessionKey; const sessionId = ctx.sessionId; if (sessionFilter.shouldSkipCtx(ctx)) { api.logger.debug?.(`${TAG} [agent_end] Skipping filtered session`); return; } const messages = (e.messages as unknown[]) ?? []; const resolvedSessionKey = resolveSessionKey(sessionKey); if (!resolvedSessionKey) { return; } // Estimate LLM reasoning time: recallEnd → agentEnd start const recallEndTs = pendingRecallEndTimestamps.get(resolvedSessionKey); if (recallEndTs) { const llmEstimatedMs = startMs - recallEndTs; api.logger.info( `${TAG} ⏱ Turn timing: recallEnd→agentEnd=${llmEstimatedMs}ms ` + `(≈ LLM reasoning + prompt build + tool calls)`, ); pendingRecallEndTimestamps.delete(resolvedSessionKey); } // Retrieve cached original prompt (don't delete — retry may trigger multiple agent_end; // stale entries are swept by TTL in before_prompt_build) const cachedPrompt = sessionKey ? pendingOriginalPrompts.get(sessionKey) : undefined; const originalUserText = cachedPrompt?.text; const originalUserMessageCount = cachedPrompt?.messageCount; try { // Lazy-start the scheduler on first real conversation (Solution C). // This is a no-op after the first call. await ensureSchedulerStarted(); const captureResult = await performAutoCapture({ messages, sessionKey: resolvedSessionKey, sessionId: sessionId || undefined, cfg, pluginDataDir, logger: api.logger, scheduler, originalUserText, originalUserMessageCount, pluginStartTimestamp, vectorStore: sharedVectorStore, embeddingService: sharedEmbeddingService, }); const captureMs = Date.now() - startMs; api.logger.info( `${TAG} [agent_end] Auto-capture complete (${captureMs}ms), ` + `l0Recorded=${captureResult.l0RecordedCount}, ` + `schedulerNotified=${captureResult.schedulerNotified}`, ); // ── agent_turn metric: one-line trace of the full turn ── // Retrieve and delete recall cache (delete-after-use to prevent leak) const cachedRecall = sessionKey ? pendingRecallCache.get(sessionKey) : undefined; if (sessionKey) pendingRecallCache.delete(sessionKey); if (instanceId) { report("agent_turn", { sessionKey: resolvedSessionKey, // User input userPrompt: originalUserText ?? null, // Recall results (from before_prompt_build cache) recalledL1Memories: cachedRecall?.l1Memories ?? [], recalledL1Count: cachedRecall?.l1Memories?.length ?? 0, recalledL3Persona: cachedRecall?.l3Persona ?? null, recallStrategy: cachedRecall?.strategy ?? null, recallDurationMs: cachedRecall?.durationMs ?? 0, // L0 write-to-disk results l0CapturedMessages: captureResult.filteredMessages.map((m) => ({ role: m.role, content: m.content, ts: m.timestamp, })), l0CapturedCount: captureResult.l0RecordedCount, l0VectorsWritten: captureResult.l0VectorsWritten, // Timing captureDurationMs: captureMs, totalDurationMs: Date.now() - startMs, }); } } catch (err) { const elapsedMs = Date.now() - startMs; api.logger.error(`${TAG} [agent_end] Auto-capture failed after ${elapsedMs}ms: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); // ── error_degradation metric ── if (instanceId) { report("error_degradation", { module: "auto-capture", action: "performAutoCapture", errorType: "exception", errorMessage: err instanceof Error ? err.message : String(err), degradedTo: "no_capture", impact: "non-blocking", }); } } }); } else { api.logger.debug?.(`${TAG} Auto-capture disabled`); } // memoryCleaner gateway_stop is handled in the unified handler above (inside extraction.enabled block). // For the case where capture is enabled but extraction is disabled, register cleanup separately. if (memoryCleaner && !cfg.extraction.enabled) { api.on("gateway_stop", async () => { const startMs = Date.now(); try { memoryCleaner?.destroy(); if (sharedMemoryCleaner === memoryCleaner) { sharedMemoryCleaner = undefined; } api.logger.info(`${TAG} [gateway_stop] Memory cleaner destroyed (${Date.now() - startMs}ms)`); } catch (error) { api.logger.error(`${TAG} [gateway_stop] Error during memory cleaner destruction (${Date.now() - startMs}ms): ${error instanceof Error ? error.message : String(error)}`); } }); } // ============================ // CLI registration // ============================ api.registerCli( ({ program, config, logger: cliLogger }) => { const memoryTdai = program .command("memory-tdai") .description("memory-tdai plugin commands (seed, query, stats)"); registerMemoryTdaiCli(memoryTdai, { config, pluginConfig: api.pluginConfig, stateDir: api.runtime.state.resolveStateDir(), logger: cliLogger, }); }, { commands: ["memory-tdai"] }, ); api.logger.debug?.( `${TAG} Plugin registration complete (v3). ` + `startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`, ); } // ============================ // Helpers // ============================