Files
TencentDB-Agent-Memory/index.ts
T

982 lines
40 KiB
TypeScript
Raw Normal View History

2026-04-09 18:23:46 +08:00
/**
* 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";
2026-04-09 18:23:46 +08:00
import { SessionFilter } from "./src/utils/session-filter.js";
import type { IMemoryStore } from "./src/store/types.js";
2026-04-09 18:23:46 +08:00
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";
2026-04-09 18:23:46 +08:00
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<string, { text: string; ts: number; messageCount: number }>();
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<string, {
l1Memories: Array<{ content: string; score: number; type: string }>;
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<string, number>();
// 进程级单例,避免同一进程重复启动清理器导致并发清理竞态
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();
2026-04-09 18:23:46 +08:00
const _require = createRequire(import.meta.url);
const pluginVersion = (() => { try { return (_require("./package.json") as { version?: string }).version ?? "unknown"; } catch { return "unknown"; } })();
api.logger.debug?.(
2026-04-09 18:23:46 +08:00
`${TAG} Registering plugin ... ` +
`startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`,
);
let cfg: MemoryTdaiConfig;
try {
cfg = parseConfig(api.pluginConfig as Record<string, unknown> | undefined);
api.logger.debug?.(
2026-04-09 18:23:46 +08:00
`${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)}`);
});
2026-04-09 18:23:46 +08:00
// 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(", ")}`);
2026-04-09 18:23:46 +08:00
}
// 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)`);
2026-04-09 18:23:46 +08:00
} else {
api.logger.debug?.(`${TAG} Memory cleaner already started in this process, reusing existing instance`);
2026-04-09 18:23:46 +08:00
}
memoryCleaner = sharedMemoryCleaner;
} else {
api.logger.debug?.(`${TAG} Memory cleaner disabled (retentionDays not configured)`);
2026-04-09 18:23:46 +08:00
}
// 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;
2026-04-09 18:23:46 +08:00
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)
2026-04-09 18:23:46 +08:00
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.",
2026-04-09 18:23:46 +08:00
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<string, unknown>) {
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)
2026-04-09 18:23:46 +08:00
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.",
2026-04-09 18:23:46 +08:00
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<string, unknown>) {
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)`);
2026-04-09 18:23:46 +08:00
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<void> => {
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);
2026-04-09 18:23:46 +08:00
};
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;
2026-04-09 18:23:46 +08:00
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;
2026-04-09 18:23:46 +08:00
// Keep cleaner's SQLite handle updated (singleton cleaner may start earlier).
memoryCleaner?.setVectorStore(vectorStore);
if (vectorStore?.pullProfiles) {
2026-04-09 18:23:46 +08:00
try {
await ensureL2L3Local(pluginDataDir, vectorStore, api.logger);
2026-04-09 18:23:46 +08:00
} catch (err) {
api.logger.warn(`${TAG} Startup L2/L3 pull failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
2026-04-09 18:23:46 +08:00
}
}
// If embedding provider/model/dimensions changed, re-embed all existing texts
if (stores.needsReindex && embeddingService && vectorStore) {
const svc = embeddingService;
const vs = vectorStore;
2026-04-09 18:23:46 +08:00
api.logger.info(
`${TAG} Embedding config changed (${stores.reindexReason}). ` +
`Starting background re-embed of all stored texts...`,
2026-04-09 18:23:46 +08:00
);
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 }) => {
2026-04-09 18:23:46 +08:00
api.logger.info(
`${TAG} Re-embed complete: L1=${l1Count} records, L0=${l0Count} messages`,
2026-04-09 18:23:46 +08:00
);
}).catch((err) => {
api.logger.error(
`${TAG} Re-embed failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
});
2026-04-09 18:23:46 +08:00
}
})();
2026-04-09 18:23:46 +08:00
// === Create pipeline manager (sync — does not need store) ===
scheduler = createPipelineManager(cfg, api.logger, sessionFilter);
2026-04-09 18:23:46 +08:00
// 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,
}));
2026-04-09 18:23:46 +08:00
// Persister via shared factory
scheduler!.setPersister(createPersister(pluginDataDir, api.logger));
2026-04-09 18:23:46 +08:00
// L2 runner: read L1 records (incremental) → SceneExtractor
scheduler!.setL2Runner(async (sessionKey: string, cursor?: string) => {
try {
const l2Runner = createL2Runner({
2026-04-09 18:23:46 +08:00
pluginDataDir,
cfg,
openclawConfig: api.config,
vectorStore,
2026-04-09 18:23:46 +08:00
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;
2026-04-09 18:23:46 +08:00
}
});
2026-04-09 18:23:46 +08:00
// L3 runner: persona trigger + generation
scheduler!.setL3Runner(async () => {
try {
const l3Runner = createL3Runner({
pluginDataDir,
cfg,
openclawConfig: api.config,
vectorStore,
logger: api.logger,
instanceId,
2026-04-09 18:23:46 +08:00
});
await l3Runner();
} catch (err) {
api.logger.error(`${TAG} [pipeline-l3] Failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
2026-04-09 18:23:46 +08:00
}
});
}).catch((err) => {
api.logger.error(
`${TAG} Store init failed; vector/FTS recall and dedup will be unavailable: ${err instanceof Error ? err.message : String(err)}`,
);
2026-04-09 18:23:46 +08:00
});
// Register a SINGLE gateway_stop hook for ordered shutdown.
// Order: memoryCleaner → scheduler → vectorStore → embeddingService → resetStores
2026-04-09 18:23:46 +08:00
// (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(() => {});
2026-04-09 18:23:46 +08:00
const doCleanup = async (): Promise<void> => {
// 1. Stop the memory cleaner first (it may be running deleteL1ExpiredByUpdatedTime)
2026-04-09 18:23:46 +08:00
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`);
2026-04-09 18:23:46 +08:00
}
// 3. Close VectorStore last (after all consumers are done)
if (vectorStore) {
api.logger.info(`${TAG} [gateway_stop] Closing VectorStore`);
vectorStore.close();
2026-04-09 18:23:46 +08:00
}
// 4. Release embedding service resources (model memory, GPU, etc.)
2026-04-09 18:23:46 +08:00
if (embeddingService?.close) {
try {
api.logger.info(`${TAG} [gateway_stop] Closing EmbeddingService`);
2026-04-09 18:23:46 +08:00
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<typeof setTimeout> | undefined;
try {
await Promise.race([
doCleanup(),
new Promise<never>((_, 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();
2026-04-09 18:23:46 +08:00
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)`);
2026-04-09 18:23:46 +08:00
api.on("agent_end", async (event, ctx) => {
const startMs = Date.now();
api.logger.debug?.(`${TAG} [agent_end] Hook triggered`);
const e = event as Record<string, unknown>;
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`);
2026-04-09 18:23:46 +08:00
}
// 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
// ============================
2026-04-09 18:23:46 +08:00
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"] },
);
2026-04-09 18:23:46 +08:00
api.logger.debug?.(
`${TAG} Plugin registration complete (v3). ` +
`startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`,
2026-04-09 18:23:46 +08:00
);
}
// ============================
// Helpers
// ============================