feat: release v0.2.2 — TCVDB backend, BM25 hybrid retrieval, pipeline refactor

This commit is contained in:
chrishuan
2026-05-13 01:23:05 +08:00
parent 5bf5f890a3
commit a74b0b3e43
45 changed files with 8247 additions and 756 deletions
+179 -535
View File
@@ -10,7 +10,6 @@
* All processing is local, zero external API dependencies.
*/
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
@@ -19,54 +18,33 @@ 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 { SceneExtractor } from "./src/scene/scene-extractor.js";
import { CheckpointManager } from "./src/utils/checkpoint.js";
import { PersonaTrigger } from "./src/persona/persona-trigger.js";
import { PersonaGenerator } from "./src/persona/persona-generator.js";
import { prewarmEmbeddedAgent } from "./src/utils/clean-context-runner.js";
import {
prewarmEmbeddedAgent,
setPreferredEmbeddedAgentRuntime,
} from "./src/utils/clean-context-runner.js";
import { SessionFilter } from "./src/utils/session-filter.js";
import { extractL1Memories } from "./src/record/l1-extractor.js";
import { readConversationMessagesGroupedBySessionId } from "./src/conversation/l0-recorder.js";
import type { ConversationMessage } from "./src/conversation/l0-recorder.js";
import { VectorStore } from "./src/store/vector-store.js";
import { createEmbeddingService } from "./src/store/embedding.js";
import type { IMemoryStore } from "./src/store/types.js";
import type { EmbeddingService } from "./src/store/embedding.js";
import { executeMemorySearch, formatSearchResponse } from "./src/tools/memory-search.js";
import { executeConversationSearch, formatConversationSearchResponse } from "./src/tools/conversation-search.js";
import { LocalMemoryCleaner } from "./src/utils/memory-cleaner.js";
import { getOrCreateInstanceId, initReporter, report } from "./src/report/reporter.js";
import { registerMemoryTdaiCli } from "./src/cli/index.js";
import {
initDataDirectories,
initStores,
resetStores,
createPipelineManager,
createL1Runner,
createPersister,
createL2Runner,
createL3Runner,
} from "./src/utils/pipeline-factory.js";
import { getOrCreateInstanceId, initReporter, report, resetReporter } from "./src/report/reporter.js";
import { ensureL2L3Local } from "./src/profile/profile-sync.js";
const TAG = "[memory-tdai]";
/**
* Initialize all required data directories under the plugin data root.
*
* Called once at plugin registration time so downstream modules
* (L0 recorder, L1 writer, scene extractor, persona generator, etc.)
* don't need to lazily mkdir on every write — the directories are
* guaranteed to exist from startup.
*
* Directory layout:
* <pluginDataDir>/
* ├── conversations/ — L0 daily JSONL shards (one message per line)
* ├── records/ — L1 daily JSONL shards (extracted memories)
* ├── scene_blocks/ — L2 scene block .md files (LLM-managed)
* ├── .metadata/ — checkpoint, scene_index.json
* └── .backup/ — rotating backups (persona, scene_blocks)
*/
function initDataDirectories(dataDir: string): void {
const dirs = [
"conversations",
"records",
"scene_blocks",
".metadata",
".backup",
];
for (const sub of dirs) {
fs.mkdirSync(path.join(dataDir, sub), { recursive: true });
}
}
/**
* Epoch ms when the plugin was registered (cold-start timestamp).
* Used as a fallback cursor in performAutoCapture when no checkpoint
@@ -151,20 +129,20 @@ function sweepStaleCaches(): void {
export default function register(api: OpenClawPluginApi) {
pluginStartTimestamp = Date.now();
setPreferredEmbeddedAgentRuntime(api.runtime.agent);
// Reset reporter singleton so config changes take effect on hot-reload.
resetReporter();
const _require = createRequire(import.meta.url);
const pluginVersion = (() => { try { return (_require("./package.json") as { version?: string }).version ?? "unknown"; } catch { return "unknown"; } })();
api.logger.info(
api.logger.debug?.(
`${TAG} Registering plugin ... ` +
`startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`,
);
// Persistent instance ID for metric reporting (populated async below)
let instanceId: string | undefined;
let cfg: MemoryTdaiConfig;
try {
cfg = parseConfig(api.pluginConfig as Record<string, unknown> | undefined);
api.logger.info(
api.logger.debug?.(
`${TAG} Config parsed: ` +
`capture=${cfg.capture.enabled}, ` +
`recall=${cfg.recall.enabled}(maxResults=${cfg.recall.maxResults}), ` +
@@ -186,12 +164,26 @@ export default function register(api: OpenClawPluginApi) {
// 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.info(`${TAG} Data dir: ${pluginDataDir} (all subdirectories initialized)`);
api.logger.debug?.(`${TAG} Data dir: ${pluginDataDir} (all subdirectories initialized)`);
// Kick off instanceId resolution immediately after data dir is ready.
// getOrCreateInstanceId only reads/writes a small UUID file and caches the
// result — starting it here means it will almost certainly be settled before
// the first L1 runner fires, avoiding the need to defer metric reporting.
let instanceId: string | undefined;
getOrCreateInstanceId(pluginDataDir).then((id) => {
instanceId = id;
// initReporter is guarded by a "already initialised" check, so calling it
// here is safe even if the registration-complete call below fires first.
initReporter({ enabled: cfg.report.enabled, type: cfg.report.type, logger: api.logger, instanceId: id, pluginVersion });
}).catch((err) => {
api.logger.warn(`${TAG} Failed to initialize instanceId for metrics: ${err instanceof Error ? err.message : String(err)}`);
});
// Unified session/agent filter: combines internal-session detection + user-configured excludeAgents
const sessionFilter = new SessionFilter(cfg.capture.excludeAgents);
if (cfg.capture.excludeAgents.length > 0) {
api.logger.info(`${TAG} Agent exclude patterns: ${cfg.capture.excludeAgents.join(", ")}`);
api.logger.debug?.(`${TAG} Agent exclude patterns: ${cfg.capture.excludeAgents.join(", ")}`);
}
// Daily local JSONL cleaner (L0/L1), enabled only when retentionDays is configured.
@@ -205,13 +197,13 @@ export default function register(api: OpenClawPluginApi) {
logger: api.logger,
});
sharedMemoryCleaner.start();
api.logger.info(`${TAG} Memory cleaner started (singleton)`);
api.logger.debug?.(`${TAG} Memory cleaner started (singleton)`);
} else {
api.logger.info(`${TAG} Memory cleaner already started in this process, reusing existing instance`);
api.logger.debug?.(`${TAG} Memory cleaner already started in this process, reusing existing instance`);
}
memoryCleaner = sharedMemoryCleaner;
} else {
api.logger.info(`${TAG} Memory cleaner disabled (retentionDays not configured)`);
api.logger.debug?.(`${TAG} Memory cleaner disabled (retentionDays not configured)`);
}
// Hardcoded actor ID (legacy, to be removed)
@@ -228,7 +220,7 @@ export default function register(api: OpenClawPluginApi) {
// ============================
// Shared references for tools (populated when extraction scheduler creates them)
let sharedVectorStore: VectorStore | undefined;
let sharedVectorStore: IMemoryStore | undefined;
let sharedEmbeddingService: EmbeddingService | undefined;
/**
@@ -272,12 +264,14 @@ export default function register(api: OpenClawPluginApi) {
};
// tdai_memory_search — Agent-callable L1 memory search tool
// TODO: implement hard per-turn call limit via before_tool_call hook + execute early-return (方案 D)
api.registerTool(
{
name: "tdai_memory_search",
label: "Memory Search",
description:
"Search through the user's long-term memories. Use this when you need to recall specific information about the user's preferences, past events, instructions, or context from previous conversations. Returns relevant memory records ranked by relevance.",
"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: {
@@ -367,6 +361,7 @@ export default function register(api: OpenClawPluginApi) {
);
// 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",
@@ -375,7 +370,8 @@ export default function register(api: OpenClawPluginApi) {
"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.",
"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: {
@@ -464,7 +460,7 @@ export default function register(api: OpenClawPluginApi) {
// (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.info(`${TAG} Registering before_prompt_build hook (auto-recall)`);
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`);
@@ -619,343 +615,128 @@ export default function register(api: OpenClawPluginApi) {
scheduler.start({});
}
// Pre-warm the embedded agent import so the first extraction run doesn't
// pay the cold-start cost (~35s jiti compile → <50ms with dist/ path).
prewarmEmbeddedAgent(api.logger);
// Pre-warm the embedded agent entrypoint. When runtime already exposes
// runEmbeddedPiAgent this becomes a no-op; otherwise it still preloads
// the legacy dist bridge to reduce first-run cold start.
prewarmEmbeddedAgent(api.logger, api.runtime.agent);
};
if (cfg.extraction.enabled) {
// === Initialize VectorStore (always) + EmbeddingService (only when embedding enabled) ===
let vectorStore: VectorStore | undefined;
// === Store + scheduler initialization (async, runs eagerly) ===
// Wrapped in an async IIFE because register() is synchronous.
// initStores() is once-async: the first call creates the store,
// subsequent calls (e.g. from seed CLI) reuse the cached result.
let vectorStore: IMemoryStore | undefined;
let embeddingService: EmbeddingService | undefined;
// VectorStore is always created as the metadata store for L0/L1 records.
// It works as a pure SQLite store even without embedding — keyword search,
// L0/L1 reads, and pipeline queries all use structured SQL, not vectors.
try {
const dims = cfg.embedding.dimensions; // 0 when provider="none" → vec0 tables deferred
const dbPath = path.join(pluginDataDir, "vectors.db");
vectorStore = new VectorStore(dbPath, dims, api.logger);
const storeReady = (async () => {
const stores = await initStores(cfg, pluginDataDir, api.logger);
vectorStore = stores.vectorStore;
embeddingService = stores.embeddingService;
// Create EmbeddingService only when embedding is enabled (remote provider configured)
if (cfg.embedding.enabled) {
// Share with tools immediately
sharedVectorStore = vectorStore;
sharedEmbeddingService = embeddingService;
// Keep cleaner's SQLite handle updated (singleton cleaner may start earlier).
memoryCleaner?.setVectorStore(vectorStore);
if (vectorStore?.pullProfiles) {
try {
if (cfg.embedding.provider !== "local" && cfg.embedding.apiKey) {
// Remote embedding provider (OpenAI-compatible API: OpenAI, Azure, self-hosted, etc.)
embeddingService = createEmbeddingService({
provider: cfg.embedding.provider,
baseUrl: cfg.embedding.baseUrl,
apiKey: cfg.embedding.apiKey,
model: cfg.embedding.model,
dimensions: cfg.embedding.dimensions,
proxyUrl: cfg.embedding.proxyUrl,
maxInputChars: cfg.embedding.maxInputChars,
timeoutMs: cfg.embedding.timeoutMs,
}, api.logger);
} else {
// Local provider (node-llama-cpp) — preserved internally but not reachable from user config
embeddingService = createEmbeddingService({
provider: "local",
modelPath: cfg.embedding.model || undefined,
modelCacheDir: cfg.embedding.modelCacheDir,
}, api.logger);
}
await ensureL2L3Local(pluginDataDir, vectorStore, api.logger);
} catch (err) {
api.logger.warn(
`${TAG} EmbeddingService init failed, continuing with keyword-only mode: ${err instanceof Error ? err.message : String(err)}`,
);
embeddingService = undefined;
api.logger.warn(`${TAG} Startup L2/L3 pull failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
}
} else {
api.logger.info(`${TAG} Embedding disabled by config, VectorStore will serve as metadata-only store`);
}
// Init VectorStore with provider info (undefined when no embedding → skips provider change detection)
const providerInfo = embeddingService?.getProviderInfo();
const initResult = vectorStore.init(providerInfo);
// If VectorStore entered degraded mode (e.g. sqlite-vec load failed),
// treat it as unavailable and fall back to keyword-only mode.
if (vectorStore.isDegraded()) {
api.logger.warn(
`${TAG} VectorStore is in degraded mode, falling back to keyword dedup`,
);
vectorStore = undefined;
embeddingService = undefined;
} else {
// If embedding provider/model/dimensions changed, re-embed all existing texts
if (stores.needsReindex && embeddingService && vectorStore) {
const svc = embeddingService;
const vs = vectorStore;
api.logger.info(
`${TAG} VectorStore initialized: ${dbPath} (${dims}D, provider=${cfg.embedding.provider})`,
`${TAG} Embedding config changed (${stores.reindexReason}). ` +
`Starting background re-embed of all stored texts...`,
);
// If embedding provider/model/dimensions changed, re-embed all existing texts
if (initResult.needsReindex && embeddingService) {
const svc = embeddingService; // capture for async closure
const vs = vectorStore; // capture for async closure
vs.reindexAll(
(text) => svc.embed(text),
(done, total, layer) => {
if (done === total || done % 50 === 0) {
api.logger.debug?.(`${TAG} Re-embed progress: ${layer} ${done}/${total}`);
}
},
).then(({ l1Count, l0Count }) => {
api.logger.info(
`${TAG} Embedding config changed (${initResult.reason}). ` +
`Starting background re-embed of all stored texts...`,
`${TAG} Re-embed complete: L1=${l1Count} records, L0=${l0Count} messages`,
);
// Run re-embed asynchronously so it doesn't block plugin startup
vs.reindexAll(
(text) => svc.embed(text),
(done, total, layer) => {
if (done === total || done % 50 === 0) {
api.logger.debug?.(`${TAG} Re-embed progress: ${layer} ${done}/${total}`);
}
},
).then(({ l1Count, l0Count }) => {
api.logger.info(
`${TAG} Re-embed complete: L1=${l1Count} records, L0=${l0Count} messages`,
);
}).catch((err) => {
api.logger.error(
`${TAG} Re-embed failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
});
}
}).catch((err) => {
api.logger.error(
`${TAG} Re-embed failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
});
}
} catch (err) {
api.logger.warn(
`${TAG} VectorStore init failed; vector/FTS recall and dedup conflict detection will be unavailable: ${err instanceof Error ? err.message : String(err)}`,
);
vectorStore = undefined;
embeddingService = undefined;
}
})();
// Share vectorStore/embeddingService with tdai_memory_search tool
sharedVectorStore = vectorStore;
sharedEmbeddingService = embeddingService;
// === Create pipeline manager (sync — does not need store) ===
scheduler = createPipelineManager(cfg, api.logger, sessionFilter);
// Keep cleaner's SQLite handle updated (singleton cleaner may start earlier).
memoryCleaner?.setVectorStore(vectorStore);
// 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,
}));
// === Create pipeline manager ===
scheduler = new MemoryPipelineManager(
{
everyNConversations: cfg.pipeline.everyNConversations,
enableWarmup: cfg.pipeline.enableWarmup,
l1: { idleTimeoutSeconds: cfg.pipeline.l1IdleTimeoutSeconds },
l2: {
delayAfterL1Seconds: cfg.pipeline.l2DelayAfterL1Seconds,
minIntervalSeconds: cfg.pipeline.l2MinIntervalSeconds,
maxIntervalSeconds: cfg.pipeline.l2MaxIntervalSeconds,
sessionActiveWindowHours: cfg.pipeline.sessionActiveWindowHours,
},
},
api.logger,
sessionFilter,
);
// Persister via shared factory
scheduler!.setPersister(createPersister(pluginDataDir, api.logger));
// L1 runner: read L0 from DB (primary) or JSONL (fallback) → local LLM extraction → L1 JSONL + VectorStore
scheduler.setL1Runner(async ({ sessionKey }) => {
// L1 reads L0 data from VectorStore DB (primary, indexed query).
// Fallback: read from L0 JSONL files when VectorStore is unavailable.
if (!api.config) {
api.logger.debug?.(`${TAG} [pipeline-l1] No OpenClaw config, skipping L1 extraction`);
return { processedCount: 0 };
}
const checkpoint = new CheckpointManager(pluginDataDir, api.logger);
const cp = await checkpoint.read();
const runnerState = checkpoint.getRunnerState(cp, sessionKey);
api.logger.info(
`${TAG} [pipeline-l1] Session ${sessionKey}: ` +
`l1_cursor=${runnerState.last_l1_cursor || "(start)"}`,
);
try {
// Read L0 messages since last L1 cursor, grouped by sessionId.
// Within the same sessionKey, different sessionIds represent different
// conversation instances (e.g. after /reset). Each group is extracted
// independently so its sessionId is correctly associated with L1 records.
//
// Primary path: read from VectorStore DB (indexed query, fast).
// Fallback: read from L0 JSONL files (scan + parse, slower).
let groups: Array<{ sessionId: string; messages: ConversationMessage[] }>;
if (vectorStore && !vectorStore.isDegraded()) {
// DB path: fast indexed query
// NOTE: When last_l1_cursor is 0 (first L1 run), we pass undefined
// to query all messages — but only those captured AFTER plugin start
// (L0 capture uses pluginStartTimestamp as floor, so DB won't contain
// pre-existing messages). This is safe because auto-capture already
// filters out messages older than pluginStartTimestamp.
const l1Cursor = runnerState.last_l1_cursor > 0
? runnerState.last_l1_cursor
: undefined;
const dbGroups = vectorStore.queryL0GroupedBySessionId(
sessionKey,
l1Cursor,
);
// Cast role from string to "user" | "assistant" (DB stores as string)
groups = dbGroups.map((g) => ({
sessionId: g.sessionId,
messages: g.messages.map((m) => ({
id: m.id,
role: m.role as "user" | "assistant",
content: m.content,
timestamp: m.timestamp,
})),
}));
api.logger.debug?.(
`${TAG} [pipeline-l1] L0 data source: VectorStore DB`,
);
} else {
// Fallback: JSONL files
api.logger.debug?.(
`${TAG} [pipeline-l1] L0 data source: JSONL files (VectorStore unavailable)`,
);
const jsonlGroups = await readConversationMessagesGroupedBySessionId(
sessionKey,
// L2 runner: read L1 records (incremental) → SceneExtractor
scheduler!.setL2Runner(async (sessionKey: string, cursor?: string) => {
try {
const l2Runner = createL2Runner({
pluginDataDir,
runnerState.last_l1_cursor || undefined,
api.logger,
50, // Match DB path limit (queryL0ForL1 default)
);
// Convert SessionIdMessageGroup[] to the same shape
groups = jsonlGroups.map((g) => ({
sessionId: g.sessionId,
messages: g.messages,
}));
}
if (groups.length === 0) {
api.logger.debug?.(`${TAG} [pipeline-l1] No new L0 messages for session ${sessionKey}`);
return { processedCount: 0 };
}
const totalMessages = groups.reduce((sum, g) => sum + g.messages.length, 0);
api.logger.info(
`${TAG} [pipeline-l1] Processing ${totalMessages} L0 messages across ${groups.length} sessionId group(s) for session ${sessionKey}`,
);
let totalExtracted = 0;
let totalStored = 0;
let lastSceneName: string | undefined;
let maxTimestamp = 0;
for (const group of groups) {
api.logger.debug?.(
`${TAG} [pipeline-l1] Group sessionId=${group.sessionId || "(empty)"}: ${group.messages.length} messages`,
);
const l1Result = await extractL1Memories({
messages: group.messages,
sessionKey,
sessionId: group.sessionId,
baseDir: pluginDataDir,
config: api.config,
options: {
enableDedup: cfg.extraction.enableDedup,
maxMemoriesPerSession: cfg.extraction.maxMemoriesPerSession,
model: cfg.extraction.model,
previousSceneName: lastSceneName ?? (runnerState.last_scene_name || undefined),
vectorStore,
embeddingService,
conflictRecallTopK: cfg.embedding.conflictRecallTopK,
},
cfg,
openclawConfig: api.config,
vectorStore,
logger: api.logger,
instanceId,
});
totalExtracted += l1Result.extractedCount;
totalStored += l1Result.storedCount;
if (l1Result.lastSceneName) {
lastSceneName = l1Result.lastSceneName;
}
const groupMaxTs = Math.max(...group.messages.map((m) => m.timestamp));
maxTimestamp = Math.max(maxTimestamp, groupMaxTs);
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;
}
});
// Update checkpoint on disk — cursor is the global max timestamp across all groups
await checkpoint.markL1ExtractionComplete(
sessionKey,
totalStored,
maxTimestamp,
lastSceneName,
);
api.logger.info(
`${TAG} [pipeline-l1] L1 complete: extracted=${totalExtracted}, stored=${totalStored} (${groups.length} group(s))`,
);
return { processedCount: totalMessages };
} catch (err) {
api.logger.error(`${TAG} [pipeline-l1] L1 failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
// ── error_degradation metric ──
if (instanceId) {
report("error_degradation", {
module: "l1-extraction",
action: "extractL1Memories",
errorType: "exception",
errorMessage: err instanceof Error ? err.message : String(err),
degradedTo: null,
impact: "blocking",
// L3 runner: persona trigger + generation
scheduler!.setL3Runner(async () => {
try {
const l3Runner = createL3Runner({
pluginDataDir,
cfg,
openclawConfig: api.config,
vectorStore,
logger: api.logger,
instanceId,
});
await l3Runner();
} catch (err) {
api.logger.error(`${TAG} [pipeline-l3] Failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
}
throw err; // rethrow so pipeline-manager can retry
}
});
}).catch((err) => {
api.logger.error(
`${TAG} Store init failed; vector/FTS recall and dedup will be unavailable: ${err instanceof Error ? err.message : String(err)}`,
);
});
// Persister: saves pipeline session states to checkpoint
scheduler.setPersister(async (states) => {
const checkpoint = new CheckpointManager(pluginDataDir, api.logger);
await checkpoint.mergePipelineStates(states);
});
// L2 runner: read L1 records (incremental) → SceneExtractor
scheduler.setL2Runner(async (sessionKey: string, cursor?: string) => {
try {
return await runLocalL2Extraction(api, cfg, pluginDataDir, sessionKey, vectorStore, cursor, instanceId);
} catch (err) {
api.logger.error(`${TAG} [pipeline-l2] L2 failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
throw err; // rethrow so pipeline-manager can handle retry/fallback (consistent with L1 runner)
}
});
// L3 runner: persona trigger + generation
scheduler.setL3Runner(async () => {
try {
const trigger = new PersonaTrigger({
dataDir: pluginDataDir,
interval: cfg.persona.triggerEveryN,
logger: api.logger,
});
const { should, reason } = await trigger.shouldGenerate();
if (!should) {
api.logger.debug?.(`${TAG} [pipeline-l3] Persona generation not needed`);
return;
}
if (!api.config) {
api.logger.warn(`${TAG} [pipeline-l3] No OpenClaw config, skipping persona generation`);
return;
}
api.logger.info(`${TAG} [pipeline-l3] Starting persona generation: ${reason}`);
const generator = new PersonaGenerator({
dataDir: pluginDataDir,
config: api.config,
model: cfg.persona.model,
backupCount: cfg.persona.backupCount,
logger: api.logger,
instanceId,
});
const genResult = await generator.generate(reason);
api.logger.info(`${TAG} [pipeline-l3] Persona generation ${genResult ? "succeeded" : "skipped (no changes)"}`);
} catch (err) {
api.logger.error(`${TAG} [pipeline-l3] Failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
}
});
// Capture vectorStore reference for cleanup
const vectorStoreRef = vectorStore;
// Register a SINGLE gateway_stop hook for ordered shutdown.
// Order: memoryCleaner → scheduler → vectorStore → embeddingService
// Order: memoryCleaner → scheduler → vectorStore → embeddingService → resetStores
// (memoryCleaner may use VectorStore during cleanup, so it must stop first)
//
// The entire hook is wrapped with a 3 s timeout to guarantee we never
@@ -965,8 +746,11 @@ export default function register(api: OpenClawPluginApi) {
const GATEWAY_STOP_TIMEOUT_MS = 3_000;
const hookStartMs = Date.now();
// Ensure store init has completed before tearing down
await storeReady.catch(() => {});
const doCleanup = async (): Promise<void> => {
// 1. Stop the memory cleaner
// 1. Stop the memory cleaner first (it may be running deleteL1ExpiredByUpdatedTime)
if (memoryCleaner) {
try {
memoryCleaner.destroy();
@@ -983,16 +767,20 @@ export default function register(api: OpenClawPluginApi) {
const t = Date.now();
await scheduler.destroy();
api.logger.info(`${TAG} [gateway_stop] Scheduler destroyed (${Date.now() - t}ms)`);
} else {
api.logger.info(`${TAG} [gateway_stop] Scheduler was never started, skipping destroy`);
}
// 3. Close VectorStore
if (vectorStoreRef) {
vectorStoreRef.close();
// 3. Close VectorStore last (after all consumers are done)
if (vectorStore) {
api.logger.info(`${TAG} [gateway_stop] Closing VectorStore`);
vectorStore.close();
}
// 4. Release embedding service resources
// 4. Release embedding service resources (model memory, GPU, etc.)
if (embeddingService?.close) {
try {
api.logger.info(`${TAG} [gateway_stop] Closing EmbeddingService`);
await embeddingService.close();
} catch (err) {
api.logger.warn(`${TAG} [gateway_stop] EmbeddingService close error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1021,11 +809,14 @@ export default function register(api: OpenClawPluginApi) {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
// 5. Reset store singleton cache so hot-restart can re-initialize
resetStores();
api.logger.info(`${TAG} [gateway_stop] Cleanup finished, all resources released (${Date.now() - hookStartMs}ms)`);
});
}
api.logger.info(`${TAG} Registering agent_end hook (auto-capture)`);
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`);
@@ -1139,7 +930,7 @@ export default function register(api: OpenClawPluginApi) {
}
});
} else {
api.logger.info(`${TAG} Auto-capture disabled`);
api.logger.debug?.(`${TAG} Auto-capture disabled`);
}
// memoryCleaner gateway_stop is handled in the unified handler above (inside extraction.enabled block).
@@ -1159,177 +950,30 @@ export default function register(api: OpenClawPluginApi) {
});
}
api.logger.info(
// ============================
// CLI registration
// ============================
api.registerCli(
({ program, config, logger: cliLogger }) => {
const memoryTdai = program
.command("memory-tdai")
.description("memory-tdai plugin commands (seed, query, stats)");
registerMemoryTdaiCli(memoryTdai, {
config,
pluginConfig: api.pluginConfig,
stateDir: api.runtime.state.resolveStateDir(),
logger: cliLogger,
});
},
{ commands: ["memory-tdai"] },
);
api.logger.debug?.(
`${TAG} Plugin registration complete (v3). ` +
`startTimestamp=${pluginStartTimestamp} (${new Date(pluginStartTimestamp).toISOString()})`,
);
// Resolve persistent instance ID for metric reporting (used by agent_turn, error_degradation, etc.)
getOrCreateInstanceId(pluginDataDir).then((id) => {
instanceId = 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)}`);
});
}
// ============================
// L2 extraction implementations
// ============================
/**
* Local L2 extraction: read L1 records → SceneExtractor.
*
* Uses **incremental** reads when VectorStore is available:
* 1. Receive the pipeline cursor (`last_extraction_updated_time`) from pipeline-manager
* 2. Query only L1 records updated AFTER that cursor via `queryMemoryRecords`
* 3. Return the latest `updatedAt` from the batch so pipeline-manager can advance the cursor
*
* Falls back to JSONL read (with client-side time filtering) when VectorStore is unavailable.
*/
async function runLocalL2Extraction(
api: OpenClawPluginApi,
cfg: MemoryTdaiConfig,
pluginDataDir: string,
sessionKey: string,
vectorStore?: VectorStore,
updatedAfter?: string,
instanceId?: string,
): Promise<{ latestCursor?: string } | void> {
api.logger.debug?.(
`${TAG} [L2-local] session=${sessionKey}, updatedAfter=${updatedAfter ?? "(full)"}`,
);
let records: Array<{ content: string; created_at: string; id: string; updatedAt: string }>;
// Prefer incremental SQLite query when VectorStore is available
if (vectorStore && !vectorStore.isDegraded()) {
const { queryMemoryRecords } = await import("./src/record/l1-reader.js");
const memRecords = queryMemoryRecords(vectorStore, {
sessionKey,
updatedAfter,
}, api.logger);
if (memRecords.length === 0) {
api.logger.debug?.(
`${TAG} [L2-local] No new L1 records since cursor (session=${sessionKey}, updatedAfter=${updatedAfter ?? "(full)"}), skipping scene extraction`,
);
return;
}
api.logger.debug?.(
`${TAG} [L2-local] Incremental query returned ${memRecords.length} record(s) (session=${sessionKey})`,
);
records = memRecords.map((r) => ({
content: r.content,
created_at: r.createdAt,
id: r.id,
updatedAt: r.updatedAt,
}));
} else {
// Fallback: read JSONL files with client-side time filtering
api.logger.debug?.(`${TAG} [L2-local] VectorStore unavailable, falling back to JSONL read`);
const { readAllMemoryRecords } = await import("./src/record/l1-reader.js");
let allRecords = await readAllMemoryRecords(pluginDataDir, api.logger);
// Apply updatedAfter filter on JSONL records (same semantics as SQLite path)
if (updatedAfter) {
const beforeCount = allRecords.length;
allRecords = allRecords.filter((r) => {
const t = r.updatedAt || r.createdAt || "";
return t > updatedAfter;
});
api.logger.debug?.(
`${TAG} [L2-local] JSONL time filter: ${beforeCount}${allRecords.length} record(s) (updatedAfter=${updatedAfter})`,
);
}
if (allRecords.length === 0) {
api.logger.debug?.(`${TAG} [L2-local] No new L1 records found (JSONL fallback), skipping scene extraction`);
return;
}
records = allRecords.map((r) => ({
content: r.content,
created_at: r.createdAt,
id: r.id,
updatedAt: r.updatedAt,
}));
}
const extractor = new SceneExtractor({
dataDir: pluginDataDir,
config: api.config!,
model: cfg.persona.model,
maxScenes: cfg.persona.maxScenes,
sceneBackupCount: cfg.persona.sceneBackupCount,
logger: api.logger,
instanceId,
});
const memories = records.map((r) => ({
content: r.content,
created_at: r.created_at,
id: r.id,
}));
// ── Checkpoint guard ──────────────────────────────────────────────
// Snapshot critical counters BEFORE the LLM agent runs.
// The LLM operates on checkpoint via raw file tools (write_to_file /
// replace_in_file) which bypass CheckpointManager's file lock.
// If the LLM accidentally overwrites the entire checkpoint (e.g. via
// write_to_file), system-managed counters like scenes_processed and
// memories_since_last_persona can be reset to stale values.
// After extraction we detect and repair such corruption.
const preCheckpoint = new CheckpointManager(pluginDataDir, api.logger);
const preState = await preCheckpoint.read();
const preScenesProcessed = preState.scenes_processed;
const preMemoriesSince = preState.memories_since_last_persona;
const preTotalProcessed = preState.total_processed;
const extractResult = await extractor.extract(memories);
if (extractResult.success && extractResult.memoriesProcessed > 0) {
const checkpoint = new CheckpointManager(pluginDataDir, api.logger);
// Detect and repair LLM-caused checkpoint corruption.
// If the LLM wrote the entire checkpoint file (instead of using
// replace_in_file on specific fields), system-managed counters may
// have been overwritten with stale/zero values.
const postState = await checkpoint.read();
if (
postState.scenes_processed < preScenesProcessed ||
postState.total_processed < preTotalProcessed
) {
api.logger.warn(
`${TAG} [L2-local] ⚠️ Checkpoint corruption detected! ` +
`scenes_processed: ${preScenesProcessed}${postState.scenes_processed}, ` +
`total_processed: ${preTotalProcessed}${postState.total_processed}, ` +
`memories_since: ${preMemoriesSince}${postState.memories_since_last_persona}. ` +
`Repairing...`,
);
await checkpoint.write({
...postState,
scenes_processed: Math.max(postState.scenes_processed, preScenesProcessed),
total_processed: Math.max(postState.total_processed, preTotalProcessed),
memories_since_last_persona: Math.max(postState.memories_since_last_persona, preMemoriesSince),
});
api.logger.info(`${TAG} [L2-local] Checkpoint repaired`);
}
await checkpoint.incrementScenesProcessed();
// Return the max updatedAt from this batch as the new cursor
const latestCursor = records.reduce((latest, r) => {
return r.updatedAt > latest ? r.updatedAt : latest;
}, "");
api.logger.debug?.(
`${TAG} [L2-local] Extraction complete: processed=${extractResult.memoriesProcessed}, latestCursor=${latestCursor}`,
);
return { latestCursor: latestCursor || undefined };
}
}
// ============================