/** * 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. * * v3.1: Refactored to use TdaiCore + OpenClawHostAdapter. * index.ts is now a thin shell that: * - Registers tools and hooks with OpenClaw * - Translates OpenClaw events into TdaiCore calls * - Manages prompt caching and metric reporting * * Core memory logic lives in src/core/tdai-core.ts (host-neutral). */ 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 { registerOffload } from "./src/offload/index.js"; import { setPreferredEmbeddedAgentRuntime, prewarmEmbeddedAgent, } from "./src/utils/clean-context-runner.js"; import { SessionFilter } from "./src/utils/session-filter.js"; import { LocalMemoryCleaner } from "./src/utils/memory-cleaner.js"; import { registerMemoryTdaiCli } from "./src/cli/index.js"; import { initDataDirectories, resetStores } from "./src/utils/pipeline-factory.js"; import { getOrCreateInstanceId, initReporter, report, resetReporter } from "./src/core/report/reporter.js"; import { ensureL2L3Local } from "./src/core/profile/profile-sync.js"; // Core abstractions (host-neutral) import { OpenClawHostAdapter } from "./src/adapters/openclaw/host-adapter.js"; import { TdaiCore } from "./src/core/tdai-core.js"; import { ensurePluginHookPolicy, decideHookPolicy, } from "./src/utils/ensure-hook-policy.js"; import { resolveOpenClawStateDir } from "./src/utils/openclaw-state-dir.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) { // ─── CLI metadata mode: register CLI commands only, skip all runtime init ─── // In this mode, runtime is `{} as PluginRuntime` (empty object). // OpenClaw calls this to discover CLI subcommands without starting the full plugin. if (api.registrationMode === "cli-metadata") { 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: resolveOpenClawStateDir((api.runtime as any)?.state), logger: cliLogger, }); }, { commands: ["memory-tdai"] }, ); return; } // ─── Full / discovery mode: complete runtime initialization ─── 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 { // OpenClaw calls register() N times (plugin scan → gateway start → // per-channel bootstrap → config reload). Each call receives the full // pluginConfig from openclaw.json, so we parse it directly every time. const rawPluginConfig = api.pluginConfig as Record | undefined; const rawKeys = rawPluginConfig ? Object.keys(rawPluginConfig) : []; api.logger.debug?.( `${TAG} pluginConfig received (${rawKeys.length} keys)`, ); cfg = parseConfig(rawPluginConfig); 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}), ` + `offload(enabled=${cfg.offload.enabled}, backendUrl=${cfg.offload.backendUrl ?? "(none)"}, mildRatio=${cfg.offload.mildOffloadRatio}, aggressiveRatio=${cfg.offload.aggressiveCompressRatio}, retentionDays=${cfg.offload.offloadRetentionDays})`, ); } catch (err) { api.logger.error(`${TAG} Config parsing failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } // ============================ // Hook policy auto-patch (v2026.4.24+ compat) // ============================ // `allowConversationAccess` hook policy was introduced in v2026.4.23; // the zod schema fix landed in v2026.4.24. Older hosts don't understand // the field and don't need it patched in. // // Note: `api.runtime.version` is only exposed on v2026.4.15+. On older // hosts it is `undefined`; we MUST treat that as "does not need the // patch" (old hosts have no gate), otherwise we would silently mutate // the user's openclaw.json on every gateway start. { // Gate: only apply the auto-patch when host version >= 2026.4.24. // decideHookPolicy() parses the leading x.y.z prefix numerically // (ignoring `-beta.N`, `-N`, etc.) and returns apply=false for any // version we cannot parse — which is the safe default on old hosts // that don't expose `api.runtime.version`. See ensure-hook-policy.ts // for the full policy + co-located unit tests. const rawVersion = (api.runtime as any)?.version; const decision = decideHookPolicy(rawVersion); const parsedStr = decision.parsedXYZ ? decision.parsedXYZ.join(".") : ""; const minStr = decision.minXYZ.join("."); if (!decision.apply) { api.logger.debug?.( `${TAG} Hook policy auto-patch skipped: ` + `original=${JSON.stringify(rawVersion)}, parsed=${parsedStr}, min=${minStr}`, ); } else { api.logger.debug?.( `${TAG} Hook policy auto-patch applying: ` + `original=${JSON.stringify(rawVersion)}, parsed=${parsedStr} >= min=${minStr}`, ); try { ensurePluginHookPolicy({ rootConfig: api.config, runtimeConfig: api.runtime?.config, logger: api.logger, }); } catch (err) { api.logger.warn(`${TAG} Hook policy check failed (non-fatal): ${err instanceof Error ? err.message : String(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 openclawStateDir = resolveOpenClawStateDir((api.runtime as any)?.state); const pluginDataDir = path.join(openclawStateDir, "memory-tdai"); initDataDirectories(pluginDataDir); api.logger.debug?.(`${TAG} Data dir: ${pluginDataDir} (all subdirectories initialized)`); // ============================ // Create OpenClawHostAdapter + TdaiCore // ============================ const hostAdapter = new OpenClawHostAdapter({ api, pluginDataDir, openclawConfig: api.config, }); const sessionFilter = new SessionFilter(cfg.capture.excludeAgents); if (cfg.capture.excludeAgents.length > 0) { api.logger.debug?.(`${TAG} Agent exclude patterns: ${cfg.capture.excludeAgents.join(", ")}`); } const core = new TdaiCore({ hostAdapter, config: cfg, sessionFilter, }); // Initialize TdaiCore (async — store init, pipeline wiring) const coreReady = core.initialize().then(() => { // Keep cleaner's SQLite handle updated after store init memoryCleaner?.setVectorStore(core.getVectorStore()); // Pull L2/L3 profiles if remote store supports it const vs = core.getVectorStore(); if (vs?.pullProfiles) { ensureL2L3Local(pluginDataDir, vs, api.logger).catch((err) => { api.logger.warn(`${TAG} Startup L2/L3 pull failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`); }); } }).catch((err) => { api.logger.error(`${TAG} Core init failed: ${err instanceof Error ? err.message : String(err)}`); }); // Kick off instanceId resolution immediately after data dir is ready. let instanceId: string | undefined; getOrCreateInstanceId(pluginDataDir).then((id) => { instanceId = id; core.setInstanceId(id); 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)}`); }); // 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)`); } 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; }; /** * Whether embedding warmup has been triggered. * Deferred until first real conversation to avoid model downloads during CLI commands. */ let embeddingWarmupTriggered = false; const ensureEmbeddingWarmup = (): void => { const svc = core.getEmbeddingService(); if (!svc) return; if (!embeddingWarmupTriggered) { embeddingWarmupTriggered = true; api.logger.debug?.(`${TAG} Triggering lazy embedding warmup on first conversation`); svc.startWarmup(); return; } if (!svc.isReady()) { api.logger.debug?.(`${TAG} Embedding not ready, re-triggering warmup (retry)`); svc.startWarmup(); } }; // ============================ // Tool registration — delegate to TdaiCore // ============================ // 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) if (cfg.recall.enabled || cfg.capture.enabled) { 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 core.searchMemories({ query, limit, type: typeFilter, scene: sceneFilter }); const elapsedMs = Date.now() - startMs; api.logger.debug?.( `${TAG} [tool] tdai_memory_search completed (${elapsedMs}ms): ` + `total=${result.total}, strategy=${result.strategy}, ` + `responseLength=${result.text.length} chars`, ); report("tool_call", { tool: "tdai_memory_search", query, limit, typeFilter, sceneFilter, resultCount: result.total, strategy: result.strategy, durationMs: elapsedMs, success: true, }); return { content: [{ type: "text" as const, text: result.text }], 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 core.searchConversations({ query, limit, sessionKey: sessionKeyFilter }); const elapsedMs = Date.now() - startMs; api.logger.debug?.( `${TAG} [tool] tdai_conversation_search completed (${elapsedMs}ms): ` + `total=${result.total}, responseLength=${result.text.length} chars`, ); report("tool_call", { tool: "tdai_conversation_search", query, limit, sessionKeyFilter, resultCount: result.total, durationMs: elapsedMs, success: true, }); return { content: [{ type: "text" as const, text: result.text }], 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" }, ); } else { api.logger.debug?.(`${TAG} Memory tools (tdai_memory_search, tdai_conversation_search) not registered — memory features disabled`); } // ============================ // Lifecycle hooks — delegate to TdaiCore // ============================ // Before prompt build: auto-recall relevant memories 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; } 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 { await coreReady; const recallStartMs = Date.now(); const result = await core.handleBeforeRecall(userText, resolvedSessionKey); 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 || result?.prependContext) { const appendLen = result.appendSystemContext?.length ?? 0; const prependLen = result.prependContext?.length ?? 0; api.logger.info( `${TAG} [before_prompt_build] Recall complete (${elapsedMs}ms), ` + `appendSystemContext=${appendLen} chars, prependContext=${prependLen} 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)}`); 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", }); } } }); } // Strip from user messages before they are persisted to // the session JSONL. The current-turn LLM already saw the full prompt // (effectivePrompt lives in memory), but we don't want recall artifacts // polluting the historical transcript for future replays. api.logger.debug?.(`${TAG} Registering before_message_write hook (strip )`); api.on("before_message_write", (event) => { const msg = event.message as { role?: string; content?: unknown }; const contentType = typeof msg.content === "string" ? "string" : Array.isArray(msg.content) ? "parts" : typeof msg.content; api.logger.debug?.(`${TAG} [before_message_write] role=${msg.role}, contentType=${contentType}`); if (msg.role !== "user") return; // UserMessage.content: string | (TextContent | ImageContent)[] const STRIP_RE = /[\s\S]*?<\/relevant-memories>\s*/g; if (typeof msg.content === "string") { if (!msg.content.includes("")) return; const cleaned = msg.content.replace(STRIP_RE, "").trim(); if (cleaned === msg.content) return; api.logger.debug?.(`${TAG} [before_message_write] Stripped: ${msg.content.length} → ${cleaned.length} chars`); return { message: { ...event.message, content: cleaned } as typeof event.message }; } if (Array.isArray(msg.content)) { let totalStripped = 0; const cleanedParts = (msg.content as Array>).map((part) => { if (part.type !== "text" || typeof part.text !== "string") return part; if (!(part.text as string).includes("")) return part; const cleaned = (part.text as string).replace(STRIP_RE, "").trim(); totalStripped += (part.text as string).length - cleaned.length; return { ...part, text: cleaned }; }); if (totalStripped === 0) return; api.logger.debug?.(`${TAG} [before_message_write] Stripped from parts: removed ${totalStripped} chars`); return { message: { ...event.message, content: cleanedParts } as unknown as typeof event.message }; } }); // After agent end: auto-capture + L0 record + L1/L2/L3 schedule if (cfg.capture.enabled) { 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 const cachedPrompt = sessionKey ? pendingOriginalPrompts.get(sessionKey) : undefined; const originalUserText = cachedPrompt?.text; try { await coreReady; // Pre-warm the embedded agent on first conversation if (!core.isSchedulerStarted()) { prewarmEmbeddedAgent(api.logger, api.runtime.agent); } const captureResult = await core.handleTurnCommitted({ userText: originalUserText ?? "", assistantText: "", messages, sessionKey: resolvedSessionKey, sessionId: sessionId || undefined, startedAt: pluginStartTimestamp, originalUserMessageCount: cachedPrompt?.messageCount, }); 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 ── const cachedRecall = sessionKey ? pendingRecallCache.get(sessionKey) : undefined; if (sessionKey) pendingRecallCache.delete(sessionKey); if (instanceId) { report("agent_turn", { sessionKey: resolvedSessionKey, userPrompt: originalUserText ?? null, recalledL1Memories: cachedRecall?.l1Memories ?? [], recalledL1Count: cachedRecall?.l1Memories?.length ?? 0, recalledL3Persona: cachedRecall?.l3Persona ?? null, recallStrategy: cachedRecall?.strategy ?? null, recallDurationMs: cachedRecall?.durationMs ?? 0, l0CapturedMessages: captureResult.filteredMessages.map((m) => ({ role: m.role, content: m.content, ts: m.timestamp, })), l0CapturedCount: captureResult.l0RecordedCount, l0VectorsWritten: captureResult.l0VectorsWritten, 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)}`); 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", }); } } }); // gateway_stop: ordered shutdown via TdaiCore.destroy() api.on("gateway_stop", async () => { const GATEWAY_STOP_TIMEOUT_MS = 3_000; const hookStartMs = Date.now(); await coreReady.catch(() => {}); const doCleanup = async (): Promise => { // 1. Stop memory cleaner first 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 TdaiCore (scheduler flush + VectorStore close + EmbeddingService close) await core.destroy(); }; // Race cleanup against a hard timeout 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); } resetStores(); api.logger.info(`${TAG} [gateway_stop] Cleanup finished, all resources released (${Date.now() - hookStartMs}ms)`); }); } else { api.logger.debug?.(`${TAG} Auto-capture disabled`); } // memoryCleaner gateway_stop for capture-enabled-but-extraction-disabled case 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)}`); } }); } // ============================ // Context Offload (conditional) // ============================ if (cfg.offload.enabled) { api.logger.debug?.(`${TAG} Offload enabled, registering offload module...`); try { registerOffload(api, cfg.offload); api.logger.debug?.(`${TAG} Offload module registered successfully`); } catch (err) { api.logger.error(`${TAG} Offload module registration failed: ${err instanceof Error ? err.message : String(err)}`); } } else { api.logger.debug?.(`${TAG} Offload disabled (offload.enabled=false)`); } // ============================ // 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: openclawStateDir, logger: cliLogger, }); }, { commands: ["memory-tdai"] }, ); api.logger.debug?.( `${TAG} Plugin registration complete (v3.1 — TdaiCore). ` + `startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`, ); }