mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 12:34:27 +00:00
feat: release v0.2.2 — TCVDB backend, BM25 hybrid retrieval, pipeline refactor
This commit is contained in:
+64
-1
@@ -127,6 +127,37 @@ export interface MemoryCleanupConfig {
|
||||
cleanTime: string;
|
||||
}
|
||||
|
||||
/** BM25 sparse vector encoding configuration (local @tencentdb-agent-memory/tcvdb-text). */
|
||||
export interface BM25Config {
|
||||
/** Whether BM25 sparse encoding is enabled (default: true) */
|
||||
enabled: boolean;
|
||||
/** Language for BM25 pre-trained params: "zh" or "en" (default: "zh") */
|
||||
language: "zh" | "en";
|
||||
}
|
||||
|
||||
/** Tencent Cloud VectorDB configuration. */
|
||||
export interface TcvdbConfig {
|
||||
/** Instance URL (e.g. "http://10.0.1.1:80" or external domain) */
|
||||
url: string;
|
||||
/** Account name (default: "root") */
|
||||
username: string;
|
||||
/** API Key */
|
||||
apiKey: string;
|
||||
/** Database name (auto-generated from instance_id if empty) */
|
||||
database: string;
|
||||
/** User-friendly alias for this database (optional, for identification in database.json) */
|
||||
alias: string;
|
||||
/** Built-in embedding model (default: "bge-large-zh") */
|
||||
embeddingModel: string;
|
||||
/** Request timeout in ms (default: 10000) */
|
||||
timeout: number;
|
||||
/** Path to CA certificate PEM file (for HTTPS connections) */
|
||||
caPemPath?: string;
|
||||
}
|
||||
|
||||
/** Storage backend type. */
|
||||
export type StoreBackend = "sqlite" | "tcvdb";
|
||||
|
||||
/** Report settings — controls metric/event reporting. */
|
||||
export interface ReportConfig {
|
||||
/** Enable reporting (default: true) */
|
||||
@@ -143,6 +174,13 @@ export interface MemoryTdaiConfig {
|
||||
pipeline: PipelineTriggerConfig;
|
||||
recall: RecallConfig;
|
||||
embedding: EmbeddingConfig;
|
||||
/** Storage backend: "sqlite" (default) or "tcvdb" */
|
||||
storeBackend: StoreBackend;
|
||||
/** Tencent Cloud VectorDB configuration (required when storeBackend = "tcvdb") */
|
||||
tcvdb: TcvdbConfig;
|
||||
/** BM25 sparse vector encoding (local @tencentdb-agent-memory/tcvdb-text) */
|
||||
bm25: BM25Config;
|
||||
/** Local JSONL cleanup settings */
|
||||
memoryCleanup: MemoryCleanupConfig;
|
||||
report: ReportConfig;
|
||||
}
|
||||
@@ -272,6 +310,16 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
|
||||
|
||||
const cleanTime = normalizeCleanTime(str(captureGroup, "cleanTime")) ?? "03:00";
|
||||
|
||||
// --- BM25 (local @tencentdb-agent-memory/tcvdb-text encoder) ---
|
||||
const bm25Group = obj(c, "bm25");
|
||||
|
||||
// --- Store backend ---
|
||||
const storeBackendRaw = str(c, "storeBackend") ?? "sqlite";
|
||||
const storeBackend: StoreBackend = storeBackendRaw === "tcvdb" ? "tcvdb" : "sqlite";
|
||||
|
||||
// --- TCVDB config ---
|
||||
const tcvdbGroup = obj(c, "tcvdb");
|
||||
|
||||
const memoryCleanup: MemoryCleanupConfig = {
|
||||
retentionDays,
|
||||
enabled: retentionDays != null,
|
||||
@@ -293,7 +341,7 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
|
||||
},
|
||||
persona: {
|
||||
triggerEveryN: num(personaGroup, "triggerEveryN") ?? 50,
|
||||
maxScenes: num(personaGroup, "maxScenes") ?? 20,
|
||||
maxScenes: num(personaGroup, "maxScenes") ?? 15,
|
||||
backupCount: num(personaGroup, "backupCount") ?? 3,
|
||||
sceneBackupCount: num(personaGroup, "sceneBackupCount") ?? 10,
|
||||
model: optStr(personaGroup, "model"),
|
||||
@@ -328,6 +376,21 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
|
||||
modelCacheDir: optStr(embeddingGroup, "modelCacheDir"),
|
||||
configError: embeddingConfigError,
|
||||
},
|
||||
storeBackend,
|
||||
tcvdb: {
|
||||
url: str(tcvdbGroup, "url") ?? "",
|
||||
username: str(tcvdbGroup, "username") ?? "root",
|
||||
apiKey: str(tcvdbGroup, "apiKey") ?? "",
|
||||
database: str(tcvdbGroup, "database") ?? "",
|
||||
alias: str(tcvdbGroup, "alias") ?? "",
|
||||
embeddingModel: str(tcvdbGroup, "embeddingModel") ?? "bge-large-zh",
|
||||
timeout: num(tcvdbGroup, "timeout") ?? 10000,
|
||||
caPemPath: str(tcvdbGroup, "caPemPath") || undefined,
|
||||
},
|
||||
bm25: {
|
||||
enabled: bool(bm25Group, "enabled") ?? true,
|
||||
language: (str(bm25Group, "language") === "en" ? "en" : "zh") as "zh" | "en",
|
||||
},
|
||||
memoryCleanup,
|
||||
report: {
|
||||
enabled: bool(obj(c, "report"), "enabled") ?? false,
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function recordConversation(params: {
|
||||
// - If a message lacks a timestamp field, extractUserAssistantMessages()
|
||||
// assigns Date.now() at extraction time, which is always > previous cursor.
|
||||
const cursor = afterTimestamp ?? 0;
|
||||
const extracted = cursor > 0
|
||||
const extracted = cursor !== 0
|
||||
? allExtracted.filter((m) => m.timestamp > cursor)
|
||||
: allExtracted;
|
||||
|
||||
@@ -440,7 +440,7 @@ export async function readConversationMessages(
|
||||
*/
|
||||
export interface SessionIdMessageGroup {
|
||||
sessionId: string;
|
||||
messages: ConversationMessage[];
|
||||
messages: Array<ConversationMessage & { recordedAtMs: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,29 +451,32 @@ export interface SessionIdMessageGroup {
|
||||
* so that each group's sessionId is correctly associated with its extracted memories.
|
||||
*
|
||||
* When `limit` is provided, only the **newest** `limit` messages (across all groups)
|
||||
* are retained — matching the DB path's `ORDER BY timestamp DESC LIMIT ?` behavior.
|
||||
* are retained — matching the DB path's `ORDER BY recorded_at DESC LIMIT ?` behavior.
|
||||
* Groups that become empty after truncation are dropped.
|
||||
*
|
||||
* Groups are returned in chronological order (by earliest message timestamp).
|
||||
* Messages within each group are also in chronological order.
|
||||
*
|
||||
* @param afterRecordedAtMs - Epoch ms cursor: only messages with recordedAt > this are included.
|
||||
*/
|
||||
export async function readConversationMessagesGroupedBySessionId(
|
||||
sessionKey: string,
|
||||
baseDir: string,
|
||||
afterTimestamp?: number,
|
||||
afterRecordedAtMs?: number,
|
||||
logger?: Logger,
|
||||
limit?: number,
|
||||
): Promise<SessionIdMessageGroup[]> {
|
||||
const records = await readConversationRecords(sessionKey, baseDir, logger);
|
||||
|
||||
// Collect all messages with their sessionId, respecting afterTimestamp filter
|
||||
const allMessages: Array<{ sessionId: string; msg: ConversationMessage }> = [];
|
||||
// Collect all messages with their sessionId, filtering by recorded_at cursor
|
||||
const allMessages: Array<{ sessionId: string; msg: ConversationMessage & { recordedAtMs: number } }> = [];
|
||||
|
||||
for (const record of records) {
|
||||
const sid = record.sessionId || "";
|
||||
const recMs = Date.parse(record.recordedAt) || 0;
|
||||
if (afterRecordedAtMs && recMs <= afterRecordedAtMs) continue;
|
||||
for (const msg of record.messages) {
|
||||
if (afterTimestamp && msg.timestamp <= afterTimestamp) continue;
|
||||
allMessages.push({ sessionId: sid, msg });
|
||||
allMessages.push({ sessionId: sid, msg: { ...msg, recordedAtMs: recMs } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +494,7 @@ export async function readConversationMessagesGroupedBySessionId(
|
||||
}
|
||||
|
||||
// Re-group by sessionId
|
||||
const groupMap = new Map<string, ConversationMessage[]>();
|
||||
const groupMap = new Map<string, Array<ConversationMessage & { recordedAtMs: number }>>();
|
||||
for (const { sessionId, msg } of selected) {
|
||||
let group = groupMap.get(sessionId);
|
||||
if (!group) {
|
||||
|
||||
+82
-33
@@ -16,7 +16,7 @@ import { CheckpointManager } from "../utils/checkpoint.js";
|
||||
import type { MemoryPipelineManager } from "../utils/pipeline-manager.js";
|
||||
import { recordConversation } from "../conversation/l0-recorder.js";
|
||||
import type { ConversationMessage } from "../conversation/l0-recorder.js";
|
||||
import type { VectorStore, L0VectorRecord } from "../store/vector-store.js";
|
||||
import type { IMemoryStore, L0Record } from "../store/types.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
|
||||
const TAG = "[memory-tdai] [capture]";
|
||||
@@ -68,7 +68,7 @@ export async function performAutoCapture(params: {
|
||||
* prevents the first agent_end from dumping all session history into L0. */
|
||||
pluginStartTimestamp?: number;
|
||||
/** VectorStore for L0 vector indexing (optional). */
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
/** EmbeddingService for L0 vector indexing (optional). */
|
||||
embeddingService?: EmbeddingService;
|
||||
}): Promise<AutoCaptureResult> {
|
||||
@@ -131,32 +131,42 @@ export async function performAutoCapture(params: {
|
||||
const tL0RecordEnd = performance.now();
|
||||
|
||||
// ============================
|
||||
// Step 1.5: L0 vector indexing — metadata written synchronously,
|
||||
// embedding done in background (non-blocking)
|
||||
// Step 1.5: L0 vector indexing
|
||||
// ============================
|
||||
// PERF FIX: Remote embedding API calls (2-3s each) were blocking
|
||||
// the agent_end hook, adding 5-9s latency per conversation round.
|
||||
// Now we:
|
||||
// 1. Write L0 metadata + FTS immediately (no embedding) — ~10ms
|
||||
// 2. Fire off background task to embed + update vectors (non-blocking)
|
||||
// This way the user gets their response immediately.
|
||||
// Two paths depending on store capabilities:
|
||||
//
|
||||
// A) Store supports updateL0Embedding (sqlite):
|
||||
// - Write metadata + FTS immediately WITHOUT embedding (~ms)
|
||||
// - Fire-and-forget background task: embedBatch + updateL0Embedding
|
||||
// - PERF: avoids blocking agent_end with 2-3s embedding calls
|
||||
//
|
||||
// B) Store does NOT support updateL0Embedding (VDB / remote):
|
||||
// - Embed synchronously, then upsertL0 with embedding in one call
|
||||
// - VDB backends handle embedding server-side or need it upfront
|
||||
const tL0VecStart = performance.now();
|
||||
let l0VectorsWritten = 0;
|
||||
let l0EmbedTotalMs = 0;
|
||||
let l0UpsertTotalMs = 0;
|
||||
logger?.debug?.(
|
||||
`${TAG} [L0-vec-index] Check: filteredMessages=${filteredMessages.length}, ` +
|
||||
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
|
||||
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`,
|
||||
);
|
||||
|
||||
// Pre-generate L0 records and write metadata synchronously (fast path)
|
||||
const l0Records: Array<{ record: L0VectorRecord; content: string }> = [];
|
||||
const supportsBgEmbed = vectorStore?.supportsDeferredEmbedding === true;
|
||||
|
||||
if (filteredMessages.length > 0 && vectorStore) {
|
||||
const now = new Date().toISOString();
|
||||
logger?.debug?.(`${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey}`);
|
||||
const bgRecords: Array<{ recordId: string; content: string }> = [];
|
||||
logger?.debug?.(
|
||||
`${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey} ` +
|
||||
`(mode=${supportsBgEmbed ? "async-bg" : "sync"})`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < filteredMessages.length; i++) {
|
||||
const msg = filteredMessages[i];
|
||||
try {
|
||||
const l0Record: L0VectorRecord = {
|
||||
const l0Record: L0Record = {
|
||||
id: generateL0RecordId(sessionKey, i),
|
||||
sessionKey,
|
||||
sessionId: sessionId || "",
|
||||
@@ -166,11 +176,45 @@ export async function performAutoCapture(params: {
|
||||
timestamp: msg.timestamp,
|
||||
};
|
||||
|
||||
// Write metadata + FTS immediately WITHOUT embedding (fast, ~ms)
|
||||
const upsertOk = vectorStore.upsertL0(l0Record, undefined);
|
||||
let embedding: Float32Array | undefined;
|
||||
|
||||
if (!supportsBgEmbed && embeddingService) {
|
||||
// Path B (VDB): embed synchronously — needed for upsertL0
|
||||
// Skip local embed when using server-side embedding (NoopEmbeddingService, dims=0)
|
||||
if (embeddingService.getDimensions() === 0) {
|
||||
logger?.debug?.(
|
||||
`${TAG} [L0-vec-index] Server-side embedding (dims=0), skipping local embed for message ${i}`,
|
||||
);
|
||||
} else {
|
||||
const tEmbedStart = performance.now();
|
||||
try {
|
||||
embedding = await embeddingService.embed(msg.content);
|
||||
l0EmbedTotalMs += performance.now() - tEmbedStart;
|
||||
logger?.debug?.(
|
||||
`${TAG} [L0-vec-index] Embedding OK: dims=${embedding.length}, ` +
|
||||
`norm=${Math.sqrt(Array.from(embedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}`,
|
||||
);
|
||||
} catch (embedErr) {
|
||||
l0EmbedTotalMs += performance.now() - tEmbedStart;
|
||||
logger?.warn(
|
||||
`${TAG} [L0-vec-index] Embedding FAILED for message ${i}, ` +
|
||||
`will write metadata only: ${embedErr instanceof Error ? embedErr.message : String(embedErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path A (sqlite): pass undefined embedding — metadata + FTS only
|
||||
// Path B (VDB): pass embedding (may be undefined on failure)
|
||||
const tUpsertStart = performance.now();
|
||||
const upsertOk = await vectorStore.upsertL0(l0Record, supportsBgEmbed ? undefined : embedding);
|
||||
l0UpsertTotalMs += performance.now() - tUpsertStart;
|
||||
|
||||
if (upsertOk) {
|
||||
l0VectorsWritten++;
|
||||
l0Records.push({ record: l0Record, content: msg.content });
|
||||
if (supportsBgEmbed) {
|
||||
bgRecords.push({ recordId: l0Record.id, content: msg.content });
|
||||
}
|
||||
} else {
|
||||
logger?.warn(`${TAG} [L0-vec-index] upsertL0 returned false for message ${i}`);
|
||||
}
|
||||
@@ -178,38 +222,39 @@ export async function performAutoCapture(params: {
|
||||
logger?.warn?.(`${TAG} [L0-vec-index] FAILED for message ${i} (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
logger?.debug?.(`${TAG} [L0-vec-index] DONE: ${l0VectorsWritten}/${filteredMessages.length} metadata records written (sync)`);
|
||||
|
||||
// Fire-and-forget: batch embed + update vectors in background
|
||||
if (l0Records.length > 0 && embeddingService) {
|
||||
const bgVectorStore = vectorStore; // capture for closure
|
||||
const modeLabel = supportsBgEmbed ? "metadata-only, embed=background" : `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms`;
|
||||
logger?.debug?.(`${TAG} [L0-vec-index] DONE: ${l0VectorsWritten}/${filteredMessages.length} records written (${modeLabel})`);
|
||||
|
||||
// Path A only: fire-and-forget background embedding for sqlite stores
|
||||
if (supportsBgEmbed && bgRecords.length > 0 && embeddingService) {
|
||||
const bgVectorStore = vectorStore;
|
||||
const bgEmbeddingService = embeddingService;
|
||||
const bgRecords = [...l0Records]; // snapshot
|
||||
const bgSnapshot = [...bgRecords];
|
||||
const bgLogger = logger;
|
||||
|
||||
// Do NOT await — this runs in background after response is sent
|
||||
// Do NOT await — runs in background after response is sent
|
||||
void (async () => {
|
||||
const tBgStart = performance.now();
|
||||
try {
|
||||
// Use embedBatch for a single API call instead of N sequential calls
|
||||
const texts = bgRecords.map((r) => r.content);
|
||||
const texts = bgSnapshot.map((r) => r.content);
|
||||
const embeddings = await bgEmbeddingService.embedBatch(texts);
|
||||
|
||||
let bgUpdated = 0;
|
||||
for (let i = 0; i < bgRecords.length; i++) {
|
||||
for (let i = 0; i < bgSnapshot.length; i++) {
|
||||
try {
|
||||
const ok = bgVectorStore.updateL0Embedding(bgRecords[i].record.id, embeddings[i]);
|
||||
const ok = await bgVectorStore.updateL0Embedding!(bgSnapshot[i].recordId, embeddings[i]);
|
||||
if (ok) bgUpdated++;
|
||||
} catch (err) {
|
||||
bgLogger?.warn?.(
|
||||
`${TAG} [L0-vec-index-bg] Failed to update embedding for ${bgRecords[i].record.id}: ` +
|
||||
`${TAG} [L0-vec-index-bg] Failed to update embedding for ${bgSnapshot[i].recordId}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const bgMs = performance.now() - tBgStart;
|
||||
bgLogger?.debug?.(
|
||||
`${TAG} [L0-vec-index-bg] Background embedding complete: ${bgUpdated}/${bgRecords.length} vectors updated (${bgMs.toFixed(0)}ms)`,
|
||||
`${TAG} [L0-vec-index-bg] Background embedding complete: ${bgUpdated}/${bgSnapshot.length} vectors updated (${bgMs.toFixed(0)}ms)`,
|
||||
);
|
||||
} catch (err) {
|
||||
const bgMs = performance.now() - tBgStart;
|
||||
@@ -235,11 +280,13 @@ export async function performAutoCapture(params: {
|
||||
logger?.debug?.(`${TAG} Scheduler notified of conversation round (sessionKey=${sessionKey})`);
|
||||
|
||||
const totalMs = performance.now() - tCaptureStart;
|
||||
const vecDetail = supportsBgEmbed
|
||||
? `metadata-only, embed=background, msgs=${filteredMessages.length}`
|
||||
: `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms, msgs=${filteredMessages.length}`;
|
||||
logger?.info(
|
||||
`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
|
||||
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
|
||||
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms ` +
|
||||
`(metadata-only, embed=background, msgs=${filteredMessages.length}), ` +
|
||||
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms (${vecDetail}), ` +
|
||||
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`,
|
||||
);
|
||||
|
||||
@@ -252,11 +299,13 @@ export async function performAutoCapture(params: {
|
||||
}
|
||||
|
||||
const totalMs = performance.now() - tCaptureStart;
|
||||
const vecDetail = supportsBgEmbed
|
||||
? `metadata-only, embed=background, msgs=${filteredMessages.length}`
|
||||
: `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms, msgs=${filteredMessages.length}`;
|
||||
logger?.info(
|
||||
`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
|
||||
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
|
||||
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms ` +
|
||||
`(metadata-only, embed=background, msgs=${filteredMessages.length}), ` +
|
||||
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms (${vecDetail}), ` +
|
||||
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`,
|
||||
);
|
||||
|
||||
|
||||
+21
-16
@@ -16,8 +16,8 @@ import type { MemoryTdaiConfig } from "../config.js";
|
||||
import { readSceneIndex } from "../scene/scene-index.js";
|
||||
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
|
||||
import type { MemoryRecord } from "../record/l1-reader.js";
|
||||
import type { VectorStore, VectorSearchResult, FtsSearchResult } from "../store/vector-store.js";
|
||||
import { buildFtsQuery } from "../store/vector-store.js";
|
||||
import type { IMemoryStore, L1SearchResult, L1FtsResult } from "../store/types.js";
|
||||
import { buildFtsQuery } from "../store/sqlite.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
import { sanitizeText } from "../utils/sanitize.js";
|
||||
|
||||
@@ -35,6 +35,11 @@ const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
|
||||
- **tdai_memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件节点、规则等关键信息。
|
||||
- **tdai_conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节;也可用于补充或校验 memory_search 的结果。
|
||||
- **read_file**(Scene Navigation 中的路径):当已定位到相关情境,且需要该场景的完整画像、事件经过或阶段结论时使用。
|
||||
|
||||
### ⚠️ 调用次数限制
|
||||
每轮对话中,tdai_memory_search 和 tdai_conversation_search **合计最多调用 3 次**。
|
||||
- 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
|
||||
- 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户,不要继续搜索。
|
||||
</memory-tools-guide>`
|
||||
|
||||
/**
|
||||
@@ -82,7 +87,7 @@ export async function performAutoRecall(params: {
|
||||
cfg: MemoryTdaiConfig;
|
||||
pluginDataDir: string;
|
||||
logger?: Logger;
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
embeddingService?: EmbeddingService;
|
||||
}): Promise<RecallResult | undefined> {
|
||||
const { cfg, logger } = params;
|
||||
@@ -112,7 +117,7 @@ async function performAutoRecallInner(params: {
|
||||
cfg: MemoryTdaiConfig;
|
||||
pluginDataDir: string;
|
||||
logger?: Logger;
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
embeddingService?: EmbeddingService;
|
||||
}): Promise<RecallResult | undefined> {
|
||||
const { userText, cfg, pluginDataDir, logger, vectorStore, embeddingService } = params;
|
||||
@@ -269,7 +274,7 @@ async function searchMemoriesWithDetails(
|
||||
cfg: MemoryTdaiConfig,
|
||||
logger: Logger | undefined,
|
||||
strategy: "keyword" | "embedding" | "hybrid",
|
||||
vectorStore?: VectorStore,
|
||||
vectorStore?: IMemoryStore,
|
||||
embeddingService?: EmbeddingService,
|
||||
): Promise<{ lines: string[]; memories: RecalledMemory[]; timing: SearchTiming }> {
|
||||
const result = await searchMemories(userText, pluginDataDir, cfg, logger, strategy, vectorStore, embeddingService);
|
||||
@@ -305,7 +310,7 @@ async function searchMemories(
|
||||
cfg: MemoryTdaiConfig,
|
||||
logger: Logger | undefined,
|
||||
strategy: "keyword" | "embedding" | "hybrid",
|
||||
vectorStore?: VectorStore,
|
||||
vectorStore?: IMemoryStore,
|
||||
embeddingService?: EmbeddingService,
|
||||
): Promise<SearchResult> {
|
||||
const emptyResult: SearchResult = { lines: [], timing: { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 } };
|
||||
@@ -378,14 +383,14 @@ async function searchByKeyword(
|
||||
maxResults: number,
|
||||
threshold: number,
|
||||
logger?: Logger,
|
||||
vectorStore?: VectorStore,
|
||||
vectorStore?: IMemoryStore,
|
||||
): Promise<string[]> {
|
||||
// Prefer FTS5 if available
|
||||
if (vectorStore?.isFtsAvailable()) {
|
||||
const ftsQuery = buildFtsQuery(userText);
|
||||
if (ftsQuery) {
|
||||
logger?.debug?.(`${TAG} [keyword-fts] Using FTS5 BM25 search: query="${ftsQuery}"`);
|
||||
const ftsResults = vectorStore.ftsSearchL1(ftsQuery, maxResults * 2);
|
||||
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, maxResults * 2);
|
||||
if (ftsResults.length > 0) {
|
||||
logger?.debug?.(
|
||||
`${TAG} [keyword-fts] FTS5 raw results (${ftsResults.length}): ` +
|
||||
@@ -428,7 +433,7 @@ async function searchByEmbedding(
|
||||
userText: string,
|
||||
maxResults: number,
|
||||
threshold: number,
|
||||
vectorStore: VectorStore,
|
||||
vectorStore: IMemoryStore,
|
||||
embeddingService: EmbeddingService,
|
||||
logger?: Logger,
|
||||
): Promise<string[]> {
|
||||
@@ -442,7 +447,7 @@ async function searchByEmbedding(
|
||||
`searching top-${maxResults * 2}...`,
|
||||
);
|
||||
// Retrieve more candidates for subsequent filtering
|
||||
const vecResults: VectorSearchResult[] = vectorStore.search(queryEmbedding, maxResults * 2);
|
||||
const vecResults: L1SearchResult[] = await vectorStore.searchL1Vector(queryEmbedding, maxResults * 2);
|
||||
|
||||
if (vecResults.length === 0) {
|
||||
logger?.debug?.(`${TAG} [embedding-search] Returned 0 results`);
|
||||
@@ -489,7 +494,7 @@ async function searchHybrid(
|
||||
_pluginDataDir: string,
|
||||
maxResults: number,
|
||||
_threshold: number,
|
||||
vectorStore: VectorStore,
|
||||
vectorStore: IMemoryStore,
|
||||
embeddingService: EmbeddingService,
|
||||
logger?: Logger,
|
||||
): Promise<SearchResult> {
|
||||
@@ -505,7 +510,7 @@ async function searchHybrid(
|
||||
if (vectorStore.isFtsAvailable()) {
|
||||
const ftsQuery = buildFtsQuery(userText);
|
||||
if (ftsQuery) {
|
||||
const ftsResults = vectorStore.ftsSearchL1(ftsQuery, candidateK);
|
||||
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, candidateK);
|
||||
if (ftsResults.length > 0) {
|
||||
logger?.debug?.(`${TAG} [hybrid-keyword-fts] FTS5 found ${ftsResults.length} candidates`);
|
||||
// Convert FtsSearchResult to ScoredRecord for RRF merge
|
||||
@@ -547,12 +552,12 @@ async function searchHybrid(
|
||||
logger?.debug?.(
|
||||
`${TAG} [hybrid-embedding] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
|
||||
);
|
||||
const results = vectorStore.search(queryEmbedding, candidateK);
|
||||
const results = await vectorStore.searchL1Vector(queryEmbedding, candidateK, userText);
|
||||
logger?.debug?.(`${TAG} [hybrid-embedding] Got ${results.length} candidates`);
|
||||
return { results, ms: performance.now() - tStart };
|
||||
} catch (err) {
|
||||
logger?.warn?.(`${TAG} Hybrid: embedding part failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return { results: [] as VectorSearchResult[], ms: performance.now() - tStart };
|
||||
return { results: [] as L1SearchResult[], ms: performance.now() - tStart };
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
@@ -719,7 +724,7 @@ function recordToFormatable(record: MemoryRecord): FormatableMemory {
|
||||
* Build a FormatableMemory from a VectorSearchResult (embedding search path).
|
||||
* Handles empty/invalid metadata_json, empty timestamp_str gracefully.
|
||||
*/
|
||||
function vectorResultToFormatable(r: VectorSearchResult): FormatableMemory {
|
||||
function vectorResultToFormatable(r: L1SearchResult): FormatableMemory {
|
||||
let activityStart: string | undefined;
|
||||
let activityEnd: string | undefined;
|
||||
if (r.metadata_json && r.metadata_json !== "{}") {
|
||||
@@ -743,7 +748,7 @@ function vectorResultToFormatable(r: VectorSearchResult): FormatableMemory {
|
||||
* Build a FormatableMemory from an FtsSearchResult (FTS5 keyword search path).
|
||||
* Handles empty/invalid metadata_json, empty timestamp_str gracefully.
|
||||
*/
|
||||
function ftsResultToFormatable(r: FtsSearchResult): FormatableMemory {
|
||||
function ftsResultToFormatable(r: L1FtsResult): FormatableMemory {
|
||||
let activityStart: string | undefined;
|
||||
let activityEnd: string | undefined;
|
||||
if (r.metadata_json && r.metadata_json !== "{}") {
|
||||
|
||||
@@ -53,9 +53,9 @@ export class PersonaGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute persona generation.
|
||||
* Execute local persona generation without advancing checkpoint.
|
||||
*/
|
||||
async generate(triggerReason?: string): Promise<boolean> {
|
||||
async generateLocalPersona(triggerReason?: string): Promise<boolean> {
|
||||
const startMs = Date.now();
|
||||
this.logger?.debug?.(`${TAG} Starting generation: reason="${triggerReason ?? "none"}"`);
|
||||
|
||||
@@ -181,9 +181,6 @@ export class PersonaGenerator {
|
||||
const finalContent = nav ? `${personaText}\n\n${nav}\n` : personaText;
|
||||
await fs.writeFile(personaPath, finalContent, "utf-8");
|
||||
|
||||
// 12. Update checkpoint
|
||||
await cpManager.markPersonaGenerated(cp.total_processed);
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
this.logger?.info(`${TAG} Persona written (${finalContent.length} chars) in ${elapsedMs}ms`);
|
||||
|
||||
@@ -202,4 +199,17 @@ export class PersonaGenerator {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible wrapper: local generation + checkpoint advance.
|
||||
*/
|
||||
async generate(triggerReason?: string): Promise<boolean> {
|
||||
const updated = await this.generateLocalPersona(triggerReason);
|
||||
if (!updated) return false;
|
||||
|
||||
const cpManager = new CheckpointManager(this.dataDir);
|
||||
const cp = await cpManager.read();
|
||||
await cpManager.markPersonaGenerated(cp.total_processed);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { IMemoryStore, ProfileRecord, ProfileSyncRecord } from "../store/types.js";
|
||||
import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
|
||||
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
|
||||
|
||||
const PROFILE_SCOPE = "global";
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface ProfileBaseline {
|
||||
version: number;
|
||||
contentMd5: string;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export function buildProfileStableId(scope: string, type: "l2" | "l3", filename: string): string {
|
||||
const hash = createHash("sha256")
|
||||
.update(`${scope}\u0000${type}\u0000${filename}`)
|
||||
.digest("hex");
|
||||
return `profile:v1:${hash}`;
|
||||
}
|
||||
|
||||
function md5(text: string): string {
|
||||
return createHash("md5").update(text).digest("hex");
|
||||
}
|
||||
|
||||
async function statTimes(filePath: string): Promise<{ createdAtMs: number; updatedAtMs: number }> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return {
|
||||
createdAtMs: Math.floor(stat.birthtimeMs || stat.ctimeMs || Date.now()),
|
||||
updatedAtMs: Math.floor(stat.mtimeMs || Date.now()),
|
||||
};
|
||||
} catch {
|
||||
const now = Date.now();
|
||||
return { createdAtMs: now, updatedAtMs: now };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPersonaNavigation(dataDir: string): Promise<void> {
|
||||
const personaPath = path.join(dataDir, "persona.md");
|
||||
let body: string;
|
||||
try {
|
||||
body = stripSceneNavigation(await fs.readFile(personaPath, "utf-8")).trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body) return;
|
||||
|
||||
const index = await readSceneIndex(dataDir);
|
||||
const nav = generateSceneNavigation(index);
|
||||
const finalContent = nav ? `${body}\n\n${nav}\n` : `${body}\n`;
|
||||
await fs.writeFile(personaPath, finalContent, "utf-8");
|
||||
}
|
||||
|
||||
export async function listLocalProfiles(dataDir: string): Promise<ProfileRecord[]> {
|
||||
const profiles: ProfileRecord[] = [];
|
||||
const blocksDir = path.join(dataDir, "scene_blocks");
|
||||
|
||||
try {
|
||||
const files = (await fs.readdir(blocksDir)).filter((file) => file.endsWith(".md")).sort();
|
||||
for (const filename of files) {
|
||||
const filePath = path.join(blocksDir, filename);
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const { createdAtMs, updatedAtMs } = await statTimes(filePath);
|
||||
profiles.push({
|
||||
id: buildProfileStableId(PROFILE_SCOPE, "l2", filename),
|
||||
type: "l2",
|
||||
filename,
|
||||
content,
|
||||
contentMd5: md5(content),
|
||||
version: 0,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore missing scene_blocks directory
|
||||
}
|
||||
|
||||
const personaPath = path.join(dataDir, "persona.md");
|
||||
try {
|
||||
const rawPersona = await fs.readFile(personaPath, "utf-8");
|
||||
const body = stripSceneNavigation(rawPersona).trim();
|
||||
if (body) {
|
||||
const { createdAtMs, updatedAtMs } = await statTimes(personaPath);
|
||||
profiles.push({
|
||||
id: buildProfileStableId(PROFILE_SCOPE, "l3", "persona.md"),
|
||||
type: "l3",
|
||||
filename: "persona.md",
|
||||
content: body,
|
||||
contentMd5: md5(body),
|
||||
version: 0,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore missing persona file
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
export async function pullProfilesToLocal(
|
||||
dataDir: string,
|
||||
store: IMemoryStore,
|
||||
logger: Logger,
|
||||
): Promise<Map<string, ProfileBaseline>> {
|
||||
if (!store.pullProfiles) return new Map();
|
||||
|
||||
const records = await store.pullProfiles();
|
||||
const baseline = new Map<string, ProfileBaseline>();
|
||||
const tempDir = await fs.mkdtemp(path.join(dataDir, ".profiles-pull-"));
|
||||
const tempBlocksDir = path.join(tempDir, "scene_blocks");
|
||||
await fs.mkdir(tempBlocksDir, { recursive: true });
|
||||
|
||||
try {
|
||||
for (const record of records) {
|
||||
baseline.set(record.id, {
|
||||
version: record.version,
|
||||
contentMd5: record.contentMd5,
|
||||
createdAtMs: record.createdAtMs,
|
||||
});
|
||||
|
||||
if (record.type === "l2") {
|
||||
const target = path.join(tempBlocksDir, record.filename);
|
||||
await fs.writeFile(target, record.content, "utf-8");
|
||||
if (md5(record.content) !== record.contentMd5) {
|
||||
await fs.rm(target, { force: true });
|
||||
logger.warn(`[memory-tdai][profile-sync] MD5 mismatch for ${record.filename}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.type === "l3") {
|
||||
const body = stripSceneNavigation(record.content).trim();
|
||||
await fs.writeFile(path.join(tempDir, "persona.md"), body, "utf-8");
|
||||
if (md5(body) !== record.contentMd5) {
|
||||
await fs.rm(path.join(tempDir, "persona.md"), { force: true });
|
||||
logger.warn(`[memory-tdai][profile-sync] MD5 mismatch for ${record.filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const localBlocksDir = path.join(dataDir, "scene_blocks");
|
||||
await fs.rm(localBlocksDir, { recursive: true, force: true });
|
||||
await fs.mkdir(path.dirname(localBlocksDir), { recursive: true });
|
||||
await fs.rename(tempBlocksDir, localBlocksDir);
|
||||
|
||||
const tempPersonaPath = path.join(tempDir, "persona.md");
|
||||
const localPersonaPath = path.join(dataDir, "persona.md");
|
||||
try {
|
||||
await fs.access(tempPersonaPath);
|
||||
await fs.rm(localPersonaPath, { force: true });
|
||||
await fs.rename(tempPersonaPath, localPersonaPath);
|
||||
} catch {
|
||||
await fs.rm(localPersonaPath, { force: true });
|
||||
}
|
||||
|
||||
await syncSceneIndex(dataDir);
|
||||
await refreshPersonaNavigation(dataDir);
|
||||
logger.debug?.(`[memory-tdai][profile-sync] Pulled ${records.length} profile(s) to local cache`);
|
||||
return baseline;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncLocalProfilesToStore(
|
||||
dataDir: string,
|
||||
store: IMemoryStore,
|
||||
baselineMap: Map<string, ProfileBaseline>,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const localProfiles = await listLocalProfiles(dataDir);
|
||||
const localIds = new Set(localProfiles.map((profile) => profile.id));
|
||||
|
||||
const syncRecords: ProfileSyncRecord[] = localProfiles
|
||||
.filter((profile) => baselineMap.get(profile.id)?.contentMd5 !== profile.contentMd5 || !baselineMap.has(profile.id))
|
||||
.map((profile) => ({
|
||||
...profile,
|
||||
baselineVersion: baselineMap.get(profile.id)?.version,
|
||||
}));
|
||||
|
||||
if (syncRecords.length > 0 && store.syncProfiles) {
|
||||
await store.syncProfiles(syncRecords);
|
||||
logger.info(`[memory-tdai][profile-sync] Synced ${syncRecords.length} changed profile(s)`);
|
||||
}
|
||||
|
||||
const deletedIds = [...baselineMap.keys()].filter((id) => !localIds.has(id));
|
||||
if (deletedIds.length > 0 && store.deleteProfiles) {
|
||||
await store.deleteProfiles(deletedIds);
|
||||
logger.info(`[memory-tdai][profile-sync] Deleted ${deletedIds.length} stale profile(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureL2L3Local(
|
||||
dataDir: string,
|
||||
store: IMemoryStore,
|
||||
logger: Logger,
|
||||
): Promise<Map<string, ProfileBaseline>> {
|
||||
if (!store.pullProfiles) return new Map();
|
||||
return pullProfilesToLocal(dataDir, store, logger);
|
||||
}
|
||||
@@ -8,8 +8,10 @@
|
||||
*
|
||||
* Security: The LLM is sandboxed to scene_blocks/ only (workspaceDir = scene_blocks/).
|
||||
* It has NO visibility into checkpoint, scene_index, persona.md, or any other system file.
|
||||
* File deletion is achieved via "soft-delete" — writing an empty string to the file
|
||||
* — and the SceneExtractor subsequently removes empty files with fs.unlink.
|
||||
* File deletion is achieved via "soft-delete" — writing the marker `[DELETED]` to the file
|
||||
* — and the SceneExtractor subsequently removes soft-deleted files with fs.unlink.
|
||||
* Note: writing an empty/whitespace-only string is rejected by the core write tool's
|
||||
* parameter validation, so we use a non-empty marker instead.
|
||||
*
|
||||
* Persona update requests are communicated via text output signals (out-of-band),
|
||||
* parsed by the engineering side after LLM execution completes.
|
||||
@@ -22,6 +24,8 @@ export interface SceneExtractionPromptParams {
|
||||
sceneCountWarning?: string;
|
||||
/** List of existing scene filenames (relative, e.g. ["work.md", "hobby.md"]) */
|
||||
existingSceneFiles?: string[];
|
||||
/** Maximum number of scene blocks allowed */
|
||||
maxScenes: number;
|
||||
}
|
||||
|
||||
export function buildSceneExtractionPrompt(params: SceneExtractionPromptParams): string {
|
||||
@@ -31,6 +35,7 @@ export function buildSceneExtractionPrompt(params: SceneExtractionPromptParams):
|
||||
currentTimestamp,
|
||||
sceneCountWarning,
|
||||
existingSceneFiles,
|
||||
maxScenes,
|
||||
} = params;
|
||||
|
||||
const warningSection = sceneCountWarning
|
||||
@@ -51,7 +56,7 @@ export function buildSceneExtractionPrompt(params: SceneExtractionPromptParams):
|
||||
|
||||
### Layer 2 (Processing): Scene Diaries
|
||||
- **形态**:**不是清单,是连贯的叙事文档**
|
||||
- **逻辑**:将 L1 碎片融合进特定场景文件(强制限制在15个以内)
|
||||
- **逻辑**:将 L1 碎片融合进特定场景文件
|
||||
- **动作**:Create(创建)、Integrate(整合)、Rewrite(重写)
|
||||
- **禁止**:简单追加列表
|
||||
|
||||
@@ -63,6 +68,8 @@ ${warningSection}
|
||||
2. 现有 Block 映射表 (Existing Blocks Map): 包含当前所有记忆块(Markdown 文件)的文件名和摘要的列表。
|
||||
3. 当前时间 (Current Time): 用于生成元数据的具体时间戳。
|
||||
|
||||
**⚠️ 场景文件数量上限:${maxScenes} 个。处理完成后目录中的场景文件数量必须严格小于此上限。**
|
||||
|
||||
|
||||
### 1️⃣ New Memories List
|
||||
${memoriesJson}
|
||||
@@ -86,6 +93,8 @@ ${existingSceneFiles.map((f) => `- \`${f}\``).join("\n")}
|
||||
3. **创建新场景文件时**,直接使用文件名,如 \`新场景名.md\`
|
||||
4. **场景文件支持 replace_in_file**。对于局部更新(如只更新某个章节或 META 字段),可以使用 \`replace_in_file\` 进行精确替换。对于大范围重写或结构性变更,建议使用 \`read_file\` + \`write_to_file\` 整体重写。
|
||||
5. **场景索引和系统配置由工程系统自动维护**,你只需专注于操作 \`.md\` 场景文件
|
||||
6. **删除文件的唯一方式**:使用 \`write_to_file(filename, '[DELETED]')\` 将文件内容写为 \`[DELETED]\` 标记。系统会自动清理带有此标记的文件。**禁止**写入空字符串(会被系统拒绝)。**禁止**用 \`[ARCHIVE]\`、\`[CONSOLIDATED]\` 等其他标记替代删除——只有 \`[DELETED]\` 标记会触发系统清理。
|
||||
7. **禁止创建报告/整合/汇总类文件**。你的输出必须是有意义的场景叙事文件(如"技术架构与工程实践.md"、"日常生活与工作节奏.md")。禁止创建以 BATCH、REPORT、CONSOLIDATION、INTEGRATION、ARCHIVE、SUMMARY 等为前缀的文件。
|
||||
|
||||
## 工作流与逻辑 (Workflow & Logic)
|
||||
在生成输出之前,你必须执行以下"思维链"过程:
|
||||
@@ -94,11 +103,12 @@ ${existingSceneFiles.map((f) => `- \`${f}\``).join("\n")}
|
||||
|
||||
**在处理任何记忆之前,你必须:**
|
||||
|
||||
1. **统计当前场景总数**:检查 "Existing Scene Blocks Summary" 中的场景数量
|
||||
2. **遵守分级预警,上限为15个block**:
|
||||
- 红色预警(≥ 15):**必须先合并**,将最相似的 2-4 个场景合并为 1 个,然后再处理新记忆
|
||||
- 橙色预警(= 15-1):**只能 UPDATE 现有场景,不能 CREATE 新场景**
|
||||
- 黄色预警(接近15):**优先 UPDATE 或主动 MERGE 相似场景**
|
||||
1. **统计当前场景总数**:查看 "Existing Scene Blocks Summary" 顶部标注的当前场景总数
|
||||
2. **最终目标**:处理完成后,目录中的场景文件数量必须 **严格小于 ${maxScenes}**
|
||||
3. **遵守分级预警**:
|
||||
- 红色预警(≥ ${maxScenes}):**必须先通过 MERGE 减少文件数量**,将最相似的 2-4 个场景合并为 1 个,**并删除被合并的旧文件**,直到文件数 < ${maxScenes} 后,再处理新记忆
|
||||
- 橙色预警(= ${maxScenes - 1}):**只能 UPDATE 现有场景,不能 CREATE 新场景**
|
||||
- 黄色预警(接近 ${maxScenes}):**优先 UPDATE 或主动 MERGE 相似场景**
|
||||
|
||||
**合并优先级**(当需要合并时,按以下顺序选择):
|
||||
1. **主题高度重叠**:如"Python后端开发"和"Go后端开发" → 合并为"后端开发技术栈"
|
||||
@@ -120,10 +130,11 @@ ${existingSceneFiles.map((f) => `- \`${f}\``).join("\n")}
|
||||
1. **UPDATE(更新)**【首选策略】: 如果存在相关的 Block(基于摘要或文件名的相似性),先 read 文件内的具体信息,再锁定该 Block 进行更新(write 或 replace)
|
||||
2. **MERGE(合并)**:
|
||||
- 合并的新 block 应该是生成概括性更强的场景,包含已有的多个相似场景
|
||||
- **强制合并**:当前 Block 总数 **≥ 上限**时,必须先将多个相似记忆合并,或者删除最旧或者最不重要的 block
|
||||
- **强制合并**:当前 Block 总数 **≥ ${maxScenes}** 时,必须先将多个相似记忆合并
|
||||
- **主动合并**:即使未达上限,如果两个 Block 属于同一叙事弧线,也应合并以增加深度
|
||||
- **⚠️ 合并后必须删除旧文件**:被合并的旧场景文件必须通过 \`write_to_file(旧文件名, '[DELETED]')\` 写入删除标记。**仅仅打标记(如 [ARCHIVE]、[CONSOLIDATED])不算删除,文件仍会占用配额。**
|
||||
3. **CREATE(新建)**【最后手段】:
|
||||
- **前提条件**:当前场景总数未达上限
|
||||
- **前提条件**:当前场景总数 < ${maxScenes}
|
||||
- **CREATE 前的强制验证**:必须先用 \`read_file\` 检查至少 2 个最相似的现有场景,确认新记忆确实无法融入后才能 CREATE。跳过验证直接 CREATE 是被禁止的
|
||||
- 如果话题是全新的且与现有内容区分度高,可以创建新 Block
|
||||
- **每次批处理最多新增 1 个场景**
|
||||
@@ -135,14 +146,15 @@ ${existingSceneFiles.map((f) => `- \`${f}\``).join("\n")}
|
||||
3. \`write_to_file('Python后端开发.md', B)\` → **整体重写该场景文件**
|
||||
或 \`replace_in_file('Python后端开发.md', old_section, new_section)\` → **局部更新某部分**
|
||||
|
||||
合并多个 block 的逻辑:
|
||||
**示例 B:合并多个 block(MERGE — 合并后必须删除旧文件)**
|
||||
**具体操作步骤(工具调用)**:
|
||||
1. \`read_file('Python后端开发.md')\` → 获取内容 A
|
||||
2. \`read_file('Go后端开发.md')\` → 获取内容 B
|
||||
3. 整合 A + B + 新记忆 → 生成新内容 C(\`heat = heatA + heatB + 1\`)
|
||||
4. \`write_to_file('后端开发技术栈.md', C)\` → 创建新文件,写入合并后的完整内容
|
||||
5. \`write_to_file('Python后端开发.md', '')\` → **清空旧文件 A(标记删除)**
|
||||
6. \`write_to_file('Go后端开发.md', '')\` → **清空旧文件 B(标记删除)**
|
||||
4. \`write_to_file('后端开发技术栈.md', C)\` → 创建合并后的新文件
|
||||
5. \`write_to_file('Python后端开发.md', '[DELETED]')\` → **⚠️ 删除旧文件 A(写 [DELETED] 标记)**
|
||||
6. \`write_to_file('Go后端开发.md', '[DELETED]')\` → **⚠️ 删除旧文件 B(写 [DELETED] 标记)**
|
||||
**关键**:步骤 5-6 是必须的!不执行删除 = 文件总数不减少 = 合并无效。
|
||||
|
||||
### 阶段 3:撰写与合成(核心任务)
|
||||
深度整合: 严禁简单的文本追加。你必须结合上下文(基于摘要或提供的原始内容)重写叙事,将新信息自然地融入其中。
|
||||
@@ -224,5 +236,5 @@ reason: 具体原因描述
|
||||
- 使用 \`read_file\` 读取需要更新的场景文件
|
||||
- 使用 \`write_to_file\` 创建新文件或**整体重写**已有场景文件
|
||||
- 使用 \`replace_in_file\` 对场景文件进行**局部更新**(如只更新某个章节)
|
||||
- **删除文件**:使用 \`write_to_file(filename, '')\` 将文件内容清空(系统会自动清理空文件,禁止使用其他删除方式)`;
|
||||
- **删除文件**:使用 \`write_to_file(filename, '[DELETED]')\` 将文件内容写为 **\`[DELETED]\` 标记**。系统会自动清理这些文件。**重要**:只有 \`[DELETED]\` 标记才会触发系统清理。写入空字符串会被系统拒绝,写入 \`[ARCHIVE]\`、\`[CONSOLIDATED]\` 等标记**不会删除文件**,文件会继续占用场景配额。`;
|
||||
}
|
||||
|
||||
+17
-12
@@ -16,8 +16,8 @@ import { CONFLICT_DETECTION_SYSTEM_PROMPT, formatBatchConflictPrompt } from "../
|
||||
import type { CandidateMatch } from "../prompts/l1-dedup.js";
|
||||
import { CleanContextRunner } from "../utils/clean-context-runner.js";
|
||||
import { sanitizeJsonForParse } from "../utils/sanitize.js";
|
||||
import type { VectorStore } from "../store/vector-store.js";
|
||||
import { buildFtsQuery } from "../store/vector-store.js";
|
||||
import type { IMemoryStore } from "../store/types.js";
|
||||
import { buildFtsQuery } from "../store/sqlite.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
|
||||
interface Logger {
|
||||
@@ -60,7 +60,7 @@ export async function batchDedup(params: {
|
||||
logger?: Logger;
|
||||
model?: string;
|
||||
/** Vector store for cosine similarity candidate recall */
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
/** Embedding service for computing query vectors */
|
||||
embeddingService?: EmbeddingService;
|
||||
/** Top-K candidates per new memory (default: 5) */
|
||||
@@ -81,7 +81,7 @@ export async function batchDedup(params: {
|
||||
}));
|
||||
|
||||
// Determine what recall capabilities are available
|
||||
const hasVectorData = vectorStore && vectorStore.count() > 0;
|
||||
const hasVectorData = vectorStore && (await vectorStore.countL1()) > 0;
|
||||
const hasFts = vectorStore?.isFtsAvailable() ?? false;
|
||||
|
||||
// Fast path: no recall capability at all → skip dedup
|
||||
@@ -109,7 +109,7 @@ export async function batchDedup(params: {
|
||||
);
|
||||
// Degrade to FTS keyword recall
|
||||
if (hasFts) {
|
||||
matches = findCandidatesByFts(memories, vectorStore!, logger);
|
||||
matches = await findCandidatesByFts(memories, vectorStore!, logger);
|
||||
} else {
|
||||
logger?.debug?.(`${TAG} FTS not available either, skipping conflict detection`);
|
||||
return storeAll();
|
||||
@@ -118,7 +118,7 @@ export async function batchDedup(params: {
|
||||
} else if (hasFts) {
|
||||
// === Tier 2: FTS keyword recall ===
|
||||
logger?.debug?.(`${TAG} Using FTS keyword recall mode (no embedding service or no vector data)`);
|
||||
matches = findCandidatesByFts(memories, vectorStore!, logger);
|
||||
matches = await findCandidatesByFts(memories, vectorStore!, logger);
|
||||
} else {
|
||||
// Shouldn't reach here given the fast-path check above, but be defensive
|
||||
logger?.debug?.(`${TAG} No usable recall path, skipping conflict detection`);
|
||||
@@ -191,7 +191,7 @@ async function runLlmJudgment(
|
||||
*/
|
||||
async function findCandidatesByVector(
|
||||
memories: Array<ExtractedMemory & { record_id: string }>,
|
||||
vectorStore: VectorStore,
|
||||
vectorStore: IMemoryStore,
|
||||
embeddingService: EmbeddingService,
|
||||
topK: number,
|
||||
logger?: Logger,
|
||||
@@ -209,7 +209,7 @@ async function findCandidatesByVector(
|
||||
const queryVec = embeddings[i];
|
||||
|
||||
// Vector search top-K (request extra to account for self-batch filtering)
|
||||
const searchResults = vectorStore.search(queryVec, topK + memories.length);
|
||||
const searchResults = await vectorStore.searchL1Vector(queryVec, topK + memories.length, mem.content);
|
||||
|
||||
// Exclude records from current batch, convert to MemoryRecord format
|
||||
const candidates: MemoryRecord[] = searchResults
|
||||
@@ -245,18 +245,18 @@ async function findCandidatesByVector(
|
||||
* Uses the FTS index for efficient BM25-ranked keyword matching.
|
||||
* This replaces the old Jaccard word-overlap fallback entirely.
|
||||
*/
|
||||
function findCandidatesByFts(
|
||||
async function findCandidatesByFts(
|
||||
memories: Array<ExtractedMemory & { record_id: string }>,
|
||||
vectorStore: VectorStore,
|
||||
vectorStore: IMemoryStore,
|
||||
_logger?: Logger,
|
||||
): CandidateMatch[] {
|
||||
): Promise<CandidateMatch[]> {
|
||||
const newRecordIds = new Set(memories.map((m) => m.record_id));
|
||||
const matches: CandidateMatch[] = [];
|
||||
|
||||
for (const mem of memories) {
|
||||
const ftsQuery = buildFtsQuery(mem.content);
|
||||
if (ftsQuery) {
|
||||
const ftsResults = vectorStore.ftsSearchL1(ftsQuery, 10);
|
||||
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, 10);
|
||||
// Filter out records from the current batch
|
||||
const candidates: MemoryRecord[] = ftsResults
|
||||
.filter((r) => !newRecordIds.has(r.record_id))
|
||||
@@ -333,6 +333,11 @@ function parseBatchResult(
|
||||
const d = item as Record<string, unknown>;
|
||||
|
||||
const recordId = String(d.record_id ?? "");
|
||||
// Skip entries with empty/missing record_id — they are LLM hallucinations
|
||||
if (!recordId) {
|
||||
logger?.debug?.(`${TAG} Skipping decision with empty record_id`);
|
||||
continue;
|
||||
}
|
||||
const action = String(d.action ?? "store");
|
||||
|
||||
if (!validActions.includes(action)) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { writeMemory, generateMemoryId } from "./l1-writer.js";
|
||||
import type { ExtractedMemory, MemoryRecord, MemoryType, DedupDecision } from "./l1-writer.js";
|
||||
import { CleanContextRunner } from "../utils/clean-context-runner.js";
|
||||
import { sanitizeJsonForParse, shouldExtractL1 } from "../utils/sanitize.js";
|
||||
import type { VectorStore } from "../store/vector-store.js";
|
||||
import type { IMemoryStore } from "../store/types.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
import { report } from "../report/reporter.js";
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function extractL1Memories(params: {
|
||||
/** Previous scene name for continuity */
|
||||
previousSceneName?: string;
|
||||
/** Vector store for cosine similarity candidate recall */
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
/** Embedding service for computing query vectors */
|
||||
embeddingService?: EmbeddingService;
|
||||
/** Top-K candidates for conflict recall (default: 5) */
|
||||
@@ -394,7 +394,7 @@ async function applyDecisions(params: {
|
||||
sessionKey: string;
|
||||
sessionId?: string;
|
||||
logger?: Logger;
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
embeddingService?: EmbeddingService;
|
||||
}): Promise<MemoryRecord[]> {
|
||||
const { memoriesWithIds, decisions, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService } = params;
|
||||
@@ -447,7 +447,7 @@ async function storeAllDirectly(
|
||||
sessionKey: string,
|
||||
sessionId: string | undefined,
|
||||
logger?: Logger,
|
||||
vectorStore?: VectorStore,
|
||||
vectorStore?: IMemoryStore,
|
||||
embeddingService?: EmbeddingService,
|
||||
): Promise<MemoryRecord[]> {
|
||||
const storedRecords: MemoryRecord[] = [];
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { MemoryRecord, MemoryType, EpisodicMetadata } from "./l1-writer.js";
|
||||
import type { VectorStore, L1RecordRow, L1QueryFilter } from "../store/vector-store.js";
|
||||
import type { IMemoryStore, L1RecordRow, L1QueryFilter } from "../store/types.js";
|
||||
|
||||
// Re-export types that readers need
|
||||
export type { MemoryRecord, MemoryType, EpisodicMetadata } from "./l1-writer.js";
|
||||
export type { L1QueryFilter } from "../store/vector-store.js";
|
||||
export type { L1QueryFilter } from "../store/types.js";
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
@@ -44,17 +44,17 @@ const TAG = "[memory-tdai] [l1-reader]";
|
||||
*
|
||||
* Falls back to empty array if VectorStore is null or degraded.
|
||||
*/
|
||||
export function queryMemoryRecords(
|
||||
vectorStore: VectorStore | null | undefined,
|
||||
export async function queryMemoryRecords(
|
||||
vectorStore: IMemoryStore | null | undefined,
|
||||
filter?: L1QueryFilter,
|
||||
logger?: Logger,
|
||||
): MemoryRecord[] {
|
||||
): Promise<MemoryRecord[]> {
|
||||
if (!vectorStore) {
|
||||
logger?.warn(`${TAG} queryMemoryRecords: no VectorStore available, returning empty`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = vectorStore.queryL1Records(filter);
|
||||
const rows = await vectorStore.queryL1Records(filter);
|
||||
return rows.map(rowToMemoryRecord);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import type { VectorStore } from "../store/vector-store.js";
|
||||
import type { IMemoryStore } from "../store/types.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
|
||||
// ============================
|
||||
@@ -149,7 +149,7 @@ export async function writeMemory(params: {
|
||||
sessionId?: string;
|
||||
logger?: Logger;
|
||||
/** Optional vector store for dual-write (JSONL + vector DB) */
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
/** Optional embedding service (required when vectorStore is provided) */
|
||||
embeddingService?: EmbeddingService;
|
||||
}): Promise<MemoryRecord | null> {
|
||||
@@ -208,7 +208,7 @@ export async function writeMemory(params: {
|
||||
// by memory-cleaner (which reconciles against VectorStore as source of truth).
|
||||
if (vectorStore) {
|
||||
try {
|
||||
vectorStore.deleteBatch(decision.target_ids);
|
||||
await vectorStore.deleteL1Batch(decision.target_ids);
|
||||
logger?.debug?.(`${TAG} VectorStore: deleted ${decision.target_ids.length} target record(s) for ${decision.action}`);
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
@@ -251,7 +251,7 @@ export async function writeMemory(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const upsertOk = vectorStore.upsert(record, embedding);
|
||||
const upsertOk = await vectorStore.upsertL1(record, embedding);
|
||||
logger?.debug?.(`${TAG} [vec-dual-write] upsert result=${upsertOk} id=${record.id}`);
|
||||
} catch (err) {
|
||||
// Vector write failure should NOT block the main JSONL write
|
||||
|
||||
+10
-2
@@ -19,7 +19,7 @@ let _reporter: IReporter | undefined;
|
||||
export function initReporter(opts: {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
logger: { info: (msg: string) => void };
|
||||
logger: { info: (msg: string) => void; debug?: (msg: string) => void };
|
||||
instanceId: string;
|
||||
pluginVersion: string;
|
||||
}): void {
|
||||
@@ -31,7 +31,7 @@ export function initReporter(opts: {
|
||||
break;
|
||||
// TODO: add new reporter type
|
||||
default:
|
||||
opts.logger.info(`[memory-tdai] Unknown reporter type "${opts.type}", disabled reporting`);
|
||||
opts.logger.debug?.(`[memory-tdai] Unknown reporter type "${opts.type}", disabled reporting`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ export function setReporter(reporter: IReporter): void {
|
||||
_reporter = reporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the reporter singleton so that the next `initReporter` call takes effect.
|
||||
* Must be called at plugin re-registration (hot-reload) to pick up config changes.
|
||||
*/
|
||||
export function resetReporter(): void {
|
||||
_reporter = undefined;
|
||||
}
|
||||
|
||||
export function report(event: string, data: ReportPayload): void {
|
||||
if (!_reporter) return;
|
||||
try {
|
||||
|
||||
@@ -89,7 +89,7 @@ export class SceneExtractor {
|
||||
|
||||
constructor(opts: SceneExtractorOptions) {
|
||||
this.dataDir = opts.dataDir;
|
||||
this.maxScenes = opts.maxScenes ?? 20;
|
||||
this.maxScenes = opts.maxScenes ?? 15;
|
||||
this.sceneBackupCount = opts.sceneBackupCount ?? 10;
|
||||
this.timeoutMs = opts.timeoutMs ?? 300_000; // 5 min — LLM may do multiple tool calls
|
||||
this.logger = opts.logger;
|
||||
@@ -190,6 +190,7 @@ export class SceneExtractor {
|
||||
currentTimestamp,
|
||||
sceneCountWarning,
|
||||
existingSceneFiles,
|
||||
maxScenes: this.maxScenes,
|
||||
});
|
||||
this.logger?.debug?.(`${TAG} extract() prompt built: ${prompt.length} chars (${Date.now() - promptStartMs}ms)`);
|
||||
|
||||
@@ -218,20 +219,34 @@ export class SceneExtractor {
|
||||
// Phase 5: Subsequent processing — safe cleanup of soft-deleted files
|
||||
//
|
||||
// Security: The LLM has no `exec` tool and cannot run shell commands.
|
||||
// Instead, it "deletes" files by writing empty content (soft-delete).
|
||||
// Here we detect and remove those empty files before syncing the index,
|
||||
// so syncSceneIndex won't re-index stale empty entries.
|
||||
// Instead, it "deletes" files by writing the marker `[DELETED]` to the file
|
||||
// (writing empty/whitespace-only content is rejected by core's write tool
|
||||
// parameter validation). Here we detect and remove those soft-deleted files
|
||||
// before syncing the index, so syncSceneIndex won't re-index stale entries.
|
||||
//
|
||||
// We also detect "META-only" files — files that contain only a META header
|
||||
// (e.g. [ARCHIVE] or [CONSOLIDATED] markers) but no actual scene content.
|
||||
// These are artifacts of LLM merges that didn't properly delete old files.
|
||||
const cleanupStartMs = Date.now();
|
||||
let cleanedCount = 0;
|
||||
try {
|
||||
const allFiles = (await fs.readdir(sceneBlocksDir)).filter((f) => f.endsWith(".md"));
|
||||
for (const file of allFiles) {
|
||||
const filePath = path.join(sceneBlocksDir, file);
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
if (content.trim().length === 0) {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
if (raw.trim().length === 0 || raw.trim() === "[DELETED]") {
|
||||
// Empty file or [DELETED] marker — soft-delete
|
||||
await fs.unlink(filePath);
|
||||
cleanedCount++;
|
||||
this.logger?.debug?.(`${TAG} extract() removed soft-deleted file: ${file}`);
|
||||
} else {
|
||||
// Check if file has only META header but no actual content
|
||||
const block = parseSceneBlock(raw, file);
|
||||
if (!block.content || block.content.trim().length === 0) {
|
||||
await fs.unlink(filePath);
|
||||
cleanedCount++;
|
||||
this.logger?.debug?.(`${TAG} extract() removed META-only file (no content): ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Input loading, validation, normalization, and timestamp handling for the `seed` command.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Load raw JSON from file
|
||||
* 2. Detect Format A (`{ sessions: [...] }`) vs Format B (`[...]`)
|
||||
* 3. Six-layer validation (file → top-level → session → round → message → timestamp consistency)
|
||||
* 4. Normalize into NormalizedInput with auto-generated sessionIds
|
||||
* 5. Timestamp all-or-none check + fill strategy
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import type {
|
||||
RawSession,
|
||||
FormatA,
|
||||
ValidationError,
|
||||
NormalizedInput,
|
||||
NormalizedSession,
|
||||
NormalizedRound,
|
||||
NormalizedMessage,
|
||||
SeedCommandOptions,
|
||||
} from "./types.js";
|
||||
|
||||
// ============================
|
||||
// Public API
|
||||
// ============================
|
||||
|
||||
export interface LoadAndValidateResult {
|
||||
/** Normalized input ready for pipeline consumption. */
|
||||
input: NormalizedInput;
|
||||
/** Whether the user needs to confirm timestamp auto-fill. */
|
||||
needsTimestampConfirmation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load, validate, and normalize seed input from a file.
|
||||
*
|
||||
* Throws on fatal validation errors with a human-readable message
|
||||
* that includes all collected errors.
|
||||
*/
|
||||
export function loadAndValidateInput(
|
||||
opts: Pick<SeedCommandOptions, "input" | "sessionKey" | "strictRoundRole">,
|
||||
): LoadAndValidateResult {
|
||||
// Layer 1: File — read + parse
|
||||
const raw = loadRawInput(opts.input);
|
||||
|
||||
// Layer 2: Top-level — detect A vs B
|
||||
const sessions = extractSessions(raw);
|
||||
|
||||
// Layers 3-5: session / round / message validation
|
||||
const errors: ValidationError[] = [];
|
||||
validateSessions(sessions, opts.strictRoundRole, errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new SeedValidationError(errors);
|
||||
}
|
||||
|
||||
// Layer 6: Timestamp consistency (all-have / all-missing / mixed → error)
|
||||
const tsResult = checkTimestampConsistency(sessions);
|
||||
if (tsResult.status === "mixed") {
|
||||
throw new SeedValidationError([{
|
||||
stage: "timestamp_consistency",
|
||||
message:
|
||||
"Timestamp consistency check failed: some messages have timestamps while others do not. " +
|
||||
"All messages must either have timestamps or none must have timestamps.",
|
||||
}]);
|
||||
}
|
||||
|
||||
// Normalize
|
||||
const normalized = normalizeSessions(sessions, opts.sessionKey);
|
||||
|
||||
return {
|
||||
input: {
|
||||
sessions: normalized.sessions,
|
||||
totalRounds: normalized.totalRounds,
|
||||
totalMessages: normalized.totalMessages,
|
||||
hasTimestamps: tsResult.status === "all_present",
|
||||
},
|
||||
needsTimestampConfirmation: tsResult.status === "all_missing",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill timestamps for all messages when the input has no timestamps.
|
||||
*
|
||||
* Uses a single monotonically increasing counter across ALL sessions
|
||||
* to guarantee global timestamp ordering. This is critical when multiple
|
||||
* sessions share the same sessionKey — the L0 capture cursor (advanced
|
||||
* per-session) would filter out later sessions whose timestamps fall
|
||||
* below the cursor if ordering were not globally monotonic.
|
||||
*/
|
||||
export function fillTimestamps(input: NormalizedInput): void {
|
||||
let currentTs = Date.now();
|
||||
for (const session of input.sessions) {
|
||||
for (const round of session.rounds) {
|
||||
for (let i = 0; i < round.messages.length; i++) {
|
||||
// Small offset per message to maintain strict ordering
|
||||
round.messages[i]!.timestamp = currentTs;
|
||||
currentTs += 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
input.hasTimestamps = true;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Validation error class
|
||||
// ============================
|
||||
|
||||
export class SeedValidationError extends Error {
|
||||
public readonly errors: ValidationError[];
|
||||
|
||||
constructor(errors: ValidationError[]) {
|
||||
const summary = errors.map((e) => formatValidationError(e)).join("\n");
|
||||
super(`Seed input validation failed (${errors.length} error(s)):\n${summary}`);
|
||||
this.name = "SeedValidationError";
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
function formatValidationError(e: ValidationError): string {
|
||||
const parts: string[] = [` [${e.stage}]`];
|
||||
if (e.sourceIndex != null) parts.push(`session[${e.sourceIndex}]`);
|
||||
if (e.sessionKey) parts.push(`key="${e.sessionKey}"`);
|
||||
if (e.roundIndex != null) parts.push(`round[${e.roundIndex}]`);
|
||||
if (e.messageIndex != null) parts.push(`msg[${e.messageIndex}]`);
|
||||
parts.push(e.message);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Layer 1: File loading
|
||||
// ============================
|
||||
|
||||
function loadRawInput(filePath: string): unknown {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new SeedValidationError([{
|
||||
stage: "file",
|
||||
message: `Input file not found: ${filePath}`,
|
||||
}]);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, "utf-8").trim();
|
||||
if (!content) {
|
||||
throw new SeedValidationError([{
|
||||
stage: "file",
|
||||
message: "Input file is empty.",
|
||||
}]);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
throw new SeedValidationError([{
|
||||
stage: "file",
|
||||
message: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Layer 2: Top-level format detection
|
||||
// ============================
|
||||
|
||||
function extractSessions(raw: unknown): RawSession[] {
|
||||
// Format A: { sessions: [...] }
|
||||
if (
|
||||
raw != null &&
|
||||
typeof raw === "object" &&
|
||||
!Array.isArray(raw) &&
|
||||
"sessions" in raw
|
||||
) {
|
||||
const obj = raw as FormatA;
|
||||
if (!Array.isArray(obj.sessions)) {
|
||||
throw new SeedValidationError([{
|
||||
stage: "top_level",
|
||||
message: 'Format A detected but "sessions" is not an array.',
|
||||
}]);
|
||||
}
|
||||
return obj.sessions;
|
||||
}
|
||||
|
||||
// Format B: [...]
|
||||
if (Array.isArray(raw)) {
|
||||
return raw as RawSession[];
|
||||
}
|
||||
|
||||
throw new SeedValidationError([{
|
||||
stage: "top_level",
|
||||
message:
|
||||
"Unrecognized input format. Expected either:\n" +
|
||||
' Format A: { "sessions": [...] }\n' +
|
||||
" Format B: [ { sessionKey, conversations }, ... ]",
|
||||
}]);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Layers 3-5: session / round / message validation
|
||||
// ============================
|
||||
|
||||
function validateSessions(
|
||||
sessions: RawSession[],
|
||||
strictRoundRole: boolean,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (sessions.length === 0) {
|
||||
errors.push({
|
||||
stage: "session",
|
||||
message: "No sessions found in input.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (let si = 0; si < sessions.length; si++) {
|
||||
const session = sessions[si]!;
|
||||
|
||||
// Layer 3: session validation
|
||||
if (!session.sessionKey || typeof session.sessionKey !== "string" || session.sessionKey.trim() === "") {
|
||||
errors.push({
|
||||
stage: "session",
|
||||
sourceIndex: si,
|
||||
message: '"sessionKey" is required and must be a non-empty string.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(session.conversations)) {
|
||||
errors.push({
|
||||
stage: "session",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
message: '"conversations" must be a two-dimensional array (array of rounds).',
|
||||
});
|
||||
continue; // Can't validate rounds
|
||||
}
|
||||
|
||||
// Check that conversations is a 2D array
|
||||
for (let ri = 0; ri < session.conversations.length; ri++) {
|
||||
const round = session.conversations[ri];
|
||||
|
||||
// Layer 4: round validation
|
||||
if (!Array.isArray(round)) {
|
||||
errors.push({
|
||||
stage: "round",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
message: "Round must be an array of messages.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (round.length === 0) {
|
||||
errors.push({
|
||||
stage: "round",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
message: "Round must be a non-empty array.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strict round-role: each round must have at least one user and one assistant
|
||||
if (strictRoundRole) {
|
||||
const roles = new Set(round.map((m) => m.role));
|
||||
if (!roles.has("user")) {
|
||||
errors.push({
|
||||
stage: "round",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
message: '--strict-round-role: round must contain at least one "user" message.',
|
||||
});
|
||||
}
|
||||
if (!roles.has("assistant")) {
|
||||
errors.push({
|
||||
stage: "round",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
message: '--strict-round-role: round must contain at least one "assistant" message.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 5: message validation
|
||||
for (let mi = 0; mi < round.length; mi++) {
|
||||
const msg = round[mi]!;
|
||||
|
||||
if (!msg.role || typeof msg.role !== "string") {
|
||||
errors.push({
|
||||
stage: "message",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
messageIndex: mi,
|
||||
message: '"role" is required and must be a non-empty string.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!msg.content || typeof msg.content !== "string" || msg.content.trim() === "") {
|
||||
errors.push({
|
||||
stage: "message",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
messageIndex: mi,
|
||||
message: '"content" is required and must be a non-empty string.',
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.timestamp !== undefined) {
|
||||
if (typeof msg.timestamp === "number") {
|
||||
if (!Number.isInteger(msg.timestamp)) {
|
||||
errors.push({
|
||||
stage: "message",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
messageIndex: mi,
|
||||
message: '"timestamp" must be an integer (epoch milliseconds). Negative values are allowed for dates before 1970.',
|
||||
});
|
||||
}
|
||||
} else if (typeof msg.timestamp === "string") {
|
||||
if (Number.isNaN(new Date(msg.timestamp).getTime())) {
|
||||
errors.push({
|
||||
stage: "message",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
messageIndex: mi,
|
||||
message: `"timestamp" string is not a valid ISO 8601 date: "${msg.timestamp}".`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors.push({
|
||||
stage: "message",
|
||||
sourceIndex: si,
|
||||
sessionKey: session.sessionKey,
|
||||
roundIndex: ri,
|
||||
messageIndex: mi,
|
||||
message: '"timestamp" must be a number (epoch ms) or an ISO 8601 string.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Layer 6: Timestamp consistency
|
||||
// ============================
|
||||
|
||||
interface TimestampCheckResult {
|
||||
status: "all_present" | "all_missing" | "mixed";
|
||||
}
|
||||
|
||||
function checkTimestampConsistency(sessions: RawSession[]): TimestampCheckResult {
|
||||
let hasTs = false;
|
||||
let missingTs = false;
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!Array.isArray(session.conversations)) continue;
|
||||
for (const round of session.conversations) {
|
||||
if (!Array.isArray(round)) continue;
|
||||
for (const msg of round) {
|
||||
if (msg.timestamp !== undefined && msg.timestamp !== null) {
|
||||
hasTs = true;
|
||||
} else {
|
||||
missingTs = true;
|
||||
}
|
||||
// Early exit on mixed
|
||||
if (hasTs && missingTs) {
|
||||
return { status: "mixed" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTs && !missingTs) return { status: "all_present" };
|
||||
if (!hasTs && missingTs) return { status: "all_missing" };
|
||||
// No messages at all — treat as all_missing (will be caught by session validation)
|
||||
return { status: "all_missing" };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Normalization
|
||||
// ============================
|
||||
|
||||
function normalizeSessions(
|
||||
sessions: RawSession[],
|
||||
fallbackSessionKey?: string,
|
||||
): { sessions: NormalizedSession[]; totalRounds: number; totalMessages: number } {
|
||||
const normalized: NormalizedSession[] = [];
|
||||
let totalRounds = 0;
|
||||
let totalMessages = 0;
|
||||
|
||||
for (let si = 0; si < sessions.length; si++) {
|
||||
const raw = sessions[si]!;
|
||||
|
||||
const sessionKey = raw.sessionKey || fallbackSessionKey || "seed-user";
|
||||
const sessionId = raw.sessionId || crypto.randomUUID();
|
||||
|
||||
const rounds: NormalizedRound[] = [];
|
||||
for (const rawRound of raw.conversations) {
|
||||
if (!Array.isArray(rawRound)) continue;
|
||||
|
||||
const messages: NormalizedMessage[] = rawRound.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
// Normalize timestamp: ISO string → epoch ms, number → pass-through, missing → 0 (filled later)
|
||||
timestamp: msg.timestamp == null
|
||||
? 0
|
||||
: typeof msg.timestamp === "string"
|
||||
? new Date(msg.timestamp).getTime()
|
||||
: msg.timestamp,
|
||||
}));
|
||||
|
||||
rounds.push({ messages });
|
||||
totalMessages += messages.length;
|
||||
}
|
||||
|
||||
totalRounds += rounds.length;
|
||||
normalized.push({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
rounds,
|
||||
sourceIndex: si,
|
||||
});
|
||||
}
|
||||
|
||||
return { sessions: normalized, totalRounds, totalMessages };
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Seed runtime: L0→L1→L2→L3 orchestration for the `seed` command.
|
||||
*
|
||||
* Uses the shared pipeline-factory for VectorStore/EmbeddingService init,
|
||||
* L1 runner, L2 runner, L3 runner, and persister wiring — keeping this
|
||||
* module focused on seed-specific concerns:
|
||||
* - Synchronous per-round L0 capture with progress reporting
|
||||
* - waitForL1Idle polling (L1 only — see FIXME below)
|
||||
* - Ctrl+C graceful shutdown
|
||||
*
|
||||
* FIXME: Currently we only wait for L1 to become idle before destroying the
|
||||
* pipeline. L2 (scene extraction) and L3 (persona generation) may still be
|
||||
* in-flight when `pipeline.destroy()` is called. This is intentional for now
|
||||
* to avoid excessively long seed runs, but means seed output may not include
|
||||
* the latest L2/L3 artifacts. Re-evaluate adding a full L1+L2+L3 idle wait
|
||||
* once pipeline-manager exposes reliable L2/L3 idle signals.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { parseConfig } from "../config.js";
|
||||
import type { MemoryTdaiConfig } from "../config.js";
|
||||
import { performAutoCapture } from "../hooks/auto-capture.js";
|
||||
import { createPipeline, createL2Runner, createL3Runner } from "../utils/pipeline-factory.js";
|
||||
import type { PipelineInstance, PipelineLogger } from "../utils/pipeline-factory.js";
|
||||
import { readManifest, writeManifest } from "../utils/manifest.js";
|
||||
import type { MemoryPipelineManager } from "../utils/pipeline-manager.js";
|
||||
import type {
|
||||
NormalizedInput,
|
||||
SeedProgress,
|
||||
SeedSummary,
|
||||
} from "./types.js";
|
||||
|
||||
const TAG = "[memory-tdai] [seed]";
|
||||
|
||||
// ============================
|
||||
// Seed pipeline options
|
||||
// ============================
|
||||
|
||||
export interface SeedRuntimeOptions {
|
||||
/** Directory to store all seed output (L0, checkpoint, vectors.db). */
|
||||
outputDir: string;
|
||||
/** OpenClaw config object (needed for LLM calls in L1). */
|
||||
openclawConfig: unknown;
|
||||
/** Raw plugin config (same shape as api.pluginConfig). */
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
/** Original input file path (for manifest traceability). */
|
||||
inputFile?: string;
|
||||
/** Logger instance. */
|
||||
logger: PipelineLogger;
|
||||
/** Progress callback (called after each round). */
|
||||
onProgress?: (progress: SeedProgress) => void;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Seed pipeline creation
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create a seed pipeline using the shared factory, with L2/L3 runners
|
||||
* wired via shared factory functions (same logic as index.ts live runtime).
|
||||
*/
|
||||
async function createSeedPipeline(opts: SeedRuntimeOptions): Promise<{ pipeline: PipelineInstance; cfg: MemoryTdaiConfig }> {
|
||||
const { outputDir, openclawConfig, pluginConfig, logger } = opts;
|
||||
|
||||
// Parse config — all values come from pluginConfig (or parseConfig defaults)
|
||||
const cfg = parseConfig(pluginConfig);
|
||||
|
||||
logger.info(
|
||||
`${TAG} Creating seed pipeline: outputDir=${outputDir}, ` +
|
||||
`everyN=${cfg.pipeline.everyNConversations}, l1Idle=${cfg.pipeline.l1IdleTimeoutSeconds}s, ` +
|
||||
`l2Delay=${cfg.pipeline.l2DelayAfterL1Seconds}s, l2Min=${cfg.pipeline.l2MinIntervalSeconds}s, l2Max=${cfg.pipeline.l2MaxIntervalSeconds}s`,
|
||||
);
|
||||
|
||||
// Use shared factory for everything: store init, L1 runner, persister, destroy
|
||||
const pipeline = await createPipeline({
|
||||
pluginDataDir: outputDir,
|
||||
cfg,
|
||||
openclawConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Wire L2 runner via shared factory (same logic as index.ts live runtime)
|
||||
pipeline.scheduler.setL2Runner(createL2Runner({
|
||||
pluginDataDir: outputDir,
|
||||
cfg,
|
||||
openclawConfig,
|
||||
vectorStore: pipeline.vectorStore,
|
||||
logger,
|
||||
}));
|
||||
|
||||
// Wire L3 runner via shared factory (same logic as index.ts live runtime)
|
||||
pipeline.scheduler.setL3Runner(createL3Runner({
|
||||
pluginDataDir: outputDir,
|
||||
cfg,
|
||||
openclawConfig,
|
||||
vectorStore: pipeline.vectorStore,
|
||||
logger,
|
||||
}));
|
||||
|
||||
return { pipeline, cfg };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// waitForL1Idle
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Poll pipeline queue status until L1 is idle for a given session.
|
||||
* Modeled after benchmark-ingest.ts waitForPipelineIdle() but focused on L1 only.
|
||||
*/
|
||||
async function waitForL1Idle(
|
||||
scheduler: MemoryPipelineManager,
|
||||
sessionKeys: string[],
|
||||
logger: PipelineLogger,
|
||||
opts: {
|
||||
pollIntervalMs?: number;
|
||||
stableRounds?: number;
|
||||
maxWaitMs?: number;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const pollInterval = opts.pollIntervalMs ?? 1_000;
|
||||
const stableRounds = opts.stableRounds ?? 3;
|
||||
const maxWait = opts.maxWaitMs ?? 300_000; // 5 min default
|
||||
|
||||
const startTime = Date.now();
|
||||
let consecutiveIdle = 0;
|
||||
|
||||
while (true) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > maxWait) {
|
||||
logger.warn(`${TAG} [waitL1] Max wait time reached (${(maxWait / 1000).toFixed(0)}s), proceeding`);
|
||||
break;
|
||||
}
|
||||
|
||||
const queues = scheduler.getQueueSizes();
|
||||
|
||||
// Check per-session: buffered messages + conversation count
|
||||
let totalBuffered = 0;
|
||||
let totalConversationCount = 0;
|
||||
for (const key of sessionKeys) {
|
||||
totalBuffered += scheduler.getBufferedMessageCount(key);
|
||||
const state = scheduler.getSessionState(key);
|
||||
if (state) {
|
||||
totalConversationCount += state.conversation_count;
|
||||
}
|
||||
}
|
||||
|
||||
const isIdle =
|
||||
queues.l1Idle &&
|
||||
totalBuffered === 0 &&
|
||||
totalConversationCount === 0;
|
||||
|
||||
if (isIdle) {
|
||||
consecutiveIdle++;
|
||||
if (consecutiveIdle >= stableRounds) {
|
||||
logger.debug?.(`${TAG} [waitL1] L1 stable for ${stableRounds} consecutive polls`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
consecutiveIdle = 0;
|
||||
logger.debug?.(
|
||||
`${TAG} [waitL1] Waiting: l1Queue=${queues.l1}, l1Pending=${queues.l1Pending}, l1Idle=${queues.l1Idle}, ` +
|
||||
`buffered=${totalBuffered}, convCount=${totalConversationCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Main execution function
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Execute the seed pipeline: feed normalized input through L0 → L1.
|
||||
*
|
||||
* L2/L3 runners are wired but their completion is **not** awaited — see the
|
||||
* module-level FIXME. The pipeline is destroyed after L1 idle, so L2/L3 may
|
||||
* be interrupted mid-run.
|
||||
*
|
||||
* This is the core runtime called by `src/cli/commands/seed.ts` after
|
||||
* all input validation and user confirmation are complete.
|
||||
*/
|
||||
export async function executeSeed(
|
||||
input: NormalizedInput,
|
||||
opts: SeedRuntimeOptions,
|
||||
): Promise<SeedSummary> {
|
||||
const { logger, onProgress } = opts;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Track interrupt signal
|
||||
let interrupted = false;
|
||||
const onSigint = () => {
|
||||
if (interrupted) {
|
||||
// Second Ctrl+C — force exit
|
||||
logger.warn(`${TAG} Force exit (second Ctrl+C)`);
|
||||
process.exit(1);
|
||||
}
|
||||
interrupted = true;
|
||||
logger.warn(`${TAG} Interrupt received, finishing current round and shutting down...`);
|
||||
};
|
||||
process.on("SIGINT", onSigint);
|
||||
|
||||
let pipeline: PipelineInstance | undefined;
|
||||
let totalL0Recorded = 0;
|
||||
let roundsProcessed = 0;
|
||||
|
||||
try {
|
||||
// Create and start pipeline (returns both the pipeline instance and the
|
||||
// seed-optimized config so we don't need to parse config again)
|
||||
const seed = await createSeedPipeline(opts);
|
||||
pipeline = seed.pipeline;
|
||||
const seedCfg = seed.cfg;
|
||||
|
||||
pipeline.scheduler.start({});
|
||||
logger.info(`${TAG} Pipeline started, processing ${input.sessions.length} session(s), ${input.totalRounds} round(s)`);
|
||||
|
||||
// Seed-specific: use 0 so the cold-start guard in captureAtomically()
|
||||
// does NOT filter out historical messages. In live mode Date.now()
|
||||
// prevents the first agent_end from dumping full session history,
|
||||
// but seed intentionally feeds all historical data.
|
||||
const captureStartTimestamp = 0;
|
||||
|
||||
// Process each session → each round
|
||||
// Key invariant: after every everyNConversations rounds we must wait for L1
|
||||
// to finish before feeding more rounds. Without this pause the for-loop
|
||||
// would dump all rounds into L0 back-to-back and L1 would only run once
|
||||
// with the full batch (defeating the "every N" batching semantics).
|
||||
const everyN = seedCfg.pipeline.everyNConversations;
|
||||
|
||||
for (const session of input.sessions) {
|
||||
if (interrupted) break;
|
||||
|
||||
logger.info(`${TAG} Session: key="${session.sessionKey}" id="${session.sessionId}" rounds=${session.rounds.length}`);
|
||||
|
||||
for (let ri = 0; ri < session.rounds.length; ri++) {
|
||||
if (interrupted) break;
|
||||
|
||||
const round = session.rounds[ri]!;
|
||||
roundsProcessed++;
|
||||
|
||||
// Build messages in the format expected by performAutoCapture.
|
||||
// Field must be named "timestamp" (not "ts") because l0-recorder's
|
||||
// extractUserAssistantMessages reads m.timestamp for incremental filtering.
|
||||
const messages = round.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await performAutoCapture({
|
||||
messages,
|
||||
sessionKey: session.sessionKey,
|
||||
sessionId: session.sessionId,
|
||||
cfg: seedCfg,
|
||||
pluginDataDir: opts.outputDir,
|
||||
logger,
|
||||
scheduler: pipeline.scheduler,
|
||||
pluginStartTimestamp: captureStartTimestamp,
|
||||
vectorStore: pipeline.vectorStore,
|
||||
embeddingService: pipeline.embeddingService,
|
||||
});
|
||||
|
||||
totalL0Recorded += result.l0RecordedCount;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`${TAG} L0 capture failed for session="${session.sessionKey}" round=${ri}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Report progress
|
||||
onProgress?.({
|
||||
currentRound: roundsProcessed,
|
||||
totalRounds: input.totalRounds,
|
||||
sessionKey: session.sessionKey,
|
||||
stage: "l0_captured",
|
||||
});
|
||||
|
||||
// After every N rounds, wait for the triggered L1 to finish before
|
||||
// feeding the next batch. This keeps L1 batches aligned with the
|
||||
// everyNConversations boundary instead of letting all rounds pile up.
|
||||
const roundInSession = ri + 1; // 1-based
|
||||
if (roundInSession % everyN === 0 && !interrupted) {
|
||||
onProgress?.({
|
||||
currentRound: roundsProcessed,
|
||||
totalRounds: input.totalRounds,
|
||||
sessionKey: session.sessionKey,
|
||||
stage: "l1_waiting",
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`${TAG} Pausing after round ${roundInSession}/${session.rounds.length} ` +
|
||||
`for session="${session.sessionKey}" — waiting for L1 to drain`,
|
||||
);
|
||||
|
||||
await waitForL1Idle(
|
||||
pipeline.scheduler,
|
||||
[session.sessionKey],
|
||||
logger,
|
||||
{ pollIntervalMs: 500, stableRounds: 2, maxWaitMs: 120_000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After all rounds for this session, wait for any residual L1 work
|
||||
// (handles the tail when total rounds is not a multiple of everyN)
|
||||
if (!interrupted) {
|
||||
onProgress?.({
|
||||
currentRound: roundsProcessed,
|
||||
totalRounds: input.totalRounds,
|
||||
sessionKey: session.sessionKey,
|
||||
stage: "l1_waiting",
|
||||
});
|
||||
|
||||
await waitForL1Idle(
|
||||
pipeline.scheduler,
|
||||
[session.sessionKey],
|
||||
logger,
|
||||
{ pollIntervalMs: 1_000, stableRounds: 3, maxWaitMs: 300_000 },
|
||||
);
|
||||
|
||||
logger.info(`${TAG} L1 idle for session="${session.sessionKey}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final wait for all sessions
|
||||
if (!interrupted) {
|
||||
const allKeys = input.sessions.map((s) => s.sessionKey);
|
||||
logger.info(`${TAG} Final L1 idle wait for all sessions...`);
|
||||
await waitForL1Idle(
|
||||
pipeline.scheduler,
|
||||
allKeys,
|
||||
logger,
|
||||
{ pollIntervalMs: 1_000, stableRounds: 3, maxWaitMs: 300_000 },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
process.removeListener("SIGINT", onSigint);
|
||||
|
||||
// Graceful shutdown
|
||||
if (pipeline) {
|
||||
try {
|
||||
await pipeline.destroy();
|
||||
} catch (err) {
|
||||
logger.error(`${TAG} Pipeline destroy error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
const summary: SeedSummary = {
|
||||
sessionsProcessed: input.sessions.length,
|
||||
roundsProcessed,
|
||||
messagesProcessed: input.totalMessages,
|
||||
l0RecordedCount: totalL0Recorded,
|
||||
durationMs,
|
||||
outputDir: opts.outputDir,
|
||||
};
|
||||
|
||||
if (interrupted) {
|
||||
logger.warn(`${TAG} Seed interrupted after ${roundsProcessed}/${input.totalRounds} rounds`);
|
||||
} else {
|
||||
logger.info(
|
||||
`${TAG} Seed complete: sessions=${summary.sessionsProcessed}, ` +
|
||||
`rounds=${summary.roundsProcessed}, messages=${summary.messagesProcessed}, ` +
|
||||
`l0Recorded=${summary.l0RecordedCount}, duration=${(durationMs / 1000).toFixed(1)}s`,
|
||||
);
|
||||
}
|
||||
|
||||
// Append seed info to manifest (non-fatal if it fails)
|
||||
try {
|
||||
const manifest = readManifest(opts.outputDir);
|
||||
if (manifest) {
|
||||
manifest.seed = {
|
||||
inputFile: opts.inputFile ? path.basename(opts.inputFile) : undefined,
|
||||
sessions: summary.sessionsProcessed,
|
||||
rounds: summary.roundsProcessed,
|
||||
messages: summary.messagesProcessed,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
writeManifest(opts.outputDir, manifest);
|
||||
logger.info(`${TAG} Manifest updated with seed info`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`${TAG} Failed to update manifest with seed info (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Shared type definitions for the `seed` command.
|
||||
*
|
||||
* Covers:
|
||||
* - Raw input shapes (Format A / B / JSONL)
|
||||
* - Normalized internal structures
|
||||
* - Validation error descriptors
|
||||
*/
|
||||
|
||||
// ============================
|
||||
// Raw input types (before validation)
|
||||
// ============================
|
||||
|
||||
/** A single message in a conversation round. */
|
||||
export interface RawMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
/**
|
||||
* Epoch milliseconds (number) **or** ISO 8601 string (e.g. `"2024-04-01T12:00:00Z"`).
|
||||
* ISO strings are parsed via `new Date()` during normalization and
|
||||
* stored internally as epoch ms.
|
||||
*/
|
||||
timestamp?: number | string;
|
||||
}
|
||||
|
||||
/** A single session entry (shared between Format A wrapper and Format B array). */
|
||||
export interface RawSession {
|
||||
sessionKey: string;
|
||||
sessionId?: string;
|
||||
conversations: RawMessage[][];
|
||||
}
|
||||
|
||||
/** Format A: `{ sessions: [...] }` */
|
||||
export interface FormatA {
|
||||
sessions: RawSession[];
|
||||
}
|
||||
|
||||
/** Format B: `[...]` (top-level array of sessions) */
|
||||
export type FormatB = RawSession[];
|
||||
|
||||
// ============================
|
||||
// Normalized types (after validation)
|
||||
// ============================
|
||||
|
||||
export interface NormalizedMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
/** Epoch ms — always present after normalization (filled if originally missing). */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface NormalizedRound {
|
||||
messages: NormalizedMessage[];
|
||||
}
|
||||
|
||||
export interface NormalizedSession {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
rounds: NormalizedRound[];
|
||||
/** Index in the original input array (for progress reporting). */
|
||||
sourceIndex: number;
|
||||
}
|
||||
|
||||
export interface NormalizedInput {
|
||||
sessions: NormalizedSession[];
|
||||
/** Total number of rounds across all sessions. */
|
||||
totalRounds: number;
|
||||
/** Total number of messages across all sessions. */
|
||||
totalMessages: number;
|
||||
/** Whether timestamps were present in the original input. */
|
||||
hasTimestamps: boolean;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Validation
|
||||
// ============================
|
||||
|
||||
/** Stages where a validation error can occur. */
|
||||
export type ValidationStage =
|
||||
| "file"
|
||||
| "top_level"
|
||||
| "session"
|
||||
| "round"
|
||||
| "message"
|
||||
| "timestamp_consistency";
|
||||
|
||||
/** A single validation error with location context. */
|
||||
export interface ValidationError {
|
||||
stage: ValidationStage;
|
||||
sourceIndex?: number;
|
||||
sessionKey?: string;
|
||||
roundIndex?: number;
|
||||
messageIndex?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Seed command options (from CLI)
|
||||
// ============================
|
||||
|
||||
export interface SeedCommandOptions {
|
||||
/** Path to input file (required). */
|
||||
input: string;
|
||||
/** Output directory (optional, auto-generated if missing). */
|
||||
outputDir?: string;
|
||||
/** Fallback session key when input lacks one. */
|
||||
sessionKey?: string;
|
||||
/** Strict round-role validation (each round must have user + assistant). */
|
||||
strictRoundRole: boolean;
|
||||
/** Skip interactive confirmations. */
|
||||
yes: boolean;
|
||||
/** Path to memory-tdai config override file (JSON, deep-merged on top of current plugin config). */
|
||||
configFile?: string;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Seed runtime types
|
||||
// ============================
|
||||
|
||||
/** Progress info emitted during seed execution. */
|
||||
export interface SeedProgress {
|
||||
/** Current round index (1-based, across all sessions). */
|
||||
currentRound: number;
|
||||
/** Total rounds. */
|
||||
totalRounds: number;
|
||||
/** Current session key. */
|
||||
sessionKey: string;
|
||||
/** Current stage description. */
|
||||
stage: string;
|
||||
}
|
||||
|
||||
/** Final summary after seed completes. */
|
||||
export interface SeedSummary {
|
||||
sessionsProcessed: number;
|
||||
roundsProcessed: number;
|
||||
messagesProcessed: number;
|
||||
l0RecordedCount: number;
|
||||
durationMs: number;
|
||||
outputDir: string;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* BM25 Sparse Vector Encoding Client.
|
||||
*
|
||||
* HTTP client for the BM25 Python sidecar service (bm25_server.py).
|
||||
* Used by TCVDB backend to generate sparse vectors for hybridSearch.
|
||||
*
|
||||
* Two operations:
|
||||
* - `encodeTexts(texts)` — encode documents for upsert (TF-based)
|
||||
* - `encodeQueries(texts)` — encode queries for search (IDF-based)
|
||||
*
|
||||
* Graceful degradation: if the sidecar is unreachable, all methods
|
||||
* return empty arrays and `isHealthy()` returns false. Callers can
|
||||
* check health to dynamically downgrade to pure semantic search.
|
||||
*/
|
||||
|
||||
// ============================
|
||||
// Types
|
||||
// ============================
|
||||
|
||||
/** Sparse vector: array of [token_hash, weight] pairs. */
|
||||
export type SparseVector = Array<[number, number]>;
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface BM25ClientConfig {
|
||||
/** Sidecar service URL (default: "http://127.0.0.1:8084") */
|
||||
serviceUrl: string;
|
||||
/** Request timeout in ms (default: 5000) */
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
interface EncodeResponse {
|
||||
vectors: SparseVector[];
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Implementation
|
||||
// ============================
|
||||
|
||||
const TAG = "[memory-tdai][bm25-client]";
|
||||
|
||||
export class BM25Client {
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeout: number;
|
||||
private readonly logger?: Logger;
|
||||
|
||||
/** Cached health status to avoid repeated checks on every call. */
|
||||
private _healthy: boolean | undefined;
|
||||
private _lastHealthCheck = 0;
|
||||
private static readonly HEALTH_CHECK_INTERVAL_MS = 30_000; // re-check every 30s
|
||||
|
||||
constructor(config: BM25ClientConfig, logger?: Logger) {
|
||||
this.baseUrl = config.serviceUrl.replace(/\/+$/, "");
|
||||
this.timeout = config.timeout;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode document texts for upsert (TF-based BM25 scoring).
|
||||
* Returns one SparseVector per input text.
|
||||
* Returns empty array on error (non-throwing).
|
||||
*/
|
||||
async encodeTexts(texts: string[]): Promise<SparseVector[]> {
|
||||
if (texts.length === 0) return [];
|
||||
return this._encode("/encode_texts", texts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode query texts for search (IDF-based BM25 scoring).
|
||||
* Returns one SparseVector per input text.
|
||||
* Returns empty array on error (non-throwing).
|
||||
*/
|
||||
async encodeQueries(texts: string[]): Promise<SparseVector[]> {
|
||||
if (texts.length === 0) return [];
|
||||
return this._encode("/encode_queries", texts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the BM25 sidecar is reachable.
|
||||
* Result is cached for 30 seconds to avoid spamming health checks.
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
this._healthy !== undefined &&
|
||||
now - this._lastHealthCheck < BM25Client.HEALTH_CHECK_INTERVAL_MS
|
||||
) {
|
||||
return this._healthy;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${this.baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
this._healthy = resp.ok;
|
||||
} catch {
|
||||
this._healthy = false;
|
||||
}
|
||||
this._lastHealthCheck = now;
|
||||
|
||||
if (!this._healthy) {
|
||||
this.logger?.warn(`${TAG} BM25 sidecar health check failed (${this.baseUrl})`);
|
||||
}
|
||||
|
||||
return this._healthy;
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
private async _encode(path: string, texts: string[]): Promise<SparseVector[]> {
|
||||
try {
|
||||
const resp = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ texts }),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text().catch(() => "(unreadable)");
|
||||
this.logger?.warn(
|
||||
`${TAG} ${path} HTTP ${resp.status}: ${errBody.slice(0, 200)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = (await resp.json()) as EncodeResponse;
|
||||
return json.vectors ?? [];
|
||||
} catch (err) {
|
||||
// Mark unhealthy on connection errors
|
||||
this._healthy = false;
|
||||
this._lastHealthCheck = Date.now();
|
||||
|
||||
this.logger?.warn(
|
||||
`${TAG} ${path} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create a BM25Client if BM25 is enabled in config.
|
||||
* Returns undefined if disabled — callers should check before using.
|
||||
*/
|
||||
export function createBM25Client(
|
||||
config: { enabled: boolean; serviceUrl: string; timeout: number },
|
||||
logger?: Logger,
|
||||
): BM25Client | undefined {
|
||||
if (!config.enabled) {
|
||||
logger?.info(`${TAG} BM25 sparse encoding disabled`);
|
||||
return undefined;
|
||||
}
|
||||
logger?.info(`${TAG} BM25 client → ${config.serviceUrl}`);
|
||||
return new BM25Client(
|
||||
{ serviceUrl: config.serviceUrl, timeout: config.timeout },
|
||||
logger,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Local BM25 Sparse Vector Encoder.
|
||||
*
|
||||
* Pure TypeScript replacement for the Python sidecar BM25 client.
|
||||
* Uses @tencentdb-agent-memory/tcvdb-text package for tokenization (jieba-wasm) and BM25 encoding.
|
||||
*
|
||||
* Two operations (same contract as the old BM25Client):
|
||||
* - `encodeTexts(texts)` — encode documents for upsert (TF-based)
|
||||
* - `encodeQueries(texts)` — encode queries for search (IDF-based)
|
||||
*/
|
||||
|
||||
import { BM25Encoder } from "@tencentdb-agent-memory/tcvdb-text";
|
||||
import type { SparseVector } from "@tencentdb-agent-memory/tcvdb-text";
|
||||
|
||||
export type { SparseVector };
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface BM25LocalConfig {
|
||||
/** Whether BM25 sparse encoding is enabled (default: true) */
|
||||
enabled: boolean;
|
||||
/** Language for BM25 pre-trained params: "zh" or "en" (default: "zh") */
|
||||
language?: "zh" | "en";
|
||||
}
|
||||
|
||||
const TAG = "[memory-tdai][bm25-local]";
|
||||
|
||||
// ============================
|
||||
// Implementation
|
||||
// ============================
|
||||
|
||||
export class BM25LocalEncoder {
|
||||
private readonly encoder: BM25Encoder;
|
||||
private readonly logger?: Logger;
|
||||
|
||||
constructor(language: "zh" | "en" = "zh", logger?: Logger) {
|
||||
this.logger = logger;
|
||||
this.encoder = BM25Encoder.default(language);
|
||||
logger?.debug?.(`${TAG} Initialized BM25 local encoder (language=${language})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode document texts for upsert (TF-based BM25 scoring).
|
||||
* Returns one SparseVector per input text.
|
||||
*/
|
||||
encodeTexts(texts: string[]): SparseVector[] {
|
||||
if (texts.length === 0) return [];
|
||||
try {
|
||||
return this.encoder.encodeTexts(texts);
|
||||
} catch (err) {
|
||||
this.logger?.warn(
|
||||
`${TAG} encodeTexts failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode query texts for search (IDF-based BM25 scoring).
|
||||
* Returns one SparseVector per input text.
|
||||
*/
|
||||
encodeQueries(texts: string[]): SparseVector[] {
|
||||
if (texts.length === 0) return [];
|
||||
try {
|
||||
return this.encoder.encodeQueries(texts);
|
||||
} catch (err) {
|
||||
this.logger?.warn(
|
||||
`${TAG} encodeQueries failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create a BM25LocalEncoder if BM25 is enabled in config.
|
||||
* Returns undefined if disabled — callers should check before using.
|
||||
*/
|
||||
export function createBM25Encoder(
|
||||
config: BM25LocalConfig,
|
||||
logger?: Logger,
|
||||
): BM25LocalEncoder | undefined {
|
||||
if (!config.enabled) {
|
||||
logger?.debug?.(`${TAG} BM25 sparse encoding disabled`);
|
||||
return undefined;
|
||||
}
|
||||
return new BM25LocalEncoder(config.language ?? "zh", logger);
|
||||
}
|
||||
+53
-5
@@ -149,10 +149,20 @@ function sanitizeAndNormalize(vec: number[] | Float32Array): Float32Array {
|
||||
*/
|
||||
type LocalInitState = "idle" | "initializing" | "ready" | "failed";
|
||||
|
||||
/** Function that dynamically imports node-llama-cpp. Overridable for testing. */
|
||||
export type ImportLlamaFn = () => Promise<{
|
||||
getLlama: (opts: { logLevel: number }) => Promise<unknown>;
|
||||
resolveModelFile: (model: string, cacheDir?: string) => Promise<string>;
|
||||
LlamaLogLevel: { error: number };
|
||||
}>;
|
||||
|
||||
const defaultImportLlama: ImportLlamaFn = () => import("node-llama-cpp") as unknown as ReturnType<ImportLlamaFn>;
|
||||
|
||||
export class LocalEmbeddingService implements EmbeddingService {
|
||||
private readonly modelPath: string;
|
||||
private readonly modelCacheDir?: string;
|
||||
private readonly logger?: Logger;
|
||||
private readonly importLlama: ImportLlamaFn;
|
||||
|
||||
// Initialization state machine
|
||||
private initState: LocalInitState = "idle";
|
||||
@@ -162,10 +172,11 @@ export class LocalEmbeddingService implements EmbeddingService {
|
||||
getEmbeddingFor: (text: string) => Promise<{ vector: Float32Array | number[] }>;
|
||||
} | null = null;
|
||||
|
||||
constructor(config?: LocalEmbeddingConfig, logger?: Logger) {
|
||||
constructor(config?: LocalEmbeddingConfig, logger?: Logger, importLlama?: ImportLlamaFn) {
|
||||
this.modelPath = config?.modelPath?.trim() || DEFAULT_LOCAL_MODEL;
|
||||
this.modelCacheDir = config?.modelCacheDir?.trim();
|
||||
this.logger = logger;
|
||||
this.importLlama = importLlama ?? defaultImportLlama;
|
||||
}
|
||||
|
||||
getDimensions(): number {
|
||||
@@ -307,7 +318,7 @@ export class LocalEmbeddingService implements EmbeddingService {
|
||||
this.logger?.debug?.(`${TAG} Loading node-llama-cpp for local embedding...`);
|
||||
|
||||
// Dynamic import — node-llama-cpp is a peer dependency of OpenClaw
|
||||
const { getLlama, resolveModelFile, LlamaLogLevel } = await import("node-llama-cpp");
|
||||
const { getLlama, resolveModelFile, LlamaLogLevel } = await this.importLlama();
|
||||
|
||||
const llama = await getLlama({ logLevel: LlamaLogLevel.error });
|
||||
this.logger?.debug?.(`${TAG} Llama instance created`);
|
||||
@@ -581,18 +592,55 @@ export function createEmbeddingService(
|
||||
): EmbeddingService {
|
||||
// Remote OpenAI-compatible provider: any provider value other than "local"
|
||||
if (config && config.provider !== "local" && "apiKey" in config && config.apiKey) {
|
||||
logger?.info(`${TAG} Using remote embedding (provider=${config.provider}, model=${config.model})`);
|
||||
logger?.debug?.(`${TAG} Using remote embedding (provider=${config.provider}, model=${config.model})`);
|
||||
return new OpenAIEmbeddingService(config as OpenAIEmbeddingConfig, logger);
|
||||
}
|
||||
|
||||
// Explicit local config
|
||||
if (config && config.provider === "local") {
|
||||
const localConfig = config as LocalEmbeddingConfig;
|
||||
logger?.info(`${TAG} Using local embedding (node-llama-cpp, model=${localConfig.modelPath ?? DEFAULT_LOCAL_MODEL})`);
|
||||
logger?.debug?.(`${TAG} Using local embedding (node-llama-cpp, model=${localConfig.modelPath ?? DEFAULT_LOCAL_MODEL})`);
|
||||
return new LocalEmbeddingService(localConfig, logger);
|
||||
}
|
||||
|
||||
// Fallback: no config or empty apiKey → use local
|
||||
logger?.info(`${TAG} No remote embedding configured, falling back to local embedding (node-llama-cpp)`);
|
||||
logger?.debug?.(`${TAG} No remote embedding configured, falling back to local embedding (node-llama-cpp)`);
|
||||
return new LocalEmbeddingService(undefined, logger);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// NoopEmbeddingService (for server-side embedding backends)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* No-op embedding service for backends with built-in server-side embedding
|
||||
* (e.g., TCVDB with Collection-level embedding config).
|
||||
*
|
||||
* All embed() calls return an empty Float32Array because the server generates
|
||||
* vectors automatically from the text field during upsert/search.
|
||||
*/
|
||||
export class NoopEmbeddingService implements EmbeddingService {
|
||||
embed(_text: string): Promise<Float32Array> {
|
||||
return Promise.resolve(new Float32Array(0));
|
||||
}
|
||||
|
||||
embedBatch(texts: string[]): Promise<Float32Array[]> {
|
||||
return Promise.resolve(texts.map(() => new Float32Array(0)));
|
||||
}
|
||||
|
||||
getDimensions(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
getProviderInfo(): EmbeddingProviderInfo {
|
||||
return { provider: "noop", model: "server-side" };
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
startWarmup(): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Store Factory — creates the appropriate storage backend and embedding service
|
||||
* based on plugin configuration.
|
||||
*
|
||||
* Supports:
|
||||
* - "sqlite" (default): local SQLite + sqlite-vec + FTS5
|
||||
* - "tcvdb": Tencent Cloud VectorDB (server-side embedding + hybridSearch)
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import type { MemoryTdaiConfig } from "../config.js";
|
||||
import type { IMemoryStore, IEmbeddingService, StoreLogger } from "./types.js";
|
||||
import { VectorStore } from "./sqlite.js";
|
||||
import { TcvdbMemoryStore } from "./tcvdb.js";
|
||||
import { createEmbeddingService, NoopEmbeddingService } from "./embedding.js";
|
||||
import type { EmbeddingService } from "./embedding.js";
|
||||
import { createBM25Encoder } from "./bm25-local.js";
|
||||
import type { BM25LocalEncoder } from "./bm25-local.js";
|
||||
|
||||
// Re-export for convenience
|
||||
export type { IMemoryStore, IEmbeddingService, StoreLogger, BM25LocalEncoder };
|
||||
|
||||
const TAG = "[memory-tdai][factory]";
|
||||
|
||||
export interface StoreBundle {
|
||||
store: IMemoryStore;
|
||||
embedding: IEmbeddingService;
|
||||
bm25Encoder?: BM25LocalEncoder;
|
||||
/** Snapshot of current store config for manifest writing. */
|
||||
storeSnapshot: import("../utils/manifest.js").StoreConfigSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the storage backend, embedding service, and optional BM25 encoder
|
||||
* based on plugin configuration.
|
||||
*
|
||||
* @param config Fully resolved plugin config.
|
||||
* @param options.dataDir Plugin data directory.
|
||||
* @param options.logger Logger instance.
|
||||
*/
|
||||
export function createStoreBundle(
|
||||
config: MemoryTdaiConfig,
|
||||
options: { dataDir: string; logger?: StoreLogger },
|
||||
): StoreBundle {
|
||||
const { logger } = options;
|
||||
|
||||
// ── BM25 local encoder ──
|
||||
const bm25Encoder = createBM25Encoder(config.bm25, logger);
|
||||
|
||||
switch (config.storeBackend) {
|
||||
case "tcvdb": {
|
||||
const tcvdbCfg = config.tcvdb;
|
||||
if (!tcvdbCfg.url || !tcvdbCfg.apiKey) {
|
||||
throw new Error(`${TAG} TCVDB backend requires tcvdb.url and tcvdb.apiKey`);
|
||||
}
|
||||
if (!tcvdbCfg.database) {
|
||||
throw new Error(`${TAG} TCVDB backend requires tcvdb.database — please set a unique database name in your openclaw.json plugin config`);
|
||||
}
|
||||
const database = tcvdbCfg.database;
|
||||
const store = new TcvdbMemoryStore({
|
||||
url: tcvdbCfg.url,
|
||||
username: tcvdbCfg.username,
|
||||
apiKey: tcvdbCfg.apiKey,
|
||||
database,
|
||||
embeddingModel: tcvdbCfg.embeddingModel,
|
||||
timeout: tcvdbCfg.timeout,
|
||||
caPemPath: tcvdbCfg.caPemPath,
|
||||
logger,
|
||||
bm25Encoder: bm25Encoder ?? undefined,
|
||||
});
|
||||
|
||||
logger?.debug?.(
|
||||
`${TAG} Store created: backend=tcvdb, database=${database}, model=${tcvdbCfg.embeddingModel}, ` +
|
||||
`bm25=${bm25Encoder ? "enabled" : "disabled"}`,
|
||||
);
|
||||
|
||||
return {
|
||||
store,
|
||||
embedding: new NoopEmbeddingService(),
|
||||
bm25Encoder,
|
||||
storeSnapshot: {
|
||||
type: "tcvdb",
|
||||
tcvdbUrl: tcvdbCfg.url,
|
||||
tcvdbDatabase: database,
|
||||
tcvdbAlias: tcvdbCfg.alias || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "sqlite":
|
||||
default: {
|
||||
// ── Embedding service (only when enabled) ──
|
||||
let embeddingService: EmbeddingService | undefined;
|
||||
if (config.embedding.enabled && config.embedding.provider !== "local" && config.embedding.apiKey) {
|
||||
embeddingService = createEmbeddingService({
|
||||
provider: config.embedding.provider,
|
||||
baseUrl: config.embedding.baseUrl,
|
||||
apiKey: config.embedding.apiKey,
|
||||
model: config.embedding.model,
|
||||
dimensions: config.embedding.dimensions,
|
||||
maxInputChars: config.embedding.maxInputChars,
|
||||
}, logger);
|
||||
}
|
||||
|
||||
// dimensions from config (0 when provider="none" → vec0 deferred)
|
||||
const dims = config.embedding.dimensions;
|
||||
const dbPath = path.join(options.dataDir, "vectors.db");
|
||||
const store = new VectorStore(dbPath, dims, logger);
|
||||
|
||||
logger?.debug?.(
|
||||
`${TAG} Store created: backend=sqlite, dbPath=${dbPath}, dimensions=${dims}, ` +
|
||||
`embedding=${embeddingService ? "enabled" : "disabled"}, ` +
|
||||
`bm25=${bm25Encoder ? "enabled" : "disabled"}`,
|
||||
);
|
||||
|
||||
return {
|
||||
store,
|
||||
embedding: embeddingService as unknown as IEmbeddingService,
|
||||
bm25Encoder,
|
||||
storeSnapshot: {
|
||||
type: "sqlite",
|
||||
sqlitePath: path.relative(options.dataDir, dbPath),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Search utilities — shared helpers for memory search across backends.
|
||||
*
|
||||
* Contains:
|
||||
* - RRF (Reciprocal Rank Fusion) merge — used by SQLite hybrid search
|
||||
* (eliminates the 3x duplication in auto-recall, memory-search, conversation-search)
|
||||
* - FTS query building — re-exported from sqlite for convenience
|
||||
*/
|
||||
|
||||
// ============================
|
||||
// RRF (Reciprocal Rank Fusion)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Standard RRF constant from the original RRF paper.
|
||||
* Higher k → more weight on lower-ranked items (smoother distribution).
|
||||
*/
|
||||
export const RRF_K = 60;
|
||||
|
||||
/**
|
||||
* Merge multiple ranked lists via Reciprocal Rank Fusion.
|
||||
*
|
||||
* Each item's RRF score = sum over all lists of 1/(k + rank + 1).
|
||||
* Items appearing in multiple lists get their scores summed.
|
||||
*
|
||||
* @param lists Array of ranked lists. Each list must have items with an `id` field.
|
||||
* @param k RRF constant (default: 60).
|
||||
* @returns Merged list sorted by descending RRF score, with `rrfScore` attached.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const merged = rrfMerge(
|
||||
* [ftsResults, vecResults],
|
||||
* (item) => item.record_id,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function rrfMerge<T>(
|
||||
lists: T[][],
|
||||
getId: (item: T) => string,
|
||||
k: number = RRF_K,
|
||||
): Array<T & { rrfScore: number }> {
|
||||
const map = new Map<string, { item: T; rrfScore: number }>();
|
||||
|
||||
for (const list of lists) {
|
||||
for (let rank = 0; rank < list.length; rank++) {
|
||||
const item = list[rank];
|
||||
const id = getId(item);
|
||||
const score = 1 / (k + rank + 1);
|
||||
const existing = map.get(id);
|
||||
if (existing) {
|
||||
existing.rrfScore += score;
|
||||
} else {
|
||||
map.set(id, { item, rrfScore: score });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.values()]
|
||||
.sort((a, b) => b.rrfScore - a.rrfScore)
|
||||
.map(({ item, rrfScore }) => ({ ...item, rrfScore }));
|
||||
}
|
||||
+2303
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Tencent Cloud VectorDB HTTP Client.
|
||||
*
|
||||
* Thin wrapper around the VectorDB HTTP API. Handles authentication, timeouts,
|
||||
* retries (5xx / timeout), and error normalization.
|
||||
*
|
||||
* API docs: https://cloud.tencent.com/document/product/1709
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import { request as undiciRequest, Agent as UndiciAgent } from "undici";
|
||||
import type { Dispatcher } from "undici";
|
||||
import type { StoreLogger } from "./types.js";
|
||||
|
||||
// ============================
|
||||
// Types
|
||||
// ============================
|
||||
|
||||
export interface TcvdbClientConfig {
|
||||
/** Instance URL (e.g. "http://10.0.1.1:80") */
|
||||
url: string;
|
||||
/** Account name (default: "root") */
|
||||
username: string;
|
||||
/** API Key */
|
||||
apiKey: string;
|
||||
/** Database name */
|
||||
database: string;
|
||||
/** Request timeout in ms (default: 10000) */
|
||||
timeout: number;
|
||||
/** Path to CA certificate PEM file (for HTTPS connections) */
|
||||
caPemPath?: string;
|
||||
}
|
||||
|
||||
/** Standard VectorDB API response envelope. */
|
||||
interface ApiResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Search/hybridSearch response shape. */
|
||||
export interface SearchResponse {
|
||||
documents: Array<Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
/** Query response shape. */
|
||||
export interface QueryResponse {
|
||||
documents: Array<Record<string, unknown>>;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** Collection info from describeCollection. */
|
||||
export interface CollectionInfo {
|
||||
collection: string;
|
||||
database: string;
|
||||
documentCount?: number;
|
||||
embedding?: {
|
||||
field: string;
|
||||
vectorField: string;
|
||||
model: string;
|
||||
};
|
||||
indexes?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class TcvdbApiError extends Error {
|
||||
readonly apiCode: number;
|
||||
constructor(path: string, code: number, msg: string) {
|
||||
super(`VectorDB ${path}: code=${code}, msg=${msg}`);
|
||||
this.name = "TcvdbApiError";
|
||||
this.apiCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Client
|
||||
// ============================
|
||||
|
||||
const TAG = "[memory-tdai][tcvdb-client]";
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
export class TcvdbClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly authHeader: string;
|
||||
private readonly database: string;
|
||||
private readonly timeout: number;
|
||||
private readonly logger?: StoreLogger;
|
||||
/** undici dispatcher for HTTPS + custom CA. */
|
||||
private readonly dispatcher?: Dispatcher;
|
||||
|
||||
constructor(config: TcvdbClientConfig, logger?: StoreLogger) {
|
||||
this.baseUrl = config.url.replace(/\/+$/, "");
|
||||
this.authHeader = `Bearer account=${config.username}&api_key=${config.apiKey}`;
|
||||
this.database = config.database;
|
||||
this.timeout = config.timeout;
|
||||
this.logger = logger;
|
||||
|
||||
// Log connection info at construction time.
|
||||
this.logger?.debug?.(`${TAG} url=${this.baseUrl} db=${this.database} timeout=${this.timeout}${this.baseUrl.startsWith("https://") ? ` https=true caPemPath=${config.caPemPath ?? "(none)"}` : ""}`);
|
||||
|
||||
// For HTTPS with a custom CA certificate, create a dedicated undici Agent.
|
||||
// We use undici.request() instead of global fetch because fetch's
|
||||
// `dispatcher` option is unreliable across Node versions.
|
||||
if (this.baseUrl.startsWith("https://") && config.caPemPath) {
|
||||
try {
|
||||
const ca = fs.readFileSync(config.caPemPath, "utf-8");
|
||||
this.dispatcher = new UndiciAgent({ connect: { ca } });
|
||||
this.logger?.debug?.(`${TAG} HTTPS enabled with CA from ${config.caPemPath}`);
|
||||
} catch (err) {
|
||||
this.logger?.error(`${TAG} Failed to load CA PEM from ${config.caPemPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generic request ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a POST request to VectorDB API.
|
||||
* Handles auth, timeout, retries (5xx/timeout), and error unwrapping.
|
||||
*/
|
||||
async request<T = ApiResponse>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
this.logger?.debug?.(`${TAG} → ${path} body=${JSON.stringify(body).slice(0, 500)}`);
|
||||
const { statusCode, body: respBody } = await undiciRequest(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
...(this.dispatcher ? { dispatcher: this.dispatcher } : {}),
|
||||
});
|
||||
|
||||
const text = await respBody.text();
|
||||
const json = JSON.parse(text) as ApiResponse;
|
||||
this.logger?.debug?.(`${TAG} ← ${path} status=${statusCode} code=${json.code} msg=${json.msg} keys=[${Object.keys(json).join(",")}]`);
|
||||
|
||||
if (json.code !== 0) {
|
||||
const err = new TcvdbApiError(path, json.code, json.msg);
|
||||
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) throw err;
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
|
||||
return json as unknown as T;
|
||||
} catch (err) {
|
||||
if (err instanceof TcvdbApiError && err.apiCode !== 0) throw err;
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = 500 * (attempt + 1);
|
||||
this.logger?.debug?.(`${TAG} ${path} retry ${attempt + 1}/${MAX_RETRIES} in ${delay}ms`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`${TAG} ${path} failed after retries`);
|
||||
}
|
||||
|
||||
// ── Database operations ─────────────────────────────────
|
||||
|
||||
async createDatabase(dbName?: string): Promise<boolean> {
|
||||
const name = dbName ?? this.database;
|
||||
// SDK pattern: list first, create only if not found
|
||||
const listResp = await this.request<{ databases: string[] }>("/database/list", {});
|
||||
const exists = (listResp.databases ?? []).includes(name);
|
||||
if (exists) {
|
||||
this.logger?.debug?.(`${TAG} Database already exists: ${name}`);
|
||||
return false;
|
||||
}
|
||||
await this.request("/database/create", { database: name });
|
||||
this.logger?.info(`${TAG} Database created: ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Collection operations ───────────────────────────────
|
||||
|
||||
async createCollection(params: Record<string, unknown>): Promise<void> {
|
||||
const name = String(params.collection ?? "");
|
||||
// SDK pattern: try describe first, create only if not found (code 15302)
|
||||
try {
|
||||
await this.describeCollection(name);
|
||||
this.logger?.debug?.(`${TAG} Collection already exists: ${name}`);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!(err instanceof TcvdbApiError && err.apiCode === 15302)) {
|
||||
throw err; // unexpected error
|
||||
}
|
||||
// 15302 = collection not found → proceed to create
|
||||
}
|
||||
try {
|
||||
await this.request("/collection/create", {
|
||||
database: this.database,
|
||||
...params,
|
||||
});
|
||||
this.logger?.info(`${TAG} Collection created: ${name}`);
|
||||
} catch (err) {
|
||||
// 15202 = collection already exists — race between describe and create.
|
||||
// Semantically identical to "describe found it", so treat as success.
|
||||
if (err instanceof TcvdbApiError && err.apiCode === 15202) {
|
||||
this.logger?.debug?.(`${TAG} Collection already exists (race): ${name}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async describeCollection(collection: string): Promise<CollectionInfo> {
|
||||
const resp = await this.request<{ collection: CollectionInfo }>("/collection/describe", {
|
||||
database: this.database,
|
||||
collection,
|
||||
});
|
||||
return resp.collection;
|
||||
}
|
||||
|
||||
// ── Document operations ─────────────────────────────────
|
||||
|
||||
async upsert(collection: string, documents: Record<string, unknown>[]): Promise<void> {
|
||||
await this.request("/document/upsert", {
|
||||
database: this.database,
|
||||
collection,
|
||||
buildIndex: true,
|
||||
documents,
|
||||
});
|
||||
}
|
||||
|
||||
async search(collection: string, searchParams: Record<string, unknown>): Promise<SearchResponse> {
|
||||
return this.request<SearchResponse>("/document/search", {
|
||||
database: this.database,
|
||||
collection,
|
||||
readConsistency: "strongConsistency",
|
||||
search: searchParams,
|
||||
});
|
||||
}
|
||||
|
||||
async hybridSearch(collection: string, searchParams: Record<string, unknown>): Promise<SearchResponse> {
|
||||
return this.request<SearchResponse>("/document/hybridSearch", {
|
||||
database: this.database,
|
||||
collection,
|
||||
readConsistency: "strongConsistency",
|
||||
search: searchParams,
|
||||
});
|
||||
}
|
||||
|
||||
async query(collection: string, queryParams: Record<string, unknown>): Promise<QueryResponse> {
|
||||
return this.request<QueryResponse>("/document/query", {
|
||||
database: this.database,
|
||||
collection,
|
||||
readConsistency: "strongConsistency",
|
||||
query: queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDoc(collection: string, params: Record<string, unknown>): Promise<void> {
|
||||
await this.request("/document/delete", {
|
||||
database: this.database,
|
||||
collection,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count documents matching an optional filter.
|
||||
* Uses the dedicated /document/count endpoint.
|
||||
*/
|
||||
async count(collection: string, filter?: string): Promise<number> {
|
||||
const query: Record<string, unknown> = {};
|
||||
if (filter) query.filter = filter;
|
||||
const resp = await this.request<{ count: number }>("/document/count", {
|
||||
database: this.database,
|
||||
collection,
|
||||
readConsistency: "strongConsistency",
|
||||
query,
|
||||
});
|
||||
return resp.count ?? 0;
|
||||
}
|
||||
|
||||
// ── Convenience getters ─────────────────────────────────
|
||||
|
||||
getDatabase(): string {
|
||||
return this.database;
|
||||
}
|
||||
}
|
||||
+1180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Memory Store Abstraction Layer — Core Types & Interfaces.
|
||||
*
|
||||
* This module defines the storage contracts that all backend implementations
|
||||
* (SQLite local, Tencent Cloud VectorDB, etc.) must satisfy.
|
||||
*
|
||||
* Design principles:
|
||||
* 1. **Backend-agnostic**: Upper-layer modules (hooks, tools, pipeline, record)
|
||||
* depend only on these interfaces — never on concrete implementations.
|
||||
* 2. **Capability-based**: Features like vector search, FTS, and hybrid search
|
||||
* are expressed as capability flags so callers can gracefully degrade.
|
||||
* 3. **Fault-tolerant**: All methods return empty results or `false` on
|
||||
* failure rather than throwing, unless explicitly documented otherwise.
|
||||
* 4. **Sync-first**: Matches current SQLite DatabaseSync usage. TCVDB backend
|
||||
* adapts internally without changing these signatures.
|
||||
*/
|
||||
|
||||
import type { MemoryRecord } from "../record/l1-writer.js";
|
||||
import type { EmbeddingProviderInfo } from "./embedding.js";
|
||||
|
||||
// Re-export so consumers can import everything from types.ts
|
||||
export type { MemoryRecord, EmbeddingProviderInfo };
|
||||
|
||||
// ============================
|
||||
// Common Types
|
||||
// ============================
|
||||
|
||||
/** Minimal logger interface accepted by store implementations. */
|
||||
export interface StoreLogger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// L1 Types (Structured Memories)
|
||||
// ============================
|
||||
|
||||
/** Result from an L1 vector similarity search. */
|
||||
export interface L1SearchResult {
|
||||
record_id: string;
|
||||
content: string;
|
||||
type: string;
|
||||
priority: number;
|
||||
scene_name: string;
|
||||
/** Similarity score (0–1, higher is better). */
|
||||
score: number;
|
||||
timestamp_str: string;
|
||||
timestamp_start: string;
|
||||
timestamp_end: string;
|
||||
session_key: string;
|
||||
session_id: string;
|
||||
metadata_json: string;
|
||||
}
|
||||
|
||||
/** Result from an L1 FTS keyword search. */
|
||||
export interface L1FtsResult {
|
||||
record_id: string;
|
||||
content: string;
|
||||
type: string;
|
||||
priority: number;
|
||||
scene_name: string;
|
||||
/** BM25-derived score (0–1, higher is better). */
|
||||
score: number;
|
||||
timestamp_str: string;
|
||||
timestamp_start: string;
|
||||
timestamp_end: string;
|
||||
session_key: string;
|
||||
session_id: string;
|
||||
metadata_json: string;
|
||||
}
|
||||
|
||||
/** Filter options for querying L1 records. */
|
||||
export interface L1QueryFilter {
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
/** Only return records with updated_time strictly after this ISO 8601 UTC timestamp. */
|
||||
updatedAfter?: string;
|
||||
}
|
||||
|
||||
/** Row shape returned by L1 query methods. */
|
||||
export interface L1RecordRow {
|
||||
record_id: string;
|
||||
content: string;
|
||||
type: string;
|
||||
priority: number;
|
||||
scene_name: string;
|
||||
session_key: string;
|
||||
session_id: string;
|
||||
timestamp_str: string;
|
||||
timestamp_start: string;
|
||||
timestamp_end: string;
|
||||
created_time: string;
|
||||
updated_time: string;
|
||||
metadata_json: string;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// L0 Types (Raw Conversations)
|
||||
// ============================
|
||||
|
||||
/** An L0 conversation message record for vector indexing. */
|
||||
export interface L0Record {
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
role: string;
|
||||
messageText: string;
|
||||
recordedAt: string;
|
||||
/** Original message timestamp (epoch ms). */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Result from an L0 vector similarity search. */
|
||||
export interface L0SearchResult {
|
||||
record_id: string;
|
||||
session_key: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
message_text: string;
|
||||
/** Similarity score (0–1, higher is better). */
|
||||
score: number;
|
||||
recorded_at: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Result from an L0 FTS keyword search. */
|
||||
export interface L0FtsResult {
|
||||
record_id: string;
|
||||
session_key: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
message_text: string;
|
||||
/** BM25-derived score (0–1, higher is better). */
|
||||
score: number;
|
||||
recorded_at: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Raw L0 row returned by query methods (used by L1 runner). */
|
||||
export interface L0QueryRow {
|
||||
record_id: string;
|
||||
session_key: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
message_text: string;
|
||||
recorded_at: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** L0 messages grouped by session ID (for L1 runner). */
|
||||
export interface L0SessionGroup {
|
||||
sessionId: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
/** Epoch ms when this message was recorded into L0 (used by L1 cursor). */
|
||||
recordedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Store Init Result
|
||||
// ============================
|
||||
|
||||
/** Result of store initialization. */
|
||||
export interface StoreInitResult {
|
||||
/** Whether embeddings need to be regenerated (provider/model change). */
|
||||
needsReindex: boolean;
|
||||
/** Human-readable reason (for logging). */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Capability Flags
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Describes what search capabilities a store backend supports.
|
||||
* Callers use this to select search strategies and degrade gracefully.
|
||||
*/
|
||||
export interface StoreCapabilities {
|
||||
/** Whether vector (embedding) search is available. */
|
||||
vectorSearch: boolean;
|
||||
/** Whether FTS (full-text keyword) search is available. */
|
||||
ftsSearch: boolean;
|
||||
/** Whether native hybrid search is supported (e.g., TCVDB hybridSearch). */
|
||||
nativeHybridSearch: boolean;
|
||||
/** Whether the store supports sparse vectors (BM25 encoding). */
|
||||
sparseVectors: boolean;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// L2/L3 Profile Sync Types
|
||||
// ============================
|
||||
|
||||
/** Canonical L2/L3 profile row shared between local cache and remote store. */
|
||||
export interface ProfileRecord {
|
||||
/** Stable ID: `profile:v1:${sha256(scope + "\0" + type + "\0" + filename)}`. */
|
||||
id: string;
|
||||
type: "l2" | "l3";
|
||||
filename: string;
|
||||
content: string;
|
||||
contentMd5: string;
|
||||
agentId?: string;
|
||||
version: number;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
/** Profile upsert payload with optimistic-lock baseline from the last pull. */
|
||||
export interface ProfileSyncRecord extends ProfileRecord {
|
||||
baselineVersion?: number;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// IMemoryStore — The Core Abstraction
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Unified memory store interface.
|
||||
*
|
||||
* Implementations:
|
||||
* - `SqliteMemoryStore` (sqlite.ts) — local SQLite + sqlite-vec + FTS5
|
||||
* - `TcvdbMemoryStore` (tcvdb.ts) — Tencent Cloud VectorDB (future)
|
||||
*
|
||||
* All methods are fault-tolerant: they return empty results or `false` on
|
||||
* failure rather than throwing, unless explicitly documented otherwise.
|
||||
*/
|
||||
/**
|
||||
* Helper type: a value that may be sync or async.
|
||||
* Callers should always `await` the result — it's safe for both sync and async values.
|
||||
*/
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export interface IMemoryStore {
|
||||
// ── Capabilities ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether this store supports deferred (background) embedding updates.
|
||||
*
|
||||
* When `true`, auto-capture writes metadata-only via `upsertL0(record, undefined)`
|
||||
* and later calls `updateL0Embedding()` in a fire-and-forget background task.
|
||||
* When `false` or absent, embedding is computed inline and passed to `upsertL0()`.
|
||||
*/
|
||||
readonly supportsDeferredEmbedding?: boolean;
|
||||
|
||||
// ── Lifecycle (always sync) ──────────────────────────────
|
||||
|
||||
init(providerInfo?: EmbeddingProviderInfo): MaybePromise<StoreInitResult>;
|
||||
isDegraded(): boolean;
|
||||
getCapabilities(): StoreCapabilities;
|
||||
close(): void;
|
||||
|
||||
// ── L1 Write ─────────────────────────────────────────────
|
||||
|
||||
upsertL1(record: MemoryRecord, embedding?: Float32Array): MaybePromise<boolean>;
|
||||
deleteL1(recordId: string): MaybePromise<boolean>;
|
||||
deleteL1Batch(recordIds: string[]): MaybePromise<boolean>;
|
||||
deleteL1Expired(cutoffIso: string): MaybePromise<number>;
|
||||
|
||||
// ── L1 Read ──────────────────────────────────────────────
|
||||
|
||||
countL1(): MaybePromise<number>;
|
||||
queryL1Records(filter?: L1QueryFilter): MaybePromise<L1RecordRow[]>;
|
||||
getAllL1Texts(): MaybePromise<Array<{ record_id: string; content: string; updated_time: string }>>;
|
||||
|
||||
// ── L1 Search ────────────────────────────────────────────
|
||||
|
||||
searchL1Vector(queryEmbedding: Float32Array, topK?: number, queryText?: string): MaybePromise<L1SearchResult[]>;
|
||||
searchL1Fts(ftsQuery: string, limit?: number): MaybePromise<L1FtsResult[]>;
|
||||
searchL1Hybrid?(params: {
|
||||
query?: string;
|
||||
queryEmbedding?: Float32Array;
|
||||
sparseVector?: Array<[number, number]>;
|
||||
topK?: number;
|
||||
}): MaybePromise<L1SearchResult[]>;
|
||||
|
||||
// ── L0 Write ─────────────────────────────────────────────
|
||||
|
||||
upsertL0(record: L0Record, embedding?: Float32Array): MaybePromise<boolean>;
|
||||
/** Update only the vector embedding for an existing L0 record (sqlite background path). */
|
||||
updateL0Embedding?(recordId: string, embedding: Float32Array): MaybePromise<boolean>;
|
||||
deleteL0(recordId: string): MaybePromise<boolean>;
|
||||
deleteL0Expired(cutoffIso: string): MaybePromise<number>;
|
||||
|
||||
// ── L0 Read ──────────────────────────────────────────────
|
||||
|
||||
countL0(): MaybePromise<number>;
|
||||
queryL0ForL1(sessionKey: string, afterRecordedAtMs?: number, limit?: number): MaybePromise<L0QueryRow[]>;
|
||||
queryL0GroupedBySessionId(sessionKey: string, afterRecordedAtMs?: number, limit?: number): MaybePromise<L0SessionGroup[]>;
|
||||
getAllL0Texts(): MaybePromise<Array<{ record_id: string; message_text: string; recorded_at: string }>>;
|
||||
|
||||
// ── L0 Search ────────────────────────────────────────────
|
||||
|
||||
searchL0Vector(queryEmbedding: Float32Array, topK?: number, queryText?: string): MaybePromise<L0SearchResult[]>;
|
||||
searchL0Fts(ftsQuery: string, limit?: number): MaybePromise<L0FtsResult[]>;
|
||||
|
||||
pullProfiles?(): Promise<ProfileRecord[]>;
|
||||
syncProfiles?(records: ProfileSyncRecord[]): Promise<void>;
|
||||
deleteProfiles?(recordIds: string[]): Promise<void>;
|
||||
|
||||
// ── Re-index ─────────────────────────────────────────────
|
||||
|
||||
reindexAll(
|
||||
embedFn: (text: string) => Promise<Float32Array>,
|
||||
onProgress?: (done: number, total: number, layer: "L1" | "L0") => void,
|
||||
): Promise<{ l1Count: number; l0Count: number }>;
|
||||
|
||||
// ── FTS (always sync — cached flag) ──────────────────────
|
||||
|
||||
isFtsAvailable(): boolean;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// IEmbeddingService — re-exported from embedding.ts for convenience
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Re-export EmbeddingService as IEmbeddingService for backward compatibility.
|
||||
* The canonical definition lives in `./embedding.ts`. All concrete implementations
|
||||
* (LocalEmbeddingService, OpenAIEmbeddingService, NoopEmbeddingService) implement
|
||||
* the EmbeddingService interface from embedding.ts.
|
||||
*/
|
||||
export type { EmbeddingService as IEmbeddingService } from "./embedding.js";
|
||||
@@ -10,8 +10,8 @@
|
||||
* The tool is registered via `api.registerTool()` in index.ts.
|
||||
*/
|
||||
|
||||
import type { VectorStore, L0VectorSearchResult } from "../store/vector-store.js";
|
||||
import { buildFtsQuery } from "../store/vector-store.js";
|
||||
import type { IMemoryStore, L0SearchResult } from "../store/types.js";
|
||||
import { buildFtsQuery } from "../store/sqlite.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
|
||||
// ============================
|
||||
@@ -90,7 +90,7 @@ export async function executeConversationSearch(params: {
|
||||
query: string;
|
||||
limit: number;
|
||||
sessionKey?: string;
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
embeddingService?: EmbeddingService;
|
||||
logger?: Logger;
|
||||
}): Promise<ConversationSearchResult> {
|
||||
@@ -152,7 +152,7 @@ export async function executeConversationSearch(params: {
|
||||
return [];
|
||||
}
|
||||
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 query: "${ftsQuery}"`);
|
||||
const ftsResults = vectorStore.ftsSearchL0(ftsQuery, candidateK);
|
||||
const ftsResults = await vectorStore.searchL0Fts(ftsQuery, candidateK);
|
||||
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 returned ${ftsResults.length} candidates`);
|
||||
return ftsResults.map((r) => ({
|
||||
id: r.record_id,
|
||||
@@ -179,7 +179,7 @@ export async function executeConversationSearch(params: {
|
||||
logger?.debug?.(
|
||||
`${TAG} [hybrid-vec] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
|
||||
);
|
||||
const vecResults: L0VectorSearchResult[] = vectorStore.searchL0(queryEmbedding, candidateK);
|
||||
const vecResults: L0SearchResult[] = await vectorStore.searchL0Vector(queryEmbedding, candidateK, query);
|
||||
logger?.debug?.(`${TAG} [hybrid-vec] Vector search returned ${vecResults.length} candidates`);
|
||||
return vecResults.map((r) => ({
|
||||
id: r.record_id,
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* The tool is registered via `api.registerTool()` in index.ts.
|
||||
*/
|
||||
|
||||
import type { VectorStore, VectorSearchResult } from "../store/vector-store.js";
|
||||
import { buildFtsQuery } from "../store/vector-store.js";
|
||||
import type { IMemoryStore, L1SearchResult } from "../store/types.js";
|
||||
import { buildFtsQuery } from "../store/sqlite.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
|
||||
// ============================
|
||||
@@ -90,7 +90,7 @@ export async function executeMemorySearch(params: {
|
||||
limit: number;
|
||||
type?: string;
|
||||
scene?: string;
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
embeddingService?: EmbeddingService;
|
||||
logger?: Logger;
|
||||
}): Promise<MemorySearchResult> {
|
||||
@@ -153,7 +153,7 @@ export async function executeMemorySearch(params: {
|
||||
return [];
|
||||
}
|
||||
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 query: "${ftsQuery}"`);
|
||||
const ftsResults = vectorStore.ftsSearchL1(ftsQuery, candidateK);
|
||||
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, candidateK);
|
||||
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 returned ${ftsResults.length} candidates`);
|
||||
return ftsResults.map((r) => ({
|
||||
id: r.record_id,
|
||||
@@ -182,7 +182,7 @@ export async function executeMemorySearch(params: {
|
||||
logger?.debug?.(
|
||||
`${TAG} [hybrid-vec] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
|
||||
);
|
||||
const vecResults: VectorSearchResult[] = vectorStore.search(queryEmbedding, candidateK);
|
||||
const vecResults: L1SearchResult[] = await vectorStore.searchL1Vector(queryEmbedding, candidateK, query);
|
||||
logger?.debug?.(`${TAG} [hybrid-vec] Vector search returned ${vecResults.length} candidates`);
|
||||
return vecResults.map((r) => ({
|
||||
id: r.record_id,
|
||||
|
||||
+7
-62
@@ -297,63 +297,6 @@ export class CheckpointManager {
|
||||
// Public API — mutating (all serialized via file lock)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Advance the captured timestamp after successful upload/recording.
|
||||
* Also updates total_processed and persona counters.
|
||||
*
|
||||
* NOTE: This advances the GLOBAL cursor (`Checkpoint.last_captured_timestamp`).
|
||||
* For per-session cursor advancement, use `advanceSessionCapturedTimestamp()`.
|
||||
* The global cursor is kept for aggregate stats / backward compat, but should
|
||||
* NOT be used as the L0 incremental-capture filter (use per-session instead).
|
||||
*/
|
||||
async advanceCapturedTimestamp(maxTimestamp: number, messageCount: number): Promise<void> {
|
||||
const cp = await this.mutate((cp) => {
|
||||
cp.last_captured_timestamp = maxTimestamp;
|
||||
cp.total_processed += messageCount;
|
||||
cp.memories_since_last_persona += messageCount;
|
||||
});
|
||||
this.logger.info(
|
||||
`[checkpoint] advanceCapturedTimestamp: -> ${maxTimestamp} (+${messageCount} msgs), ` +
|
||||
`total_processed=${cp.total_processed}, memories_since_last_persona=${cp.memories_since_last_persona}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the per-session L0 capture cursor after recording messages.
|
||||
* This is the **primary** cursor for incremental L0 recording — each session
|
||||
* tracks its own progress independently, preventing cross-session cursor drift.
|
||||
*
|
||||
* Also updates the global cursor / total_processed for aggregate stats.
|
||||
*/
|
||||
async advanceSessionCapturedTimestamp(
|
||||
sessionKey: string,
|
||||
maxTimestamp: number,
|
||||
messageCount: number,
|
||||
): Promise<void> {
|
||||
const cp = await this.mutate((cp) => {
|
||||
// Per-session cursor (runner-owned)
|
||||
const state = this.getRunnerState(cp, sessionKey);
|
||||
state.last_captured_timestamp = maxTimestamp;
|
||||
// Global stats (aggregate only — not used for filtering)
|
||||
cp.last_captured_timestamp = Math.max(cp.last_captured_timestamp, maxTimestamp);
|
||||
cp.total_processed += messageCount;
|
||||
cp.memories_since_last_persona += messageCount;
|
||||
});
|
||||
this.logger.info(
|
||||
`[checkpoint] advanceSessionCapturedTimestamp session=${sessionKey}: -> ${maxTimestamp} ` +
|
||||
`(+${messageCount} msgs), total_processed=${cp.total_processed}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment L0 conversation count.
|
||||
*/
|
||||
async incrementL0ConversationCount(): Promise<void> {
|
||||
await this.mutate((cp) => {
|
||||
cp.l0_conversations_count += 1;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Persona methods (L3)
|
||||
// ============================
|
||||
@@ -460,17 +403,20 @@ export class CheckpointManager {
|
||||
/**
|
||||
* Mark L1 extraction completed: reset sinceL1 counter, advance L1 cursor,
|
||||
* and optionally save the last scene name for cross-batch continuity.
|
||||
*
|
||||
* @param cursorRecordedAtMs - The max recorded_at epoch ms of processed L0 messages.
|
||||
* This becomes the new `last_l1_cursor` value (recorded_at semantics, not conversation timestamp).
|
||||
*/
|
||||
async markL1ExtractionComplete(
|
||||
sessionKey: string,
|
||||
memoriesExtracted: number,
|
||||
cursorTimestamp?: number,
|
||||
cursorRecordedAtMs?: number,
|
||||
lastSceneName?: string,
|
||||
): Promise<void> {
|
||||
await this.mutate((cp) => {
|
||||
const state = this.getRunnerState(cp, sessionKey);
|
||||
if (cursorTimestamp) {
|
||||
state.last_l1_cursor = cursorTimestamp;
|
||||
if (cursorRecordedAtMs) {
|
||||
state.last_l1_cursor = cursorRecordedAtMs;
|
||||
}
|
||||
if (lastSceneName !== undefined) {
|
||||
state.last_scene_name = lastSceneName;
|
||||
@@ -480,7 +426,7 @@ export class CheckpointManager {
|
||||
});
|
||||
this.logger.info(
|
||||
`[checkpoint] markL1ExtractionComplete session=${sessionKey}: ` +
|
||||
`extracted=${memoriesExtracted}, cursor=${cursorTimestamp ?? "(unchanged)"}, ` +
|
||||
`extracted=${memoriesExtracted}, cursor=${cursorRecordedAtMs ?? "(unchanged)"}, ` +
|
||||
`lastScene="${lastSceneName ?? "(unchanged)"}"`,
|
||||
);
|
||||
}
|
||||
@@ -533,7 +479,6 @@ export class CheckpointManager {
|
||||
// Global stats (aggregate only — not used for filtering)
|
||||
cp.last_captured_timestamp = Math.max(cp.last_captured_timestamp, result.maxTimestamp);
|
||||
cp.total_processed += result.messageCount;
|
||||
cp.memories_since_last_persona += result.messageCount;
|
||||
// Increment L0 conversation count (was a separate mutate() call before)
|
||||
cp.l0_conversations_count += 1;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { report } from "../report/reporter.js";
|
||||
|
||||
/**
|
||||
@@ -55,7 +56,42 @@ interface RunnerLogger {
|
||||
}
|
||||
|
||||
// Dynamic import type — runEmbeddedPiAgent is an internal API
|
||||
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
|
||||
// Prefer the public plugin runtime signature so host-injected runtimes stay assignable.
|
||||
type RunEmbeddedPiAgentFn = OpenClawPluginApi["runtime"]["agent"]["runEmbeddedPiAgent"];
|
||||
|
||||
export interface EmbeddedAgentRuntimeLike {
|
||||
runEmbeddedPiAgent?: RunEmbeddedPiAgentFn;
|
||||
}
|
||||
|
||||
let _preferredAgentRuntime: EmbeddedAgentRuntimeLike | undefined;
|
||||
|
||||
export function setPreferredEmbeddedAgentRuntime(
|
||||
agentRuntime: EmbeddedAgentRuntimeLike | undefined,
|
||||
): void {
|
||||
_preferredAgentRuntime = agentRuntime;
|
||||
}
|
||||
|
||||
function resolveInjectedRunEmbeddedPiAgent(
|
||||
agentRuntime?: EmbeddedAgentRuntimeLike,
|
||||
): RunEmbeddedPiAgentFn | undefined {
|
||||
const candidate =
|
||||
agentRuntime?.runEmbeddedPiAgent ?? _preferredAgentRuntime?.runEmbeddedPiAgent;
|
||||
return typeof candidate === "function" ? candidate : undefined;
|
||||
}
|
||||
|
||||
async function resolveRunEmbeddedPiAgent(
|
||||
agentRuntime: EmbeddedAgentRuntimeLike | undefined,
|
||||
logger?: RunnerLogger,
|
||||
): Promise<RunEmbeddedPiAgentFn> {
|
||||
const injected = resolveInjectedRunEmbeddedPiAgent(agentRuntime);
|
||||
if (injected) {
|
||||
logger?.debug?.(
|
||||
`${TAG} resolveRunEmbeddedPiAgent: using injected runtime.agent.runEmbeddedPiAgent`,
|
||||
);
|
||||
return injected;
|
||||
}
|
||||
return loadRunEmbeddedPiAgent(logger);
|
||||
}
|
||||
|
||||
// ── Core import (mirrors voice-call/core-bridge.ts — dist/ only, no jiti) ──
|
||||
|
||||
@@ -123,7 +159,17 @@ function loadRunEmbeddedPiAgent(logger?: RunnerLogger): Promise<RunEmbeddedPiAge
|
||||
* the cold-start penalty on the first actual extraction run.
|
||||
* Returns immediately (fire-and-forget) — errors are swallowed.
|
||||
*/
|
||||
export function prewarmEmbeddedAgent(logger?: RunnerLogger): void {
|
||||
export function prewarmEmbeddedAgent(
|
||||
logger?: RunnerLogger,
|
||||
agentRuntime?: EmbeddedAgentRuntimeLike,
|
||||
): void {
|
||||
if (resolveInjectedRunEmbeddedPiAgent(agentRuntime)) {
|
||||
logger?.debug?.(
|
||||
`${TAG} prewarmEmbeddedAgent: runtime capability already available, skipping legacy preload`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
loadRunEmbeddedPiAgent(logger).catch((err) => {
|
||||
logger?.warn(`${TAG} prewarmEmbeddedAgent: failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
@@ -232,6 +278,8 @@ export interface CleanContextRunnerOptions {
|
||||
* automatically falls back to the main config's `agents.defaults.model`.
|
||||
*/
|
||||
modelRef?: string;
|
||||
/** Preferred runtime seam. When absent, falls back to the legacy dist bridge. */
|
||||
agentRuntime?: EmbeddedAgentRuntimeLike;
|
||||
/** Allow the LLM to use tools (read_file, write_to_file, etc). Default: false */
|
||||
enableTools?: boolean;
|
||||
/** Logger instance for detailed tracing */
|
||||
@@ -318,11 +366,14 @@ export class CleanContextRunner {
|
||||
try {
|
||||
const sessionFile = path.join(tmpDir, "session.json");
|
||||
|
||||
// Phase 1: Load runEmbeddedPiAgent (fast if dist/ exists or already cached)
|
||||
// Phase 1: Resolve runEmbeddedPiAgent (prefer runtime, fallback to legacy dist bridge)
|
||||
const importStartMs = Date.now();
|
||||
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent(this.logger);
|
||||
const runEmbeddedPiAgent = await resolveRunEmbeddedPiAgent(
|
||||
this.options.agentRuntime,
|
||||
this.logger,
|
||||
);
|
||||
const importElapsedMs = Date.now() - importStartMs;
|
||||
this.logger?.debug?.(`${TAG} run() dynamic import phase: ${importElapsedMs}ms`);
|
||||
this.logger?.debug?.(`${TAG} run() runner resolution phase: ${importElapsedMs}ms`);
|
||||
|
||||
// Derive a config with plugins disabled to prevent loadOpenClawPlugins
|
||||
// from re-registering plugins when the workspaceDir differs from the
|
||||
@@ -347,10 +398,10 @@ export class CleanContextRunner {
|
||||
},
|
||||
};
|
||||
|
||||
// Build the effective prompt:
|
||||
// If systemPrompt is provided, pass it as a separate parameter to the agent
|
||||
// and use `prompt` as the user message. Fallback: prepend to prompt if the
|
||||
// embedded agent doesn't support systemPrompt natively.
|
||||
// Build the effective prompt.
|
||||
// Keep prepending the optional systemPrompt into the user-visible prompt so
|
||||
// runtime and legacy fallback paths preserve the same behavior without
|
||||
// relying on a newer native extraSystemPrompt contract.
|
||||
const effectivePrompt = params.systemPrompt
|
||||
? `${params.systemPrompt}\n\n---\n\n${params.prompt}`
|
||||
: params.prompt;
|
||||
@@ -368,7 +419,6 @@ export class CleanContextRunner {
|
||||
workspaceDir: cleanWorkspace,
|
||||
config: cleanConfig,
|
||||
prompt: effectivePrompt,
|
||||
systemPrompt: params.systemPrompt,
|
||||
timeoutMs: params.timeoutMs ?? 120_000,
|
||||
runId,
|
||||
provider: this.resolvedProvider,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Manifest — self-describing metadata for a memory-tdai data directory.
|
||||
*
|
||||
* Lives at `<dataDir>/.metadata/manifest.json`.
|
||||
*
|
||||
* - **store**: written once on first successful store init; never overwritten.
|
||||
* On subsequent starts the current config is compared against the persisted
|
||||
* store binding — mismatches are logged as warnings.
|
||||
* - **seed**: written once when a seed run completes; null for live-runtime dirs.
|
||||
*
|
||||
* This file is informational / read-only from the user's perspective.
|
||||
* The plugin reads it on startup for consistency checks.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// ============================
|
||||
// Types
|
||||
// ============================
|
||||
|
||||
export interface ManifestStoreInfo {
|
||||
type: "sqlite" | "tcvdb";
|
||||
sqlite?: {
|
||||
/** Relative path to the SQLite DB file (relative to dataDir). */
|
||||
path: string;
|
||||
};
|
||||
tcvdb?: {
|
||||
url: string;
|
||||
database: string;
|
||||
/** User-friendly alias (optional). */
|
||||
alias?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManifestSeedInfo {
|
||||
/** Original input file name (basename only). */
|
||||
inputFile?: string;
|
||||
sessions: number;
|
||||
rounds: number;
|
||||
messages: number;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
/** Schema version for future migrations. */
|
||||
version: 1;
|
||||
/** Timestamp when the manifest was first created. */
|
||||
createdAt: string;
|
||||
/** Store binding — written once on first init. */
|
||||
store: ManifestStoreInfo;
|
||||
/** Seed run info — null for live-runtime directories. */
|
||||
seed: ManifestSeedInfo | null;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Paths
|
||||
// ============================
|
||||
|
||||
const METADATA_DIR = ".metadata";
|
||||
const MANIFEST_FILE = "manifest.json";
|
||||
|
||||
export function manifestPath(dataDir: string): string {
|
||||
return path.join(dataDir, METADATA_DIR, MANIFEST_FILE);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Read / Write
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Read an existing manifest from disk. Returns `null` if not found or unparseable.
|
||||
*/
|
||||
export function readManifest(dataDir: string): Manifest | null {
|
||||
const p = manifestPath(dataDir);
|
||||
try {
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = fs.readFileSync(p, "utf-8");
|
||||
return JSON.parse(raw) as Manifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a manifest to disk (creates `.metadata/` if needed).
|
||||
*/
|
||||
export function writeManifest(dataDir: string, manifest: Manifest): void {
|
||||
const dir = path.join(dataDir, METADATA_DIR);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
manifestPath(dataDir),
|
||||
JSON.stringify(manifest, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Store binding helpers
|
||||
// ============================
|
||||
|
||||
export interface StoreConfigSnapshot {
|
||||
type: "sqlite" | "tcvdb";
|
||||
sqlitePath?: string;
|
||||
tcvdbUrl?: string;
|
||||
tcvdbDatabase?: string;
|
||||
tcvdbAlias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ManifestStoreInfo from the current store config snapshot.
|
||||
*/
|
||||
export function buildStoreInfo(snapshot: StoreConfigSnapshot): ManifestStoreInfo {
|
||||
const info: ManifestStoreInfo = { type: snapshot.type };
|
||||
if (snapshot.type === "sqlite") {
|
||||
info.sqlite = { path: snapshot.sqlitePath ?? "vectors.db" };
|
||||
} else {
|
||||
info.tcvdb = {
|
||||
url: snapshot.tcvdbUrl!,
|
||||
database: snapshot.tcvdbDatabase!,
|
||||
alias: snapshot.tcvdbAlias || undefined,
|
||||
};
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the persisted store binding against the current config.
|
||||
* Returns a list of human-readable mismatch descriptions (empty = all good).
|
||||
*/
|
||||
export function diffStoreBinding(
|
||||
persisted: ManifestStoreInfo,
|
||||
current: ManifestStoreInfo,
|
||||
): string[] {
|
||||
const diffs: string[] = [];
|
||||
|
||||
if (persisted.type !== current.type) {
|
||||
diffs.push(`store type changed: ${persisted.type} → ${current.type}`);
|
||||
return diffs; // no point comparing fields across different types
|
||||
}
|
||||
|
||||
if (persisted.type === "sqlite" && current.type === "sqlite") {
|
||||
if (persisted.sqlite?.path !== current.sqlite?.path) {
|
||||
diffs.push(`sqlite path changed: ${persisted.sqlite?.path} → ${current.sqlite?.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (persisted.type === "tcvdb" && current.type === "tcvdb") {
|
||||
if (persisted.tcvdb?.url !== current.tcvdb?.url) {
|
||||
diffs.push(`tcvdb url changed: ${persisted.tcvdb?.url} → ${current.tcvdb?.url}`);
|
||||
}
|
||||
if (persisted.tcvdb?.database !== current.tcvdb?.database) {
|
||||
diffs.push(`tcvdb database changed: ${persisted.tcvdb?.database} → ${current.tcvdb?.database}`);
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { VectorStore } from "../store/vector-store.js";
|
||||
import type { IMemoryStore } from "../store/types.js";
|
||||
import { ManagedTimer } from "./managed-timer.js";
|
||||
|
||||
interface Logger {
|
||||
@@ -16,7 +16,7 @@ export interface MemoryCleanerOptions {
|
||||
retentionDays: number;
|
||||
cleanTime: string;
|
||||
logger?: Logger;
|
||||
vectorStore?: VectorStore;
|
||||
vectorStore?: IMemoryStore;
|
||||
}
|
||||
|
||||
interface CleanupStats {
|
||||
@@ -33,14 +33,14 @@ const L1_DIR_NAME = "records";
|
||||
export class LocalMemoryCleaner {
|
||||
private readonly timer: ManagedTimer;
|
||||
private destroyed = false;
|
||||
private vectorStore?: VectorStore;
|
||||
private vectorStore?: IMemoryStore;
|
||||
|
||||
constructor(private readonly opts: MemoryCleanerOptions) {
|
||||
this.timer = new ManagedTimer("memory-tdai-cleaner", () => this.destroyed);
|
||||
this.vectorStore = opts.vectorStore;
|
||||
}
|
||||
|
||||
setVectorStore(vectorStore: VectorStore | undefined): void {
|
||||
setVectorStore(vectorStore: IMemoryStore | undefined): void {
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
@@ -51,10 +51,10 @@ export class LocalMemoryCleaner {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown";
|
||||
const utcOffset = formatUtcOffset(-now.getTimezoneOffset());
|
||||
|
||||
this.opts.logger?.info(
|
||||
this.opts.logger?.debug?.(
|
||||
`${TAG} Enabled: retentionDays=${this.opts.retentionDays}, cleanTime=${this.opts.cleanTime}, dirs=[${L0_DIR_NAME}, ${L1_DIR_NAME}]`,
|
||||
);
|
||||
this.opts.logger?.info(
|
||||
this.opts.logger?.debug?.(
|
||||
`${TAG} Runtime clock: nowLocal=${formatLocalDateTime(now)}, nowIso=${now.toISOString()}, tz=${tz}, utcOffset=${utcOffset}`,
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@ export class LocalMemoryCleaner {
|
||||
let failedL1DbCleanup = 0;
|
||||
|
||||
try {
|
||||
removedL0 = vectorStore.deleteL0ExpiredByRecordedAt(cutoffIso);
|
||||
removedL0 = await vectorStore.deleteL0Expired(cutoffIso);
|
||||
} catch (err) {
|
||||
failedL0DbCleanup = 1;
|
||||
this.opts.logger?.warn(
|
||||
@@ -119,7 +119,7 @@ export class LocalMemoryCleaner {
|
||||
}
|
||||
|
||||
try {
|
||||
removedL1 = vectorStore.deleteL1ExpiredByUpdatedTime(cutoffIso);
|
||||
removedL1 = await vectorStore.deleteL1Expired(cutoffIso);
|
||||
} catch (err) {
|
||||
failedL1DbCleanup = 1;
|
||||
this.opts.logger?.warn(
|
||||
@@ -150,7 +150,7 @@ export class LocalMemoryCleaner {
|
||||
const passedToday = targetToday <= nowMs;
|
||||
const delayMs = Math.max(0, next - nowMs);
|
||||
|
||||
this.opts.logger?.info(
|
||||
this.opts.logger?.debug?.(
|
||||
`${TAG} Schedule next run: nowLocal=${formatLocalDateTime(now)}, cleanTime=${this.opts.cleanTime}, targetTodayLocal=${formatLocalDateTime(new Date(targetToday))}, passedToday=${passedToday}, nextRunLocal=${formatLocalDateTime(new Date(next))}, nextRunIso=${new Date(next).toISOString()}, delayMs=${delayMs}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* Pipeline factory: shared infrastructure for creating and wiring
|
||||
* MemoryPipelineManager instances with VectorStore, EmbeddingService,
|
||||
* L1 runner, L2 runner, L3 runner, and persister.
|
||||
*
|
||||
* Used by both:
|
||||
* - `index.ts` (live plugin runtime)
|
||||
* - `seed-runtime.ts` (standalone seed CLI command)
|
||||
*
|
||||
* This avoids duplicating VectorStore init, L1/L2/L3 extraction logic,
|
||||
* persister wiring, and destroy sequences across multiple callers.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { MemoryTdaiConfig } from "../config.js";
|
||||
import { MemoryPipelineManager } from "./pipeline-manager.js";
|
||||
import type { L2Runner, L3Runner } from "./pipeline-manager.js";
|
||||
import { SessionFilter } from "./session-filter.js";
|
||||
import { extractL1Memories } from "../record/l1-extractor.js";
|
||||
import { readConversationMessagesGroupedBySessionId } from "../conversation/l0-recorder.js";
|
||||
import type { ConversationMessage } from "../conversation/l0-recorder.js";
|
||||
import { CheckpointManager } from "./checkpoint.js";
|
||||
import type { PipelineSessionState } from "./checkpoint.js";
|
||||
import { createStoreBundle } from "../store/factory.js";
|
||||
import type { IMemoryStore } from "../store/types.js";
|
||||
import type { EmbeddingService } from "../store/embedding.js";
|
||||
import {
|
||||
readManifest,
|
||||
writeManifest,
|
||||
buildStoreInfo,
|
||||
diffStoreBinding,
|
||||
type Manifest,
|
||||
} from "./manifest.js";
|
||||
import { SceneExtractor } from "../scene/scene-extractor.js";
|
||||
import { PersonaTrigger } from "../persona/persona-trigger.js";
|
||||
import { PersonaGenerator } from "../persona/persona-generator.js";
|
||||
import { pullProfilesToLocal, syncLocalProfilesToStore } from "../profile/profile-sync.js";
|
||||
|
||||
const TAG = "[memory-tdai] [pipeline-factory]";
|
||||
|
||||
function supportsProfileSyncWrite(store?: IMemoryStore): boolean {
|
||||
return !!(store?.syncProfiles || store?.deleteProfiles);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Logger interface
|
||||
// ============================
|
||||
|
||||
export interface PipelineLogger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Factory options
|
||||
// ============================
|
||||
|
||||
export interface PipelineFactoryOptions {
|
||||
/** Plugin data directory (L0, records, scene_blocks, vectors.db, etc.). */
|
||||
pluginDataDir: string;
|
||||
/** Parsed memory-tdai config. */
|
||||
cfg: MemoryTdaiConfig;
|
||||
/** OpenClaw config object (needed for LLM calls in L1). */
|
||||
openclawConfig: unknown;
|
||||
/** Logger instance. */
|
||||
logger: PipelineLogger;
|
||||
/** Session filter (optional, defaults to empty). */
|
||||
sessionFilter?: SessionFilter;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Factory result
|
||||
// ============================
|
||||
|
||||
export interface PipelineInstance {
|
||||
/** The pipeline scheduler. */
|
||||
scheduler: MemoryPipelineManager;
|
||||
/** VectorStore (undefined if init failed or degraded). */
|
||||
vectorStore: IMemoryStore | undefined;
|
||||
/** EmbeddingService (undefined if not configured or init failed). */
|
||||
embeddingService: EmbeddingService | undefined;
|
||||
/**
|
||||
* Destroy all resources (scheduler, VectorStore, EmbeddingService).
|
||||
* Call this on shutdown / cleanup.
|
||||
*/
|
||||
destroy: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Data directory init
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Ensure all required data subdirectories exist under `pluginDataDir`.
|
||||
* Safe to call multiple times (mkdirSync with `recursive: true`).
|
||||
*/
|
||||
export 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 });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Store init (once-async singleton)
|
||||
// ============================
|
||||
|
||||
export interface StoreInitResult {
|
||||
vectorStore: IMemoryStore | undefined;
|
||||
embeddingService: EmbeddingService | undefined;
|
||||
/** Whether a background re-index is needed (embedding config changed). */
|
||||
needsReindex: boolean;
|
||||
reindexReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached store init promises — keyed by `pluginDataDir` so that different
|
||||
* data directories (e.g. live runtime vs. seed output) each get their own
|
||||
* store instance, while concurrent callers for the *same* directory share
|
||||
* one initialization.
|
||||
*/
|
||||
const _storeInitCache = new Map<string, Promise<StoreInitResult>>();
|
||||
|
||||
/**
|
||||
* Initialize store backend and (optionally) EmbeddingService.
|
||||
*
|
||||
* **Once-async semantics per dataDir**: the first call for a given
|
||||
* `pluginDataDir` creates the store and caches the result; subsequent
|
||||
* calls with the same dir return the cached Promise immediately.
|
||||
* Call `resetStores()` during shutdown to clear the cache.
|
||||
*
|
||||
* Supports both SQLite (sync init) and TCVDB (async init) backends.
|
||||
*/
|
||||
export function initStores(
|
||||
cfg: MemoryTdaiConfig,
|
||||
pluginDataDir: string,
|
||||
logger: PipelineLogger,
|
||||
): Promise<StoreInitResult> {
|
||||
const key = pluginDataDir;
|
||||
if (!_storeInitCache.has(key)) {
|
||||
_storeInitCache.set(key, _doInitStores(cfg, pluginDataDir, logger));
|
||||
}
|
||||
return _storeInitCache.get(key)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached store singleton(s).
|
||||
*
|
||||
* Call this during `gateway_stop` (after closing the actual store/embedding
|
||||
* resources) so that a subsequent `register()` on hot-restart can
|
||||
* re-initialize fresh instances.
|
||||
*
|
||||
* @param pluginDataDir If provided, only clear the cache for that dir.
|
||||
* If omitted, clear all cached stores.
|
||||
*/
|
||||
export function resetStores(pluginDataDir?: string): void {
|
||||
if (pluginDataDir) {
|
||||
_storeInitCache.delete(pluginDataDir);
|
||||
} else {
|
||||
_storeInitCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: actual store initialization logic (called once by the cache).
|
||||
*/
|
||||
async function _doInitStores(
|
||||
cfg: MemoryTdaiConfig,
|
||||
pluginDataDir: string,
|
||||
logger: PipelineLogger,
|
||||
): Promise<StoreInitResult> {
|
||||
let vectorStore: IMemoryStore | undefined;
|
||||
let embeddingService: EmbeddingService | undefined;
|
||||
let needsReindex = false;
|
||||
let reindexReason: string | undefined;
|
||||
|
||||
try {
|
||||
const bundle = createStoreBundle(cfg, {
|
||||
dataDir: pluginDataDir,
|
||||
logger,
|
||||
});
|
||||
vectorStore = bundle.store;
|
||||
embeddingService = bundle.embedding ?? undefined;
|
||||
|
||||
const providerInfo = embeddingService?.getProviderInfo();
|
||||
const initResult = await vectorStore.init(providerInfo);
|
||||
|
||||
if (vectorStore.isDegraded()) {
|
||||
logger.warn(`${TAG} Store is in degraded mode, falling back to keyword dedup`);
|
||||
vectorStore = undefined;
|
||||
embeddingService = undefined;
|
||||
} else {
|
||||
logger.debug?.(
|
||||
`${TAG} Store initialized: backend=${cfg.storeBackend}, provider=${cfg.embedding.provider}`,
|
||||
);
|
||||
needsReindex = initResult.needsReindex;
|
||||
reindexReason = initResult.reason;
|
||||
|
||||
// ── Manifest: first-write + config-drift detection ──
|
||||
try {
|
||||
const currentStoreInfo = buildStoreInfo(bundle.storeSnapshot);
|
||||
const existing = readManifest(pluginDataDir);
|
||||
|
||||
if (!existing) {
|
||||
// First init — write manifest
|
||||
const manifest: Manifest = {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
store: currentStoreInfo,
|
||||
seed: null,
|
||||
};
|
||||
writeManifest(pluginDataDir, manifest);
|
||||
logger.debug?.(`${TAG} Manifest created: ${JSON.stringify(currentStoreInfo)}`);
|
||||
} else {
|
||||
// Compare persisted store binding against current config
|
||||
const diffs = diffStoreBinding(existing.store, currentStoreInfo);
|
||||
if (diffs.length > 0) {
|
||||
logger.warn(
|
||||
`${TAG} ⚠️ Store config has changed since this data directory was created! ` +
|
||||
`Diffs: ${diffs.join("; ")}. ` +
|
||||
`Local JSONL data may not match the current store. ` +
|
||||
`Consider re-seeding or migrating data.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`${TAG} Failed to read/write manifest (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`${TAG} Store init failed; vector/FTS recall and dedup conflict detection will be unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
vectorStore = undefined;
|
||||
embeddingService = undefined;
|
||||
}
|
||||
|
||||
return { vectorStore, embeddingService, needsReindex, reindexReason };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// L1 Runner factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create the standard L1 runner function.
|
||||
*
|
||||
* Reads L0 messages (from VectorStore DB or JSONL fallback), groups by sessionId,
|
||||
* runs extractL1Memories for each group, and updates the checkpoint cursor.
|
||||
*/
|
||||
export function createL1Runner(opts: {
|
||||
pluginDataDir: string;
|
||||
cfg: MemoryTdaiConfig;
|
||||
openclawConfig: unknown;
|
||||
vectorStore: IMemoryStore | undefined;
|
||||
embeddingService: EmbeddingService | undefined;
|
||||
logger: PipelineLogger;
|
||||
/**
|
||||
* Getter for the plugin instance ID used for metric reporting.
|
||||
* Called at runner execution time (not at creation time) so that the ID is
|
||||
* available even when the runner is wired before instanceId is resolved.
|
||||
* Metrics are skipped when the getter returns undefined.
|
||||
*/
|
||||
getInstanceId?: () => string | undefined;
|
||||
}): (params: { sessionKey: string }) => Promise<{ processedCount: number }> {
|
||||
const { pluginDataDir, cfg, openclawConfig, vectorStore, embeddingService, logger, getInstanceId } = opts;
|
||||
const config = openclawConfig as Record<string, unknown> | undefined;
|
||||
|
||||
return async ({ sessionKey }) => {
|
||||
if (!config) {
|
||||
logger.debug?.(`${TAG} [l1] No OpenClaw config, skipping L1 extraction`);
|
||||
return { processedCount: 0 };
|
||||
}
|
||||
|
||||
const checkpoint = new CheckpointManager(pluginDataDir, logger);
|
||||
const cp = await checkpoint.read();
|
||||
const runnerState = checkpoint.getRunnerState(cp, sessionKey);
|
||||
|
||||
logger.info(
|
||||
`${TAG} [l1] Session ${sessionKey}: l1_cursor=${runnerState.last_l1_cursor || "(start)"}`,
|
||||
);
|
||||
|
||||
try {
|
||||
let groups: Array<{ sessionId: string; messages: ConversationMessage[] }>;
|
||||
let maxRecordedAtMs = 0;
|
||||
|
||||
if (vectorStore && !vectorStore.isDegraded()) {
|
||||
const l1Cursor = runnerState.last_l1_cursor > 0
|
||||
? runnerState.last_l1_cursor
|
||||
: undefined;
|
||||
const dbGroups = await vectorStore.queryL0GroupedBySessionId(sessionKey, l1Cursor);
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
// Compute max recordedAtMs across all groups for cursor advancement
|
||||
for (const g of dbGroups) {
|
||||
for (const m of g.messages) {
|
||||
if (m.recordedAtMs > maxRecordedAtMs) maxRecordedAtMs = m.recordedAtMs;
|
||||
}
|
||||
}
|
||||
logger.debug?.(`${TAG} [l1] L0 data source: VectorStore DB`);
|
||||
} else {
|
||||
logger.debug?.(`${TAG} [l1] L0 data source: JSONL files (VectorStore unavailable)`);
|
||||
const jsonlGroups = await readConversationMessagesGroupedBySessionId(
|
||||
sessionKey,
|
||||
pluginDataDir,
|
||||
runnerState.last_l1_cursor || undefined,
|
||||
logger,
|
||||
50,
|
||||
);
|
||||
groups = jsonlGroups.map((g) => ({
|
||||
sessionId: g.sessionId,
|
||||
messages: g.messages,
|
||||
}));
|
||||
// Compute max recordedAtMs from JSONL groups
|
||||
for (const g of jsonlGroups) {
|
||||
for (const m of g.messages) {
|
||||
if (m.recordedAtMs > maxRecordedAtMs) maxRecordedAtMs = m.recordedAtMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
logger.debug?.(`${TAG} [l1] No new L0 messages for session ${sessionKey}`);
|
||||
return { processedCount: 0 };
|
||||
}
|
||||
|
||||
const totalMessages = groups.reduce((sum, g) => sum + g.messages.length, 0);
|
||||
logger.info(
|
||||
`${TAG} [l1] Processing ${totalMessages} L0 messages across ${groups.length} sessionId group(s) for session ${sessionKey}`,
|
||||
);
|
||||
|
||||
let totalExtracted = 0;
|
||||
let totalStored = 0;
|
||||
let lastSceneName: string | undefined;
|
||||
|
||||
for (const group of groups) {
|
||||
logger.debug?.(
|
||||
`${TAG} [l1] Group sessionId=${group.sessionId || "(empty)"}: ${group.messages.length} messages`,
|
||||
);
|
||||
|
||||
const l1Result = await extractL1Memories({
|
||||
messages: group.messages,
|
||||
sessionKey,
|
||||
sessionId: group.sessionId,
|
||||
baseDir: pluginDataDir,
|
||||
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,
|
||||
},
|
||||
logger,
|
||||
instanceId: getInstanceId?.(),
|
||||
});
|
||||
|
||||
totalExtracted += l1Result.extractedCount;
|
||||
totalStored += l1Result.storedCount;
|
||||
if (l1Result.lastSceneName) {
|
||||
lastSceneName = l1Result.lastSceneName;
|
||||
}
|
||||
}
|
||||
|
||||
// Use maxRecordedAtMs (write time) as cursor — always positive, TCVDB-safe
|
||||
await checkpoint.markL1ExtractionComplete(sessionKey, totalStored, maxRecordedAtMs || undefined, lastSceneName);
|
||||
logger.info(
|
||||
`${TAG} [l1] L1 complete: extracted=${totalExtracted}, stored=${totalStored} (${groups.length} group(s))`,
|
||||
);
|
||||
|
||||
return { processedCount: totalMessages };
|
||||
} catch (err) {
|
||||
logger.error(`${TAG} [l1] L1 failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Persister factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create the standard pipeline state persister.
|
||||
* Saves pipeline session states to the checkpoint file.
|
||||
*/
|
||||
export function createPersister(
|
||||
pluginDataDir: string,
|
||||
logger: PipelineLogger,
|
||||
): (states: Record<string, PipelineSessionState>) => Promise<void> {
|
||||
return async (states) => {
|
||||
const checkpoint = new CheckpointManager(pluginDataDir, logger);
|
||||
await checkpoint.mergePipelineStates(states);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// L2 Runner factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create the standard L2 runner function (scene extraction).
|
||||
*
|
||||
* Reads L1 memory records (incremental via VectorStore or JSONL fallback),
|
||||
* runs SceneExtractor, and returns the latest cursor for pipeline-manager
|
||||
* to track incremental progress.
|
||||
*
|
||||
* Used by both `index.ts` (live runtime) and `seed-runtime.ts` (seed CLI).
|
||||
*/
|
||||
export function createL2Runner(opts: {
|
||||
pluginDataDir: string;
|
||||
cfg: MemoryTdaiConfig;
|
||||
openclawConfig: unknown;
|
||||
vectorStore: IMemoryStore | undefined;
|
||||
logger: PipelineLogger;
|
||||
instanceId?: string;
|
||||
}): L2Runner {
|
||||
const { pluginDataDir, cfg, openclawConfig, vectorStore, logger, instanceId } = opts;
|
||||
let profileBaseline = new Map<string, { version: number; contentMd5: string; createdAtMs: number }>();
|
||||
|
||||
return async (sessionKey: string, cursor?: string) => {
|
||||
logger.debug?.(
|
||||
`${TAG} [L2] session=${sessionKey}, updatedAfter=${cursor ?? "(full)"}`,
|
||||
);
|
||||
|
||||
let records: Array<{ content: string; created_at: string; id: string; updatedAt: string }>;
|
||||
|
||||
if (vectorStore?.pullProfiles && !vectorStore.isDegraded()) {
|
||||
profileBaseline = await pullProfilesToLocal(pluginDataDir, vectorStore, logger);
|
||||
}
|
||||
|
||||
if (vectorStore && !vectorStore.isDegraded()) {
|
||||
const { queryMemoryRecords } = await import("../record/l1-reader.js");
|
||||
const memRecords = await queryMemoryRecords(vectorStore, {
|
||||
sessionKey,
|
||||
updatedAfter: cursor,
|
||||
}, logger);
|
||||
|
||||
if (memRecords.length === 0) {
|
||||
logger.debug?.(
|
||||
`${TAG} [L2] No new L1 records since cursor (session=${sessionKey}, updatedAfter=${cursor ?? "(full)"}), skipping scene extraction`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug?.(
|
||||
`${TAG} [L2] 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 {
|
||||
logger.debug?.(`${TAG} [L2] VectorStore unavailable, falling back to JSONL read (session=${sessionKey})`);
|
||||
const { readMemoryRecords } = await import("../record/l1-reader.js");
|
||||
let sessionRecords = await readMemoryRecords(sessionKey, pluginDataDir, logger);
|
||||
|
||||
if (cursor) {
|
||||
const beforeCount = sessionRecords.length;
|
||||
sessionRecords = sessionRecords.filter((r) => {
|
||||
const t = r.updatedAt || r.createdAt || "";
|
||||
return t > cursor;
|
||||
});
|
||||
logger.debug?.(
|
||||
`${TAG} [L2] JSONL time filter: ${beforeCount} → ${sessionRecords.length} record(s) (updatedAfter=${cursor})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionRecords.length === 0) {
|
||||
logger.debug?.(`${TAG} [L2] No new L1 records found (JSONL fallback, session=${sessionKey}), skipping scene extraction`);
|
||||
return;
|
||||
}
|
||||
|
||||
records = sessionRecords.map((r) => ({
|
||||
content: r.content,
|
||||
created_at: r.createdAt,
|
||||
id: r.id,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const extractor = new SceneExtractor({
|
||||
dataDir: pluginDataDir,
|
||||
config: openclawConfig!,
|
||||
model: cfg.persona.model,
|
||||
maxScenes: cfg.persona.maxScenes,
|
||||
sceneBackupCount: cfg.persona.sceneBackupCount,
|
||||
logger,
|
||||
instanceId,
|
||||
});
|
||||
|
||||
const memories = records.map((r) => ({
|
||||
content: r.content,
|
||||
created_at: r.created_at,
|
||||
id: r.id,
|
||||
}));
|
||||
|
||||
const preCheckpoint = new CheckpointManager(pluginDataDir, 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, logger);
|
||||
const postState = await checkpoint.read();
|
||||
if (
|
||||
postState.scenes_processed < preScenesProcessed ||
|
||||
postState.total_processed < preTotalProcessed
|
||||
) {
|
||||
logger.warn(
|
||||
`${TAG} [L2] ⚠️ 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),
|
||||
});
|
||||
logger.info(`${TAG} [L2] Checkpoint repaired`);
|
||||
}
|
||||
|
||||
if (vectorStore && supportsProfileSyncWrite(vectorStore)) {
|
||||
await syncLocalProfilesToStore(pluginDataDir, vectorStore, profileBaseline, logger);
|
||||
}
|
||||
await checkpoint.incrementScenesProcessed();
|
||||
|
||||
const latestCursor = records.reduce((latest, r) => {
|
||||
return r.updatedAt > latest ? r.updatedAt : latest;
|
||||
}, "");
|
||||
|
||||
logger.debug?.(
|
||||
`${TAG} [L2] Extraction complete: processed=${extractResult.memoriesProcessed}, latestCursor=${latestCursor}`,
|
||||
);
|
||||
|
||||
return { latestCursor: latestCursor || undefined };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// L3 Runner factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create the standard L3 runner function (persona generation).
|
||||
*
|
||||
* Uses PersonaTrigger to check if generation is needed, then runs
|
||||
* PersonaGenerator. Used by both `index.ts` and `seed-runtime.ts`.
|
||||
*/
|
||||
export function createL3Runner(opts: {
|
||||
pluginDataDir: string;
|
||||
cfg: MemoryTdaiConfig;
|
||||
openclawConfig: unknown;
|
||||
vectorStore?: IMemoryStore;
|
||||
logger: PipelineLogger;
|
||||
instanceId?: string;
|
||||
}): L3Runner {
|
||||
const { pluginDataDir, cfg, openclawConfig, vectorStore, logger, instanceId } = opts;
|
||||
|
||||
return async () => {
|
||||
const trigger = new PersonaTrigger({
|
||||
dataDir: pluginDataDir,
|
||||
interval: cfg.persona.triggerEveryN,
|
||||
logger,
|
||||
});
|
||||
|
||||
const { should, reason } = await trigger.shouldGenerate();
|
||||
if (!should) {
|
||||
logger.debug?.(`${TAG} [L3] Persona generation not needed`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openclawConfig) {
|
||||
logger.warn(`${TAG} [L3] No OpenClaw config, skipping persona generation`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pull remote profiles to establish fresh baseline before generation.
|
||||
// This ensures syncLocalProfilesToStore() has correct baselineVersion
|
||||
// for the optimistic-lock check instead of defaulting to 0.
|
||||
let profileBaseline = new Map<string, { version: number; contentMd5: string; createdAtMs: number }>();
|
||||
if (vectorStore?.pullProfiles && !vectorStore.isDegraded()) {
|
||||
profileBaseline = await pullProfilesToLocal(pluginDataDir, vectorStore, logger);
|
||||
}
|
||||
|
||||
logger.info(`${TAG} [L3] Starting persona generation: ${reason}`);
|
||||
const generator = new PersonaGenerator({
|
||||
dataDir: pluginDataDir,
|
||||
config: openclawConfig,
|
||||
model: cfg.persona.model,
|
||||
backupCount: cfg.persona.backupCount,
|
||||
logger,
|
||||
instanceId,
|
||||
});
|
||||
const genResult = await generator.generateLocalPersona(reason);
|
||||
if (!genResult) {
|
||||
logger.info(`${TAG} [L3] Persona generation skipped (no changes)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (vectorStore && supportsProfileSyncWrite(vectorStore)) {
|
||||
await syncLocalProfilesToStore(pluginDataDir, vectorStore, profileBaseline, logger);
|
||||
}
|
||||
|
||||
const checkpoint = new CheckpointManager(pluginDataDir, logger);
|
||||
const cp = await checkpoint.read();
|
||||
await checkpoint.markPersonaGenerated(cp.total_processed);
|
||||
logger.info(`${TAG} [L3] Persona generation succeeded`);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Pipeline Manager factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create a MemoryPipelineManager with the standard config mapping.
|
||||
*/
|
||||
export function createPipelineManager(
|
||||
cfg: MemoryTdaiConfig,
|
||||
logger: PipelineLogger,
|
||||
sessionFilter?: SessionFilter,
|
||||
): MemoryPipelineManager {
|
||||
return 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,
|
||||
},
|
||||
},
|
||||
logger,
|
||||
sessionFilter ?? new SessionFilter([]),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Full pipeline factory
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Create a fully wired pipeline instance: VectorStore + EmbeddingService +
|
||||
* MemoryPipelineManager with L1 runner and persister attached.
|
||||
*
|
||||
* This is the high-level entry point used by both `index.ts` and `seed-runtime.ts`.
|
||||
* Callers should attach L2/L3 runners after creation using `createL2Runner()`
|
||||
* and `createL3Runner()` from this module.
|
||||
*/
|
||||
export async function createPipeline(opts: PipelineFactoryOptions): Promise<PipelineInstance> {
|
||||
const { pluginDataDir, cfg, openclawConfig, logger, sessionFilter } = opts;
|
||||
|
||||
// Ensure data directories exist
|
||||
initDataDirectories(pluginDataDir);
|
||||
|
||||
// Initialize stores (once-async: reuses cached result if already initialized)
|
||||
const stores = await initStores(cfg, pluginDataDir, logger);
|
||||
const { vectorStore, embeddingService } = stores;
|
||||
|
||||
// Create pipeline manager
|
||||
const scheduler = createPipelineManager(cfg, logger, sessionFilter);
|
||||
|
||||
// Wire L1 runner
|
||||
scheduler.setL1Runner(createL1Runner({
|
||||
pluginDataDir,
|
||||
cfg,
|
||||
openclawConfig,
|
||||
vectorStore,
|
||||
embeddingService,
|
||||
logger,
|
||||
}));
|
||||
|
||||
// Wire persister
|
||||
scheduler.setPersister(createPersister(pluginDataDir, logger));
|
||||
|
||||
// Destroy function
|
||||
const destroy = async () => {
|
||||
logger.info(`${TAG} Destroying pipeline...`);
|
||||
await scheduler.destroy();
|
||||
if (vectorStore) {
|
||||
logger.info(`${TAG} Closing VectorStore`);
|
||||
vectorStore.close();
|
||||
}
|
||||
if (embeddingService?.close) {
|
||||
try {
|
||||
logger.info(`${TAG} Closing EmbeddingService`);
|
||||
await embeddingService.close();
|
||||
} catch (err) {
|
||||
logger.warn(`${TAG} Error closing EmbeddingService: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
logger.info(`${TAG} Pipeline destroyed`);
|
||||
};
|
||||
|
||||
return { scheduler, vectorStore, embeddingService, destroy };
|
||||
}
|
||||
@@ -262,7 +262,7 @@ export class MemoryPipelineManager {
|
||||
this.logger = logger;
|
||||
this.sessionFilter = sessionFilter ?? new SessionFilter();
|
||||
|
||||
this.logger?.info(
|
||||
this.logger?.debug?.(
|
||||
`${TAG} Initialized: everyNConversations=${config.everyNConversations}, ` +
|
||||
`warmup=${config.enableWarmup ? "enabled" : "disabled"}, ` +
|
||||
`l1IdleTimeout=${config.l1.idleTimeoutSeconds}s, ` +
|
||||
@@ -1076,12 +1076,22 @@ export class MemoryPipelineManager {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
/** Queue sizes for monitoring. */
|
||||
getQueueSizes(): { l1: number; l2: number; l3: number } {
|
||||
/** Queue sizes and running state for monitoring. */
|
||||
getQueueSizes(): {
|
||||
l1: number; l2: number; l3: number;
|
||||
l1Pending: boolean; l2Pending: boolean; l3Pending: boolean;
|
||||
l1Idle: boolean; l2Idle: boolean; l3Idle: boolean;
|
||||
} {
|
||||
return {
|
||||
l1: this.l1Queue.size,
|
||||
l2: this.l2Queue.size,
|
||||
l3: this.l3Queue.size,
|
||||
l1Pending: this.l1Queue.pending,
|
||||
l2Pending: this.l2Queue.pending,
|
||||
l3Pending: this.l3Queue.pending,
|
||||
l1Idle: this.l1Queue.idle,
|
||||
l2Idle: this.l2Queue.idle,
|
||||
l3Idle: this.l3Queue.idle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export function sanitizeText(text: string): string {
|
||||
// Remove framework reply directive tags: [[reply_to_current]], [[reply_to_xxx]], etc.
|
||||
cleaned = cleaned.replace(/\[\[reply_to[^\]]*\]\]\s*/g, "");
|
||||
|
||||
// Remove injected skill-selection wrappers, e.g. ¥¥[... ]¥¥
|
||||
cleaned = cleaned.replace(/¥¥\[[\s\S]*?\]¥¥/g, "");
|
||||
|
||||
// Remove line-leading timestamps, e.g. "[Tue 2026-03-24 03:48 UTC]"
|
||||
// or "[Tue 2026-03-24 20:21 GMT+8]", "[Thu 2026-03-24 01:51 GMT+5:30]"
|
||||
// Matches brackets containing word chars, digits, hyphens, colons, plus signs,
|
||||
|
||||
@@ -50,6 +50,11 @@ export class SerialQueue {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
/** Whether the queue is idle (no queued tasks and nothing running). */
|
||||
get idle(): boolean {
|
||||
return this.queue.length === 0 && !this.running;
|
||||
}
|
||||
|
||||
/** Add a task to the queue. Returns the task's result promise. */
|
||||
add<T>(task: Task<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user