feat: init Agent-Memory

This commit is contained in:
chrishuan
2026-04-09 18:23:46 +08:00
commit 7a5fce9f12
39 changed files with 13488 additions and 0 deletions
+382
View File
@@ -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: [],
}));
}
+500
View File
@@ -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;
}
+218
View File
@@ -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, "_");
}
+280
View File
@@ -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}`;
}