2026-05-29 17:33:12 +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.
*
* 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 fs from "node:fs" ;
import { createRequire } from "node:module" ;
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core" ;
2026-06-09 15:28:05 +08:00
import registerClientOpenClawPlugin from "./openclaw-plugin/index.js" ;
2026-05-29 17:33:12 +08:00
import { parseConfig } from "./src/config.js" ;
import type { MemoryTdaiConfig } from "./src/config.js" ;
import { executeReadCos , READ_COS_TOOL_SCHEMA , READ_COS_TOOL_NAME , READ_COS_TOOL_DESCRIPTION } from "./src/core/tools/read-cos.js" ;
import { LocalStorageBackend } from "./src/core/storage/local-backend.js" ;
import { StaticCredentialProvider } from "./src/core/storage/credential-provider.js" ;
import type { IStorageBackend } from "./src/core/storage/types.js" ;
import { registerOffload } from "./src/offload/index.js" ;
2026-06-09 15:28:05 +08:00
import { registerOffloadClient } from "./src/offload-client/index.js" ;
2026-05-29 17:33:12 +08:00
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 { readCosToolEnvConfig } from "./src/utils/env-config.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]" ;
2026-06-09 15:28:05 +08:00
type OpenClawAdapterMode = "local" | "client" ;
function resolveOpenClawAdapterMode ( rawPluginConfig : Record < string , unknown > | undefined ) : OpenClawAdapterMode {
const rawMode = typeof rawPluginConfig ? . mode === "string"
? rawPluginConfig . mode . trim (). toLowerCase ()
: "" ;
if ( rawMode === "client" || rawMode === "gateway" || rawMode === "remote" ) {
return "client" ;
}
// Default to local/function mode: OpenClaw calls this plugin in-process and
// memory processing uses the host LLM runner (no standalone Gateway needed).
return "local" ;
}
2026-05-29 17:33:12 +08:00
/**
* 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 ) {
// ─── 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 ───
2026-06-09 15:28:05 +08:00
const rawPluginConfigForMode = api . pluginConfig as Record < string , unknown > | undefined ;
const adapterMode = resolveOpenClawAdapterMode ( rawPluginConfigForMode );
if ( adapterMode === "client" ) {
api . logger . info ? .( ` ${ TAG } mode=client: delegating to memory-tencentdb-client adapter` );
return registerClientOpenClawPlugin ( api as any );
}
2026-05-29 17:33:12 +08:00
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 < string , unknown > | 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 } ), ` +
2026-06-09 15:28:05 +08:00
`offload(enabled= ${ cfg . offload . enabled } , mode= ${ cfg . offload . mode } , ${ cfg . offload . mode === "client" ? `serverUrl= ${ cfg . offload . serverUrl ?? "(none)" } ` : `backendUrl= ${ cfg . offload . backendUrl ?? "(none)" } , mildRatio= ${ cfg . offload . mildOffloadRatio } , aggressiveRatio= ${ cfg . offload . aggressiveCompressRatio } ` } , retentionDays= ${ cfg . offload . offloadRetentionDays } )` ,
2026-05-29 17:33:12 +08:00
);
} 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 ( "." ) : "<unparsable>" ;
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 ,
2026-06-09 15:28:05 +08:00
runtimeConfig : ( api . runtime as any ) ? . config ,
2026-05-29 17:33:12 +08:00
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 < 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 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 < 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 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" },
);
// tdai_read_cos — Agent-callable tool for reading files from COS/local storage
{
// Read COS config from {pluginDataDir}/cos.env
// Format: KEY=VALUE per line (standard .env)
const cosEnvPath = path . join ( pluginDataDir , "cos.env" );
const cosEnv : Record < string , string > = {};
if ( fs . existsSync ( cosEnvPath )) {
const lines = fs . readFileSync ( cosEnvPath , "utf-8" ). split ( "\n" );
for ( const line of lines ) {
const trimmed = line . trim ();
if ( ! trimmed || trimmed . startsWith ( "#" )) continue ;
const eqIdx = trimmed . indexOf ( "=" );
if ( eqIdx > 0 ) {
cosEnv [ trimmed . slice ( 0 , eqIdx ). trim ()] = trimmed . slice ( eqIdx + 1 ). trim ();
}
}
}
// Priority: env vars > cos.env file
const {
cosSecretId ,
cosSecretKey ,
cosBucket ,
cosRegion ,
cosPrefix ,
cosDomain ,
} = readCosToolEnvConfig ( cosEnv );
// Lazily resolve the storage backend on first call. Optional COS support
// is loaded dynamically; when unavailable, the tool falls back to local
// storage. The resolution is cached so subsequent calls are zero-cost.
let storagePromise : Promise < IStorageBackend > | null = null ;
const resolveReadCosStorage = () : Promise < IStorageBackend > => {
if ( storagePromise ) return storagePromise ;
storagePromise = ( async () : Promise < IStorageBackend > => {
if ( cosSecretId && cosSecretKey && cosBucket ) {
try {
2026-06-09 15:28:05 +08:00
const cosBackendModulePath = [ "./src/integrations/cos" , "cos-backend.js" ]. join ( "/" );
const { CosStorageBackend } = await import ( cosBackendModulePath ) as {
CosStorageBackend : new ( options : any ) => IStorageBackend ;
};
2026-05-29 17:33:12 +08:00
const backend = new CosStorageBackend ({
credentialProvider : new StaticCredentialProvider ({
secretId : cosSecretId ,
secretKey : cosSecretKey ,
bucket : cosBucket ,
region : cosRegion ,
prefix : cosPrefix ,
}),
logger : api.logger ,
cosEndpointDomain : cosDomain ,
});
api . logger . info ( ` ${ TAG } read_cos: using COS backend (bucket= ${ cosBucket } , prefix= ${ cosPrefix } )` );
return backend ;
} catch ( err ) {
const storageDataDir = path . join ( pluginDataDir , "cos-storage" );
const backend = new LocalStorageBackend ({ rootDir : storageDataDir , logger : api.logger });
api . logger . warn (
` ${ TAG } read_cos: COS integration not available ( ${ err instanceof Error ? err.message : String ( err ) } ), ` +
`falling back to local backend ( ${ storageDataDir } ).` ,
);
return backend ;
}
}
const storageDataDir = path . join ( pluginDataDir , "cos-storage" );
const backend = new LocalStorageBackend ({ rootDir : storageDataDir , logger : api.logger });
api . logger . info ( ` ${ TAG } read_cos: no COS credentials, using local backend ( ${ storageDataDir } ). Place cos.env in ${ pluginDataDir } to enable COS.` );
return backend ;
})();
return storagePromise ;
};
api . registerTool (
{
name : READ_COS_TOOL_NAME ,
label : "Read COS File" ,
description : READ_COS_TOOL_DESCRIPTION ,
parameters : READ_COS_TOOL_SCHEMA ,
async execute ( _toolCallId : string , params : Record < string , unknown >) {
const startMs = Date . now ();
const filePath = String ( params . path ?? "" );
api . logger . debug ? .( ` ${ TAG } [tool] tdai_read_cos called: path=" ${ filePath } "` );
const readCosStorage = await resolveReadCosStorage ();
const result = await executeReadCos (
{ path : filePath },
readCosStorage ,
api . logger ,
);
const elapsedMs = Date . now () - startMs ;
api . logger . debug ? .(
` ${ TAG } [tool] tdai_read_cos completed ( ${ elapsedMs } ms): ` +
`success= ${ result . success } , size= ${ result . size } ` ,
);
report ( "tool_call" , {
tool : "tdai_read_cos" ,
path : filePath ,
durationMs : elapsedMs ,
success : result.success ,
size : result.size ,
error : result.error ,
});
if ( result . success ) {
return {
content : [{ type : "text" as const , text : result.content }],
details : { path : result.path , size : result.size },
};
} else {
return {
content : [{ type : "text" as const , text : `Read failed: ${ result . error } ` }],
details : { path : result.path , error : result.error },
};
}
},
},
{ name : READ_COS_TOOL_NAME },
);
api . logger . info ( ` ${ TAG } Registered tool: tdai_read_cos (storage backend resolved lazily on first call)` );
}
} 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 <relevant-memories> 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 <relevant-memories>)` );
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 = /<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g ;
if ( typeof msg . content === "string" ) {
if ( ! msg . content . includes ( "<relevant-memories>" )) 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 < Record < string , unknown >>). map (( part ) => {
if ( part . type !== "text" || typeof part . text !== "string" ) return part ;
if ( ! ( part . text as string ). includes ( "<relevant-memories>" )) 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 < 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
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 < void > => {
// 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 < 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 );
}
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 {
2026-06-09 15:28:05 +08:00
if ( cfg . offload . mode === "client" ) {
// New: stateless client mode — all compression delegated to server
registerOffloadClient ( api as any , {
enabled : cfg.offload.enabled ,
serverUrl : cfg.offload.serverUrl ?? "" ,
apiKey : cfg.offload.apiKey ?? "" ,
serviceId : cfg.offload.serviceId ?? "" ,
agentName : cfg.offload.agentName ?? "default" ,
compactionRatio : cfg.offload.compactionRatio ?? 0.5 ,
ingestTimeoutMs : cfg.offload.ingestTimeoutMs ?? 5000 ,
compactionTimeoutMs : cfg.offload.compactionTimeoutMs ?? 30000 ,
});
api . logger . debug ? .( ` ${ TAG } Offload client module registered (mode=client)` );
} else {
// Legacy: full local L3 compression
registerOffload ( api , cfg . offload );
api . logger . debug ? .( ` ${ TAG } Offload module registered successfully (mode= ${ cfg . offload . mode } )` );
}
2026-05-29 17:33:12 +08:00
} 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 () } )` ,
);
}