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" ;
2026-05-13 01:23:05 +08:00
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" ;
2026-05-13 01:23:05 +08:00
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" ;
2026-05-13 01:23:05 +08:00
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 ();
2026-05-13 01:23:05 +08:00
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" ; } })();
2026-05-13 01:23:05 +08:00
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 );
2026-05-13 01:23:05 +08:00
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 );
2026-05-13 01:23:05 +08:00
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 ) {
2026-05-13 01:23:05 +08:00
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 ();
2026-05-13 01:23:05 +08:00
api . logger . debug ? .( ` ${ TAG } Memory cleaner started (singleton)` );
2026-04-09 18:23:46 +08:00
} else {
2026-05-13 01:23:05 +08:00
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 {
2026-05-13 01:23:05 +08:00
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)
2026-05-13 01:23:05 +08:00
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
2026-05-13 01:23:05 +08:00
// 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 :
2026-05-13 01:23:05 +08:00
"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
2026-05-13 01:23:05 +08:00
// 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 " +
2026-05-13 01:23:05 +08:00
"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 ) {
2026-05-13 01:23:05 +08:00
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 ({});
}
2026-05-13 01:23:05 +08:00
// 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 ) {
2026-05-13 01:23:05 +08:00
// === 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 ;
2026-05-13 01:23:05 +08:00
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
2026-05-13 01:23:05 +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 {
2026-05-13 01:23:05 +08:00
await ensureL2L3Local ( pluginDataDir , vectorStore , api . logger );
2026-04-09 18:23:46 +08:00
} catch ( err ) {
2026-05-13 01:23:05 +08:00
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
}
}
2026-05-13 01:23:05 +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 (
2026-05-13 01:23:05 +08:00
` ${ TAG } Embedding config changed ( ${ stores . reindexReason } ). ` +
`Starting background re-embed of all stored texts...` ,
2026-04-09 18:23:46 +08:00
);
2026-05-13 01:23:05 +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 (
2026-05-13 01:23:05 +08:00
` ${ TAG } Re-embed complete: L1= ${ l1Count } records, L0= ${ l0Count } messages` ,
2026-04-09 18:23:46 +08:00
);
2026-05-13 01:23:05 +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-05-13 01:23:05 +08:00
})();
2026-04-09 18:23:46 +08:00
2026-05-13 01:23:05 +08:00
// === Create pipeline manager (sync — does not need store) ===
scheduler = createPipelineManager ( cfg , api . logger , sessionFilter );
2026-04-09 18:23:46 +08:00
2026-05-13 01:23:05 +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
2026-05-13 01:23:05 +08:00
// Persister via shared factory
scheduler ! . setPersister ( createPersister ( pluginDataDir , api . logger ));
2026-04-09 18:23:46 +08:00
2026-05-13 01:23:05 +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 ,
2026-05-13 01:23:05 +08:00
cfg ,
openclawConfig : api.config ,
vectorStore ,
2026-04-09 18:23:46 +08:00
logger : api.logger ,
instanceId ,
});
2026-05-13 01:23:05 +08:00
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-05-13 01:23:05 +08:00
});
2026-04-09 18:23:46 +08:00
2026-05-13 01:23:05 +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
});
2026-05-13 01:23:05 +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
}
2026-05-13 01:23:05 +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.
2026-05-13 01:23:05 +08:00
// 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 ();
2026-05-13 01:23:05 +08:00
// Ensure store init has completed before tearing down
await storeReady . catch (() => {});
2026-04-09 18:23:46 +08:00
const doCleanup = async () : Promise < void > => {
2026-05-13 01:23:05 +08:00
// 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)` );
2026-05-13 01:23:05 +08:00
} else {
api . logger . info ( ` ${ TAG } [gateway_stop] Scheduler was never started, skipping destroy` );
2026-04-09 18:23:46 +08:00
}
2026-05-13 01:23:05 +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
}
2026-05-13 01:23:05 +08:00
// 4. Release embedding service resources (model memory, GPU, etc.)
2026-04-09 18:23:46 +08:00
if ( embeddingService ? . close ) {
try {
2026-05-13 01:23:05 +08:00
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 );
}
2026-05-13 01:23:05 +08:00
// 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)` );
});
}
2026-05-13 01:23:05 +08:00
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 {
2026-05-13 01:23:05 +08:00
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 ) } ` );
}
});
}
2026-05-13 01:23:05 +08:00
// ============================
// CLI registration
// ============================
2026-04-09 18:23:46 +08:00
2026-05-13 01:23:05 +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 ? .(
2026-05-13 01:23:05 +08:00
` ${ TAG } Plugin registration complete (v3). ` +
`startTimestamp= ${ pluginStartTimestamp } ( ${ new Date ( pluginStartTimestamp ). toISOString () } )` ,
2026-04-09 18:23:46 +08:00
);
}
// ============================
// Helpers
// ============================