mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 04:44:29 +00:00
feat: init Agent-Memory
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* L1 Memory Conflict Detection (Batch Mode): decides how to handle multiple new
|
||||
* memories against existing records in a single LLM call.
|
||||
*
|
||||
* v4: Removed JSONL-based Jaccard fallback. Candidate recall now relies exclusively
|
||||
* on vector search (primary) and FTS5 BM25 (degraded). If neither is available,
|
||||
* conflict detection is skipped entirely — all memories go straight to store.
|
||||
*
|
||||
* Two-phase approach:
|
||||
* 1. Candidate search per new memory — vector recall or FTS5 keyword recall (fast, no LLM)
|
||||
* 2. Batch LLM judgment on all new memories + their candidate pools (single call)
|
||||
*/
|
||||
|
||||
import type { ExtractedMemory, MemoryRecord, DedupDecision, MemoryType } from "./l1-writer.js";
|
||||
import { CONFLICT_DETECTION_SYSTEM_PROMPT, formatBatchConflictPrompt } from "../prompts/l1-dedup.js";
|
||||
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 { EmbeddingService } from "../store/embedding.js";
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
const TAG = "[memory-tdai][l1-dedup]";
|
||||
|
||||
// ============================
|
||||
// Core function (batch mode)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Batch conflict detection: compare all new memories against existing records
|
||||
* in a single LLM call.
|
||||
*
|
||||
* Candidate recall strategy (3-tier degradation):
|
||||
* 1. Vector recall (vectorStore + embeddingService) — cosine similarity (best)
|
||||
* 2. FTS5 keyword recall (vectorStore with FTS available) — BM25 ranking (degraded)
|
||||
* 3. Skip conflict detection entirely — all memories go straight to "store"
|
||||
*
|
||||
* The old JSONL-based Jaccard fallback has been removed. If neither vector search
|
||||
* nor FTS is available, we skip dedup rather than paying the O(N) full-file-scan cost.
|
||||
*
|
||||
* @param memories - Newly extracted memories (with record_id)
|
||||
* @param config - OpenClaw config (for LLM access)
|
||||
* @param logger - Optional logger
|
||||
* @param model - Optional model override
|
||||
* @param vectorStore - Optional vector store for cosine similarity search
|
||||
* @param embeddingService - Optional embedding service for computing query vectors
|
||||
* @param conflictRecallTopK - Top-K candidates to recall per new memory (default: 5)
|
||||
* @returns Array of dedup decisions, one per new memory
|
||||
*/
|
||||
export async function batchDedup(params: {
|
||||
memories: Array<ExtractedMemory & { record_id: string }>;
|
||||
config: unknown;
|
||||
logger?: Logger;
|
||||
model?: string;
|
||||
/** Vector store for cosine similarity candidate recall */
|
||||
vectorStore?: VectorStore;
|
||||
/** Embedding service for computing query vectors */
|
||||
embeddingService?: EmbeddingService;
|
||||
/** Top-K candidates per new memory (default: 5) */
|
||||
conflictRecallTopK?: number;
|
||||
}): Promise<DedupDecision[]> {
|
||||
const { memories, config, logger, model, vectorStore, embeddingService } = params;
|
||||
const topK = params.conflictRecallTopK ?? 5;
|
||||
|
||||
if (memories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const storeAll = () =>
|
||||
memories.map((m) => ({
|
||||
record_id: m.record_id,
|
||||
action: "store" as const,
|
||||
target_ids: [],
|
||||
}));
|
||||
|
||||
// Determine what recall capabilities are available
|
||||
const hasVectorData = vectorStore && vectorStore.count() > 0;
|
||||
const hasFts = vectorStore?.isFtsAvailable() ?? false;
|
||||
|
||||
// Fast path: no recall capability at all → skip dedup
|
||||
if (!hasVectorData && !hasFts) {
|
||||
logger?.debug?.(`${TAG} No vector data and no FTS available, skipping conflict detection for ${memories.length} memories`);
|
||||
return storeAll();
|
||||
}
|
||||
|
||||
// Phase 1: Find candidates
|
||||
//
|
||||
// Decision tree (after the fast-path guard above, vectorStore is guaranteed non-null):
|
||||
// hasVectorData + embeddingService → Tier 1 vector recall (FTS fallback on error)
|
||||
// otherwise hasFts → Tier 2 FTS keyword recall
|
||||
// otherwise → skip dedup (defensive; shouldn't reach here)
|
||||
let matches: CandidateMatch[];
|
||||
|
||||
if (hasVectorData && embeddingService) {
|
||||
// === Tier 1: Vector recall mode ===
|
||||
logger?.debug?.(`${TAG} Using vector recall mode (topK=${topK})`);
|
||||
try {
|
||||
matches = await findCandidatesByVector(memories, vectorStore!, embeddingService, topK, logger);
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`${TAG} Vector recall failed, falling back to FTS keyword: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Degrade to FTS keyword recall
|
||||
if (hasFts) {
|
||||
matches = findCandidatesByFts(memories, vectorStore!, logger);
|
||||
} else {
|
||||
logger?.debug?.(`${TAG} FTS not available either, skipping conflict detection`);
|
||||
return storeAll();
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
} else {
|
||||
// Shouldn't reach here given the fast-path check above, but be defensive
|
||||
logger?.debug?.(`${TAG} No usable recall path, skipping conflict detection`);
|
||||
return storeAll();
|
||||
}
|
||||
|
||||
// Check if any memory has candidates
|
||||
const hasAnyCandidates = matches.some((m) => m.candidates.length > 0);
|
||||
|
||||
if (!hasAnyCandidates) {
|
||||
logger?.debug?.(`${TAG} No similar records found for any memory, all will be stored`);
|
||||
return storeAll();
|
||||
}
|
||||
|
||||
// Phase 2: Batch LLM judgment
|
||||
return runLlmJudgment(matches, memories, config, logger, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Run batch LLM judgment on candidate matches.
|
||||
*/
|
||||
async function runLlmJudgment(
|
||||
matches: CandidateMatch[],
|
||||
memories: Array<ExtractedMemory & { record_id: string }>,
|
||||
config: unknown,
|
||||
logger: Logger | undefined,
|
||||
model: string | undefined,
|
||||
): Promise<DedupDecision[]> {
|
||||
logger?.debug?.(`${TAG} Running batch conflict detection for ${memories.length} memories`);
|
||||
|
||||
try {
|
||||
const runner = new CleanContextRunner({
|
||||
config,
|
||||
modelRef: model,
|
||||
enableTools: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const userPrompt = formatBatchConflictPrompt(matches);
|
||||
|
||||
const result = await runner.run({
|
||||
prompt: userPrompt,
|
||||
systemPrompt: CONFLICT_DETECTION_SYSTEM_PROMPT,
|
||||
taskId: "l1-conflict-detection",
|
||||
timeoutMs: 180_000,
|
||||
// maxTokens: 4000, remove maxTokens use model default or inherit from config
|
||||
});
|
||||
|
||||
const decisions = parseBatchResult(result, memories, logger);
|
||||
return decisions;
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`${TAG} Batch conflict detection failed, defaulting all to store: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return memories.map((m) => ({
|
||||
record_id: m.record_id,
|
||||
action: "store" as const,
|
||||
target_ids: [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Candidate recall strategies
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Vector-based candidate recall (aligned with prototype):
|
||||
* batch-embed new memories → cosine search in VectorStore → exclude self-batch → return candidates.
|
||||
*/
|
||||
async function findCandidatesByVector(
|
||||
memories: Array<ExtractedMemory & { record_id: string }>,
|
||||
vectorStore: VectorStore,
|
||||
embeddingService: EmbeddingService,
|
||||
topK: number,
|
||||
logger?: Logger,
|
||||
): Promise<CandidateMatch[]> {
|
||||
const newRecordIds = new Set(memories.map((m) => m.record_id));
|
||||
|
||||
// Batch-compute embeddings for all new memories
|
||||
const texts = memories.map((m) => m.content);
|
||||
const embeddings = await embeddingService.embedBatch(texts);
|
||||
|
||||
const matches: CandidateMatch[] = [];
|
||||
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const mem = memories[i];
|
||||
const queryVec = embeddings[i];
|
||||
|
||||
// Vector search top-K (request extra to account for self-batch filtering)
|
||||
const searchResults = vectorStore.search(queryVec, topK + memories.length);
|
||||
|
||||
// Exclude records from current batch, convert to MemoryRecord format
|
||||
const candidates: MemoryRecord[] = searchResults
|
||||
.filter((r) => !newRecordIds.has(r.record_id))
|
||||
.slice(0, topK)
|
||||
.map((r) => ({
|
||||
id: r.record_id,
|
||||
content: r.content,
|
||||
type: r.type as MemoryRecord["type"],
|
||||
priority: r.priority,
|
||||
scene_name: r.scene_name,
|
||||
source_message_ids: [],
|
||||
metadata: {},
|
||||
timestamps: [r.timestamp_str].filter(Boolean),
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
sessionKey: r.session_key,
|
||||
sessionId: r.session_id,
|
||||
}));
|
||||
|
||||
matches.push({ newMemory: mem, candidates });
|
||||
}
|
||||
|
||||
logger?.debug?.(
|
||||
`${TAG} Vector recall: ${matches.map((m) => `${m.newMemory.record_id}→${m.candidates.length}`).join(", ")}`,
|
||||
);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* FTS5-based candidate recall:
|
||||
* Uses the FTS index for efficient BM25-ranked keyword matching.
|
||||
* This replaces the old Jaccard word-overlap fallback entirely.
|
||||
*/
|
||||
function findCandidatesByFts(
|
||||
memories: Array<ExtractedMemory & { record_id: string }>,
|
||||
vectorStore: VectorStore,
|
||||
_logger?: Logger,
|
||||
): 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);
|
||||
// Filter out records from the current batch
|
||||
const candidates: MemoryRecord[] = ftsResults
|
||||
.filter((r) => !newRecordIds.has(r.record_id))
|
||||
.slice(0, 5)
|
||||
.map((r) => ({
|
||||
id: r.record_id,
|
||||
content: r.content,
|
||||
type: r.type as MemoryRecord["type"],
|
||||
priority: r.priority,
|
||||
scene_name: r.scene_name,
|
||||
source_message_ids: [],
|
||||
metadata: r.metadata_json ? (() => { try { return JSON.parse(r.metadata_json); } catch { return {}; } })() : {},
|
||||
timestamps: [r.timestamp_str].filter(Boolean),
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
sessionKey: r.session_key,
|
||||
sessionId: r.session_id,
|
||||
}));
|
||||
matches.push({ newMemory: mem, candidates });
|
||||
} else {
|
||||
matches.push({ newMemory: mem, candidates: [] });
|
||||
}
|
||||
}
|
||||
|
||||
_logger?.debug?.(`${TAG} FTS keyword recall: ${matches.map((m) => `${m.newMemory.record_id}→${m.candidates.length}`).join(", ")}`);
|
||||
return matches;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Result parsing
|
||||
// ============================
|
||||
|
||||
const VALID_TYPES: MemoryType[] = ["persona", "episodic", "instruction"];
|
||||
|
||||
/**
|
||||
* Parse the LLM's batch conflict detection JSON response.
|
||||
*
|
||||
* Expected format: [{record_id, action, target_ids, merged_content, merged_type, merged_priority, merged_timestamps}]
|
||||
*/
|
||||
function parseBatchResult(
|
||||
raw: string,
|
||||
memories: Array<ExtractedMemory & { record_id: string }>,
|
||||
logger?: Logger,
|
||||
): DedupDecision[] {
|
||||
try {
|
||||
// Strip markdown code block wrappers
|
||||
let cleaned = raw.trim();
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
||||
}
|
||||
|
||||
// Extract JSON array
|
||||
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (!arrayMatch) {
|
||||
logger?.warn?.(`${TAG} No JSON array found in conflict detection response`);
|
||||
return fallbackStoreAll(memories);
|
||||
}
|
||||
|
||||
// Sanitize control characters inside JSON string literals that LLM may produce
|
||||
const sanitized = sanitizeJsonForParse(arrayMatch[0]);
|
||||
const parsed = JSON.parse(sanitized) as unknown[];
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
logger?.warn?.(`${TAG} Conflict detection response is not an array`);
|
||||
return fallbackStoreAll(memories);
|
||||
}
|
||||
|
||||
// Build decisions from LLM output
|
||||
const decisions: DedupDecision[] = [];
|
||||
const validActions = ["store", "update", "merge", "skip"];
|
||||
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const d = item as Record<string, unknown>;
|
||||
|
||||
const recordId = String(d.record_id ?? "");
|
||||
const action = String(d.action ?? "store");
|
||||
|
||||
if (!validActions.includes(action)) {
|
||||
logger?.warn?.(`${TAG} Invalid action "${action}" for record ${recordId}, defaulting to store`);
|
||||
}
|
||||
|
||||
decisions.push({
|
||||
record_id: recordId,
|
||||
action: validActions.includes(action) ? (action as DedupDecision["action"]) : "store",
|
||||
target_ids: Array.isArray(d.target_ids) ? d.target_ids.map(String) : [],
|
||||
merged_content: typeof d.merged_content === "string" ? d.merged_content : undefined,
|
||||
merged_type: VALID_TYPES.includes(d.merged_type as MemoryType) ? (d.merged_type as MemoryType) : undefined,
|
||||
merged_priority: typeof d.merged_priority === "number" ? d.merged_priority : undefined,
|
||||
merged_timestamps: Array.isArray(d.merged_timestamps) ? d.merged_timestamps.map(String) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure all memories have a decision (fill missing with "store")
|
||||
const decidedIds = new Set(decisions.map((d) => d.record_id));
|
||||
for (const mem of memories) {
|
||||
if (!decidedIds.has(mem.record_id)) {
|
||||
logger?.debug?.(`${TAG} No decision for record ${mem.record_id}, defaulting to store`);
|
||||
decisions.push({
|
||||
record_id: mem.record_id,
|
||||
action: "store",
|
||||
target_ids: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return decisions;
|
||||
} catch (err) {
|
||||
logger?.warn?.(`${TAG} Failed to parse conflict detection result: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return fallbackStoreAll(memories);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: store all memories when parsing fails.
|
||||
*/
|
||||
function fallbackStoreAll(memories: Array<ExtractedMemory & { record_id: string }>): DedupDecision[] {
|
||||
return memories.map((m) => ({
|
||||
record_id: m.record_id,
|
||||
action: "store" as const,
|
||||
target_ids: [],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* L1 Memory Extractor: extracts structured memories from L0 conversation messages
|
||||
* using a single LLM call with JSON-mode structured output.
|
||||
*
|
||||
* v3: Aligned with Kenty's prompt — scene segmentation + memory extraction in one call,
|
||||
* followed by batch conflict detection.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Read recent messages from L0 (split into background + new)
|
||||
* 2. Call LLM to extract scene-segmented memories
|
||||
* 3. Batch conflict detection against existing records
|
||||
* 4. Write to L1 JSONL files
|
||||
*/
|
||||
|
||||
import type { ConversationMessage } from "../conversation/l0-recorder.js";
|
||||
import { EXTRACT_MEMORIES_SYSTEM_PROMPT, formatExtractionPrompt } from "../prompts/l1-extraction.js";
|
||||
import { batchDedup } from "./l1-dedup.js";
|
||||
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 { EmbeddingService } from "../store/embedding.js";
|
||||
import { report } from "../report/reporter.js";
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
const TAG = "[memory-tdai][l1-extractor]";
|
||||
|
||||
// ============================
|
||||
// Types
|
||||
// ============================
|
||||
|
||||
/** A scene segment with its extracted memories (LLM output) */
|
||||
interface SceneSegment {
|
||||
scene_name: string;
|
||||
message_ids: string[];
|
||||
memories: Array<{
|
||||
content: string;
|
||||
type: string;
|
||||
priority: number;
|
||||
source_message_ids: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface L1ExtractionResult {
|
||||
/** Whether extraction succeeded */
|
||||
success: boolean;
|
||||
/** Number of memories extracted */
|
||||
extractedCount: number;
|
||||
/** Number of memories actually stored (after dedup) */
|
||||
storedCount: number;
|
||||
/** The memory records that were stored */
|
||||
records: MemoryRecord[];
|
||||
/** Scene names detected during extraction */
|
||||
sceneNames: string[];
|
||||
/** Last scene name (for continuity in next extraction) */
|
||||
lastSceneName?: string;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Core function
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Run the full L1 extraction pipeline on conversation messages.
|
||||
*
|
||||
* @param messages - Filtered conversation messages (from L0 or directly from hook)
|
||||
* @param sessionKey - The session key
|
||||
* @param baseDir - Base data directory (~/.openclaw/memory-tdai/)
|
||||
* @param config - OpenClaw config (for LLM access)
|
||||
* @param options - Extraction options
|
||||
* @param logger - Optional logger
|
||||
*/
|
||||
export async function extractL1Memories(params: {
|
||||
messages: ConversationMessage[];
|
||||
sessionKey: string;
|
||||
sessionId?: string;
|
||||
baseDir: string;
|
||||
config: unknown;
|
||||
options?: {
|
||||
/** Max new messages to send in one extraction call */
|
||||
maxMessagesPerExtraction?: number;
|
||||
/** Max background messages for context */
|
||||
maxBackgroundMessages?: number;
|
||||
/** Enable conflict detection */
|
||||
enableDedup?: boolean;
|
||||
/** Max memories extracted per call */
|
||||
maxMemoriesPerSession?: number;
|
||||
/** LLM model override */
|
||||
model?: string;
|
||||
/** Previous scene name for continuity */
|
||||
previousSceneName?: string;
|
||||
/** Vector store for cosine similarity candidate recall */
|
||||
vectorStore?: VectorStore;
|
||||
/** Embedding service for computing query vectors */
|
||||
embeddingService?: EmbeddingService;
|
||||
/** Top-K candidates for conflict recall (default: 5) */
|
||||
conflictRecallTopK?: number;
|
||||
};
|
||||
logger?: Logger;
|
||||
/** Plugin instance ID for metric reporting (optional — metrics skipped if absent) */
|
||||
instanceId?: string;
|
||||
}): Promise<L1ExtractionResult> {
|
||||
const { messages, sessionKey, sessionId, baseDir, config, logger, instanceId: metricInstanceId } = params;
|
||||
const options = params.options ?? {};
|
||||
const maxNewMessages = options.maxMessagesPerExtraction ?? 10;
|
||||
const maxBgMessages = options.maxBackgroundMessages ?? 5;
|
||||
const enableDedup = options.enableDedup ?? true;
|
||||
const maxMemoriesPerSession = options.maxMemoriesPerSession ?? 10;
|
||||
|
||||
if (messages.length === 0) {
|
||||
logger?.debug?.(`${TAG} No messages to extract from`);
|
||||
return { success: true, extractedCount: 0, storedCount: 0, records: [], sceneNames: [] };
|
||||
}
|
||||
|
||||
const l1StartMs = Date.now();
|
||||
|
||||
// Quality gate: filter messages through L1 extraction rules (length, symbols,
|
||||
// prompt injection, etc.) before sending to the LLM. L0 deliberately captures
|
||||
// everything; the strict filtering happens here at L1 stage.
|
||||
const qualifiedMessages = messages.filter((m) => shouldExtractL1(m.content));
|
||||
if (qualifiedMessages.length < messages.length) {
|
||||
logger?.debug?.(
|
||||
`${TAG} L1 quality filter: ${messages.length} → ${qualifiedMessages.length} messages ` +
|
||||
`(${messages.length - qualifiedMessages.length} filtered out)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (qualifiedMessages.length === 0) {
|
||||
logger?.debug?.(`${TAG} All messages filtered out by L1 quality gate`);
|
||||
return { success: true, extractedCount: 0, storedCount: 0, records: [], sceneNames: [] };
|
||||
}
|
||||
|
||||
// Split messages into background (older) + new (recent)
|
||||
const newMessages = qualifiedMessages.slice(-maxNewMessages);
|
||||
const bgEndIdx = qualifiedMessages.length - newMessages.length;
|
||||
const backgroundMessages = bgEndIdx > 0
|
||||
? qualifiedMessages.slice(Math.max(0, bgEndIdx - maxBgMessages), bgEndIdx)
|
||||
: [];
|
||||
|
||||
logger?.debug?.(`${TAG} Extracting from ${newMessages.length} new messages (+ ${backgroundMessages.length} background) [${qualifiedMessages.length} qualified from ${messages.length} input]`);
|
||||
|
||||
// Step 1: LLM extraction (scene segmentation + memory extraction)
|
||||
let scenes: SceneSegment[];
|
||||
try {
|
||||
scenes = await callLlmExtraction({
|
||||
newMessages,
|
||||
backgroundMessages,
|
||||
previousSceneName: options.previousSceneName,
|
||||
config,
|
||||
logger,
|
||||
model: options.model,
|
||||
});
|
||||
logger?.debug?.(`${TAG} LLM detected ${scenes.length} scene(s)`);
|
||||
} catch (err) {
|
||||
logger?.error(`${TAG} LLM extraction failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return { success: false, extractedCount: 0, storedCount: 0, records: [], sceneNames: [] };
|
||||
}
|
||||
|
||||
// Flatten all memories across scenes
|
||||
const allExtracted: ExtractedMemory[] = [];
|
||||
const sceneNames: string[] = [];
|
||||
|
||||
for (const scene of scenes) {
|
||||
sceneNames.push(scene.scene_name);
|
||||
for (const mem of scene.memories) {
|
||||
const memType = normalizeType(mem.type);
|
||||
if (!memType) {
|
||||
logger?.warn?.(`${TAG} Skipping memory with invalid type "${mem.type}"`);
|
||||
continue;
|
||||
}
|
||||
allExtracted.push({
|
||||
content: mem.content,
|
||||
type: memType,
|
||||
priority: typeof mem.priority === "number" ? mem.priority : 50,
|
||||
source_message_ids: Array.isArray(mem.source_message_ids) ? mem.source_message_ids : [],
|
||||
metadata: mem.metadata ?? {},
|
||||
scene_name: scene.scene_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger?.debug?.(`${TAG} Total extracted memories: ${allExtracted.length} across ${scenes.length} scene(s)`);
|
||||
|
||||
if (allExtracted.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
extractedCount: 0,
|
||||
storedCount: 0,
|
||||
records: [],
|
||||
sceneNames,
|
||||
lastSceneName: sceneNames[sceneNames.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
// Limit per session
|
||||
let extracted = allExtracted;
|
||||
if (extracted.length > maxMemoriesPerSession) {
|
||||
logger?.debug?.(`${TAG} Limiting from ${extracted.length} to ${maxMemoriesPerSession} memories per session`);
|
||||
extracted = extracted.slice(0, maxMemoriesPerSession);
|
||||
}
|
||||
|
||||
// Assign temporary IDs to extracted memories (needed for batch dedup)
|
||||
const memoriesWithIds = extracted.map((m) => ({
|
||||
...m,
|
||||
record_id: generateMemoryId(),
|
||||
}));
|
||||
|
||||
// Step 2: Batch Conflict Detection + Write
|
||||
let storedRecords: MemoryRecord[];
|
||||
|
||||
if (enableDedup) {
|
||||
try {
|
||||
const decisions = await batchDedup({
|
||||
memories: memoriesWithIds,
|
||||
config,
|
||||
logger,
|
||||
model: options.model,
|
||||
vectorStore: options.vectorStore,
|
||||
embeddingService: options.embeddingService,
|
||||
conflictRecallTopK: options.conflictRecallTopK,
|
||||
});
|
||||
|
||||
storedRecords = await applyDecisions({
|
||||
memoriesWithIds,
|
||||
decisions,
|
||||
baseDir,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
logger,
|
||||
vectorStore: options.vectorStore,
|
||||
embeddingService: options.embeddingService,
|
||||
});
|
||||
} catch (err) {
|
||||
logger?.warn?.(`${TAG} Batch dedup failed, storing all as new: ${err instanceof Error ? err.message : String(err)}`);
|
||||
storedRecords = await storeAllDirectly(memoriesWithIds, baseDir, sessionKey, sessionId, logger, options.vectorStore, options.embeddingService);
|
||||
}
|
||||
} else {
|
||||
storedRecords = await storeAllDirectly(memoriesWithIds, baseDir, sessionKey, sessionId, logger, options.vectorStore, options.embeddingService);
|
||||
}
|
||||
|
||||
logger?.info(`${TAG} Extraction complete: extracted=${extracted.length}, stored=${storedRecords.length}`);
|
||||
|
||||
// ── l1_extraction metric ──
|
||||
if (metricInstanceId && logger) {
|
||||
// Build type distribution of stored memories
|
||||
const memoriesByType: Record<string, number> = {};
|
||||
for (const r of storedRecords) {
|
||||
memoriesByType[r.type] = (memoriesByType[r.type] ?? 0) + 1;
|
||||
}
|
||||
report("l1_extraction", {
|
||||
sessionKey,
|
||||
inputMessageCount: messages.length,
|
||||
memoriesExtracted: extracted.length,
|
||||
memoriesStored: storedRecords.length,
|
||||
memoriesStoredContent: storedRecords.map((r) => ({
|
||||
content: r.content,
|
||||
type: r.type,
|
||||
scene: r.scene_name ?? null,
|
||||
})),
|
||||
memoriesByType,
|
||||
totalDurationMs: Date.now() - l1StartMs,
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
extractedCount: extracted.length,
|
||||
storedCount: storedRecords.length,
|
||||
records: storedRecords,
|
||||
sceneNames,
|
||||
lastSceneName: sceneNames[sceneNames.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// LLM call
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Call LLM to extract scene-segmented memories from conversation messages.
|
||||
*/
|
||||
async function callLlmExtraction(params: {
|
||||
newMessages: ConversationMessage[];
|
||||
backgroundMessages: ConversationMessage[];
|
||||
previousSceneName?: string;
|
||||
config: unknown;
|
||||
logger?: Logger;
|
||||
model?: string;
|
||||
}): Promise<SceneSegment[]> {
|
||||
const { newMessages, backgroundMessages, previousSceneName, config, logger, model } = params;
|
||||
|
||||
const runner = new CleanContextRunner({
|
||||
config,
|
||||
modelRef: model,
|
||||
enableTools: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const userPrompt = formatExtractionPrompt({
|
||||
newMessages,
|
||||
backgroundMessages,
|
||||
previousSceneName,
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
prompt: userPrompt,
|
||||
systemPrompt: EXTRACT_MEMORIES_SYSTEM_PROMPT,
|
||||
taskId: "l1-extraction",
|
||||
timeoutMs: 180_000,
|
||||
// maxTokens: 4000,
|
||||
});
|
||||
|
||||
return parseExtractionResult(result, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the LLM's JSON response into SceneSegment array.
|
||||
* Expected format: [{scene_name, message_ids, memories: [...]}]
|
||||
*/
|
||||
function parseExtractionResult(raw: string, logger?: Logger): SceneSegment[] {
|
||||
try {
|
||||
// Strip markdown code block wrappers if present
|
||||
let cleaned = raw.trim();
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
||||
}
|
||||
|
||||
// Try to extract JSON array
|
||||
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (!arrayMatch) {
|
||||
logger?.warn?.(`${TAG} No JSON array found in extraction response`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sanitize control characters inside JSON string literals that LLM may produce
|
||||
const sanitized = sanitizeJsonForParse(arrayMatch[0]);
|
||||
const parsed = JSON.parse(sanitized) as unknown[];
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
logger?.warn?.(`${TAG} Extraction response is not an array`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const scenes: SceneSegment[] = [];
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const s = item as Record<string, unknown>;
|
||||
|
||||
scenes.push({
|
||||
scene_name: typeof s.scene_name === "string" ? s.scene_name : "未知情境",
|
||||
message_ids: Array.isArray(s.message_ids) ? s.message_ids.map(String) : [],
|
||||
memories: Array.isArray(s.memories)
|
||||
? (s.memories as Array<Record<string, unknown>>)
|
||||
.filter((m) => m && typeof m === "object" && typeof m.content === "string" && (m.content as string).length > 0)
|
||||
.map((m) => ({
|
||||
content: String(m.content),
|
||||
type: String(m.type ?? "episodic"),
|
||||
priority: typeof m.priority === "number" ? m.priority : 50,
|
||||
source_message_ids: Array.isArray(m.source_message_ids) ? m.source_message_ids.map(String) : [],
|
||||
metadata: (m.metadata && typeof m.metadata === "object" ? m.metadata : {}) as Record<string, unknown>,
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
return scenes;
|
||||
} catch (err) {
|
||||
logger?.warn?.(`${TAG} Failed to parse extraction result: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Write helpers
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Apply batch dedup decisions — write memories according to their decisions.
|
||||
*/
|
||||
async function applyDecisions(params: {
|
||||
memoriesWithIds: Array<ExtractedMemory & { record_id: string }>;
|
||||
decisions: DedupDecision[];
|
||||
baseDir: string;
|
||||
sessionKey: string;
|
||||
sessionId?: string;
|
||||
logger?: Logger;
|
||||
vectorStore?: VectorStore;
|
||||
embeddingService?: EmbeddingService;
|
||||
}): Promise<MemoryRecord[]> {
|
||||
const { memoriesWithIds, decisions, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService } = params;
|
||||
const storedRecords: MemoryRecord[] = [];
|
||||
|
||||
// Build a map from record_id → decision
|
||||
const decisionMap = new Map<string, DedupDecision>();
|
||||
for (const d of decisions) {
|
||||
decisionMap.set(d.record_id, d);
|
||||
}
|
||||
|
||||
for (const memoryWithId of memoriesWithIds) {
|
||||
const decision = decisionMap.get(memoryWithId.record_id) ?? {
|
||||
record_id: memoryWithId.record_id,
|
||||
action: "store" as const,
|
||||
target_ids: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const record = await writeMemory({
|
||||
memory: memoryWithId,
|
||||
decision,
|
||||
baseDir,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
logger,
|
||||
vectorStore,
|
||||
embeddingService,
|
||||
});
|
||||
|
||||
if (record) {
|
||||
storedRecords.push(record);
|
||||
}
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`${TAG} Write failed for memory "${memoryWithId.content.slice(0, 50)}...": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return storedRecords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store all memories directly (no dedup).
|
||||
*/
|
||||
async function storeAllDirectly(
|
||||
memoriesWithIds: Array<ExtractedMemory & { record_id: string }>,
|
||||
baseDir: string,
|
||||
sessionKey: string,
|
||||
sessionId: string | undefined,
|
||||
logger?: Logger,
|
||||
vectorStore?: VectorStore,
|
||||
embeddingService?: EmbeddingService,
|
||||
): Promise<MemoryRecord[]> {
|
||||
const storedRecords: MemoryRecord[] = [];
|
||||
|
||||
for (const memoryWithId of memoriesWithIds) {
|
||||
try {
|
||||
const record = await writeMemory({
|
||||
memory: memoryWithId,
|
||||
decision: {
|
||||
record_id: memoryWithId.record_id,
|
||||
action: "store",
|
||||
target_ids: [],
|
||||
},
|
||||
baseDir,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
logger,
|
||||
vectorStore,
|
||||
embeddingService,
|
||||
});
|
||||
if (record) {
|
||||
storedRecords.push(record);
|
||||
}
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`${TAG} Write failed for memory "${memoryWithId.content.slice(0, 50)}...": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return storedRecords;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Helpers
|
||||
// ============================
|
||||
|
||||
const VALID_TYPES: MemoryType[] = ["persona", "episodic", "instruction"];
|
||||
|
||||
function normalizeType(raw: string): MemoryType | null {
|
||||
const lower = raw.toLowerCase().trim();
|
||||
if (VALID_TYPES.includes(lower as MemoryType)) {
|
||||
return lower as MemoryType;
|
||||
}
|
||||
// Handle legacy type names
|
||||
if (lower === "episode") return "episodic";
|
||||
if (lower === "instruct") return "instruction";
|
||||
if (lower === "preference") return "persona"; // fold preference into persona
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* L1 Memory Reader: reads persisted L1 memory records.
|
||||
*
|
||||
* Provides two data paths:
|
||||
*
|
||||
* 1. **SQLite** (preferred): `queryMemoryRecords()` — uses VectorStore's `queryL1Records()`
|
||||
* with composite indexes on (session_key, updated_time) and (session_id, updated_time)
|
||||
* for efficient session-scoped and time-range queries.
|
||||
*
|
||||
* 2. **JSONL** (fallback): `readMemoryRecords()` / `readAllMemoryRecords()` — reads from
|
||||
* `records/YYYY-MM-DD.jsonl` files. Used when VectorStore is unavailable or degraded.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
// Re-export types that readers need
|
||||
export type { MemoryRecord, MemoryType, EpisodicMetadata } from "./l1-writer.js";
|
||||
export type { L1QueryFilter } from "../store/vector-store.js";
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
const TAG = "[memory-tdai] [l1-reader]";
|
||||
|
||||
// ============================
|
||||
// SQLite-based queries (preferred)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Query L1 memory records from SQLite via VectorStore.
|
||||
*
|
||||
* This is the **preferred** read path — it uses the composite index
|
||||
* `idx_l1_session_updated(session_id, updated_time)` for efficient
|
||||
* session-scoped and time-range queries.
|
||||
*
|
||||
* All timestamps are UTC ISO 8601 (as stored by l1-writer's dual-write).
|
||||
*
|
||||
* Falls back to empty array if VectorStore is null or degraded.
|
||||
*/
|
||||
export function queryMemoryRecords(
|
||||
vectorStore: VectorStore | null | undefined,
|
||||
filter?: L1QueryFilter,
|
||||
logger?: Logger,
|
||||
): MemoryRecord[] {
|
||||
if (!vectorStore) {
|
||||
logger?.warn(`${TAG} queryMemoryRecords: no VectorStore available, returning empty`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = vectorStore.queryL1Records(filter);
|
||||
return rows.map(rowToMemoryRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw SQLite L1RecordRow to a MemoryRecord (same shape as JSONL records).
|
||||
*/
|
||||
function rowToMemoryRecord(row: L1RecordRow): MemoryRecord {
|
||||
let metadata: EpisodicMetadata | Record<string, never> = {};
|
||||
try {
|
||||
metadata = JSON.parse(row.metadata_json) as EpisodicMetadata | Record<string, never>;
|
||||
} catch {
|
||||
// malformed JSON — use empty object
|
||||
}
|
||||
|
||||
// Reconstruct timestamps array from timestamp_start / timestamp_end
|
||||
const timestamps: string[] = [];
|
||||
if (row.timestamp_str) timestamps.push(row.timestamp_str);
|
||||
if (row.timestamp_start && row.timestamp_start !== row.timestamp_str) timestamps.push(row.timestamp_start);
|
||||
if (row.timestamp_end && row.timestamp_end !== row.timestamp_str && row.timestamp_end !== row.timestamp_start) {
|
||||
timestamps.push(row.timestamp_end);
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.record_id,
|
||||
content: row.content,
|
||||
type: row.type as MemoryType,
|
||||
priority: row.priority,
|
||||
scene_name: row.scene_name,
|
||||
source_message_ids: [], // not stored in SQLite (vector search doesn't need them)
|
||||
metadata,
|
||||
timestamps,
|
||||
createdAt: row.created_time,
|
||||
updatedAt: row.updated_time,
|
||||
sessionKey: row.session_key,
|
||||
sessionId: row.session_id,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// JSONL-based reads (fallback)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Read all memory records for a session from JSONL files.
|
||||
*
|
||||
* Current naming mode:
|
||||
* - Daily merged file: records/YYYY-MM-DD.jsonl (all sessions in one file)
|
||||
*/
|
||||
export async function readMemoryRecords(
|
||||
sessionKey: string,
|
||||
baseDir: string,
|
||||
logger?: Logger,
|
||||
): Promise<MemoryRecord[]> {
|
||||
const recordsDir = path.join(baseDir, "records");
|
||||
const dateFilePattern = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
||||
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(recordsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Directory doesn't exist yet
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetFiles = entries
|
||||
.filter((entry) => entry.isFile() && dateFilePattern.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
if (targetFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const records: MemoryRecord[] = [];
|
||||
|
||||
for (const fileName of targetFiles) {
|
||||
const filePath = path.join(recordsDir, fileName);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
logger?.warn?.(`${TAG} Failed to read L1 file: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = raw.split("\n").filter((line) => line.trim());
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Partial<MemoryRecord>;
|
||||
if (parsed.sessionKey !== sessionKey) {
|
||||
continue;
|
||||
}
|
||||
records.push(parsed as MemoryRecord);
|
||||
} catch {
|
||||
logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records.sort((a, b) => {
|
||||
const ta = a.updatedAt || a.createdAt || "";
|
||||
const tb = b.updatedAt || b.createdAt || "";
|
||||
return ta.localeCompare(tb);
|
||||
});
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ALL memory records across all session JSONL files.
|
||||
*/
|
||||
export async function readAllMemoryRecords(
|
||||
baseDir: string,
|
||||
logger?: Logger,
|
||||
): Promise<MemoryRecord[]> {
|
||||
const recordsDir = path.join(baseDir, "records");
|
||||
try {
|
||||
const files = await fs.readdir(recordsDir);
|
||||
const allRecords: MemoryRecord[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".jsonl")) continue;
|
||||
const filePath = path.join(recordsDir, file);
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
const lines = raw.split("\n").filter((line: string) => line.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
allRecords.push(JSON.parse(line) as MemoryRecord);
|
||||
} catch {
|
||||
logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${file}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger?.warn?.(`${TAG} Failed to read ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
allRecords.sort((a, b) => {
|
||||
const ta = a.updatedAt || a.createdAt || "";
|
||||
const tb = b.updatedAt || b.createdAt || "";
|
||||
return ta.localeCompare(tb);
|
||||
});
|
||||
|
||||
return allRecords;
|
||||
|
||||
} catch {
|
||||
// records/ directory doesn't exist yet
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Helpers
|
||||
// ============================
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* L1 Memory Writer: writes extracted memories to JSONL files.
|
||||
*
|
||||
* File naming: records/YYYY-MM-DD.jsonl (daily shards, all sessions merged).
|
||||
* Each record includes sessionKey for traceability.
|
||||
*
|
||||
* Write strategy:
|
||||
* - JSONL is the append-only persistent store (source of truth for backup/recovery).
|
||||
* - VectorStore (SQLite) is the primary retrieval engine.
|
||||
* - On update/merge, old records are deleted from VectorStore in real-time;
|
||||
* JSONL is append-only and cleaned up periodically by memory-cleaner.
|
||||
*
|
||||
* Supports store (append), update, merge, and skip operations.
|
||||
*
|
||||
* v3: Aligned with Kenty's prompt output format — 3 memory types (persona/episodic/instruction),
|
||||
* numeric priority, scene_name, source_message_ids, metadata, timestamps.
|
||||
*/
|
||||
|
||||
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 { EmbeddingService } from "../store/embedding.js";
|
||||
|
||||
// ============================
|
||||
// Types
|
||||
// ============================
|
||||
|
||||
/** v3: 3 memory types aligned with Kenty's extraction prompt */
|
||||
export type MemoryType = "persona" | "episodic" | "instruction";
|
||||
|
||||
/** Metadata for episodic memories (activity time range) */
|
||||
export interface EpisodicMetadata {
|
||||
activity_start_time?: string; // ISO 8601
|
||||
activity_end_time?: string; // ISO 8601
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted memory record in L1 JSONL files.
|
||||
*
|
||||
* v3 changes from v2:
|
||||
* - `importance: "high"|"medium"|"low"` → `priority: number` (0-100, -1 for strict global instructions)
|
||||
* - Added `scene_name`, `source_message_ids`, `metadata`, `timestamps`
|
||||
* - Removed `keywords` (will be rebuilt from content for search)
|
||||
* - MemoryType reduced from 4 to 3 (removed "preference", folded into "persona")
|
||||
*/
|
||||
export interface MemoryRecord {
|
||||
/** Unique ID for dedup updates */
|
||||
id: string;
|
||||
/** Memory content */
|
||||
content: string;
|
||||
/** Memory type: persona / episodic / instruction */
|
||||
type: MemoryType;
|
||||
/** Priority score: 0-100 (higher = more important), -1 = strict global instruction */
|
||||
priority: number;
|
||||
/** Scene name this memory belongs to */
|
||||
scene_name: string;
|
||||
/** Source message IDs that contributed to this memory */
|
||||
source_message_ids: string[];
|
||||
/** Type-specific metadata (e.g., activity_start_time for episodic) */
|
||||
metadata: EpisodicMetadata | Record<string, never>;
|
||||
/** Timestamp trail: all timestamps related to this memory (for merge history tracking) */
|
||||
timestamps: string[];
|
||||
/** Creation timestamp (ISO) */
|
||||
createdAt: string;
|
||||
/** Last update timestamp (ISO) */
|
||||
updatedAt: string;
|
||||
/** Source session key (conversation channel identifier) */
|
||||
sessionKey: string;
|
||||
/** Source session ID (single conversation instance identifier) */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A memory as extracted by LLM (before dedup / persistence).
|
||||
* Matches the output format of Kenty's extraction prompt.
|
||||
*/
|
||||
export interface ExtractedMemory {
|
||||
content: string;
|
||||
type: MemoryType;
|
||||
priority: number;
|
||||
source_message_ids: string[];
|
||||
metadata: EpisodicMetadata | Record<string, never>;
|
||||
/** Scene name this memory was extracted in */
|
||||
scene_name: string;
|
||||
}
|
||||
|
||||
export type DedupAction = "store" | "update" | "merge" | "skip";
|
||||
|
||||
/**
|
||||
* v3 batch dedup decision — one per new memory, aligned with Kenty's conflict detection prompt.
|
||||
*
|
||||
* Key changes:
|
||||
* - `targetId` → `target_ids` (array, supports multi-target merge/update)
|
||||
* - Added `merged_type`, `merged_priority`, `merged_timestamps` for cross-type merge
|
||||
*/
|
||||
export interface DedupDecision {
|
||||
/** Which new memory this decision is about */
|
||||
record_id: string;
|
||||
action: DedupAction;
|
||||
/** IDs of existing records to replace/remove (for update/merge) */
|
||||
target_ids: string[];
|
||||
/** Merged/updated content text (for update/merge) */
|
||||
merged_content?: string;
|
||||
/** Best type after merge (for update/merge, may differ from original) */
|
||||
merged_type?: MemoryType;
|
||||
/** Priority after merge (for update/merge) */
|
||||
merged_priority?: number;
|
||||
/** Union of all related timestamps (for update/merge) */
|
||||
merged_timestamps?: string[];
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
const TAG = "[memory-tdai][l1-writer]";
|
||||
|
||||
// ============================
|
||||
// Core functions
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Generate a unique memory ID.
|
||||
*/
|
||||
export function generateMemoryId(): string {
|
||||
return `m_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a memory record according to the dedup decision.
|
||||
*
|
||||
* - store: append new record
|
||||
* - update: remove target records + append updated record
|
||||
* - merge: remove target records + append merged record
|
||||
* - skip: do nothing
|
||||
*
|
||||
* v3: supports multi-target removal for update/merge.
|
||||
* v3.1: optional VectorStore + EmbeddingService for dual-write (JSONL + vector).
|
||||
*/
|
||||
export async function writeMemory(params: {
|
||||
memory: ExtractedMemory;
|
||||
decision: DedupDecision;
|
||||
baseDir: string;
|
||||
sessionKey: string;
|
||||
sessionId?: string;
|
||||
logger?: Logger;
|
||||
/** Optional vector store for dual-write (JSONL + vector DB) */
|
||||
vectorStore?: VectorStore;
|
||||
/** Optional embedding service (required when vectorStore is provided) */
|
||||
embeddingService?: EmbeddingService;
|
||||
}): Promise<MemoryRecord | null> {
|
||||
const { memory, decision, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService } = params;
|
||||
|
||||
if (decision.action === "skip") {
|
||||
logger?.debug?.(`${TAG} Skipping memory: ${memory.content.slice(0, 50)}...`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Determine final content, type, priority based on action
|
||||
let finalContent: string;
|
||||
let finalType: MemoryType;
|
||||
let finalPriority: number;
|
||||
let finalTimestamps: string[];
|
||||
|
||||
if (decision.action === "merge" || decision.action === "update") {
|
||||
finalContent = decision.merged_content ?? memory.content;
|
||||
finalType = decision.merged_type ?? memory.type;
|
||||
finalPriority = decision.merged_priority ?? memory.priority;
|
||||
finalTimestamps = decision.merged_timestamps ?? [now];
|
||||
} else {
|
||||
// store
|
||||
finalContent = memory.content;
|
||||
finalType = memory.type;
|
||||
finalPriority = memory.priority;
|
||||
finalTimestamps = [now];
|
||||
}
|
||||
|
||||
const record: MemoryRecord = {
|
||||
id: decision.record_id || generateMemoryId(),
|
||||
content: finalContent,
|
||||
type: finalType,
|
||||
priority: finalPriority,
|
||||
scene_name: memory.scene_name,
|
||||
source_message_ids: memory.source_message_ids,
|
||||
metadata: memory.metadata,
|
||||
timestamps: finalTimestamps,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sessionKey,
|
||||
sessionId: sessionId || "",
|
||||
};
|
||||
|
||||
const recordsDir = path.join(baseDir, "records");
|
||||
await fs.mkdir(recordsDir, { recursive: true });
|
||||
|
||||
const shardDate = formatLocalDate(new Date());
|
||||
const filePath = path.join(recordsDir, `${shardDate}.jsonl`);
|
||||
|
||||
if ((decision.action === "update" || decision.action === "merge") && decision.target_ids.length > 0) {
|
||||
// Remove target records from VectorStore (real-time deletion for retrieval accuracy).
|
||||
// JSONL is append-only — old records remain in files and are cleaned up periodically
|
||||
// by memory-cleaner (which reconciles against VectorStore as source of truth).
|
||||
if (vectorStore) {
|
||||
try {
|
||||
vectorStore.deleteBatch(decision.target_ids);
|
||||
logger?.debug?.(`${TAG} VectorStore: deleted ${decision.target_ids.length} target record(s) for ${decision.action}`);
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`${TAG} VectorStore delete failed for ${decision.action}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
|
||||
logger?.debug?.(`${TAG} ${decision.action} memory: removed [${decision.target_ids.join(",")}] from VectorStore → ${record.id}: ${finalContent.slice(0, 80)}...`);
|
||||
} else {
|
||||
// store: append a new line
|
||||
await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
|
||||
logger?.debug?.(`${TAG} Stored memory ${record.id}: ${finalContent.slice(0, 80)}...`);
|
||||
}
|
||||
|
||||
// === Vector Store dual-write ===
|
||||
if (vectorStore) {
|
||||
try {
|
||||
logger?.debug?.(
|
||||
`${TAG} [vec-dual-write] START id=${record.id}, contentLen=${record.content.length}, ` +
|
||||
`content="${record.content.slice(0, 80)}..."`,
|
||||
);
|
||||
|
||||
let embedding: Float32Array | undefined;
|
||||
|
||||
if (embeddingService) {
|
||||
try {
|
||||
embedding = await embeddingService.embed(record.content);
|
||||
logger?.debug?.(
|
||||
`${TAG} [vec-dual-write] Embedding OK: dims=${embedding.length}, ` +
|
||||
`norm=${Math.sqrt(Array.from(embedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}`,
|
||||
);
|
||||
} catch (embedErr) {
|
||||
// Embedding failed — pass undefined to upsert() which writes
|
||||
// metadata + FTS only, skipping the vec0 table.
|
||||
logger?.warn(
|
||||
`${TAG} [vec-dual-write] Embedding FAILED for id=${record.id}, ` +
|
||||
`will write metadata only: ${embedErr instanceof Error ? embedErr.message : String(embedErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const upsertOk = vectorStore.upsert(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
|
||||
logger?.warn?.(
|
||||
`${TAG} [vec-dual-write] FAILED (JSONL already written) id=${record.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger?.debug?.(
|
||||
`${TAG} [vec-dual-write] SKIPPED id=${record.id}: vectorStore=${!!vectorStore}`,
|
||||
);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Helpers
|
||||
// ============================
|
||||
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
Reference in New Issue
Block a user