mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
535 lines
19 KiB
TypeScript
535 lines
19 KiB
TypeScript
/**
|
|
* TdaiCore — Host-neutral facade for TDAI memory capabilities.
|
|
*
|
|
* This is the single entry point that both OpenClaw and Hermes/Gateway call
|
|
* to perform recall, capture, search, and pipeline management. It depends
|
|
* only on abstract interfaces (HostAdapter, LLMRunner), never on a specific host.
|
|
*
|
|
* Usage:
|
|
* // OpenClaw path (in-process)
|
|
* const adapter = new OpenClawHostAdapter({ api, pluginDataDir, config });
|
|
* const core = new TdaiCore({ hostAdapter: adapter, config: parsedCfg });
|
|
* await core.initialize();
|
|
* const recall = await core.handleBeforeRecall("user query", "session-1");
|
|
*
|
|
* // Gateway path (HTTP)
|
|
* const adapter = new StandaloneHostAdapter({ ... });
|
|
* const core = new TdaiCore({ hostAdapter: adapter, config: parsedCfg });
|
|
* await core.initialize();
|
|
* // HTTP handler calls core.handleBeforeRecall / core.handleTurnCommitted / etc.
|
|
*/
|
|
|
|
import type {
|
|
HostAdapter,
|
|
Logger,
|
|
LLMRunnerFactory,
|
|
RecallResult,
|
|
CaptureResult,
|
|
CompletedTurn,
|
|
MemorySearchParams,
|
|
ConversationSearchParams,
|
|
} from "./types.js";
|
|
import type { MemoryTdaiConfig } from "../config.js";
|
|
import type { IMemoryStore } from "./store/types.js";
|
|
import type { EmbeddingService } from "./store/embedding.js";
|
|
import { performAutoRecall } from "./hooks/auto-recall.js";
|
|
import { performAutoCapture } from "./hooks/auto-capture.js";
|
|
import { executeMemorySearch, formatSearchResponse } from "./tools/memory-search.js";
|
|
import { executeConversationSearch, formatConversationSearchResponse } from "./tools/conversation-search.js";
|
|
import {
|
|
initDataDirectories,
|
|
initStores,
|
|
resetStores,
|
|
createPipelineManager,
|
|
createL1Runner,
|
|
createPersister,
|
|
createL2Runner,
|
|
createL3Runner,
|
|
} from "../utils/pipeline-factory.js";
|
|
import { MemoryPipelineManager } from "../utils/pipeline-manager.js";
|
|
import { CheckpointManager } from "../utils/checkpoint.js";
|
|
import { SessionFilter } from "../utils/session-filter.js";
|
|
import { StandaloneLLMRunnerFactory } from "../adapters/standalone/llm-runner.js";
|
|
|
|
const TAG = "[memory-tdai] [core]";
|
|
|
|
// ============================
|
|
// Constructor options
|
|
// ============================
|
|
|
|
export interface TdaiCoreOptions {
|
|
/** Host adapter providing runtime context, logger, and LLM runner factory. */
|
|
hostAdapter: HostAdapter;
|
|
/** Parsed TDAI memory configuration. */
|
|
config: MemoryTdaiConfig;
|
|
/** Session filter for excluding internal/benchmark sessions. */
|
|
sessionFilter?: SessionFilter;
|
|
/** Plugin instance ID for metric reporting. */
|
|
instanceId?: string;
|
|
}
|
|
|
|
// ============================
|
|
// TdaiCore
|
|
// ============================
|
|
|
|
export class TdaiCore {
|
|
private hostAdapter: HostAdapter;
|
|
private cfg: MemoryTdaiConfig;
|
|
private logger: Logger;
|
|
private dataDir: string;
|
|
private runnerFactory: LLMRunnerFactory;
|
|
private sessionFilter: SessionFilter;
|
|
private instanceId?: string;
|
|
|
|
// Lazy-initialized resources
|
|
private vectorStore?: IMemoryStore;
|
|
private embeddingService?: EmbeddingService;
|
|
private scheduler?: MemoryPipelineManager;
|
|
/**
|
|
* Promise gate for the one-shot scheduler-start sequence.
|
|
*
|
|
* ``ensureSchedulerStarted`` reads a checkpoint file (async) and then
|
|
* calls ``scheduler.start(restoredStates)``. Under the Gateway, several
|
|
* HTTP requests can reach ``handleTurnCommitted`` concurrently and all
|
|
* race into that function. Using a plain boolean flag is unsafe: the
|
|
* first caller flips the flag to ``true`` *before* the await completes,
|
|
* so subsequent callers slip past the check and touch the scheduler
|
|
* before ``start()`` has actually run — which makes ``start()``'s
|
|
* ``sessionStates.set(key, restored)`` later clobber the state that
|
|
* those concurrent captures already incremented.
|
|
*
|
|
* Storing the in-flight promise lets every concurrent caller ``await``
|
|
* the same start sequence. Once it resolves the promise is kept as a
|
|
* sentinel so subsequent calls are a single already-resolved await
|
|
* (effectively a no-op).
|
|
*/
|
|
private schedulerStartPromise?: Promise<void>;
|
|
private storeReady?: Promise<void>;
|
|
|
|
/**
|
|
* In-flight fire-and-forget background tasks started by
|
|
* ``handleTurnCommitted`` (currently: deferred L0 embedding for
|
|
* SQLite-style stores — see auto-capture.ts path A).
|
|
*
|
|
* ``destroy()`` awaits all pending entries (with a hard timeout)
|
|
* before closing ``vectorStore`` / ``embeddingService`` so that a
|
|
* late ``updateL0Embedding`` cannot land on an already-closed
|
|
* database connection.
|
|
*
|
|
* Each task registers itself on creation and removes itself in its
|
|
* own ``finally`` handler, so the set stays bounded by the number
|
|
* of currently-running background tasks.
|
|
*/
|
|
private readonly bgTasks = new Set<Promise<void>>();
|
|
|
|
constructor(opts: TdaiCoreOptions) {
|
|
this.hostAdapter = opts.hostAdapter;
|
|
this.cfg = opts.config;
|
|
this.logger = opts.hostAdapter.getLogger();
|
|
this.dataDir = opts.hostAdapter.getRuntimeContext().dataDir;
|
|
this.runnerFactory = opts.hostAdapter.getLLMRunnerFactory();
|
|
this.sessionFilter = opts.sessionFilter ?? new SessionFilter([]);
|
|
this.instanceId = opts.instanceId;
|
|
}
|
|
|
|
// ============================
|
|
// Lifecycle
|
|
// ============================
|
|
|
|
/**
|
|
* Initialize data directories, storage, and pipeline scheduler.
|
|
* Must be called once before any other methods.
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
this.logger.debug?.(`${TAG} Initializing TDAI Core: dataDir=${this.dataDir}`);
|
|
initDataDirectories(this.dataDir);
|
|
|
|
// Initialize stores (async)
|
|
this.storeReady = this.initStores();
|
|
|
|
// Create pipeline manager (sync — does not need store)
|
|
if (this.cfg.extraction.enabled) {
|
|
this.scheduler = createPipelineManager(this.cfg, this.logger, this.sessionFilter);
|
|
// Wire runners after store is ready (or after store init fails — runners
|
|
// still work in degraded mode with JSONL fallback and no embedding)
|
|
this.storeReady
|
|
.then(() => this.wirePipelineRunners())
|
|
.catch((err) => {
|
|
this.logger.error(`${TAG} Store init failed; wiring pipeline runners in degraded mode: ${err instanceof Error ? err.message : String(err)}`);
|
|
this.wirePipelineRunners();
|
|
});
|
|
}
|
|
|
|
this.logger.debug?.(`${TAG} TDAI Core initialized`);
|
|
}
|
|
|
|
/**
|
|
* Destroy all resources. Call on shutdown.
|
|
*/
|
|
async destroy(): Promise<void> {
|
|
this.logger.debug?.(`${TAG} Destroying TDAI Core...`);
|
|
|
|
// Wait for store init to complete before tearing down
|
|
await this.storeReady?.catch(() => {});
|
|
|
|
if (this.scheduler && this.schedulerStartPromise) {
|
|
await this.scheduler.destroy();
|
|
this.schedulerStartPromise = undefined;
|
|
this.logger.debug?.(`${TAG} Scheduler destroyed`);
|
|
}
|
|
|
|
// Drain fire-and-forget background tasks started by auto-capture
|
|
// (currently: deferred L0 embedding writes). We must wait for
|
|
// them here — BEFORE closing vectorStore / embeddingService —
|
|
// otherwise a late updateL0Embedding lands on an already-closed
|
|
// DB connection and either throws "database is not open" or
|
|
// (worse) corrupts state. A hard timeout keeps destroy bounded
|
|
// when a background task is stuck on a hung embed HTTP call.
|
|
if (this.bgTasks.size > 0) {
|
|
const pending = [...this.bgTasks];
|
|
this.logger.debug?.(
|
|
`${TAG} Draining ${pending.length} background task(s) before closing stores...`,
|
|
);
|
|
const BG_DRAIN_TIMEOUT_MS = 5_000;
|
|
let drainTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
await Promise.race([
|
|
Promise.allSettled(pending).then(() => undefined),
|
|
new Promise<never>((_, reject) => {
|
|
drainTimeoutId = setTimeout(
|
|
() => reject(new Error("bgTasks drain timeout")),
|
|
BG_DRAIN_TIMEOUT_MS,
|
|
);
|
|
}),
|
|
]);
|
|
this.logger.debug?.(`${TAG} Background tasks drained`);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`${TAG} Background-task drain timed out (${BG_DRAIN_TIMEOUT_MS}ms): ` +
|
|
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
`Closing stores anyway — residual writes may surface as warnings.`,
|
|
);
|
|
} finally {
|
|
if (drainTimeoutId !== undefined) clearTimeout(drainTimeoutId);
|
|
}
|
|
}
|
|
|
|
if (this.vectorStore) {
|
|
this.vectorStore.close();
|
|
this.vectorStore = undefined;
|
|
this.logger.debug?.(`${TAG} VectorStore closed`);
|
|
}
|
|
|
|
if (this.embeddingService?.close) {
|
|
try {
|
|
await this.embeddingService.close();
|
|
} catch (err) {
|
|
this.logger.warn(`${TAG} EmbeddingService close error: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
this.embeddingService = undefined;
|
|
}
|
|
|
|
resetStores(this.dataDir);
|
|
this.logger.debug?.(`${TAG} TDAI Core destroyed`);
|
|
}
|
|
|
|
// ============================
|
|
// Core capabilities
|
|
// ============================
|
|
|
|
/**
|
|
* Handle recall (memory retrieval) before an LLM turn.
|
|
* Maps to: OpenClaw `before_prompt_build` / Hermes `prefetch()`.
|
|
*/
|
|
async handleBeforeRecall(userText: string, sessionKey: string): Promise<RecallResult> {
|
|
await this.storeReady?.catch(() => {});
|
|
|
|
const result = await performAutoRecall({
|
|
userText,
|
|
actorId: "default_user",
|
|
sessionKey,
|
|
cfg: this.cfg,
|
|
pluginDataDir: this.dataDir,
|
|
logger: this.logger,
|
|
vectorStore: this.vectorStore,
|
|
embeddingService: this.embeddingService,
|
|
});
|
|
|
|
return result ?? {};
|
|
}
|
|
|
|
/**
|
|
* Handle turn commitment (conversation capture + pipeline trigger).
|
|
* Maps to: OpenClaw `agent_end` / Hermes `sync_turn()`.
|
|
*/
|
|
async handleTurnCommitted(turn: CompletedTurn): Promise<CaptureResult> {
|
|
await this.storeReady?.catch(() => {});
|
|
await this.ensureSchedulerStarted();
|
|
|
|
return performAutoCapture({
|
|
messages: turn.messages,
|
|
sessionKey: turn.sessionKey,
|
|
sessionId: turn.sessionId,
|
|
cfg: this.cfg,
|
|
pluginDataDir: this.dataDir,
|
|
logger: this.logger,
|
|
scheduler: this.scheduler,
|
|
originalUserText: turn.userText,
|
|
originalUserMessageCount: turn.originalUserMessageCount,
|
|
pluginStartTimestamp: turn.startedAt ?? Date.now(),
|
|
vectorStore: this.vectorStore,
|
|
embeddingService: this.embeddingService,
|
|
bgTaskRegistry: this.bgTasks,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Search L1 structured memories.
|
|
* Maps to: `tdai_memory_search` tool.
|
|
*/
|
|
async searchMemories(params: MemorySearchParams): Promise<{ text: string; total: number; strategy: string }> {
|
|
const result = await executeMemorySearch({
|
|
query: params.query,
|
|
limit: params.limit ?? 5,
|
|
type: params.type,
|
|
scene: params.scene,
|
|
vectorStore: this.vectorStore,
|
|
embeddingService: this.embeddingService,
|
|
logger: this.logger,
|
|
});
|
|
|
|
return {
|
|
text: formatSearchResponse(result),
|
|
total: result.total,
|
|
strategy: result.strategy,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Search L0 raw conversations.
|
|
* Maps to: `tdai_conversation_search` tool.
|
|
*/
|
|
async searchConversations(params: ConversationSearchParams): Promise<{ text: string; total: number }> {
|
|
const result = await executeConversationSearch({
|
|
query: params.query,
|
|
limit: params.limit ?? 5,
|
|
sessionKey: params.sessionKey,
|
|
vectorStore: this.vectorStore,
|
|
embeddingService: this.embeddingService,
|
|
logger: this.logger,
|
|
});
|
|
|
|
return {
|
|
text: formatConversationSearchResponse(result),
|
|
total: result.total,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle end-of-conversation for a single session.
|
|
*
|
|
* ⚠️ Read this if you are editing the method:
|
|
*
|
|
* There are two distinct shutdown-ish events, and they must **NOT**
|
|
* share an implementation:
|
|
*
|
|
* - **`gateway_stop` (OpenClaw / process exit)**
|
|
* The host is going away. Tear everything down — scheduler,
|
|
* VectorStore, EmbeddingService, caches. That is
|
|
* {@link destroy}, not this method.
|
|
*
|
|
* - **`on_session_end` (Hermes) / `POST /session/end` (Gateway)**
|
|
* One conversation ended while the process keeps serving other
|
|
* concurrent sessions. **Only** this session's buffered work
|
|
* should be flushed; every other session's timers, buffers,
|
|
* pipeline state, and the shared scheduler itself MUST remain
|
|
* untouched. That is this method.
|
|
*
|
|
* Historically this method did ``scheduler.destroy() +
|
|
* createPipelineManager()``, which conflated the two semantics and
|
|
* wiped concurrent sessions' in-memory state on every ``/session/end``
|
|
* call. That bug is covered by the concurrency test
|
|
* ``P0-1: handleSessionEnd must be scoped to its session``.
|
|
*
|
|
* @param sessionKey Session whose buffered work should be flushed.
|
|
* Unknown keys are tolerated as a no-op so callers
|
|
* don't have to pre-check whether the session was
|
|
* already evicted or never produced a capture.
|
|
*/
|
|
async handleSessionEnd(sessionKey: string): Promise<void> {
|
|
if (!sessionKey) return;
|
|
await this.storeReady?.catch(() => {});
|
|
if (!this.scheduler) return;
|
|
await this.scheduler.flushSession(sessionKey);
|
|
}
|
|
|
|
// ============================
|
|
// Accessors (for migration bridge)
|
|
// ============================
|
|
|
|
/** Get the LLM runner factory (for creating host-neutral LLM runners). */
|
|
getLLMRunnerFactory(): LLMRunnerFactory {
|
|
return this.runnerFactory;
|
|
}
|
|
|
|
/** Get the shared VectorStore (may be undefined if init failed). */
|
|
getVectorStore(): IMemoryStore | undefined {
|
|
return this.vectorStore;
|
|
}
|
|
|
|
/** Get the shared EmbeddingService (may be undefined if not configured). */
|
|
getEmbeddingService(): EmbeddingService | undefined {
|
|
return this.embeddingService;
|
|
}
|
|
|
|
/** Get the pipeline scheduler (may be undefined if extraction disabled). */
|
|
getScheduler(): MemoryPipelineManager | undefined {
|
|
return this.scheduler;
|
|
}
|
|
|
|
/** Whether the scheduler has been started (or is currently starting). */
|
|
isSchedulerStarted(): boolean {
|
|
return this.schedulerStartPromise !== undefined;
|
|
}
|
|
|
|
/** Set the instance ID for metrics (may be resolved asynchronously). */
|
|
setInstanceId(id: string): void {
|
|
this.instanceId = id;
|
|
if (this.scheduler) {
|
|
this.scheduler.instanceId = id;
|
|
}
|
|
}
|
|
|
|
// ============================
|
|
// Internal helpers
|
|
// ============================
|
|
|
|
private async initStores(): Promise<void> {
|
|
try {
|
|
const stores = await initStores(this.cfg, this.dataDir, this.logger);
|
|
this.vectorStore = stores.vectorStore;
|
|
this.embeddingService = stores.embeddingService;
|
|
this.logger.debug?.(`${TAG} Stores initialized: backend=${this.cfg.storeBackend}, embedding=${this.cfg.embedding.provider}`);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`${TAG} Store init failed; recall/dedup degraded: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private wirePipelineRunners(): void {
|
|
if (!this.scheduler) return;
|
|
|
|
// Determine whether to use standalone LLM runner for extraction.
|
|
// Priority: cfg.llm.enabled (explicit override) > hostType detection.
|
|
const useStandaloneRunner = this.cfg.llm.enabled || this.hostAdapter.hostType !== "openclaw";
|
|
|
|
const openclawConfig = (!useStandaloneRunner && this.hostAdapter.hostType === "openclaw")
|
|
? (this.hostAdapter as { getOpenClawConfig?(): unknown }).getOpenClawConfig?.()
|
|
: undefined;
|
|
|
|
// When standalone runner is active, create LLM runners from the factory.
|
|
// If cfg.llm is configured AND we're in OpenClaw mode, build a dedicated
|
|
// StandaloneLLMRunnerFactory from cfg.llm to override the host runner.
|
|
let runnerFactory = this.runnerFactory;
|
|
if (useStandaloneRunner && this.cfg.llm.enabled && this.hostAdapter.hostType === "openclaw") {
|
|
runnerFactory = new StandaloneLLMRunnerFactory({
|
|
config: {
|
|
baseUrl: this.cfg.llm.baseUrl,
|
|
apiKey: this.cfg.llm.apiKey,
|
|
model: this.cfg.llm.model,
|
|
maxTokens: this.cfg.llm.maxTokens,
|
|
timeoutMs: this.cfg.llm.timeoutMs,
|
|
},
|
|
logger: this.logger,
|
|
});
|
|
this.logger.debug?.(`${TAG} Using standalone LLM override: model=${this.cfg.llm.model}, baseUrl=${this.cfg.llm.baseUrl}`);
|
|
}
|
|
|
|
const l1LlmRunner = useStandaloneRunner
|
|
? runnerFactory.createRunner({ enableTools: false })
|
|
: undefined;
|
|
const l2l3LlmRunner = useStandaloneRunner
|
|
? runnerFactory.createRunner({ enableTools: true })
|
|
: undefined;
|
|
|
|
// L1 runner
|
|
this.scheduler.setL1Runner(createL1Runner({
|
|
pluginDataDir: this.dataDir,
|
|
cfg: this.cfg,
|
|
openclawConfig,
|
|
vectorStore: this.vectorStore,
|
|
embeddingService: this.embeddingService,
|
|
logger: this.logger,
|
|
getInstanceId: () => this.instanceId,
|
|
llmRunner: l1LlmRunner,
|
|
}));
|
|
|
|
// Persister
|
|
this.scheduler.setPersister(createPersister(this.dataDir, this.logger));
|
|
|
|
// L2 runner
|
|
this.scheduler.setL2Runner(async (sessionKey: string, cursor?: string) => {
|
|
const l2Runner = createL2Runner({
|
|
pluginDataDir: this.dataDir,
|
|
cfg: this.cfg,
|
|
openclawConfig,
|
|
vectorStore: this.vectorStore,
|
|
logger: this.logger,
|
|
instanceId: this.instanceId,
|
|
llmRunner: l2l3LlmRunner,
|
|
});
|
|
return l2Runner(sessionKey, cursor);
|
|
});
|
|
|
|
// L3 runner
|
|
this.scheduler.setL3Runner(async () => {
|
|
const l3Runner = createL3Runner({
|
|
pluginDataDir: this.dataDir,
|
|
cfg: this.cfg,
|
|
openclawConfig,
|
|
vectorStore: this.vectorStore,
|
|
logger: this.logger,
|
|
instanceId: this.instanceId,
|
|
llmRunner: l2l3LlmRunner,
|
|
});
|
|
await l3Runner();
|
|
});
|
|
|
|
this.logger.debug?.(`${TAG} Pipeline runners wired`);
|
|
}
|
|
|
|
private ensureSchedulerStarted(): Promise<void> {
|
|
// Fast path: already started (or starting) — every concurrent caller
|
|
// awaits the same in-flight promise. The promise is kept around as a
|
|
// permanently-resolved sentinel after success so subsequent calls
|
|
// collapse into a cheap already-resolved await.
|
|
if (this.schedulerStartPromise) return this.schedulerStartPromise;
|
|
if (!this.scheduler) return Promise.resolve();
|
|
|
|
// Capture scheduler locally so TypeScript narrows inside the closure
|
|
// even after ``this.scheduler`` is re-assigned by handleSessionEnd.
|
|
const scheduler = this.scheduler;
|
|
this.schedulerStartPromise = (async () => {
|
|
try {
|
|
const checkpoint = new CheckpointManager(this.dataDir, this.logger);
|
|
const cp = await checkpoint.read();
|
|
scheduler.start(checkpoint.getAllPipelineStates(cp));
|
|
this.logger.debug?.(`${TAG} Scheduler started`);
|
|
} catch (err) {
|
|
this.logger.error(`${TAG} Failed to restore checkpoint: ${err instanceof Error ? err.message : String(err)}`);
|
|
scheduler.start({});
|
|
}
|
|
})();
|
|
|
|
// If the start sequence itself rejects we clear the gate so the next
|
|
// caller can retry; on success we keep the resolved promise so it
|
|
// short-circuits permanently.
|
|
this.schedulerStartPromise.catch(() => {
|
|
this.schedulerStartPromise = undefined;
|
|
});
|
|
|
|
return this.schedulerStartPromise;
|
|
}
|
|
}
|