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
+14
View File
@@ -0,0 +1,14 @@
# Dependencies
node_modules/
# Runtime workspace
workspace/
# Environment variables
.env
# Test caches
__tests__/soak/.model-cache/
node_modules/
benchmark-runs/
+29
View File
@@ -0,0 +1,29 @@
# Changelog
本文件记录 `@tencentdb-agent-memory/memory-tencentdb` 插件的所有显著变更,格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [Semantic Versioning](https://semver.org/)。
---
## [0.1.2] — 2026-03-26
### 更新内容
1. 优化对话捕获与记忆抽取过滤机制
## [0.1.1] — 2026-03-25
### 更新内容
1. 兼容 openclaw 2026.3.23 更新
## [0.1.0] — 2026-03-25
> 首个正式发布版本。本地优先的四层记忆系统(L0→L1→L2→L3),基于 SQLite + LLM 实现对话捕获、记忆提取、场景归纳与用户画像。
### 更新内容
1. 关键字检索增加 FTS5 全文索引,采用 jieba 分词
2. 未配置远程 embedding 服务时,默认不开启 embedding 能力(不自动使用本地 embedding,且封禁主动使用本地 embedding 的配置入口)
3. 优化 L2、L3 生成 prompt 以控制生成内容大小(减少 token 开销)
4. Pipeline 调度器优化文件锁用法
5. 避免全量读取 L0、L1 数据
+27
View File
@@ -0,0 +1,27 @@
Tencent is pleased to support the open source community by making TencentDB Agent Memory available.
Copyright (C) 2026 Tencent. All rights reserved.
TencentDB Agent Memory is licensed under the MIT.
Terms of the MIT:
--------------------------------------------------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+1337
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
{
"id": "memory-tencentdb",
"name": "Memory (TencentDB)",
"description": "Four-layer memory system — auto-captures, structures, and profiles conversational knowledge",
"configSchema": {
"type": "object",
"additionalProperties": true,
"properties": {
"capture": {
"type": "object",
"description": "对话自动捕获设置 (L0)",
"properties": {
"enabled": { "type": "boolean", "default": true, "description": "是否启用自动对话捕获" },
"excludeAgents": { "type": "array", "items": { "type": "string" }, "default": [], "description": "排除的 Agent glob 模式列表(如 bench-judge-*),匹配的 agent 不会被捕获、召回或参与 pipeline 调度" },
"l0l1RetentionDays": { "type": "number", "default": 0, "description": "L0/L1 本地文件保留天数(单位天)。0=不清理;非0时默认必须>=3。若为1或2,需显式开启 allowAggressiveCleanup" },
"allowAggressiveCleanup": { "type": "boolean", "default": false, "description": "是否允许高风险清理配置(l0l1RetentionDays=1或2" },
"cleanTime": { "type": "string", "default": "03:00", "description": "每日清理执行时间(HH:mm" }
}
},
"extraction": {
"type": "object",
"description": "记忆提取设置 (L1)",
"properties": {
"enabled": { "type": "boolean", "default": true, "description": "是否启用后台提取" },
"enableDedup": { "type": "boolean", "default": true, "description": "L1 智能去重(基于向量相似度或关键词进行冲突检测)" },
"maxMemoriesPerSession": { "type": "number", "default": 20, "description": "单次 L1 提取每 session 最大记忆条数" },
"model": { "type": "string", "description": "提取使用模型 (格式: provider/model),未填写时使用openclaw默认模型" }
}
},
"persona": {
"type": "object",
"description": "场景归纳与用户画像设置 (L2/L3)",
"properties": {
"triggerEveryN": { "type": "number", "default": 50, "description": "每 N 条新记忆触发画像生成" },
"maxScenes": { "type": "number", "default": 20, "description": "最大场景数" },
"backupCount": { "type": "number", "default": 3, "description": "画像备份保留数量" },
"sceneBackupCount": { "type": "number", "default": 10, "description": "场景块备份保留数量" },
"model": { "type": "string", "description": "画像生成模型 (格式: provider/model),未填写时使用openclaw默认模型" }
}
},
"pipeline": {
"type": "object",
"description": "L1→L2→L3 管线调度设置",
"properties": {
"everyNConversations": { "type": "number", "default": 5, "description": "每 N 轮对话触发 L1 批处理" },
"enableWarmup": { "type": "boolean", "default": true, "description": "Warm-up 模式:新 session 从 1 轮触发开始,每次 L1 后翻倍(1→2→4→...→everyN),加速早期记忆提取" },
"l1IdleTimeoutSeconds": { "type": "number", "default": 60, "description": "L1 空闲超时(秒):用户停止对话后多久触发 L1 批处理" },
"l2DelayAfterL1Seconds": { "type": "number", "default": 90, "description": "L1 完成后延迟多久触发 L2(秒)" },
"l2MinIntervalSeconds": { "type": "number", "default": 300, "description": "同一 session 两次 L2 抽取的最小间隔(秒)" },
"l2MaxIntervalSeconds": { "type": "number", "default": 1800, "description": "同一活跃 session 的 L2 最大轮询间隔(秒)" },
"sessionActiveWindowHours": { "type": "number", "default": 24, "description": "session 活跃窗口(小时),超过此时间不活跃的 session 停止 L2 轮询" }
}
},
"recall": {
"type": "object",
"description": "记忆召回设置",
"properties": {
"enabled": { "type": "boolean", "default": true, "description": "是否启用自动召回" },
"maxResults": { "type": "number", "default": 5, "description": "召回最大结果数" },
"scoreThreshold": { "type": "number", "default": 0.3, "description": "最低分数阈值" },
"strategy": { "type": "string", "enum": ["embedding", "keyword", "hybrid"], "default": "hybrid", "description": "搜索策略:keyword(关键词)、embedding(向量)、hybrid(混合RRF融合,推荐)" },
"timeoutMs": { "type": "number", "default": 5000, "description": "召回整体超时(毫秒),超时后跳过记忆注入并打印警告日志" }
}
},
"embedding": {
"type": "object",
"description": "向量搜索 (Embedding) 配置",
"properties": {
"enabled": { "type": "boolean", "default": true, "description": "是否启用向量搜索" },
"provider": { "type": "string", "default": "none", "description": "Embedding 服务提供者:填写兼容 OpenAI API 的远端服务名称(如 openai、deepseek 等);不填或填 none 则禁用向量搜索" },
"proxyUrl": { "type": "string", "description": "本地代理地址(仅 provider=qclaw 时必填)。配置后 embedding 请求将通过该代理转发,原始 baseUrl 作为 Remote-URL 头传递" },
"baseUrl": { "type": "string", "description": "API Base URL(必填):填写对应 provider 的 API 地址" },
"apiKey": { "type": "string", "description": "API Key(必填)" },
"model": { "type": "string", "description": "模型名称(必填)" },
"dimensions": { "type": "number", "description": "向量维度(必填,需与所选模型匹配)" },
"conflictRecallTopK": { "type": "number", "default": 5, "description": "冲突检测时召回 Top-K 数" },
"maxInputChars": { "type": "number", "default": 5000, "description": "Embedding 输入文本最大字符数,超出时截断并打印警告日志(默认 5000,适合大多数模型的 token 上限)" }
}
},
"report": {
"type": "object",
"description": "指标上报设置",
"properties": {
"enabled": { "type": "boolean", "default": false, "description": "是否启用指标上报(通过 Gateway 日志输出结构化 METRIC JSON" },
"type": { "type": "string", "default": "local", "description": "上报方式:local 表示通过 logger 输出结构化 JSON 日志" }
}
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@tencentdb-agent-memory/memory-tencentdb",
"version": "0.1.1",
"description": "Four-layer local memory system plugin for OpenClaw — auto-captures, structures, and profiles conversational knowledge using local LLM + SQLite vector search (L0→L1→L2→L3 pipeline)",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts"
},
"files": [
"index.ts",
"src/",
"openclaw.plugin.json",
"README.md",
"CHANGELOG.md",
"LICENSE",
"!src/**/*.test.ts",
"!src/**/*.spec.ts",
"!src/**/__tests__/"
],
"keywords": [
"openclaw",
"openclaw-plugin",
"memory",
"ai-memory",
"long-term-memory",
"vector-search",
"sqlite-vec",
"llm",
"conversation",
"persona",
"scene-extraction",
"embedding"
],
"author": "TencentDB Agent Memory Team",
"license": "MIT",
"engines": {
"node": ">=22.16.0"
},
"dependencies": {
"@node-rs/jieba": "^2.0.1",
"sqlite-vec": "0.1.7-alpha.2"
},
"peerDependencies": {
"openclaw": ">=2026.3.7",
"node-llama-cpp": "^3.16.2"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
},
"node-llama-cpp": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
+419
View File
@@ -0,0 +1,419 @@
/**
* Plugin configuration types and parser (v3).
*
* Config is organized into flat functional groups:
* capture, extraction, persona, pipeline, recall, embedding
*
* Minimal config (zero config): {} — all fields have sensible defaults.
*/
// ============================
// Type definitions
// ============================
/** Capture settings — controls L0 conversation recording. */
export interface CaptureConfig {
/** Enable auto-capture (default: true) */
enabled: boolean;
/** Glob patterns to exclude agents (e.g. "bench-judge-*"); matched agents are fully ignored */
excludeAgents: string[];
/**
* L0/L1 local file retention days used as TTL switch.
* 0 means cleanup disabled.(default: 0)
*/
l0l1RetentionDays: number;
/**
* Allow dangerous low retention (1 or 2 days).
* Default false: when disabled, non-zero retention must be >= 3.
*/
allowAggressiveCleanup: boolean;
}
/** Extraction settings (L1) — controls memory extraction from conversations. */
export interface ExtractionConfig {
/** Enable background extraction (default: true) */
enabled: boolean;
/** Enable L1 smart dedup (default: true) */
enableDedup: boolean;
/** Max memories per session (default: 20) */
maxMemoriesPerSession: number;
/** LLM model for extraction, format: "provider/model" (falls back to OpenClaw default model when omitted) */
model?: string;
}
/** Persona (L2/L3) settings — controls scene extraction (L2) and user profile generation (L3). */
export interface PersonaConfig {
/** Trigger persona generation every N new memories (default: 50) */
triggerEveryN: number;
/** Max scene blocks (default: 20) */
maxScenes: number;
/** Persona backup count (default: 3) */
backupCount: number;
/** Scene blocks backup count (default: 10) */
sceneBackupCount: number;
/** LLM model for persona generation, format: "provider/model" (falls back to OpenClaw default model when omitted) */
model?: string;
}
/** Pipeline trigger settings (L1→L2→L3 scheduling). */
export interface PipelineTriggerConfig {
/** Trigger L1 after every N conversation rounds (default: 5) */
everyNConversations: number;
/** Enable warm-up: start threshold at 1, double after each L1 (1→2→4→...→everyN) (default: true) */
enableWarmup: boolean;
/** L1 idle timeout: trigger L1 after this many seconds of inactivity (default: 60) */
l1IdleTimeoutSeconds: number;
/** L2 delay after L1: wait this many seconds after L1 completes before triggering L2 (default: 90) */
l2DelayAfterL1Seconds: number;
/** L2 min interval: minimum seconds between L2 runs per session (default: 300 = 5 min) */
l2MinIntervalSeconds: number;
/** L2 max interval: even without new conversations, trigger L2 at most this often per session (default: 1800 = 30 min) */
l2MaxIntervalSeconds: number;
/** Sessions inactive longer than this (hours) stop L2 polling (default: 24) */
sessionActiveWindowHours: number;
}
/** Recall settings — controls memory retrieval for context injection. */
export interface RecallConfig {
/** Enable auto-recall (default: true) */
enabled: boolean;
/** Max results to return (default: 5) */
maxResults: number;
/** Minimum score threshold (default: 0.3) */
scoreThreshold: number;
/** Search strategy (default: "hybrid") */
strategy: "embedding" | "keyword" | "hybrid";
/** Overall recall timeout in milliseconds (default: 5000). When exceeded, recall is skipped with a warning. */
timeoutMs: number;
}
/** Embedding service configuration for vector search. */
export interface EmbeddingConfig {
/** User-facing default is true in schema, but provider="none" still disables embedding effectively. */
enabled: boolean;
/** Embedding provider: default "none" disables vector search; other values (e.g. "openai", "deepseek") are treated as OpenAI-compatible remote providers. */
provider: string;
/** API Base URL (required for remote provider). */
baseUrl: string;
/** API Key (required for remote provider). */
apiKey: string;
/** Model name (required for remote provider). */
model: string;
/** Vector dimensions (required for remote provider, must match model). */
dimensions: number;
/** Top-K candidates to recall during conflict detection (default: 5) */
conflictRecallTopK: number;
/** Proxy URL for qclaw provider — when provider="qclaw", requests are forwarded through this local proxy */
proxyUrl?: string;
/** Max input text length in characters before truncation (default: 5000). Texts exceeding this limit are truncated with a warning. */
maxInputChars: number;
/** Timeout per embedding API call in milliseconds (default: 10000). */
timeoutMs: number;
/** Internal-only local model cache directory, not exposed in plugin schema. */
modelCacheDir?: string;
/** If set, contains an error message about invalid remote config (embedding is disabled) */
configError?: string;
}
/** Daily cleaner settings for local JSONL data (L0/L1). */
export interface MemoryCleanupConfig {
/** TTL switch from capture.l0l1RetentionDays. Undefined means disabled. */
retentionDays?: number;
/** Whether cleanup is enabled. True only when retentionDays is a valid positive number. */
enabled: boolean;
/** Daily execution time in HH:mm format (default: 03:00). */
cleanTime: string;
}
/** Report settings — controls metric/event reporting. */
export interface ReportConfig {
/** Enable reporting (default: true) */
enabled: boolean;
/** Reporter type: "local" logs structured JSON via logger (default: "local") */
type: string;
}
/** Fully resolved plugin configuration (v3). */
export interface MemoryTdaiConfig {
capture: CaptureConfig;
extraction: ExtractionConfig;
persona: PersonaConfig;
pipeline: PipelineTriggerConfig;
recall: RecallConfig;
embedding: EmbeddingConfig;
memoryCleanup: MemoryCleanupConfig;
report: ReportConfig;
}
// ============================
// Parser
// ============================
/**
* Parse plugin config from raw user input.
* All fields have sensible defaults — minimal config is just {}.
*/
export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTdaiConfig {
const c = raw ?? {};
// --- Capture (L0) ---
const captureGroup = obj(c, "capture");
// --- Retention days validation (from capture.l0l1RetentionDays) ---
const rawRetentionDays = num(captureGroup, "l0l1RetentionDays") ?? 0;
const allowAggressiveCleanup = bool(captureGroup, "allowAggressiveCleanup") ?? false;
let retentionDays: number | undefined;
if (rawRetentionDays <= 0) {
retentionDays = undefined;
} else if (rawRetentionDays >= 3) {
retentionDays = rawRetentionDays;
} else if (allowAggressiveCleanup) {
retentionDays = rawRetentionDays;
} else {
retentionDays = undefined;
}
// --- Extraction (L1) ---
const extractionGroup = obj(c, "extraction");
// --- Persona (L2/L3) ---
const personaGroup = obj(c, "persona");
// --- Pipeline ---
const pipelineGroup = obj(c, "pipeline");
// --- Recall ---
const recallGroup = obj(c, "recall");
// --- Embedding ---
const embeddingGroup = obj(c, "embedding");
let embeddingConfigError: string | undefined;
// Embedding config: determine provider based on user input and apiKey availability
const embeddingApiKey = str(embeddingGroup, "apiKey") ?? "";
const embeddingBaseUrl = str(embeddingGroup, "baseUrl") ?? "";
const embeddingProviderRaw = str(embeddingGroup, "provider") ?? "none";
const embeddingModelRaw = str(embeddingGroup, "model") ?? "";
const embeddingDimensionsRaw = num(embeddingGroup, "dimensions");
const embeddingProxyUrl = str(embeddingGroup, "proxyUrl");
// provider="none" → embedding disabled (default for zero-config users)
// provider="local" → no longer exposed to users; treated as disabled at entry level
// provider="qclaw" → requires proxyUrl for local proxy forwarding
// Any other value → remote mode (requires apiKey, baseUrl, model, dimensions)
let embeddingProvider: string;
let embeddingEnabled = bool(embeddingGroup, "enabled") ?? true;
if (embeddingProviderRaw === "none") {
// Explicitly disabled (default): no embedding, no vector search
embeddingProvider = "none";
embeddingEnabled = false;
} else if (embeddingProviderRaw === "local") {
// Local embedding is not exposed to users; treat as disabled at entry level.
// Internal LocalEmbeddingService code is preserved but not reachable from config.
embeddingProvider = "none";
embeddingEnabled = false;
embeddingConfigError =
"Local embedding provider is not available in user config. " +
"Please configure a remote embedding provider (e.g. openai, deepseek). Embedding has been disabled.";
} else if (embeddingProviderRaw === "qclaw") {
// qclaw provider: requires proxyUrl for local proxy forwarding
const missingFields: string[] = [];
if (!embeddingProxyUrl) missingFields.push("proxyUrl");
if (!embeddingBaseUrl) missingFields.push("baseUrl");
if (!embeddingApiKey) missingFields.push("apiKey");
if (!embeddingModelRaw) missingFields.push("model");
if (embeddingDimensionsRaw == null || embeddingDimensionsRaw <= 0) missingFields.push("dimensions");
if (missingFields.length > 0) {
const errorMsg =
`Embedding provider 'qclaw' requires 'proxyUrl', 'baseUrl', 'apiKey', 'model', and 'dimensions' to be set. ` +
`Missing: ${missingFields.join(", ")}. Embedding has been disabled.`;
embeddingConfigError = errorMsg;
embeddingEnabled = false;
embeddingProvider = embeddingProviderRaw;
} else {
embeddingProvider = embeddingProviderRaw;
}
} else {
// Remote mode — validate all required fields
const missingFields: string[] = [];
if (!embeddingApiKey) missingFields.push("apiKey");
if (!embeddingBaseUrl) missingFields.push("baseUrl");
if (!embeddingModelRaw) missingFields.push("model");
if (embeddingDimensionsRaw == null || embeddingDimensionsRaw <= 0) missingFields.push("dimensions");
if (missingFields.length > 0) {
// Configuration error: disable embedding and log detailed error
// This does NOT throw — the plugin continues running without vector search
const errorMsg =
`Remote embedding provider '${embeddingProviderRaw}' requires 'apiKey', 'baseUrl', 'model', and 'dimensions' to be set. ` +
`Missing: ${missingFields.join(", ")}. Embedding has been disabled.`;
// We store the error message so the caller (index.ts) can log it
embeddingConfigError = errorMsg;
embeddingEnabled = false;
embeddingProvider = embeddingProviderRaw; // preserve original for error context
} else {
embeddingProvider = embeddingProviderRaw;
}
}
// When provider="none", dimensions=0 signals VectorStore to skip vec0 table
// creation entirely (deferred until a real embedding provider is configured).
// This avoids creating vec0 tables with a placeholder dimension that would
// mismatch if the user later enables a different-dimensional provider.
const defaultDimensions =
embeddingProvider === "none" ? 0 :
embeddingDimensionsRaw ?? 0;
const defaultModel = embeddingProvider === "none" ? "" : embeddingModelRaw;
const cleanTime = normalizeCleanTime(str(captureGroup, "cleanTime")) ?? "03:00";
const memoryCleanup: MemoryCleanupConfig = {
retentionDays,
enabled: retentionDays != null,
cleanTime,
};
return {
capture: {
enabled: bool(captureGroup, "enabled") ?? true,
excludeAgents: strArray(captureGroup, "excludeAgents") ?? [],
l0l1RetentionDays: retentionDays ?? 0,
allowAggressiveCleanup,
},
extraction: {
enabled: bool(extractionGroup, "enabled") ?? true,
enableDedup: bool(extractionGroup, "enableDedup") ?? true,
maxMemoriesPerSession: num(extractionGroup, "maxMemoriesPerSession") ?? 20,
model: optStr(extractionGroup, "model"),
},
persona: {
triggerEveryN: num(personaGroup, "triggerEveryN") ?? 50,
maxScenes: num(personaGroup, "maxScenes") ?? 20,
backupCount: num(personaGroup, "backupCount") ?? 3,
sceneBackupCount: num(personaGroup, "sceneBackupCount") ?? 10,
model: optStr(personaGroup, "model"),
},
pipeline: {
everyNConversations: num(pipelineGroup, "everyNConversations") ?? 5,
enableWarmup: bool(pipelineGroup, "enableWarmup") ?? true,
l1IdleTimeoutSeconds: num(pipelineGroup, "l1IdleTimeoutSeconds") ?? 60,
l2DelayAfterL1Seconds: num(pipelineGroup, "l2DelayAfterL1Seconds") ?? 90,
l2MinIntervalSeconds: num(pipelineGroup, "l2MinIntervalSeconds") ?? 300,
l2MaxIntervalSeconds: num(pipelineGroup, "l2MaxIntervalSeconds") ?? 1800,
sessionActiveWindowHours: num(pipelineGroup, "sessionActiveWindowHours") ?? 24,
},
recall: {
enabled: bool(recallGroup, "enabled") ?? true,
maxResults: num(recallGroup, "maxResults") ?? 5,
scoreThreshold: num(recallGroup, "scoreThreshold") ?? 0.3,
strategy: validateStrategy(str(recallGroup, "strategy")) ?? "hybrid",
timeoutMs: num(recallGroup, "timeoutMs") ?? 5000,
},
embedding: {
enabled: embeddingEnabled,
provider: embeddingProvider,
baseUrl: embeddingBaseUrl,
apiKey: embeddingApiKey,
model: str(embeddingGroup, "model") ?? defaultModel,
dimensions: num(embeddingGroup, "dimensions") ?? defaultDimensions,
conflictRecallTopK: num(embeddingGroup, "conflictRecallTopK") ?? 5,
proxyUrl: embeddingProxyUrl,
maxInputChars: num(embeddingGroup, "maxInputChars") ?? 5000,
timeoutMs: num(embeddingGroup, "timeoutMs") ?? 10_000,
modelCacheDir: optStr(embeddingGroup, "modelCacheDir"),
configError: embeddingConfigError,
},
memoryCleanup,
report: {
enabled: bool(obj(c, "report"), "enabled") ?? false,
type: str(obj(c, "report"), "type") ?? "local",
},
};
}
// ============================
// Helper functions
// ============================
/** Get sub-object by key, or empty object if missing. */
function obj(c: Record<string, unknown>, key: string): Record<string, unknown> {
const v = c[key];
return v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
}
function str(src: Record<string, unknown>, key: string): string | undefined {
const v = src[key];
return typeof v === "string" && v.trim() ? v.trim() : undefined;
}
function optStr(src: Record<string, unknown>, key: string): string | undefined {
const v = src[key];
return typeof v === "string" ? v : undefined;
}
function num(src: Record<string, unknown>, key: string): number | undefined {
const v = src[key];
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function bool(src: Record<string, unknown>, key: string): boolean | undefined {
const v = src[key];
return typeof v === "boolean" ? v : undefined;
}
function strArray(src: Record<string, unknown>, key: string): string[] | undefined {
const v = src[key];
if (!Array.isArray(v)) return undefined;
return v.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
}
const VALID_STRATEGIES: RecallConfig["strategy"][] = ["embedding", "keyword", "hybrid"];
/**
* Validate recall strategy against whitelist.
* Returns the strategy if valid, undefined otherwise (caller falls back to default).
*/
function validateStrategy(value: string | undefined): RecallConfig["strategy"] | undefined {
if (!value) return undefined;
return VALID_STRATEGIES.includes(value as RecallConfig["strategy"])
? (value as RecallConfig["strategy"])
: undefined;
}
/**
* Normalize a cleanup time string.
*
* The input must follow "HH:MM" or "H:MM" format (24-hour clock).
* If the time is valid, it returns the normalized format "HH:MM"
* with leading zeros added when necessary.
* If the format is invalid or the time is out of range
* (hour: 023, minute: 059), it returns undefined.
*
* Examples:
* normalizeCleanTime("3:05") -> "03:05"
* normalizeCleanTime("03:05") -> "03:05"
* normalizeCleanTime("23:59") -> "23:59"
*
* normalizeCleanTime("24:00") -> undefined // hour out of range
* normalizeCleanTime("12:60") -> undefined // minute out of range
* normalizeCleanTime("3:5") -> undefined // minute must have two digits
* normalizeCleanTime("abc") -> undefined // invalid format
*/
function normalizeCleanTime(input: string | undefined): string | undefined {
if (!input) return undefined;
const trimmed = input.trim();
const m = /^(\d{1,2}):(\d{2})$/.exec(trimmed);
if (!m) return undefined;
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isInteger(hh) || !Number.isInteger(mm)) return undefined;
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return undefined;
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
}
+579
View File
@@ -0,0 +1,579 @@
/**
* L0 Conversation Recorder: records raw conversation messages to local JSONL files.
*
* Triggered from agent_end hook. Receives the conversation messages directly from
* the hook context (no file I/O needed), sanitizes them, filters out noise, and
* writes to ~/.openclaw/memory-tdai/conversations/YYYY-MM-DD.jsonl
*
* Design decisions:
* - Uses JSONL format (**one message per line** — flat, easy to grep/stream)
* - One file per day (all sessions merged into the same daily file)
* - sessionKey is stored as a field in each JSONL line, not in the filename
* - Independent from system session files — format fully controlled by plugin
* - Messages are sanitized to remove injected tags (prevent feedback loops)
* - Short/long/command messages are filtered out
*/
import fs from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../utils/sanitize.js";
// ============================
// Types
// ============================
export interface ConversationMessage {
/** Unique message ID (used by L1 prompt for source_message_ids tracking) */
id: string;
role: "user" | "assistant";
content: string;
timestamp: number; // epoch ms
}
/**
* Generate a short unique message ID.
*/
function generateMessageId(): string {
return `msg_${Date.now()}_${crypto.randomBytes(3).toString("hex")}`;
}
/**
* New flat format: one message per JSONL line.
*/
export interface L0MessageRecord {
sessionKey: string;
sessionId: string;
recordedAt: string; // ISO timestamp
id: string;
role: "user" | "assistant";
content: string;
timestamp: number; // epoch ms
}
/**
* A group of conversation messages (used by downstream consumers).
* Each L0ConversationRecord represents one or more messages from the same recording event.
*/
export interface L0ConversationRecord {
sessionKey: string;
sessionId: string;
recordedAt: string; // ISO timestamp
messageCount: number;
messages: ConversationMessage[];
}
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
const TAG = "[memory-tdai][l0]";
// ============================
// Core function
// ============================
/**
* Record a conversation round to the L0 JSONL file.
*
* Only records **incremental** messages (new since the last capture).
* Uses `afterTimestamp` as the primary filter to skip already-captured history.
*
* @param sessionKey - The session key for this conversation
* @param rawMessages - Raw messages from the agent_end hook context (full session history)
* @param baseDir - Base data directory (~/.openclaw/memory-tdai/)
* @param logger - Optional logger
* @param originalUserText - Clean original user prompt (pre-prependContext)
* @param afterTimestamp - Epoch ms cursor: only messages with timestamp > this are new.
* Pass 0 or omit for the first capture of a session.
* @returns Filtered messages (for L1 to use directly), or empty array if nothing worth recording
*/
export async function recordConversation(params: {
sessionKey: string;
sessionId?: string;
rawMessages: unknown[];
baseDir: string;
logger?: Logger;
/** Clean original user prompt (pre-prependContext) */
originalUserText?: string;
/** Epoch ms cursor: only process messages with timestamp strictly greater than this. */
afterTimestamp?: number;
/**
* Number of messages in the session at before_prompt_build time.
* Used to locate the exact user message that originalUserText corresponds to:
* rawMessages[originalUserMessageCount] is the user message appended by the framework
* AFTER before_prompt_build, i.e. the one whose content was polluted by prependContext.
*/
originalUserMessageCount?: number;
}): Promise<ConversationMessage[]> {
const { sessionKey, sessionId, rawMessages, baseDir, logger, originalUserText, afterTimestamp, originalUserMessageCount } = params;
// Step 1: Position slice + extract user/assistant messages.
//
// Dual protection against duplicate capture:
// Layer 1 (position slice): Use originalUserMessageCount (cached at before_prompt_build)
// to slice rawMessages — only keep messages added AFTER the prompt build, i.e. this
// turn's new messages. This is immune to timestamp drift after gateway restarts.
// Layer 2 (timestamp cursor): The existing afterTimestamp filter below acts as a fallback
// when the position slice is unavailable (cache expired, process restart, etc.).
const usePositionSlice = originalUserMessageCount != null && originalUserMessageCount > 0
&& originalUserMessageCount <= rawMessages.length;
const slicedMessages = usePositionSlice
? rawMessages.slice(originalUserMessageCount)
: rawMessages;
const allExtracted = extractUserAssistantMessages(slicedMessages);
if (usePositionSlice) {
logger?.debug?.(
`${TAG} Position slice: ${rawMessages.length} raw → ${slicedMessages.length} new (sliceStart=${originalUserMessageCount})`,
);
}
// Diagnostic: check whether the framework actually provides timestamp on raw messages.
// If all raw timestamps are missing, the timestamp cursor is effectively useless and
// position slice becomes the sole incremental mechanism.
if (slicedMessages.length > 0) {
const firstRaw = slicedMessages[0] as Record<string, unknown> | undefined;
const rawTs = firstRaw?.timestamp;
const hasRawTs = typeof rawTs === "number";
logger?.debug?.(
`${TAG} Raw message[0] timestamp probe: ${hasRawTs ? `present (${rawTs})` : `missing (type=${typeof rawTs}, value=${String(rawTs)})`}`,
);
}
logger?.debug?.(`${TAG} Extracted ${allExtracted.length} user/assistant messages from ${slicedMessages.length} total`);
// Step 1.5: Incremental filter — only keep messages newer than the cursor.
//
// Uses strict greater-than (>) which is safe because:
// - The cursor is set to max(timestamps) of the LAST recorded batch.
// - The next agent turn's messages will have timestamps strictly greater than
// the previous turn (there's at least one LLM API call between turns, which
// takes hundreds of milliseconds minimum — no same-millisecond collision).
// - All messages within a single turn are captured together as one batch,
// so even if multiple messages share the same timestamp, they are either
// all included (new batch) or all excluded (already captured).
// - If a message lacks a timestamp field, extractUserAssistantMessages()
// assigns Date.now() at extraction time, which is always > previous cursor.
const cursor = afterTimestamp ?? 0;
const extracted = cursor > 0
? allExtracted.filter((m) => m.timestamp > cursor)
: allExtracted;
if (extracted.length > 0) {
const first = extracted[0];
logger?.debug?.(
`${TAG} First captured message: role=${first.role}, ts=${first.timestamp}, ` +
`date=${new Date(first.timestamp).toISOString()}, content=${first.content.slice(0, 80)}${first.content.length > 80 ? "…" : ""}`,
);
}
if (cursor > 0) {
logger?.debug?.(
`${TAG} Incremental filter: ${allExtracted.length} total → ${extracted.length} new (cursor=${cursor})`,
);
// Safety valve: if timestamp filter passed everything through and position slice
// was not available, this likely indicates timestamp drift after a gateway restart.
if (!usePositionSlice && extracted.length === allExtracted.length && allExtracted.length > 8) {
logger?.warn?.(
`${TAG} ⚠ Safety valve: all ${allExtracted.length} messages passed timestamp filter (cursor=${cursor}) — ` +
`possible timestamp drift after gateway restart. Position slice was not available (no cached messageCount).`,
);
}
}
if (extracted.length === 0) {
logger?.debug?.(`${TAG} No new user/assistant messages to record`);
return [];
}
// Step 2: Replace polluted user messages with cached original prompt.
//
// Background:
// The framework appends the user's message to the session after before_prompt_build,
// then injects prependContext into it. So the user message in rawMessages is polluted.
// We cached the clean prompt (originalUserText) and the message count at
// before_prompt_build time (originalUserMessageCount) to identify which raw message
// is the real user input.
//
// Strategy:
// When position slice is active, the polluted user message is slicedMessages[0].
// Otherwise, fall back to rawMessages[originalUserMessageCount].
// In both cases, find the timestamp and match it in `extracted` for replacement.
// If matching fails, skip replacement — sanitizeText() in Step 3 is the safety net.
if (originalUserText) {
// Determine the target raw message that contains the polluted user prompt
const targetRaw: Record<string, unknown> | undefined = usePositionSlice
? slicedMessages[0] as Record<string, unknown> | undefined
: (originalUserMessageCount != null && originalUserMessageCount >= 0 && originalUserMessageCount < rawMessages.length)
? rawMessages[originalUserMessageCount] as Record<string, unknown> | undefined
: undefined;
const targetTs = targetRaw && typeof targetRaw.timestamp === "number" ? targetRaw.timestamp : undefined;
if (targetTs != null) {
let replaced = false;
for (let i = 0; i < extracted.length; i++) {
if (extracted[i].role === "user" && extracted[i].timestamp === targetTs) {
logger?.debug?.(
`${TAG} Replacing user message at timestamp=${targetTs} with cached original prompt ` +
`(${originalUserText.length} chars, was ${extracted[i].content.length} chars) [positionSlice=${usePositionSlice}]`,
);
extracted[i] = { ...extracted[i], content: originalUserText };
replaced = true;
break;
}
}
if (!replaced) {
logger?.warn?.(
`${TAG} Target user message (ts=${targetTs}) not found in extracted batch — ` +
`possibly filtered by cursor. Skipping replacement, will rely on sanitizeText().`,
);
}
} else if (targetRaw) {
logger?.warn?.(
`${TAG} Target raw message has no valid timestamp — ` +
`skipping replacement, will rely on sanitizeText().`,
);
} else {
logger?.warn?.(
`${TAG} Have originalUserText but cannot locate target raw message — ` +
`skipping replacement, will rely on sanitizeText().`,
);
}
}
// Step 3: Sanitize and filter
const filtered = extracted
.map((m) => {
let content = sanitizeText(m.content);
// Strip fenced code blocks from assistant replies to reduce embedding noise
if (m.role === "assistant") {
content = stripCodeBlocks(content);
}
return { id: m.id, role: m.role, content, timestamp: m.timestamp };
})
.filter((m) => shouldCaptureL0(m.content));
logger?.debug?.(`${TAG} After sanitize+filter: ${filtered.length} messages (from ${extracted.length})`);
if (filtered.length === 0) {
logger?.debug?.(`${TAG} All messages filtered out, skipping L0 write`);
return [];
}
// Step 4: Write to JSONL file — one message per line (flat format)
const now = new Date().toISOString();
const lines: string[] = [];
for (const msg of filtered) {
const record: L0MessageRecord = {
sessionKey,
sessionId: sessionId || "",
recordedAt: now,
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp,
};
lines.push(JSON.stringify(record));
}
const shardDate = formatLocalDate(new Date());
const outDir = path.join(baseDir, "conversations");
const outPath = path.join(outDir, `${shardDate}.jsonl`);
try {
await fs.mkdir(outDir, { recursive: true });
// Append each message as its own JSONL line
await fs.appendFile(outPath, lines.join("\n") + "\n", "utf-8");
logger?.debug?.(`${TAG} Recorded ${filtered.length} messages to ${outPath}`);
} catch (err) {
logger?.error(`${TAG} Failed to write L0 file: ${err instanceof Error ? err.message : String(err)}`);
// Return filtered messages anyway so L1 can still process them
}
return filtered;
}
/**
* Read all L0 conversation records for a session.
* Returns records in chronological order.
*
* File format: `YYYY-MM-DD.jsonl` (daily files, all sessions merged).
* Each line is an L0MessageRecord; filtered by sessionKey at line level.
*/
export async function readConversationRecords(
sessionKey: string,
baseDir: string,
logger?: Logger,
): Promise<L0ConversationRecord[]> {
const conversationsDir = path.join(baseDir, "conversations");
// Daily file pattern: YYYY-MM-DD.jsonl
const dateFilePattern = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
let entries: string[];
try {
const dirEntries = await fs.readdir(conversationsDir, { withFileTypes: true });
entries = dirEntries
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
} catch {
// Directory doesn't exist yet — normal for first conversation
return [];
}
const targetFiles = entries
.filter((name) => dateFilePattern.test(name))
.sort();
if (targetFiles.length === 0) {
return [];
}
const records: L0ConversationRecord[] = [];
for (const fileName of targetFiles) {
const filePath = path.join(conversationsDir, fileName);
let raw: string;
try {
raw = await fs.readFile(filePath, "utf-8");
} catch {
logger?.warn?.(`${TAG} Failed to read L0 file: ${filePath}`);
continue;
}
const lines = raw.split("\n").filter((line: string) => line.trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
// Filter by sessionKey at line level
const lineSessionKey = parsed.sessionKey as string | undefined;
if (lineSessionKey !== sessionKey) continue;
if (typeof parsed.role === "string" && typeof parsed.content === "string") {
// Flat format: { sessionKey, sessionId, recordedAt, id, role, content, timestamp }
// Wrap into L0ConversationRecord for uniform downstream consumption
const msg: ConversationMessage = {
id: (typeof parsed.id === "string" && parsed.id) ? parsed.id : generateMessageId(),
role: parsed.role as "user" | "assistant",
content: parsed.content as string,
timestamp: typeof parsed.timestamp === "number" ? parsed.timestamp : Date.now(),
};
records.push({
sessionKey: (parsed.sessionKey as string) || sessionKey,
sessionId: (parsed.sessionId as string) || "",
recordedAt: (parsed.recordedAt as string) || new Date().toISOString(),
messageCount: 1,
messages: [msg],
});
} else {
logger?.warn?.(`${TAG} Unrecognized JSONL line format in ${filePath}:${i + 1}`);
}
} catch {
logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`);
}
}
}
records.sort((a, b) => {
const ta = Date.parse(a.recordedAt);
const tb = Date.parse(b.recordedAt);
const na = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY;
const nb = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY;
return na - nb;
});
return records;
}
/**
* Read L0 messages across all conversation records for a session,
* optionally filtered by a cursor timestamp (messages after the cursor).
*
* When `limit` is provided, only the **newest** `limit` messages are returned
* (matching the DB path's `ORDER BY timestamp DESC LIMIT ?` behavior).
* Returned messages are always in chronological order (oldest → newest).
*
* NOTE: potential optimization — records are chronologically ordered (append-only JSONL),
* so a reverse scan could skip entire old records. Deferred for now; see Issue 5 in
* docs/05-known-issues.md.
*/
export async function readConversationMessages(
sessionKey: string,
baseDir: string,
afterTimestamp?: number,
logger?: Logger,
limit?: number,
): Promise<ConversationMessage[]> {
const records = await readConversationRecords(sessionKey, baseDir, logger);
const allMessages: ConversationMessage[] = [];
for (const record of records) {
for (const msg of record.messages) {
if (afterTimestamp && msg.timestamp <= afterTimestamp) continue;
allMessages.push(msg);
}
}
// Truncate to newest `limit` messages (keep tail, since array is chronological)
if (limit != null && limit > 0 && allMessages.length > limit) {
logger?.debug?.(
`${TAG} readConversationMessages: truncating ${allMessages.length}${limit} (newest)`,
);
return allMessages.slice(-limit);
}
return allMessages;
}
/**
* A group of conversation messages sharing the same sessionId.
*/
export interface SessionIdMessageGroup {
sessionId: string;
messages: ConversationMessage[];
}
/**
* Read L0 messages for a session, grouped by sessionId.
*
* Within the same sessionKey, different sessionIds represent different conversation
* instances (e.g. after /reset). L1 extraction should process each group independently
* so that each group's sessionId is correctly associated with its extracted memories.
*
* When `limit` is provided, only the **newest** `limit` messages (across all groups)
* are retained — matching the DB path's `ORDER BY timestamp DESC LIMIT ?` behavior.
* Groups that become empty after truncation are dropped.
*
* Groups are returned in chronological order (by earliest message timestamp).
* Messages within each group are also in chronological order.
*/
export async function readConversationMessagesGroupedBySessionId(
sessionKey: string,
baseDir: string,
afterTimestamp?: number,
logger?: Logger,
limit?: number,
): Promise<SessionIdMessageGroup[]> {
const records = await readConversationRecords(sessionKey, baseDir, logger);
// Collect all messages with their sessionId, respecting afterTimestamp filter
const allMessages: Array<{ sessionId: string; msg: ConversationMessage }> = [];
for (const record of records) {
const sid = record.sessionId || "";
for (const msg of record.messages) {
if (afterTimestamp && msg.timestamp <= afterTimestamp) continue;
allMessages.push({ sessionId: sid, msg });
}
}
// Sort by timestamp ASC (chronological) — records are already roughly ordered
// by recordedAt, but messages within may not be perfectly sorted by timestamp.
allMessages.sort((a, b) => a.msg.timestamp - b.msg.timestamp);
// Truncate to newest `limit` messages (keep tail)
let selected = allMessages;
if (limit != null && limit > 0 && allMessages.length > limit) {
logger?.debug?.(
`${TAG} readConversationMessagesGroupedBySessionId: truncating ${allMessages.length}${limit} (newest)`,
);
selected = allMessages.slice(-limit);
}
// Re-group by sessionId
const groupMap = new Map<string, ConversationMessage[]>();
for (const { sessionId, msg } of selected) {
let group = groupMap.get(sessionId);
if (!group) {
group = [];
groupMap.set(sessionId, group);
}
group.push(msg);
}
// Convert to array, sorted by earliest message timestamp in each group
const groups: SessionIdMessageGroup[] = [];
for (const [sessionId, messages] of groupMap) {
if (messages.length > 0) {
groups.push({ sessionId, messages });
}
}
groups.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
return groups;
}
// ============================
// Helpers
// ============================
/**
* Extract user and assistant messages from raw hook message array.
*/
function extractUserAssistantMessages(messages: unknown[]): ConversationMessage[] {
const result: ConversationMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
const role = m.role as string | undefined;
if (role !== "user" && role !== "assistant") continue;
let content: string | undefined;
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
const textParts: string[] = [];
for (const part of m.content) {
if (
part &&
typeof part === "object" &&
(part as Record<string, unknown>).type === "text"
) {
const text = (part as Record<string, unknown>).text;
if (typeof text === "string") textParts.push(text);
}
}
content = textParts.join("\n");
}
// Strip inline base64 image data URIs that some providers embed in string content.
// These are not useful for memory and would pollute FTS / embedding indexes.
if (content && /data:image\/[a-z+]+;base64,/i.test(content)) {
content = content.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "[image]");
}
if (content && content.trim()) {
const ts = typeof m.timestamp === "number" ? m.timestamp : Date.now();
result.push({
id: (typeof m.id === "string" && m.id) ? m.id : generateMessageId(),
role: role as "user" | "assistant",
content: content.trim(),
timestamp: ts,
});
}
}
return result;
}
/**
* Format local date as YYYY-MM-DD.
*/
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}`;
}
+270
View File
@@ -0,0 +1,270 @@
/**
* auto-capture hook (v3): records conversation messages locally (L0),
* then notifies the MemoryPipelineManager for L1/L2/L3 scheduling.
*
* Key design decisions:
* - Always write L0 locally via l0-recorder.
* - When VectorStore + EmbeddingService are available, also write L0 vector index.
* - Notify MemoryPipelineManager for L1/L2/L3 trigger evaluation.
* - L1 Runner reads from VectorStore DB (primary) or L0 JSONL files (fallback).
* - Extraction is NOT triggered here. The pipeline manager decides when.
*/
import crypto from "node:crypto";
import type { MemoryTdaiConfig } from "../config.js";
import { CheckpointManager } from "../utils/checkpoint.js";
import type { MemoryPipelineManager } from "../utils/pipeline-manager.js";
import { recordConversation } from "../conversation/l0-recorder.js";
import type { ConversationMessage } from "../conversation/l0-recorder.js";
import type { VectorStore, L0VectorRecord } from "../store/vector-store.js";
import type { EmbeddingService } from "../store/embedding.js";
const TAG = "[memory-tdai] [capture]";
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export interface AutoCaptureResult {
/** Whether the scheduler was notified (conversation count incremented) */
schedulerNotified: boolean;
/** Number of messages recorded to L0 */
l0RecordedCount: number;
/** Number of L0 message vectors written */
l0VectorsWritten: number;
/** Filtered messages for L1 immediate use */
filteredMessages: ConversationMessage[];
}
/**
* Generate a unique L0 record ID for vector indexing.
* Includes an index to distinguish multiple messages within the same round.
*/
function generateL0RecordId(sessionKey: string, index: number): string {
return `l0_${sessionKey}_${Date.now()}_${index}_${crypto.randomBytes(3).toString("hex")}`;
}
export async function performAutoCapture(params: {
messages: unknown[];
sessionKey: string;
sessionId?: string;
cfg: MemoryTdaiConfig;
pluginDataDir: string;
logger?: Logger;
scheduler?: MemoryPipelineManager;
/** Clean original user prompt from before_prompt_build cache (pre-prependContext). */
originalUserText?: string;
/**
* Number of messages in the session at before_prompt_build time.
* Used by l0-recorder to locate the exact user message that originalUserText
* corresponds to: rawMessages[originalUserMessageCount] is the polluted user message.
*/
originalUserMessageCount?: number;
/** Epoch ms when the plugin was registered (cold-start time).
* Used as fallback cursor when checkpoint has no prior timestamp —
* prevents the first agent_end from dumping all session history into L0. */
pluginStartTimestamp?: number;
/** VectorStore for L0 vector indexing (optional). */
vectorStore?: VectorStore;
/** EmbeddingService for L0 vector indexing (optional). */
embeddingService?: EmbeddingService;
}): Promise<AutoCaptureResult> {
const {
messages, sessionKey, sessionId, cfg, pluginDataDir, logger, scheduler,
originalUserText, originalUserMessageCount, pluginStartTimestamp,
vectorStore, embeddingService,
} = params;
const tCaptureStart = performance.now();
const checkpoint = new CheckpointManager(pluginDataDir, logger);
// ============================
// Step 1 + 2: L0 recording + checkpoint update (ATOMIC)
// ============================
// These steps are combined inside captureAtomically() to prevent the race
// condition where two concurrent agent_end events both read the same stale
// cursor and produce duplicate L0 records. The file lock is held for the
// entire read-cursor → recordConversation → advance-cursor sequence.
const tL0RecordStart = performance.now();
let filteredMessages: ConversationMessage[] = [];
try {
await checkpoint.captureAtomically(
sessionKey,
pluginStartTimestamp,
async (afterTimestamp) => {
logger?.debug?.(`${TAG} L0 capture cursor (per-session, atomic): afterTimestamp=${afterTimestamp} session=${sessionKey}`);
if (afterTimestamp === pluginStartTimestamp && pluginStartTimestamp && pluginStartTimestamp > 0) {
logger?.debug?.(
`${TAG} No per-session checkpoint cursor found for session=${sessionKey}` +
`using pluginStartTimestamp as floor: ` +
`${afterTimestamp} (${new Date(afterTimestamp).toISOString()})`,
);
}
filteredMessages = await recordConversation({
sessionKey,
sessionId,
rawMessages: messages,
baseDir: pluginDataDir,
logger,
originalUserText,
afterTimestamp,
originalUserMessageCount,
});
if (filteredMessages.length === 0) {
return null; // Nothing captured — cursor stays unchanged
}
logger?.debug?.(`${TAG} L0 recorded: ${filteredMessages.length} messages for session ${sessionKey}`);
const maxTs = Math.max(...filteredMessages.map((m) => m.timestamp));
return { maxTimestamp: maxTs, messageCount: filteredMessages.length };
},
);
} catch (err) {
logger?.error(`${TAG} L0 recording failed: ${err instanceof Error ? err.message : String(err)}`);
}
const tL0RecordEnd = performance.now();
// ============================
// Step 1.5: L0 vector indexing — metadata written synchronously,
// embedding done in background (non-blocking)
// ============================
// PERF FIX: Remote embedding API calls (2-3s each) were blocking
// the agent_end hook, adding 5-9s latency per conversation round.
// Now we:
// 1. Write L0 metadata + FTS immediately (no embedding) — ~10ms
// 2. Fire off background task to embed + update vectors (non-blocking)
// This way the user gets their response immediately.
const tL0VecStart = performance.now();
let l0VectorsWritten = 0;
logger?.debug?.(
`${TAG} [L0-vec-index] Check: filteredMessages=${filteredMessages.length}, ` +
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`,
);
// Pre-generate L0 records and write metadata synchronously (fast path)
const l0Records: Array<{ record: L0VectorRecord; content: string }> = [];
if (filteredMessages.length > 0 && vectorStore) {
const now = new Date().toISOString();
logger?.debug?.(`${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey}`);
for (let i = 0; i < filteredMessages.length; i++) {
const msg = filteredMessages[i];
try {
const l0Record: L0VectorRecord = {
id: generateL0RecordId(sessionKey, i),
sessionKey,
sessionId: sessionId || "",
role: msg.role,
messageText: msg.content,
recordedAt: now,
timestamp: msg.timestamp,
};
// Write metadata + FTS immediately WITHOUT embedding (fast, ~ms)
const upsertOk = vectorStore.upsertL0(l0Record, undefined);
if (upsertOk) {
l0VectorsWritten++;
l0Records.push({ record: l0Record, content: msg.content });
} else {
logger?.warn(`${TAG} [L0-vec-index] upsertL0 returned false for message ${i}`);
}
} catch (err) {
logger?.warn?.(`${TAG} [L0-vec-index] FAILED for message ${i} (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
}
}
logger?.debug?.(`${TAG} [L0-vec-index] DONE: ${l0VectorsWritten}/${filteredMessages.length} metadata records written (sync)`);
// Fire-and-forget: batch embed + update vectors in background
if (l0Records.length > 0 && embeddingService) {
const bgVectorStore = vectorStore; // capture for closure
const bgEmbeddingService = embeddingService;
const bgRecords = [...l0Records]; // snapshot
const bgLogger = logger;
// Do NOT await — this runs in background after response is sent
void (async () => {
const tBgStart = performance.now();
try {
// Use embedBatch for a single API call instead of N sequential calls
const texts = bgRecords.map((r) => r.content);
const embeddings = await bgEmbeddingService.embedBatch(texts);
let bgUpdated = 0;
for (let i = 0; i < bgRecords.length; i++) {
try {
const ok = bgVectorStore.updateL0Embedding(bgRecords[i].record.id, embeddings[i]);
if (ok) bgUpdated++;
} catch (err) {
bgLogger?.warn?.(
`${TAG} [L0-vec-index-bg] Failed to update embedding for ${bgRecords[i].record.id}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
}
}
const bgMs = performance.now() - tBgStart;
bgLogger?.debug?.(
`${TAG} [L0-vec-index-bg] Background embedding complete: ${bgUpdated}/${bgRecords.length} vectors updated (${bgMs.toFixed(0)}ms)`,
);
} catch (err) {
const bgMs = performance.now() - tBgStart;
bgLogger?.warn?.(
`${TAG} [L0-vec-index-bg] Background embedding failed (${bgMs.toFixed(0)}ms, non-fatal): ` +
`${err instanceof Error ? err.message : String(err)}`,
);
}
})();
}
} else if (filteredMessages.length > 0) {
logger?.warn(`${TAG} [L0-vec-index] SKIPPED: vectorStore not available`);
}
const tL0VecEnd = performance.now();
// ============================
// Step 3: Notify scheduler of this conversation round
// ============================
const tNotifyStart = performance.now();
// Pass empty array: L1 Runner reads from VectorStore DB (or L0 JSONL fallback), not from in-memory buffers.
if (scheduler) {
await scheduler.notifyConversation(sessionKey, []);
logger?.debug?.(`${TAG} Scheduler notified of conversation round (sessionKey=${sessionKey})`);
const totalMs = performance.now() - tCaptureStart;
logger?.info(
`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms ` +
`(metadata-only, embed=background, msgs=${filteredMessages.length}), ` +
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`,
);
return {
schedulerNotified: true,
l0RecordedCount: filteredMessages.length,
l0VectorsWritten,
filteredMessages,
};
}
const totalMs = performance.now() - tCaptureStart;
logger?.info(
`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms ` +
`(metadata-only, embed=background, msgs=${filteredMessages.length}), ` +
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`,
);
logger?.debug?.(`${TAG} No scheduler provided, skipping notification`);
return {
schedulerNotified: false,
l0RecordedCount: filteredMessages.length,
l0VectorsWritten,
filteredMessages,
};
}
+762
View File
@@ -0,0 +1,762 @@
/**
* auto-recall hook (v3): injects relevant memories + persona into agent context
* before the agent starts processing.
*
* - Searches L1 memories using configurable strategy (keyword / embedding / hybrid)
* - keyword: FTS5 BM25 (requires FTS5; returns empty if unavailable)
* - embedding: VectorStore cosine similarity
* - hybrid: keyword + embedding merged with RRF
* - L3 persona injection
* - L2 scene navigation (full injection, LLM decides relevance)
*/
import fs from "node:fs/promises";
import path from "node:path";
import type { MemoryTdaiConfig } from "../config.js";
import { readSceneIndex } from "../scene/scene-index.js";
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
import type { MemoryRecord } from "../record/l1-reader.js";
import type { VectorStore, VectorSearchResult, FtsSearchResult } from "../store/vector-store.js";
import { buildFtsQuery } from "../store/vector-store.js";
import type { EmbeddingService } from "../store/embedding.js";
import { sanitizeText } from "../utils/sanitize.js";
const TAG = "[memory-tdai] [recall]";
/**
* Memory tools usage guide — injected at the end of memory context so the
* main agent knows how to actively retrieve deeper information.
*/
const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
## 记忆工具调用指南
当上方注入的记忆片段不足以回答用户问题时,可主动调用以下工具获取更多信息:
- **tdai_memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件节点、规则等关键信息。
- **tdai_conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节;也可用于补充或校验 memory_search 的结果。
- **read_file**Scene Navigation 中的路径):当已定位到相关情境,且需要该场景的完整画像、事件经过或阶段结论时使用。
</memory-tools-guide>`
/**
* Build the dynamic scene-navigation read_file hint.
* Tells the agent how to resolve relative paths in scene navigation
* by prepending the actual pluginDataDir.
*/
function buildScenePathHint(pluginDataDir: string): string {
return `⚠️ Scene Navigation 路径提示:上方 Scene Navigation 中的 Path(如 \`scene_blocks/xxx.md\`)是相对路径,使用 read_file 读取时需拼接为绝对路径:\`${pluginDataDir}/scene_blocks/xxx.md\``;
}
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
/** A single recalled L1 memory with its search score and type. */
export interface RecalledMemory {
content: string;
score: number;
type: string;
}
export interface RecallResult {
/** Injected before user message (prepended to the user's prompt text by openclaw) */
prependContext?: string;
/** Appended to system prompt (all memory context: persona, scene navigation, relevant memories) */
appendSystemContext?: string;
// ── Metric payload (for pendingRecallCache in index.ts) ──
/** L1 memories that were recalled (with scores), for metric reporting */
recalledL1Memories?: RecalledMemory[];
/** L3 Persona raw content loaded during recall (null if none) */
recalledL3Persona?: string | null;
/** Effective search strategy used */
recallStrategy?: string;
}
export async function performAutoRecall(params: {
userText: string;
actorId: string;
sessionKey: string;
cfg: MemoryTdaiConfig;
pluginDataDir: string;
logger?: Logger;
vectorStore?: VectorStore;
embeddingService?: EmbeddingService;
}): Promise<RecallResult | undefined> {
const { cfg, logger } = params;
const timeoutMs = cfg.recall.timeoutMs ?? 5000;
let timer: ReturnType<typeof setTimeout> | undefined;
return Promise.race([
performAutoRecallInner(params).finally(() => {
if (timer) clearTimeout(timer);
}),
new Promise<undefined>((resolve) => {
timer = setTimeout(() => {
logger?.warn?.(
`${TAG} ⚠️ Recall timed out after ${timeoutMs}ms — skipping memory injection to avoid blocking the user`,
);
resolve(undefined);
}, timeoutMs);
}),
]);
}
async function performAutoRecallInner(params: {
userText: string;
actorId: string;
sessionKey: string;
cfg: MemoryTdaiConfig;
pluginDataDir: string;
logger?: Logger;
vectorStore?: VectorStore;
embeddingService?: EmbeddingService;
}): Promise<RecallResult | undefined> {
const { userText, cfg, pluginDataDir, logger, vectorStore, embeddingService } = params;
const tRecallStart = performance.now();
// Search relevant memories (L1 layer) — skip only when userText is empty/undefined
const tSearchStart = performance.now();
let memoryLines: string[] = [];
let effectiveStrategy = "skipped";
let recalledL1Memories: RecalledMemory[] = [];
let searchTiming: SearchTiming = { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 };
if (!userText || userText.length === 0) {
logger?.debug?.(`${TAG} User text empty/undefined, skipping memory search (persona/scene still injected)`);
} else {
effectiveStrategy = cfg.recall.strategy ?? "hybrid";
const searchResult = await searchMemories(userText, pluginDataDir, cfg, logger, effectiveStrategy as "keyword" | "embedding" | "hybrid", vectorStore, embeddingService);
memoryLines = searchResult.lines;
searchTiming = searchResult.timing;
// Extract structured RecalledMemory from formatted lines for metric reporting
recalledL1Memories = memoryLines.map((line) => {
const match = line.match(/^-\s+\[([^\]]+)\]\s+(.+?)(?:\s*\(活动时间:.*\))?$/);
if (match) {
const tag = match[1];
const content = match[2].trim();
const typePart = tag.includes("|") ? tag.split("|")[0] : tag;
return { content, score: 0, type: typePart };
}
return { content: line, score: 0, type: "unknown" };
});
}
const tSearchEnd = performance.now();
// Read persona (L3 layer)
const tPersonaStart = performance.now();
let personaContent: string | undefined;
try {
const personaPath = path.join(pluginDataDir, "persona.md");
const raw = await fs.readFile(personaPath, "utf-8");
personaContent = stripSceneNavigation(raw).trim();
if (!personaContent) personaContent = undefined;
logger?.debug?.(`${TAG} Persona loaded: ${personaContent ? `${personaContent.length} chars` : "empty"}`);
} catch {
logger?.debug?.(`${TAG} No persona file found (expected for new users)`);
}
const tPersonaEnd = performance.now();
// Load full scene navigation (L2 layer)
const tSceneStart = performance.now();
let sceneNavigation: string | undefined;
try {
const sceneIndex = await readSceneIndex(pluginDataDir);
if (sceneIndex.length > 0) {
sceneNavigation = generateSceneNavigation(sceneIndex);
logger?.debug?.(`${TAG} Scene navigation generated: ${sceneIndex.length} scenes`);
}
} catch {
logger?.debug?.(`${TAG} No scene index found`);
}
const tSceneEnd = performance.now();
if (memoryLines.length === 0 && !personaContent && !sceneNavigation) {
const totalMs = performance.now() - tRecallStart;
logger?.info(
`${TAG} ⏱ Recall timing: total=${totalMs.toFixed(0)}ms, ` +
`search=${(tSearchEnd - tSearchStart).toFixed(0)}ms(strategy=${effectiveStrategy},hits=${memoryLines.length},` +
`fts=${searchTiming.ftsMs.toFixed(0)}ms/${searchTiming.ftsHits}hits,` +
`vec=${searchTiming.embeddingMs.toFixed(0)}ms/${searchTiming.embeddingHits}hits), ` +
`persona=${(tPersonaEnd - tPersonaStart).toFixed(0)}ms, ` +
`scene=${(tSceneEnd - tSceneStart).toFixed(0)}ms — no context to inject`,
);
logger?.debug?.(`${TAG} No memories/persona/scenes to inject`);
return undefined;
}
// All memory context → appendSystemContext (system prompt end)
// Order: user-persona → scene-navigation → relevant-memories → tools-guide
const systemParts: string[] = [];
if (personaContent) {
systemParts.push(`<user-persona>\n${personaContent}\n</user-persona>`);
}
if (sceneNavigation) {
const pathHint = buildScenePathHint(pluginDataDir);
systemParts.push(`<scene-navigation>\n${sceneNavigation}\n\n${pathHint}\n</scene-navigation>`);
}
if (memoryLines.length > 0) {
systemParts.push(`<relevant-memories>\n${memoryLines.join("\n")}\n</relevant-memories>`);
}
// Append memory tools usage guide so the agent knows how to actively
// retrieve deeper context when the injected snippets are not enough.
if (systemParts.length > 0) {
systemParts.push(MEMORY_TOOLS_GUIDE);
}
const appendSystemContext = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
const totalMs = performance.now() - tRecallStart;
logger?.info(
`${TAG} ⏱ Recall timing: total=${totalMs.toFixed(0)}ms, ` +
`search=${(tSearchEnd - tSearchStart).toFixed(0)}ms(strategy=${effectiveStrategy},hits=${memoryLines.length},` +
`fts=${searchTiming.ftsMs.toFixed(0)}ms/${searchTiming.ftsHits}hits,` +
`vec=${searchTiming.embeddingMs.toFixed(0)}ms/${searchTiming.embeddingHits}hits), ` +
`persona=${(tPersonaEnd - tPersonaStart).toFixed(0)}ms(${personaContent ? `${personaContent.length}chars` : "none"}), ` +
`scene=${(tSceneEnd - tSceneStart).toFixed(0)}ms(${sceneNavigation ? "loaded" : "none"})`,
);
if (!appendSystemContext) {
return undefined;
}
return {
appendSystemContext,
recalledL1Memories,
recalledL3Persona: personaContent ?? null,
recallStrategy: effectiveStrategy,
};
}
// ============================
// Multi-strategy search dispatcher
// ============================
interface ScoredRecord {
record: MemoryRecord;
score: number;
}
/** Timing breakdown from memory search */
interface SearchTiming {
ftsMs: number;
embeddingMs: number;
ftsHits: number;
embeddingHits: number;
}
interface SearchResult {
lines: string[];
timing: SearchTiming;
}
/**
* Search memories and return both formatted lines and structured details.
*
* This is a thin wrapper around `searchMemories` that also captures
* the recalled memory metadata for metric reporting (agent_turn event).
* It parses the returned formatted lines to extract type/content info.
*/
async function searchMemoriesWithDetails(
userText: string,
pluginDataDir: string,
cfg: MemoryTdaiConfig,
logger: Logger | undefined,
strategy: "keyword" | "embedding" | "hybrid",
vectorStore?: VectorStore,
embeddingService?: EmbeddingService,
): Promise<{ lines: string[]; memories: RecalledMemory[]; timing: SearchTiming }> {
const result = await searchMemories(userText, pluginDataDir, cfg, logger, strategy, vectorStore, embeddingService);
// Extract structured data from formatted memory lines.
// Format: "- [type|scene] content (活动时间: ...)" or "- [type] content"
const memories: RecalledMemory[] = result.lines.map((line) => {
const match = line.match(/^-\s+\[([^\]]+)\]\s+(.+?)(?:\s*\(活动时间:.*\))?$/);
if (match) {
const tag = match[1];
const content = match[2].trim();
const typePart = tag.includes("|") ? tag.split("|")[0] : tag;
return { content, score: 0, type: typePart };
}
return { content: line, score: 0, type: "unknown" };
});
return { lines: result.lines, memories, timing: result.timing };
}
/**
* Search memories using the configured strategy.
*
* - "keyword": JSONL keyword-based (Jaccard similarity) — no embedding needed
* - "embedding": VectorStore cosine similarity — requires vectorStore + embeddingService
* - "hybrid": merge both keyword and embedding results with RRF (Reciprocal Rank Fusion)
*
* Falls back to keyword if embedding resources are unavailable.
*/
async function searchMemories(
userText: string,
pluginDataDir: string,
cfg: MemoryTdaiConfig,
logger: Logger | undefined,
strategy: "keyword" | "embedding" | "hybrid",
vectorStore?: VectorStore,
embeddingService?: EmbeddingService,
): Promise<SearchResult> {
const emptyResult: SearchResult = { lines: [], timing: { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 } };
// Strip gateway-injected inbound metadata (Sender, timestamps, media markers,
// base64 image data, etc.) so FTS / embedding queries are based on pure user intent.
const cleanText = sanitizeText(userText);
if (cleanText.length < 2) {
logger?.debug?.(`${TAG} Query too short for memory search (raw=${userText.length}, clean=${cleanText.length})`);
return emptyResult;
}
if (cleanText.length !== userText.length) {
logger?.debug?.(
`${TAG} userText sanitized: ${userText.length}${cleanText.length} chars`,
);
}
const maxResults = cfg.recall.maxResults ?? 5;
const threshold = cfg.recall.scoreThreshold ?? 0.3;
const embeddingAvailable = !!vectorStore && !!embeddingService;
logger?.debug?.(
`${TAG} [searchMemories] strategy=${strategy}, embeddingAvailable=${embeddingAvailable}, ` +
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}, ` +
`maxResults=${maxResults}, threshold=${threshold}`,
);
// Determine effective strategy (fall back to keyword if embedding not available)
let effectiveStrategy = strategy;
if ((strategy === "embedding" || strategy === "hybrid") && !embeddingAvailable) {
logger?.warn?.(
`${TAG} Strategy "${strategy}" requested but EmbeddingService not available, falling back to keyword`,
);
effectiveStrategy = "keyword";
}
logger?.debug?.(`${TAG} Search strategy: ${effectiveStrategy} (configured: ${strategy})`);
try {
if (effectiveStrategy === "keyword") {
const tFts = performance.now();
const lines = await searchByKeyword(cleanText, pluginDataDir, maxResults, threshold, logger, vectorStore);
return { lines, timing: { ftsMs: performance.now() - tFts, embeddingMs: 0, ftsHits: lines.length, embeddingHits: 0 } };
}
if (effectiveStrategy === "embedding") {
const tEmb = performance.now();
const lines = await searchByEmbedding(cleanText, maxResults, threshold, vectorStore!, embeddingService!, logger);
return { lines, timing: { ftsMs: 0, embeddingMs: performance.now() - tEmb, ftsHits: 0, embeddingHits: lines.length } };
}
// Hybrid: run both keyword and embedding, merge with RRF
return await searchHybrid(cleanText, pluginDataDir, maxResults, threshold, vectorStore!, embeddingService!, logger);
} catch (err) {
logger?.warn?.(`${TAG} Memory search failed (strategy=${effectiveStrategy}): ${err instanceof Error ? err.message : String(err)}`);
return emptyResult;
}
}
// ============================
// Strategy: Keyword (FTS5 BM25, no in-memory fallback)
// ============================
async function searchByKeyword(
userText: string,
_pluginDataDir: string,
maxResults: number,
threshold: number,
logger?: Logger,
vectorStore?: VectorStore,
): Promise<string[]> {
// Prefer FTS5 if available
if (vectorStore?.isFtsAvailable()) {
const ftsQuery = buildFtsQuery(userText);
if (ftsQuery) {
logger?.debug?.(`${TAG} [keyword-fts] Using FTS5 BM25 search: query="${ftsQuery}"`);
const ftsResults = vectorStore.ftsSearchL1(ftsQuery, maxResults * 2);
if (ftsResults.length > 0) {
logger?.debug?.(
`${TAG} [keyword-fts] FTS5 raw results (${ftsResults.length}): ` +
ftsResults.map((r) => `id=${r.record_id} score=${r.score.toFixed(6)}`).join(", "),
);
const filtered = ftsResults
.filter((r) => r.score >= threshold)
.slice(0, maxResults);
if (filtered.length > 0) {
logger?.debug?.(`${TAG} [keyword-fts] FTS5 found ${filtered.length} results (from ${ftsResults.length} raw, threshold=${threshold})`);
return filtered.map((r) => formatMemoryLine(ftsResultToFormatable(r)));
}
// BM25 absolute scores are unreliable when the document set is very
// small (e.g. 13 records) because IDF approaches 0. In that case,
// trust FTS5's MATCH + rank ordering and return the top results anyway.
if (ftsResults.length <= maxResults) {
logger?.debug?.(
`${TAG} [keyword-fts] All ${ftsResults.length} results below threshold=${threshold} ` +
`but document set is small — returning all matched results`,
);
return ftsResults.slice(0, maxResults).map((r) => formatMemoryLine(ftsResultToFormatable(r)));
}
logger?.debug?.(`${TAG} [keyword-fts] FTS5 returned 0 results above threshold (from ${ftsResults.length} raw)`);
}
}
}
// FTS5 not available or returned no results — skip in-memory fallback to avoid O(N) full scan
logger?.debug?.(`${TAG} [keyword] FTS5 unavailable or no results, skipping keyword search`);
return [];
}
// ============================
// Strategy: Embedding (VectorStore cosine)
// ============================
async function searchByEmbedding(
userText: string,
maxResults: number,
threshold: number,
vectorStore: VectorStore,
embeddingService: EmbeddingService,
logger?: Logger,
): Promise<string[]> {
logger?.debug?.(
`${TAG} [embedding-search] START query="${userText.slice(0, 80)}...", maxResults=${maxResults}, threshold=${threshold}`,
);
const queryEmbedding = await embeddingService.embed(userText);
logger?.debug?.(
`${TAG} [embedding-search] Query embedding OK: dims=${queryEmbedding.length}, ` +
`norm=${Math.sqrt(Array.from(queryEmbedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}, ` +
`searching top-${maxResults * 2}...`,
);
// Retrieve more candidates for subsequent filtering
const vecResults: VectorSearchResult[] = vectorStore.search(queryEmbedding, maxResults * 2);
if (vecResults.length === 0) {
logger?.debug?.(`${TAG} [embedding-search] Returned 0 results`);
return [];
}
logger?.debug?.(`${TAG} [embedding-search] Got ${vecResults.length} candidates, filtering by threshold=${threshold}`);
for (const r of vecResults) {
logger?.debug?.(
`${TAG} [embedding-search] candidate id=${r.record_id}, score=${r.score.toFixed(4)}, ` +
`type=${r.type}, content="${r.content.slice(0, 60)}..."`,
);
}
const filtered = vecResults
.filter((r) => r.score >= threshold)
.slice(0, maxResults);
if (filtered.length > 0) {
logger?.debug?.(`${TAG} [embedding-search] Found ${filtered.length} relevant memories above threshold (from ${vecResults.length} candidates)`);
return filtered.map((r) => formatMemoryLine(vectorResultToFormatable(r)));
}
logger?.debug?.(`${TAG} [embedding-search] No results above threshold ${threshold}`);
return [];
}
// ============================
// Strategy: Hybrid (Keyword + Embedding + RRF)
// ============================
/**
* Hybrid search: run keyword (FTS5) and embedding in parallel, merge with
* Reciprocal Rank Fusion (RRF) to combine rank lists.
*
* RRF score for a record at rank r = 1 / (k + r), where k=60 is a constant.
* If a record appears in both lists, its RRF scores are summed.
*
* If FTS5 is unavailable, the keyword side returns empty and RRF uses
* embedding results only.
*/
async function searchHybrid(
userText: string,
_pluginDataDir: string,
maxResults: number,
_threshold: number,
vectorStore: VectorStore,
embeddingService: EmbeddingService,
logger?: Logger,
): Promise<SearchResult> {
// Run keyword and embedding searches in parallel
const candidateK = maxResults * 3; // retrieve more for merging
const [keywordResult, embeddingResult] = await Promise.all([
// Keyword search: FTS5 only (no in-memory fallback)
(async () => {
const tStart = performance.now();
try {
// Try FTS5 first
if (vectorStore.isFtsAvailable()) {
const ftsQuery = buildFtsQuery(userText);
if (ftsQuery) {
const ftsResults = vectorStore.ftsSearchL1(ftsQuery, candidateK);
if (ftsResults.length > 0) {
logger?.debug?.(`${TAG} [hybrid-keyword-fts] FTS5 found ${ftsResults.length} candidates`);
// Convert FtsSearchResult to ScoredRecord for RRF merge
const records = ftsResults.map((r): ScoredRecord => ({
record: {
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,
},
score: r.score,
}));
return { records, ms: performance.now() - tStart };
}
}
}
// FTS5 not available or returned no results — skip in-memory fallback
logger?.debug?.(`${TAG} [hybrid-keyword] FTS5 unavailable or no results, skipping keyword part`);
return { records: [] as ScoredRecord[], ms: performance.now() - tStart };
} catch (err) {
logger?.warn?.(`${TAG} Hybrid: keyword part failed: ${err instanceof Error ? err.message : String(err)}`);
return { records: [] as ScoredRecord[], ms: performance.now() - tStart };
}
})(),
// Embedding search
(async () => {
const tStart = performance.now();
try {
logger?.debug?.(`${TAG} [hybrid-embedding] Generating query embedding...`);
const queryEmbedding = await embeddingService.embed(userText);
logger?.debug?.(
`${TAG} [hybrid-embedding] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
);
const results = vectorStore.search(queryEmbedding, candidateK);
logger?.debug?.(`${TAG} [hybrid-embedding] Got ${results.length} candidates`);
return { results, ms: performance.now() - tStart };
} catch (err) {
logger?.warn?.(`${TAG} Hybrid: embedding part failed: ${err instanceof Error ? err.message : String(err)}`);
return { results: [] as VectorSearchResult[], ms: performance.now() - tStart };
}
})(),
]);
const keywordResults = keywordResult.records;
const embeddingResults = embeddingResult.results;
const timing: SearchTiming = {
ftsMs: keywordResult.ms,
embeddingMs: embeddingResult.ms,
ftsHits: keywordResults.length,
embeddingHits: embeddingResults.length,
};
if (keywordResults.length === 0 && embeddingResults.length === 0) {
logger?.debug?.(`${TAG} Hybrid search: both strategies returned 0 results`);
return { lines: [], timing };
}
// RRF merge: k=60 is a standard constant from the RRF paper
const RRF_K = 60;
// Map: record_id → { rrfScore, formatable }
const mergedMap = new Map<string, { rrfScore: number; formatable: FormatableMemory }>();
// Process keyword results
for (let rank = 0; rank < keywordResults.length; rank++) {
const r = keywordResults[rank];
const id = r.record.id;
const rrfScore = 1 / (RRF_K + rank + 1);
const existing = mergedMap.get(id);
if (existing) {
existing.rrfScore += rrfScore;
} else {
mergedMap.set(id, { rrfScore, formatable: recordToFormatable(r.record) });
}
}
// Process embedding results
for (let rank = 0; rank < embeddingResults.length; rank++) {
const r = embeddingResults[rank];
const id = r.record_id;
const rrfScore = 1 / (RRF_K + rank + 1);
const existing = mergedMap.get(id);
if (existing) {
existing.rrfScore += rrfScore;
} else {
mergedMap.set(id, { rrfScore, formatable: vectorResultToFormatable(r) });
}
}
// Sort by combined RRF score and take top results
const sorted = [...mergedMap.entries()]
.sort((a, b) => b[1].rrfScore - a[1].rrfScore)
.slice(0, maxResults);
if (sorted.length > 0) {
logger?.debug?.(
`${TAG} Hybrid search found ${sorted.length} results ` +
`(keyword=${keywordResults.length}, embedding=${embeddingResults.length})`,
);
return { lines: sorted.map(([, { formatable }]) => formatMemoryLine(formatable)), timing };
}
logger?.debug?.(`${TAG} Hybrid search: no results after merge`);
return { lines: [], timing };
}
// ============================
// Unified memory line formatter
// ============================
/**
* Format a single memory record into a rich natural-language line for prompt injection.
*
* Time semantics:
* - timestamp (点时间): when the activity/event happened, e.g. "2025-03-01 mentioned something"
* - activity_start_time / activity_end_time (段时间): activity time range, e.g. "trip from 2025-05-01 to 2025-05-10"
* - All three time fields may be empty/undefined — handled gracefully.
*
* Output examples:
* - [persona] 用户叫王小明,30岁,是一名软件工程师。
* - [episodic|旅行计划] 用户计划五月去日本旅行。(活动时间: 2025-05-01 ~ 2025-05-10)
* - [episodic] 用户今天加班到很晚。(活动时间: 2025-03-01)
* - [instruction] 用户要求回答时使用中文,保持简洁。
*/
interface FormatableMemory {
type: string;
content: string;
scene_name?: string;
/** Activity time range start (段时间 start), may be empty */
activity_start_time?: string;
/** Activity time range end (段时间 end), may be empty */
activity_end_time?: string;
/** Activity point-in-time (点时间: when it happened), may be empty */
timestamp?: string;
}
function formatMemoryLine(m: FormatableMemory): string {
// 1. Type tag + optional scene name
const tag = m.scene_name ? `${m.type}|${m.scene_name}` : m.type;
// 2. Content (core)
let line = `- [${tag}] ${m.content}`;
// 3. Time info — prefer activity_start/end range; fall back to timestamp as point-in-time
const start = formatTimestamp(m.activity_start_time);
const end = formatTimestamp(m.activity_end_time);
const point = formatTimestamp(m.timestamp);
if (start && end) {
// 段时间: both start and end
line += ` (活动时间: ${start} ~ ${end})`;
} else if (start) {
// 段时间: only start
line += ` (活动时间: ${start}起)`;
} else if (end) {
// 段时间: only end
line += ` (活动时间: 至${end})`;
} else if (point) {
// 点时间: single timestamp
line += ` (活动时间: ${point})`;
}
// If all three are empty → no time info appended (graceful)
return line;
}
/**
* Format an ISO 8601 timestamp to a concise date or datetime string.
* - If the time part is 00:00:00 → show date only (e.g. "2025-03-01")
* - Otherwise → show date + time (e.g. "2025-03-01 14:30")
* - Returns undefined for empty/invalid inputs.
*/
function formatTimestamp(ts: string | undefined): string | undefined {
if (!ts) return undefined;
// Try to parse ISO format: "2025-03-01T14:30:00.000Z" or "2025-03-01"
const match = ts.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2})(?::\d{2})?)?/);
if (!match) return undefined;
const datePart = match[1];
const timePart = match[2];
if (!timePart || timePart === "00:00") {
return datePart;
}
return `${datePart} ${timePart}`;
}
/**
* Build a FormatableMemory from a full MemoryRecord (keyword search path).
* Handles empty metadata, empty timestamps array gracefully.
*/
function recordToFormatable(record: MemoryRecord): FormatableMemory {
const meta = record.metadata as { activity_start_time?: string; activity_end_time?: string } | undefined;
return {
type: record.type,
content: record.content,
scene_name: record.scene_name || undefined,
activity_start_time: meta?.activity_start_time || undefined,
activity_end_time: meta?.activity_end_time || undefined,
timestamp: (record.timestamps && record.timestamps.length > 0) ? record.timestamps[0] : undefined,
};
}
/**
* Build a FormatableMemory from a VectorSearchResult (embedding search path).
* Handles empty/invalid metadata_json, empty timestamp_str gracefully.
*/
function vectorResultToFormatable(r: VectorSearchResult): FormatableMemory {
let activityStart: string | undefined;
let activityEnd: string | undefined;
if (r.metadata_json && r.metadata_json !== "{}") {
try {
const meta = typeof r.metadata_json === "string" ? JSON.parse(r.metadata_json) : r.metadata_json;
activityStart = meta?.activity_start_time || undefined;
activityEnd = meta?.activity_end_time || undefined;
} catch { /* ignore parse errors — treat as no metadata */ }
}
return {
type: r.type,
content: r.content,
scene_name: r.scene_name || undefined,
activity_start_time: activityStart,
activity_end_time: activityEnd,
timestamp: r.timestamp_str || undefined,
};
}
/**
* Build a FormatableMemory from an FtsSearchResult (FTS5 keyword search path).
* Handles empty/invalid metadata_json, empty timestamp_str gracefully.
*/
function ftsResultToFormatable(r: FtsSearchResult): FormatableMemory {
let activityStart: string | undefined;
let activityEnd: string | undefined;
if (r.metadata_json && r.metadata_json !== "{}") {
try {
const meta = typeof r.metadata_json === "string" ? JSON.parse(r.metadata_json) : r.metadata_json;
activityStart = meta?.activity_start_time || undefined;
activityEnd = meta?.activity_end_time || undefined;
} catch { /* ignore parse errors — treat as no metadata */ }
}
return {
type: r.type,
content: r.content,
scene_name: r.scene_name || undefined,
activity_start_time: activityStart,
activity_end_time: activityEnd,
timestamp: r.timestamp_str || undefined,
};
}
+205
View File
@@ -0,0 +1,205 @@
/**
* PersonaGenerator: generates or updates user persona using the four-layer
* deep scan model via CleanContextRunner.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { CleanContextRunner } from "../utils/clean-context-runner.js";
import { CheckpointManager } from "../utils/checkpoint.js";
import { readSceneIndex } from "../scene/scene-index.js";
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
import { buildPersonaPrompt } from "../prompts/persona-generation.js";
import { BackupManager } from "../utils/backup.js";
import { escapeXmlTags } from "../utils/sanitize.js";
import { report } from "../report/reporter.js";
const TAG = "[memory-tdai] [persona]";
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export class PersonaGenerator {
private dataDir: string;
private runner: CleanContextRunner;
private logger: Logger | undefined;
private backupCount: number;
private instanceId: string | undefined;
constructor(opts: {
dataDir: string;
config: unknown;
model?: string;
backupCount?: number;
logger?: Logger;
/** Plugin instance ID for metric reporting (optional) */
instanceId?: string;
}) {
this.dataDir = opts.dataDir;
this.logger = opts.logger;
this.backupCount = opts.backupCount ?? 3;
this.instanceId = opts.instanceId;
this.runner = new CleanContextRunner({
config: opts.config,
modelRef: opts.model,
enableTools: true,
logger: opts.logger,
});
this.logger?.debug?.(`${TAG} Generator created: model=${opts.model ?? "(default)"}, dataDir=${opts.dataDir}`);
}
/**
* Execute persona generation.
*/
async generate(triggerReason?: string): Promise<boolean> {
const startMs = Date.now();
this.logger?.debug?.(`${TAG} Starting generation: reason="${triggerReason ?? "none"}"`);
const cpManager = new CheckpointManager(this.dataDir);
const cp = await cpManager.read();
this.logger?.debug?.(`${TAG} Checkpoint: total_processed=${cp.total_processed}, last_persona_at=${cp.last_persona_at}`);
const personaPath = path.join(this.dataDir, "persona.md");
// 1. Read existing persona (strip navigation)
let existingPersona: string | undefined;
try {
const raw = await fs.readFile(personaPath, "utf-8");
existingPersona = stripSceneNavigation(raw).trim() || undefined;
this.logger?.debug?.(`${TAG} Existing persona: ${existingPersona ? `${existingPersona.length} chars` : "empty"}`);
} catch {
this.logger?.debug?.(`${TAG} No existing persona file`);
}
// 2. Load scene index + identify changed scenes
const index = await readSceneIndex(this.dataDir);
const changedScenes = index.filter((e) => {
if (!cp.last_persona_time) return true;
const updatedMs = new Date(e.updated).getTime();
const personaMs = new Date(cp.last_persona_time).getTime();
// If either date is unparseable (NaN), treat as changed (conservative)
if (Number.isNaN(updatedMs) || Number.isNaN(personaMs)) return true;
return updatedMs > personaMs;
});
this.logger?.debug?.(`${TAG} Scene index: ${index.length} total, ${changedScenes.length} changed since last persona`);
// 3. Read changed scene contents (full raw content including META, matching Python reference)
const blocksDir = path.join(this.dataDir, "scene_blocks");
const changedSceneContents: string[] = [];
for (const entry of changedScenes) {
try {
const raw = await fs.readFile(path.join(blocksDir, entry.filename), "utf-8");
changedSceneContents.push(
`### [${changedSceneContents.length + 1}] ${entry.filename}\n\n\`\`\`markdown\n${raw}\n\`\`\``,
);
} catch {
this.logger?.warn(`${TAG} Could not read scene block: ${entry.filename}`);
}
}
if (changedSceneContents.length === 0 && existingPersona) {
this.logger?.debug?.(`${TAG} No scene changes and persona exists, skipping generation`);
return false;
}
// 4. Determine mode
const mode = existingPersona ? "incremental" : "first";
this.logger?.debug?.(`${TAG} Generation mode: ${mode}, ${changedSceneContents.length} scene blocks to process`);
// 5. Build changed scenes section with guidance (matching Python reference format)
let changedScenesContent: string;
if (changedSceneContents.length > 0) {
changedScenesContent =
`\n\n## 📄 变化场景完整内容\n\n` +
`*自上次 Persona 更新后,以下 ${changedSceneContents.length} 个场景发生了变化。工程已为你预加载完整内容:*\n\n` +
changedSceneContents.join("\n\n") +
`\n\n---\n\n` +
`⚠️ **重点分析变化场景**:上述场景是自上次更新后的**新增/修改内容**,请**重点分析**这些场景中的新信息。\n`;
} else {
changedScenesContent = `\n\n⚠️ **无变化场景**:所有场景均已在上次 Persona 更新中分析过,本次可直接读取所有场景进行全局审视。\n`;
}
// 6. Build prompt
const prompt = buildPersonaPrompt({
mode,
currentTime: new Date().toISOString(),
totalProcessed: cp.total_processed,
sceneCount: index.length,
changedSceneCount: changedScenes.length,
changedScenesContent,
existingPersona,
triggerInfo: triggerReason,
personaFilePath: personaPath,
checkpointPath: path.join(this.dataDir, ".metadata", "recall_checkpoint.json"),
});
// 7. Backup before LLM run (LLM writes persona.md via tools)
const bm = new BackupManager(path.join(this.dataDir, ".backup"));
await bm.backupFile(personaPath, "persona", `offset${cp.total_processed}`, this.backupCount);
// 8. Run LLM agent (sandboxed to dataDir, tools enabled — LLM writes persona.md directly)
try {
this.logger?.debug?.(`${TAG} Calling LLM for persona generation (timeout=180s, tools=enabled, workspaceDir=${this.dataDir})...`);
await this.runner.run({
prompt,
taskId: "persona-generation",
timeoutMs: 180_000,
// maxTokens omitted → core uses the resolved model's maxTokens from catalog
workspaceDir: this.dataDir,
});
this.logger?.debug?.(`${TAG} LLM runner completed`);
} catch (err) {
const elapsedMs = Date.now() - startMs;
this.logger?.error(`${TAG} Persona generation failed after ${elapsedMs}ms: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
return false;
}
// 9. Read LLM-written persona.md and apply post-processing
let personaText: string;
try {
personaText = await fs.readFile(personaPath, "utf-8");
} catch {
// LLM failed to write persona.md — treat as failure
this.logger?.error(`${TAG} LLM did not write persona.md — file not found after runner completed`);
return false;
}
// 10. Strip any navigation the LLM might have added + sanitize for safe injection
personaText = escapeXmlTags(stripSceneNavigation(personaText).trim());
if (!personaText) {
this.logger?.error(`${TAG} LLM wrote empty persona.md — skipping`);
return false;
}
// 11. Append fresh scene navigation and write final content
const nav = generateSceneNavigation(index);
const finalContent = nav ? `${personaText}\n\n${nav}\n` : personaText;
await fs.writeFile(personaPath, finalContent, "utf-8");
// 12. Update checkpoint
await cpManager.markPersonaGenerated(cp.total_processed);
const elapsedMs = Date.now() - startMs;
this.logger?.info(`${TAG} Persona written (${finalContent.length} chars) in ${elapsedMs}ms`);
// ── l3_persona_generation metric ──
if (this.instanceId && this.logger) {
report("l3_persona_generation", {
triggerReason: triggerReason ?? "unknown",
mode: existingPersona ? "incremental" : "initial",
newPersonaContent: personaText,
newPersonaLength: personaText.length,
totalDurationMs: elapsedMs,
success: true,
error: null,
});
}
return true;
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* PersonaTrigger: determines whether to trigger persona generation.
* Implements the 5 trigger conditions from the legacy system.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { CheckpointManager } from "../utils/checkpoint.js";
import { stripSceneNavigation } from "../scene/scene-navigation.js";
const TAG = "[memory-tdai] [trigger]";
interface TriggerLogger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export interface TriggerResult {
should: boolean;
reason: string;
}
export class PersonaTrigger {
private dataDir: string;
private interval: number;
private logger: TriggerLogger | undefined;
constructor(opts: { dataDir: string; interval: number; logger?: TriggerLogger }) {
this.dataDir = opts.dataDir;
this.interval = opts.interval;
this.logger = opts.logger;
}
async shouldGenerate(): Promise<TriggerResult> {
const cpManager = new CheckpointManager(this.dataDir);
const cp = await cpManager.read();
this.logger?.debug?.(`${TAG} Evaluating: total_processed=${cp.total_processed}, last_persona_at=${cp.last_persona_at}, memories_since=${cp.memories_since_last_persona}, scenes=${cp.scenes_processed}`);
// Priority 1: Agent explicitly requested persona update
if (cp.request_persona_update) {
const result: TriggerResult = {
should: true,
reason: `主动请求: ${cp.persona_update_reason || "Agent 请求更新"}`,
};
this.logger?.debug?.(`${TAG} Trigger P1 (explicit request): ${result.reason}`);
return result;
}
// Priority 2: Cold start — first extraction done, no persona yet, has scene files
if (
cp.scenes_processed > 0 &&
cp.last_persona_at === 0 &&
(await this.hasSceneFiles())
) {
const result: TriggerResult = { should: true, reason: "首次冷启动:首次提取完成且有场景文件" };
this.logger?.debug?.(`${TAG} Trigger P2 (cold start): scenes_processed=${cp.scenes_processed}, total_processed=${cp.total_processed}`);
return result;
}
// Priority 2.5: Recovery — persona was generated before but persona.md body
// is now empty (corrupted/missing). Regenerate to restore.
if (
cp.last_persona_at > 0 &&
(await this.hasSceneFiles()) &&
!(await this.hasPersonaBody())
) {
const result: TriggerResult = { should: true, reason: "恢复:persona.md 正文丢失或为空,需要重新生成" };
this.logger?.debug?.(`${TAG} Trigger P2.5 (recovery): last_persona_at=${cp.last_persona_at}, persona body missing`);
return result;
}
// Priority 3: First scene block extraction
if (cp.scenes_processed === 1 && cp.memories_since_last_persona > 0) {
const result: TriggerResult = { should: true, reason: "首次 Scene Block 提取完成" };
this.logger?.debug?.(`${TAG} Trigger P3 (first scene): scenes_processed=${cp.scenes_processed}`);
return result;
}
// Priority 4: Reached threshold
if (cp.memories_since_last_persona >= this.interval) {
const result: TriggerResult = {
should: true,
reason: `达到阈值: ${cp.memories_since_last_persona} >= ${this.interval}`,
};
this.logger?.debug?.(`${TAG} Trigger P4 (threshold): ${result.reason}`);
return result;
}
this.logger?.debug?.(`${TAG} No trigger conditions met`);
return { should: false, reason: "" };
}
private async hasSceneFiles(): Promise<boolean> {
const blocksDir = path.join(this.dataDir, "scene_blocks");
try {
const files = await fs.readdir(blocksDir);
const hasFiles = files.some((f) => f.endsWith(".md"));
return hasFiles;
} catch {
return false;
}
}
/**
* Check whether persona.md has a non-empty body (excluding scene navigation).
* Returns false if the file doesn't exist, is empty, or only contains
* scene navigation (no actual persona content).
*/
private async hasPersonaBody(): Promise<boolean> {
const personaPath = path.join(this.dataDir, "persona.md");
try {
const raw = await fs.readFile(personaPath, "utf-8");
const body = stripSceneNavigation(raw).trim();
return body.length > 0;
} catch {
return false;
}
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* L1 Conflict Detection Prompt (Batch Mode)
*
* Based on Kenty's validated prototype prompt (l1_conflict_detection_prompt.md).
* Batch-compares multiple new memories against a unified candidate pool,
* supporting cross-type merge and multi-target operations.
*/
import type { MemoryRecord, ExtractedMemory } from "../record/l1-writer.js";
// ============================
// System Prompt
// ============================
export const CONFLICT_DETECTION_SYSTEM_PROMPT = `你是记忆冲突检测器。批量比较多条【新记忆】与【统一候选记忆池】中的已有记忆,逐条决定如何处理。
## 核心规则
- **跨 type 合并**:不同 typepersona / episodic / instruction)的记忆如果语义上描述同一事实/事件,**可以合并**。
- **多对多合并**:一条新记忆可以同时替换/合并候选池中的**多条**已有记忆(通过 target_ids 数组指定)。
- 合并后你必须判断新记忆的最佳 type(merged_type)。
## 判断逻辑
1. **分辨记忆性质**
- **状态类**persona/instruction):偏好、特质、长期设定、相对稳定的事实、行为规则
- **事件类**(episodic):一次性经历、带时间点的客观记录,建议合并同一件事的前因后果
2. **判断是否同一事实/事件**:主体相同、主题一致、时间接近、scene_name 相似
3. **选择动作**
- "store":视为新信息,新增当前记忆。
- "skip":已有记忆更好,新记忆无增量或更模糊,忽略当前记忆。
- "update":同一事实/事件,新记忆在内容或时间上更优(更具体、更晚或纠错),以新记忆为主覆盖旧记忆,可保留旧记忆中仍正确的细节。
- "merge":同一事实或同一演化过程,多条记忆信息互补且不矛盾,合并成一条更完整记忆,信息尽量不冗余。
4. **策略倾向**
- 状态类:多条描述同一偏好/特质 → 倾向 merge;无增量 → skip;明确更新 → update
- 事件类:同一事件的前因后果、不同阶段 → 倾向 merge 为一条完整叙述;完全相同 → skip
- 跨类型示例:一条 episodic "用户在 2018 年开始做播客" + 一条 persona "用户有播客制作经验" → 可 merge 为一条 persona 或 episodic(取决于信息侧重)
5. **timestamp 处理**
- merge / update 时,merged_timestamps 应包含**所有相关记忆的时间戳并集**(去重排序)
- 这样可以保留事件发生的完整时间线
## 输出格式
严格输出 JSON 数组,每个元素对应一条新记忆的决策。不输出任何其他内容:
[
{
"record_id": "新记忆的 record_id",
"action": "store|update|skip|merge",
"target_ids": ["要删除的候选记忆 record_id 1", "record_id 2"],
"merged_content": "合并/更新后的记忆内容(merge/update 时必填)",
"merged_type": "合并后的最佳 typepersona|episodic|instructionmerge/update 时必填)",
"merged_priority": 85,
"merged_timestamps": ["合并后的时间戳数组,包含所有新旧记忆时间戳的并集(merge/update 时必填)"]
}
]
字段说明:
- target_ids:要删除替换的旧记忆 ID **数组**(可以 1 条或多条)。store/skip 时省略或为空。
- merged_contentmerge/update 时的最终记忆文本。store/skip 时省略。
- merged_typemerge/update 后记忆应归属的 type。根据合并后内容本质判断。
- merged_prioritymerge/update 后的新优先级(0-100 整数,merge/update 时必填)。合并后信息更完整、更确定,通常应**酌情提升** priority(例如两条 priority 70 的记忆合并后可提升到 80)。参考标准:80-100(核心特质/重要事件),60-79(一般偏好/普通活动),<60(次要信息)。
- merged_timestamps:合并后的时间戳数组。收集新记忆 + 所有被合并旧记忆的时间戳,去重排序。`;
// ============================
// Prompt Builder
// ============================
/**
* Candidate search result for a single new memory.
*/
export interface CandidateMatch {
newMemory: ExtractedMemory & { record_id: string };
candidates: MemoryRecord[];
}
/**
* Format the batch conflict detection prompt using a unified candidate pool.
*
* Format (aligned with prototype):
* 1. Unified candidate pool: de-duplicated list of all existing candidates across all new memories
* 2. Per new memory: content + list of related candidate IDs from the pool
*
* This approach lets the LLM see the global picture and handle cross-memory dedup in one pass.
*
* @param matches - Array of new memories with their candidate matches
*/
export function formatBatchConflictPrompt(matches: CandidateMatch[]): string {
// Step 1: Build unified candidate pool (de-duplicate across all new memories)
const unifiedPool = new Map<string, MemoryRecord>();
const perMemoryCandidateIds = new Map<string, string[]>();
for (const m of matches) {
const candidateIds: string[] = [];
for (const c of m.candidates) {
if (!unifiedPool.has(c.id)) {
unifiedPool.set(c.id, c);
}
candidateIds.push(c.id);
}
perMemoryCandidateIds.set(m.newMemory.record_id, candidateIds);
}
// Step 2: Format unified pool as JSON
const poolList = Array.from(unifiedPool.values()).map((c) => ({
record_id: c.id,
content: c.content,
type: c.type,
priority: c.priority,
scene_name: c.scene_name,
timestamps: c.timestamps,
}));
let poolSection: string;
if (poolList.length === 0) {
poolSection = "## 统一候选记忆池\n\n(空,没有已有记忆,所有新记忆直接 store)";
} else {
const poolStr = JSON.stringify(poolList, null, 2);
poolSection = `## 统一候选记忆池(共 ${poolList.length} 条已有记忆)\n\n${poolStr}`;
}
// Step 3: Format each new memory with its related candidate IDs
const memoryParts = matches.map((m, idx) => {
const relatedIds = perMemoryCandidateIds.get(m.newMemory.record_id) ?? [];
const relatedNote =
relatedIds.length > 0
? JSON.stringify(relatedIds)
: "[](无相似候选,直接 store";
const memStr = JSON.stringify(
{
record_id: m.newMemory.record_id,
content: m.newMemory.content,
type: m.newMemory.type,
priority: m.newMemory.priority,
scene_name: m.newMemory.scene_name,
},
null,
2,
);
return `### 第 ${idx + 1} 条新记忆 (record_id: ${m.newMemory.record_id})\n${memStr}\n\n【关联候选 ID】${relatedNote}`;
});
const newMemoriesText = memoryParts.join(
"\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n",
);
// Step 4: Assemble final prompt
return `${poolSection}
${"═".repeat(50)}
## 待判断的新记忆(共 ${matches.length} 条)
${newMemoriesText}
请逐条判断并输出决策 JSON 数组。当某条新记忆的候选列表为空时,该条直接输出 action=store。`;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* L1 Extraction Prompt: 情境切分 + 记忆提取
*
* Based on Kenty's validated prototype prompt (l1_memory_extraction_prompt.md).
* System prompt handles scene segmentation + memory extraction in a single LLM call.
* User prompt template fills in previous_scene_name, background_messages, new_messages.
*/
import type { ConversationMessage } from "../conversation/l0-recorder.js";
// ============================
// System Prompt
// ============================
export const EXTRACT_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与记忆提取专家"。
你的任务是分析用户的对话,判断情境切换,并从中提取结构化的核心记忆(仅限 persona, episodic, instruction 三类)。
### 任务一:情境切分(Scene Segmentation
分析【待提取的新消息】,结合【上一个情境】,判断并输出当前对话的情境。
- 继承:无明显切换,沿用上一个情境。
- 切换条件:用户发出明确指令(如"换话题")、意图转变、或提出独立新目标。
- 一段对话可能只有一个情境,也可能有多个情境(话题多次切换时)。
- 命名规则:"我(AI)在和xxx(用户身份)做xxx(目标活动)"(中文,30-50字,单句,全局唯一)。
---
### 任务二:核心记忆提取(Memory Extraction
结合背景和当前情境,仅从【待提取的新消息】中提取核心信息。
【通用提取原则】
1. 宁缺毋滥:过滤琐碎闲聊、临时性指令和一次性操作(如"这次、本单");剔除不可靠的边缘信息。
2. 独立完整:记忆必须"跳出当前对话依然成立",无上下文也能看懂。提取主体必须以"用户(姓名)"或"AI"为核心。
3. 归纳合并:强关联或因果关系的多条消息,必须合并为一条完整记忆,不可碎片化。
【支持提取的三大类型】(必须严格遵守类型规则)
1. 个性化记忆 (type: "persona")
- 定义:用户的稳定属性、偏好、技能、价值观、习惯(如住所、职业、饮食禁忌)。
- 提取句式:"用户([姓名])喜欢/是/擅长..."
- 打分 (priority)80-100(健康/禁忌/核心特质);50-70(一般喜好/技能);<50(模糊次要,可丢弃)。
- 触发词:喜欢、习惯、经常、我这个人...
2. 客观事件记忆 (type: "episodic")
- 定义:客观发生的动作、决定、计划或达成结果。绝不包含纯主观感受。
- 提取句式:"用户([姓名])在 [最好是精确绝对时间] 于 [地点] [做了某事(可以包含起因、经过、结果)]"。
- 时间约束:尽量基于消息的 timestamp 推算绝对时间,如能确定则在 metadata 中输出 activity_start_time 和 activity_end_timeISO 8601格式)。无法确定时可省略。
- 打分 (priority)80-100(重要事件/计划);60-70(一般完整活动);<60(琐碎事项,直接丢弃)。
3. 全局指令记忆 (type: "instruction")
- 定义:用户对 AI 提出的长期行为规则、格式偏好、语气控制。
- 提取句式:"用户要求/希望 AI 以后回答时..."
- 触发词:以后都、从现在开始、记住、必须。
- 打分 (priority)-1(极其严格的全局死命令);90-100(核心行为规则);70-80(重要要求);<70(临时要求,直接丢弃)。
---
### 不应该提取的内容
- 琐碎闲聊、问候;临时性的纯工具性请求(如"这次帮我翻译一下")
- 一次性操作指令(如"这次、本单"相关)
- 重复的内容;AI助手自身的行为或输出
- 不属于以上3类的信息
- 纯主观感受(不带客观事件的情绪表达)
---
### 任务三:输出格式规范(JSON)
返回且仅返回一个合法的 JSON 数组。数组的每一项是一个情境,包含该情境的消息范围和抽取到的记忆:
[
{
"scene_name": "当前生成或继承的情境名称",
"message_ids": ["属于该情境的消息ID列表"],
"memories": [
{
"content": "完整、独立的记忆陈述(按对应类型的句式要求)",
"type": "persona|episodic|instruction",
"priority": 80,
"source_message_ids": ["消息ID_1", "消息ID_2"],
"metadata": {}
}
]
}
]
metadata 字段说明:
- episodic 类型:如能确定活动时间,填入 {"activity_start_time": "ISO8601", "activity_end_time": "ISO8601"}
- 其他类型或无法确定时间:输出空对象 {}
如果整段对话无有意义的记忆,也要输出情境分割结果,memories 为空数组:
[
{
"scene_name": "情境名称",
"message_ids": ["id1", "id2"],
"memories": []
}
]
请严格按上述 JSON 数组格式输出,不要输出任何额外的 Markdown 代码块修饰符(如 \`\`\`json)或解释文本。`;
// ============================
// Prompt Builder
// ============================
/**
* Format the user prompt for L1 extraction.
*
* @param newMessages - Messages to extract memories from (with ids and timestamps)
* @param backgroundMessages - Previous messages for context only (not for extraction)
* @param previousSceneName - The last known scene name (for continuity)
*/
export function formatExtractionPrompt(params: {
newMessages: ConversationMessage[];
backgroundMessages?: ConversationMessage[];
previousSceneName?: string;
}): string {
const { newMessages, backgroundMessages = [], previousSceneName = "无" } = params;
const bgText = backgroundMessages.length > 0
? backgroundMessages
.map((m) => `[${m.id}] [${m.role}] [${new Date(m.timestamp).toISOString()}]: ${m.content}`)
.join("\n\n")
: "无";
const newText = newMessages
.map((m) => `[${m.id}] [${m.role}] [${new Date(m.timestamp).toISOString()}]: ${m.content}`)
.join("\n\n");
return `【上一个情境】:${previousSceneName}
【背景对话】(仅供理解上下文推断关系/时间,严禁从中提取记忆):
${bgText}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【待提取的新消息】(务必结合 timestamp 推算时间,只从这里提取记忆!):
${newText}`;
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Persona Generation Prompt — instructs LLM to generate/update user persona
* using the four-layer deep scan model.
*
* v2: Updated prompt with anti-hallucination guardrails, richer output
* template with per-chapter writing guidance, and streamlined iteration guide.
*/
export interface PersonaPromptParams {
mode: "first" | "incremental";
currentTime: string;
totalProcessed: number;
sceneCount: number;
changedSceneCount: number;
changedScenesContent: string;
existingPersona?: string;
triggerInfo?: string;
/** @deprecated Kept for call-site compatibility; no longer used in prompt. */
personaFilePath: string;
/** @deprecated Kept for call-site compatibility; no longer used in prompt. */
checkpointPath: string;
}
export function buildPersonaPrompt(params: PersonaPromptParams): string {
const {
mode,
currentTime,
totalProcessed,
sceneCount,
changedSceneCount,
changedScenesContent,
existingPersona,
triggerInfo,
} = params;
const modeLabel = mode === "first" ? "🆕 首次生成" : "🔄 迭代更新";
const triggerSection = triggerInfo
? `\n### 触发信息\n${triggerInfo}\n`
: "";
// Existing persona is now placed AFTER scene blocks (closer to the
// processing instructions) so the LLM sees fresh evidence first.
const existingPersonaSection = existingPersona
? `\n## 📄 当前 Persona(工程已预加载)\n\n` +
`*以下是现有 persona.md 的完整内容(${existingPersona.length} 字符),基于此更新后请控制在2000字内:*\n\n` +
`\`\`\`markdown\n${existingPersona}\n\`\`\`\n\n---\n`
: "";
const iterationGuide = mode === "incremental"
? `\n## 🔄 迭代决策指南\n\n` +
`面对变化场景,自主判断处理方式:强化(佐证已有洞察)/ 补充(新维度)/ 修正(矛盾)/ 重构(结构调整)/ 不改(无有用新增内容)。\n`
: "";
return `# 🧬 Persona Architect - Incremental Evolution Protocol
**⏰ 更新时间**: ${currentTime}
**模式**: ${modeLabel}
请你结合已有的 persona.md 和新增/变化的 block 信息深度分析,然后使用文件工具将结果写入 \`persona.md\` 文件。
## ⛔ 文件操作约束(必须严格遵守)
1. **必须使用文件工具将最终 persona 内容写入 \`persona.md\`**。当前工作目录已设为数据目录,直接使用文件名 \`persona.md\`
- **首次生成 / 大幅重写**:使用 \`write_to_file('persona.md', 完整内容)\` 整体写入
- **增量更新(局部修改)**:使用 \`replace_in_file('persona.md', 旧内容片段, 新内容片段)\` 精确替换,可节省开销
2. **只能操作 \`persona.md\` 这一个文件**,禁止读取或写入任何其他文件(包括 scene_blocks/、.metadata/ 等)。
3. **写入的内容必须只包含最终的 persona 文档**,不要包含你的思考过程、分析步骤或任何非 persona 内容。
4. **无需 read_file**:当前 persona.md 的完整内容已在下方提供,直接基于它进行更新即可。
### 🚫 严格禁止
- **禁止过长**persona.md 内容总长度不要超过 2000 字符,及时做总结和删除不重要的信息。
- **禁止过度推测**:没提到的信息不要过度臆想导致产生幻觉,特别是在冷启动阶段,要保持克制,如果没有相关信息完全可以不填!
- **禁止使用非场景来源的信息**:Persona 的所有内容必须且只能来自下方提供的场景数据。不要从 workspace 目录结构、文件路径、系统信息等技术元数据中提取任何关于用户的个人信息。
- **禁止操作 persona.md 以外的任何文件**。
---
${triggerSection}
## 📊 统计
- **总记忆数**: ${totalProcessed}
- **场景总数**: ${sceneCount}
- **变化场景**: ${changedSceneCount} 个(自上次更新后)
---
${changedScenesContent}
${existingPersonaSection}
## ⚙️ 核心运作逻辑 (The Core Logic)
🧠 核心思维引擎:连接与综合 (Connect & Synthesize)
请遵循 "叙事连贯性" 原则处理信息。禁止简单的罗列(No Bullet-point Spamming)。
1. 寻找"贯穿线" (The Connecting Thread)
不要孤立地看信息。要寻找不同领域行为背后的共同逻辑。
** 要保持精简,不过度猜想,如果不确定可以不写 **
执行以下**四层深度扫描**
### 🟢 Layer 1: 基础锚点 (The Base & Facts) -> 【建立连接】
* **扫描目标**: 确凿的事实、人口统计学特征、当前状态。
* **实用价值**: 为 Agent 提供**破冰话题**和**上下文感知**。
### 🔵 Layer 2: 兴趣图谱 (The Interest Graph) -> 【提供谈资】
* **扫描目标**: 用户投入时间、金钱或注意力的事物。
* **提取原则**: **区分活跃度**(活跃爱好 / 被动消费 / 休眠兴趣)。
* **实用价值**: 让 Agent 能够进行**高质量的闲聊 (Chit-chat)** 和 **生活推荐**。
### 🟡 Layer 3: 交互协议 (The Interface) -> 【消除摩擦】
* **扫描目标**: 用户的沟通习惯、雷区、工作流偏好。
* **实用价值**: 指导 Agent **如何说话、如何交付结果**,避免踩雷。
### 🔴 Layer 4: 认知内核 (The Core) -> 【深度共鸣】
* **扫描目标**: 决策逻辑、矛盾点、终极驱动力。
* **实用价值**: 让 Agent 成为**能够替用户做决策**的"副驾驶"。
${iterationGuide}
---
## 📝 输出模板 (The Persona Template)
请参考以下格式,使用 \`write_to_file('persona.md', ...)\` 写入最终内容。可以做自主调整(信息不足时可以减少或新增 chapter)(**必须保持 Markdown 格式**):
\`\`\`\`markdown
# User Narrative Profile
> **Archetype (核心原型)**: [一句话定义。例如:一位在现实重力下挣扎,但试图通过技术构建理想国的"务实理想主义者"。]
> **基本信息**
(用户的基本信息,如年龄、性别、职业等,更新时若有冲突则覆盖,不冲突尽量叠加)
-
-
> **长期偏好**
(你观察到的用户最稳定且可复用的偏好)
-
-
## 📖 Chapter 1: Context & Current State (全景语境)
*(将基础事实与当前状态融合,写成一段连贯的背景介绍)*
**[这里写连贯描述,区别较大的时候可以分点阐述]**
## 🎨 Chapter 2: The Texture of Life (生活的肌理)
*(将兴趣、消费、生活习惯串联起来,展示生活品味)*
**[这里写连贯的描述,重点在于"兴趣/偏好"和"品味"的统一性,区别较大的时候可以分点阐述]**
## 🤖 Chapter 3: Interaction & Cognitive Protocol (交互与认知协议)
*(这是 Main Agent 的行动指南。为了实用,这里保持半结构化,但要解释"为什么")*
### 3.1 沟通策略 (How to Speak)
### 3.2 决策逻辑 (How to Think)
## 🧩 Chapter 4: Deep Insights & Evolution (深层洞察与演变)
*(人类学观察笔记)*
* **矛盾统一性**: [描述用户身上看似冲突但实则合理的特质]。
* **演变轨迹**: [可加上时间,分为多点,描述用户最近发生的变化]。
* **涌现特征**: 提炼 3-7 个最核心的特质标签,每个标签单独一行并附上简短注释(10-15字)
- \`TagName\` - 简短注释说明
\`\`\`\`
---
### ⚠️ 成功标准
- ✅ **必须使用 \`write_to_file\`\`replace_in_file\` 工具写入最终结果到 \`persona.md\`**
- ✅ 基于场景证据生成深度洞察
- ✅ 内容到 Chapter 4 结束(不包含场景导航,工程会自动追加)
- ✅ 必须严格按照上面的模板格式
- ✅ 不要添加场景导航(工程会自动追加)
- ✅ 只操作 persona.md,不要操作其他文件`;
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Scene Extraction Prompt — instructs LLM to consolidate memories into scene blocks
* using file tools (read_file, write_to_file, replace_in_file).
*
* Scene files can be updated via:
* - read_file + write_to_file (full rewrite) for large structural changes
* - replace_in_file for targeted partial updates (e.g. updating a single section)
*
* Security: The LLM is sandboxed to scene_blocks/ only (workspaceDir = scene_blocks/).
* It has NO visibility into checkpoint, scene_index, persona.md, or any other system file.
* File deletion is achieved via "soft-delete" — writing an empty string to the file
* — and the SceneExtractor subsequently removes empty files with fs.unlink.
*
* Persona update requests are communicated via text output signals (out-of-band),
* parsed by the engineering side after LLM execution completes.
*/
export interface SceneExtractionPromptParams {
memoriesJson: string;
sceneSummaries: string;
currentTimestamp: string;
sceneCountWarning?: string;
/** List of existing scene filenames (relative, e.g. ["work.md", "hobby.md"]) */
existingSceneFiles?: string[];
}
export function buildSceneExtractionPrompt(params: SceneExtractionPromptParams): string {
const {
memoriesJson,
sceneSummaries,
currentTimestamp,
sceneCountWarning,
existingSceneFiles,
} = params;
const warningSection = sceneCountWarning
? `\n⚠️ **场景数量警告**: ${sceneCountWarning}\n`
: "";
return `# System Prompt: Memory Consolidation Architect
## 角色定义 (Role Definition)
你是记忆整合架构师。你的目标是为用户构建一个"数字第二大脑"。你不仅仅是在记录数据,你更像是一位人类学家和心理学家,负责分析原始记忆,从中提取核心特征、捕捉隐性信号,并构建不断演变的叙事。
## 架构模型
### Layer 1 (Input): Raw Memories
- **来源**:API 分批召回(每批 20 条)
- **状态**:碎片化、无序
### Layer 2 (Processing): Scene Diaries
- **形态**:**不是清单,是连贯的叙事文档**
- **逻辑**:将 L1 碎片融合进特定场景文件(强制限制在15个以内)
- **动作**Create(创建)、Integrate(整合)、Rewrite(重写)
- **禁止**:简单追加列表
你主要负责L1到L2的生成任务
${warningSection}
## 输入环境 (Input Context)
你将接收三个输入:
1. 新增记忆 (New Memory): 一段原始的、非结构化的新近回忆信息。
2. 现有 Block 映射表 (Existing Blocks Map): 包含当前所有记忆块(Markdown 文件)的文件名和摘要的列表。
3. 当前时间 (Current Time): 用于生成元数据的具体时间戳。
### 1️⃣ New Memories List
${memoriesJson}
### 2️⃣ Existing Scene Blocks Summary
${sceneSummaries}
### 3️⃣ Current Timestamp
${currentTimestamp}
${existingSceneFiles && existingSceneFiles.length > 0
? `### 📁 已有场景文件清单(仅以下文件可 read_file)
${existingSceneFiles.map((f) => `- \`${f}\``).join("\n")}
`
: `### 📁 已有场景文件清单
(当前无已有场景文件)
`}
## ⛔ 文件操作约束(必须严格遵守)
1. **所有文件操作使用相对文件名**(如 \`技术研究-Rust学习.md\`),当前工作目录已设为场景文件目录
2. **read_file 只能读取上面"已有场景文件清单"中列出的文件**,禁止猜测或编造不在清单中的文件名
3. **创建新场景文件时**,直接使用文件名,如 \`新场景名.md\`
4. **场景文件支持 replace_in_file**。对于局部更新(如只更新某个章节或 META 字段),可以使用 \`replace_in_file\` 进行精确替换。对于大范围重写或结构性变更,建议使用 \`read_file\` + \`write_to_file\` 整体重写。
5. **场景索引和系统配置由工程系统自动维护**,你只需专注于操作 \`.md\` 场景文件
## 工作流与逻辑 (Workflow & Logic)
在生成输出之前,你必须执行以下"思维链"过程:
### ⚠️ 阶段 0:强制检查场景总数(必须先执行)
**在处理任何记忆之前,你必须:**
1. **统计当前场景总数**:检查 "Existing Scene Blocks Summary" 中的场景数量
2. **遵守分级预警,上限为15个block**:
- 红色预警(≥ 15):**必须先合并**,将最相似的 2-4 个场景合并为 1 个,然后再处理新记忆
- 橙色预警(= 15-1):**只能 UPDATE 现有场景,不能 CREATE 新场景**
- 黄色预警(接近15):**优先 UPDATE 或主动 MERGE 相似场景**
**合并优先级**(当需要合并时,按以下顺序选择):
1. **主题高度重叠**:如"Python后端开发"和"Go后端开发" → 合并为"后端开发技术栈"
2. **叙事弧线相同**:如"求职材料-JD匹配"和"职业发展-能力对齐" → 合并为"职业发展与求职"
3. **热度最低的场景**:如果没有明显重叠,合并或删除 heat 最低的 2-3 个场景
### 阶段 1:分析与分类
分析 新增记忆。它的核心领域是什么?(例如:编程风格、情绪状态、职业轨迹、人际关系)。
提取事实事件链(触发 -> 行动 -> 结果)以及底层的心理状态。
### 阶段 2:检索与策略选择
将新记忆与 现有 Block 映射表 进行比对。
需要时使用 \`read_file\` 工具读取完整场景文件内容
**只能读取上面"已有场景文件清单"中列出的文件,禁止猜测其他文件路径。**
**核心原则:默认策略是 UPDATE,不是 CREATE。** 当犹豫于 UPDATE 和 CREATE 之间时,选择 UPDATE。
策略选择(按优先级排序):
1. **UPDATE(更新)**【首选策略】: 如果存在相关的 Block(基于摘要或文件名的相似性),先 read 文件内的具体信息,再锁定该 Block 进行更新(write 或 replace
2. **MERGE(合并)**:
- 合并的新 block 应该是生成概括性更强的场景,包含已有的多个相似场景
- **强制合并**:当前 Block 总数 **≥ 上限**时,必须先将多个相似记忆合并,或者删除最旧或者最不重要的 block
- **主动合并**:即使未达上限,如果两个 Block 属于同一叙事弧线,也应合并以增加深度
3. **CREATE(新建)**【最后手段】:
- **前提条件**:当前场景总数未达上限
- **CREATE 前的强制验证**:必须先用 \`read_file\` 检查至少 2 个最相似的现有场景,确认新记忆确实无法融入后才能 CREATE。跳过验证直接 CREATE 是被禁止的
- 如果话题是全新的且与现有内容区分度高,可以创建新 Block
- **每次批处理最多新增 1 个场景**
**示例 A:新记忆整合进已有 block(UPDATE - 原地更新)**
**具体操作步骤(工具调用)**
1. \`read_file('Python后端开发.md')\` → 获取已有内容 A
2. 分析新记忆 + 已有内容 A → 整合生成新内容 B(\`heat = 旧heat + 1\`
3. \`write_to_file('Python后端开发.md', B)\` → **整体重写该场景文件**
\`replace_in_file('Python后端开发.md', old_section, new_section)\` → **局部更新某部分**
合并多个 block 的逻辑:
**具体操作步骤(工具调用)**
1. \`read_file('Python后端开发.md')\` → 获取内容 A
2. \`read_file('Go后端开发.md')\` → 获取内容 B
3. 整合 A + B + 新记忆 → 生成新内容 C(\`heat = heatA + heatB + 1\`
4. \`write_to_file('后端开发技术栈.md', C)\` → 创建新文件,写入合并后的完整内容
5. \`write_to_file('Python后端开发.md', '')\` → **清空旧文件 A(标记删除)**
6. \`write_to_file('Go后端开发.md', '')\` → **清空旧文件 B(标记删除)**
### 阶段 3:撰写与合成(核心任务)
深度整合: 严禁简单的文本追加。你必须结合上下文(基于摘要或提供的原始内容)重写叙事,将新信息自然地融入其中。
隐性推断: 寻找用户 没说出口 的信息。更新"隐性信号"部分。
冲突检测: 如果新记忆与旧记忆相矛盾,将其记录在"演变轨迹"或"待确认/矛盾点"中。
### 撰写准则 (严格遵守)
核心部分禁止列表: "用户核心特征"和"核心叙事"必须是连贯的段落,信息要连贯,可以分段。
叙事弧线: "核心叙事"必须遵循故事结构(情境 -> 行动 -> 结果)。
### 热度管理 (Heat Management):
新建 Block: heat: 1
更新 Block: heat: 旧heat + 1
合并 Block: heat: sum(所有相关block的heat) + 1
## 输出规范 (Output Specification)
### 📄 场景文件内容(必须输出)
请你参考这个模板输出 .md 文件的内容或基于已有md进行更新,每个md控制在1500字符内。不要把模板本身放在 Markdown 代码块中,只需直接输出要写入文件的原始文本。
\`\`\`markdown
-----META-START-----
created: {{EXISTING_CREATED_TIME_OR_CURRENT_TIME}}
updated: {{CURRENT_TIME}}
summary: [30-40 words concise summary for indexing]
heat: [Integer]
-----META-END-----
## 用户基础信息
[可为空,如果没有可不写这节,可按照需求添加更多点,合并和更新方式尽量叠加,有冲突则覆盖]
-姓名:
-职业:
-居住地:
- ……
## 用户核心特征
[这里不是列表!是一段连贯的描述。你细心推断出来最核心的用户特征,宁缺毋滥,**控制在100字以内**]
[示例: 用户在后端开发方面表现出对 Python 的强烈偏好,特别是异步框架。近期(2026-02)开始关注 Rust 的所有权机制,这表明用户有向系统级编程转型的意图。]
## 用户偏好
[这里可以是列表!**如果没有可以为不写这节**,记录用户明确的偏好信息(显性偏好),注意不要重复信息,不要流水账,偏好要可复用,更新时可以动态整合甚至重写]
[示例:用户喜欢吃苹果]
## 隐性信号
[这是给人类学家看的,记录那些"没明说但很重要"的事,和显性偏好不一样,一定是你推断出来的,需要深思熟虑后再生成,可以为空,宁缺毋滥。你可以随时更新/删除/修改这里的信息]
## 核心叙事
[这里不是列表!是一段连贯的描述,**控制在400字以内**,注意不要重复信息,不要流水账,可以动态整合甚至重写]
*(这里记录连贯的故事,必须包含 Trigger -> Action -> Result)*
[ 示例:本周用户主要集中在后端重构上。初期因为旧代码的耦合度高感到沮丧(**情绪点**),但他拒绝了"打补丁"的建议,坚持进行彻底解耦(**决策点**)。他在此过程中频繁查阅架构设计模式,表现出对"代码洁癖"的执着。]
## 演变轨迹
> [注意] 可以为空,仅记录【用户偏好/性格/重大观念】转变,不记录琐碎、日常更新。当发生冲突时,不要直接覆盖,要记录变化轨迹。
- [2026-01-10]: 从 "反对加班" 转向 "接受弹性工作",原因:创业压力(记忆ID: #987)
## 待确认/矛盾点
- [记录当前无法整合的矛盾信息,等待未来记忆澄清]
\`\`\`
#### 主动触发 Persona 更新(可选)
**触发条件**:重大价值观转变、跨场景突破性洞察。
**触发方式**:在你的 text output 中输出以下标记(不是文件操作):
[PERSONA_UPDATE_REQUEST]
reason: 具体原因描述
[/PERSONA_UPDATE_REQUEST]
**执行文件操作**(必须使用工具):
- 使用 \`read_file\` 读取需要更新的场景文件
- 使用 \`write_to_file\` 创建新文件或**整体重写**已有场景文件
- 使用 \`replace_in_file\` 对场景文件进行**局部更新**(如只更新某个章节)
- **删除文件**:使用 \`write_to_file(filename, '')\` 将文件内容清空(系统会自动清理空文件,禁止使用其他删除方式)`;
}
+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}`;
}
+95
View File
@@ -0,0 +1,95 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
export const REPORT_CONST = {
PLUGIN: "plugin",
} as const;
export type ReportPayload = Record<string, unknown>;
export interface IReporter {
reportFunc(category: string, payload: ReportPayload): void;
}
// ── Singleton ──
let _reporter: IReporter | undefined;
export function initReporter(opts: {
enabled: boolean;
type: string;
logger: { info: (msg: string) => void };
instanceId: string;
pluginVersion: string;
}): void {
if (_reporter) return;
if (!opts.enabled) return;
switch (opts.type) {
case "local":
_reporter = new LocalReporter(opts.logger, opts.instanceId, opts.pluginVersion);
break;
// TODO: add new reporter type
default:
opts.logger.info(`[memory-tdai] Unknown reporter type "${opts.type}", disabled reporting`);
break;
}
}
export function setReporter(reporter: IReporter): void {
_reporter = reporter;
}
export function report(event: string, data: ReportPayload): void {
if (!_reporter) return;
try {
_reporter.reportFunc(REPORT_CONST.PLUGIN, { event, ...data });
} catch { /* never block business logic */ }
}
// ── LocalReporter (default) ──
class LocalReporter implements IReporter {
constructor(
private readonly logger: { info: (msg: string) => void },
private readonly instanceId: string,
private readonly pluginVersion: string,
) {}
reportFunc(category: string, payload: ReportPayload): void {
try {
this.logger.info(JSON.stringify({
tag: "METRIC",
category,
plugin: "memory-tdai",
instanceId: this.instanceId,
pluginVersion: this.pluginVersion,
ts: new Date().toISOString(),
...payload,
}));
} catch { /* swallow */ }
}
}
// ── Instance ID (persisted per-install) ──
let _instanceIdCache: string | undefined;
export async function getOrCreateInstanceId(pluginDataDir: string): Promise<string> {
if (_instanceIdCache) return _instanceIdCache;
const idFile = path.join(pluginDataDir, ".metadata", "instance_id");
try {
const existing = (await fs.readFile(idFile, "utf-8")).trim();
if (existing) {
_instanceIdCache = existing;
return existing;
}
} catch { /* file doesn't exist */ }
const newId = randomUUID();
await fs.mkdir(path.dirname(idFile), { recursive: true });
await fs.writeFile(idFile, newId, "utf-8");
_instanceIdCache = newId;
return newId;
}
+416
View File
@@ -0,0 +1,416 @@
/**
* SceneExtractor: LLM-driven memory extraction into scene blocks.
*
* Replaces the keyword-based SceneManager.processNewMemories() with an
* LLM agent that autonomously reads/writes scene block files using tools.
*
* Security: The LLM is sandboxed — workspaceDir is set to scene_blocks/
* so it can ONLY operate on .md scene files. System files (checkpoint,
* scene_index, persona.md) are physically invisible to the LLM.
*
* Flow:
* 1. Backup + load scene index + build summaries
* 2. Assemble extraction prompt with memories + scene context
* 3. Run via CleanContextRunner (tools enabled, sandboxed to scene_blocks/)
* 4. Cleanup: remove soft-deletes, sync index, update navigation
* 5. Parse LLM text output for out-of-band persona update signals
*/
import fs from "node:fs/promises";
import path from "node:path";
import { CleanContextRunner } from "../utils/clean-context-runner.js";
import { CheckpointManager } from "../utils/checkpoint.js";
import { BackupManager } from "../utils/backup.js";
import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js";
import type { SceneIndexEntry } from "../scene/scene-index.js";
import { parseSceneBlock } from "../scene/scene-format.js";
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
import { buildSceneExtractionPrompt } from "../prompts/scene-extraction.js";
import { report } from "../report/reporter.js";
const TAG = "[memory-tdai] [extractor]";
interface ExtractorLogger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export interface ExtractionResult {
memoriesProcessed: number;
success: boolean;
error?: string;
}
export interface SceneExtractorOptions {
dataDir: string;
config: unknown;
model?: string;
maxScenes?: number;
sceneBackupCount?: number;
timeoutMs?: number;
logger?: ExtractorLogger;
/** Plugin instance ID for metric reporting (optional) */
instanceId?: string;
}
/**
* Parse LLM text output for a persona update request signal.
*
* Supports multiple formats for robustness:
* - Block: [PERSONA_UPDATE_REQUEST]reason: xxx[/PERSONA_UPDATE_REQUEST]
* - Inline: PERSONA_UPDATE_REQUEST: xxx
*/
export function parsePersonaUpdateSignal(text: string): { reason: string } | null {
// Block format: [PERSONA_UPDATE_REQUEST]...[/PERSONA_UPDATE_REQUEST]
const blockMatch = text.match(
/\[PERSONA_UPDATE_REQUEST\]\s*(?:reason:\s*)?(.+?)\s*\[\/PERSONA_UPDATE_REQUEST\]/s,
);
if (blockMatch) return { reason: blockMatch[1]!.trim() };
// Inline format: PERSONA_UPDATE_REQUEST: reason text
const inlineMatch = text.match(
/PERSONA_UPDATE_REQUEST:\s*(.+?)(?:\n|$)/,
);
if (inlineMatch) return { reason: inlineMatch[1]!.trim() };
return null;
}
export class SceneExtractor {
private dataDir: string;
private runner: CleanContextRunner;
private maxScenes: number;
private sceneBackupCount: number;
private timeoutMs: number;
private logger: ExtractorLogger | undefined;
private instanceId: string | undefined;
constructor(opts: SceneExtractorOptions) {
this.dataDir = opts.dataDir;
this.maxScenes = opts.maxScenes ?? 20;
this.sceneBackupCount = opts.sceneBackupCount ?? 10;
this.timeoutMs = opts.timeoutMs ?? 300_000; // 5 min — LLM may do multiple tool calls
this.logger = opts.logger;
this.instanceId = opts.instanceId;
this.runner = new CleanContextRunner({
config: opts.config,
modelRef: opts.model,
enableTools: true,
logger: opts.logger,
});
this.logger?.debug?.(`${TAG} Created: dataDir=${opts.dataDir}, model=${opts.model ?? "(default)"}, maxScenes=${this.maxScenes}, timeout=${this.timeoutMs}ms`);
}
/**
* Extract a batch of memories into scene blocks using the LLM agent.
*
* @param memories - Array of raw memory records from the API
* @returns Extraction result with count and success flag
*/
async extract(memories: Array<{ content: string; created_at: string; id?: string }>): Promise<ExtractionResult> {
const extractStartMs = Date.now();
this.logger?.info(`${TAG} extract() start: ${memories.length} memories`);
if (memories.length === 0) {
this.logger?.debug?.(`${TAG} extract() skipped: no memories`);
return { memoriesProcessed: 0, success: true };
}
const sceneBlocksDir = path.join(this.dataDir, "scene_blocks");
const metadataDir = path.join(this.dataDir, ".metadata");
// Ensure directories exist
await fs.mkdir(sceneBlocksDir, { recursive: true });
await fs.mkdir(metadataDir, { recursive: true });
// Phase 1: Backup
const backupStartMs = Date.now();
const cpManager = new CheckpointManager(this.dataDir);
const cp = await cpManager.read();
const bm = new BackupManager(path.join(this.dataDir, ".backup"));
await bm.backupDirectory(sceneBlocksDir, "scene_blocks", `offset${cp.total_processed}`, this.sceneBackupCount);
this.logger?.debug?.(`${TAG} extract() backup phase: ${Date.now() - backupStartMs}ms`);
// Phase 2: Load scene index
const indexStartMs = Date.now();
const index = await readSceneIndex(this.dataDir);
this.logger?.debug?.(`${TAG} extract() scene index loaded: ${index.length} entries (${Date.now() - indexStartMs}ms)`);
// Build scene summaries for the prompt (relative filenames only)
const { summaries: sceneSummaries, filenames: existingSceneFiles } =
this.buildSceneSummaries(index);
// Build scene count warning (tiered system)
let sceneCountWarning: string | undefined;
const sceneCount = index.length;
if (sceneCount >= this.maxScenes) {
sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,已达到或超过 ${this.maxScenes} 个上限!\n**你必须先执行 MERGE 操作**,将最相似的 2-4 个场景合并为 1 个,然后再处理新记忆。\n参考合并对象:热度最低或主题高度重叠的场景。`;
this.logger?.warn(`${TAG} extract() scene count at limit: ${sceneCount}/${this.maxScenes}`);
} else if (sceneCount === this.maxScenes - 1) {
sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,距离上限只差 1 个!\n本次处理**只能 UPDATE 现有场景,不能 CREATE 新场景**。`;
this.logger?.warn(`${TAG} extract() scene count near limit (CREATE blocked): ${sceneCount}/${this.maxScenes}`);
} else if (sceneCount >= this.maxScenes - 3) {
sceneCountWarning = `当前场景数量为 **${sceneCount} 个**,建议优先考虑 UPDATE 或主动 MERGE 相似场景。`;
this.logger?.debug?.(`${TAG} extract() scene count approaching limit: ${sceneCount}/${this.maxScenes}`);
}
// Snapshot scene index + content before LLM — used later to diff created/updated/deleted
const preExtractIndex = new Map(index.map((e) => [e.filename, e.summary]));
// Also snapshot scene content so we can detect content-only changes vs metadata-only changes
const preExtractContent = new Map<string, string>();
for (const e of index) {
try {
const raw = await fs.readFile(path.join(sceneBlocksDir, e.filename), "utf-8");
const block = parseSceneBlock(raw, e.filename);
preExtractContent.set(e.filename, block.content);
} catch { /* non-fatal */ }
}
// Phase 3: Build prompt
const promptStartMs = Date.now();
const memoriesJson = JSON.stringify(
memories.map((m) => ({
content: m.content,
created_at: m.created_at,
id: m.id ?? "",
})),
null,
2,
);
const currentTimestamp = formatTimestamp(new Date());
const prompt = buildSceneExtractionPrompt({
memoriesJson,
sceneSummaries: sceneSummaries || "(无已有场景)",
currentTimestamp,
sceneCountWarning,
existingSceneFiles,
});
this.logger?.debug?.(`${TAG} extract() prompt built: ${prompt.length} chars (${Date.now() - promptStartMs}ms)`);
// Phase 4: Run LLM agent (sandboxed to scene_blocks/)
let llmOutput = "";
let llmDurationMs = 0;
try {
this.logger?.debug?.(`${TAG} extract() starting LLM runner (timeout=${this.timeoutMs}ms, maxTokens=model default)...`);
const runnerStartMs = Date.now();
llmOutput = await this.runner.run({
prompt,
taskId: `scene-extract-${Date.now()}`,
timeoutMs: this.timeoutMs,
// maxTokens omitted → core uses the resolved model's maxTokens from catalog
workspaceDir: sceneBlocksDir,
}) ?? "";
llmDurationMs = Date.now() - runnerStartMs;
this.logger?.debug?.(`${TAG} extract() LLM runner completed: ${llmDurationMs}ms`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const totalMs = Date.now() - extractStartMs;
this.logger?.error(`${TAG} extract() LLM runner failed after ${totalMs}ms: ${errMsg}`);
return { memoriesProcessed: 0, success: false, error: errMsg };
}
// Phase 5: Subsequent processing — safe cleanup of soft-deleted files
//
// Security: The LLM has no `exec` tool and cannot run shell commands.
// Instead, it "deletes" files by writing empty content (soft-delete).
// Here we detect and remove those empty files before syncing the index,
// so syncSceneIndex won't re-index stale empty entries.
const cleanupStartMs = Date.now();
let cleanedCount = 0;
try {
const allFiles = (await fs.readdir(sceneBlocksDir)).filter((f) => f.endsWith(".md"));
for (const file of allFiles) {
const filePath = path.join(sceneBlocksDir, file);
const content = await fs.readFile(filePath, "utf-8");
if (content.trim().length === 0) {
await fs.unlink(filePath);
cleanedCount++;
this.logger?.debug?.(`${TAG} extract() removed soft-deleted file: ${file}`);
}
}
} catch (cleanupErr) {
// Non-fatal — log and continue to index sync
this.logger?.warn(`${TAG} extract() soft-delete cleanup error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
}
this.logger?.debug?.(`${TAG} extract() soft-delete cleanup: removed ${cleanedCount} empty files (${Date.now() - cleanupStartMs}ms)`);
// Phase 6: Sync scene index (rebuilds from remaining non-empty files)
const syncStartMs = Date.now();
await syncSceneIndex(this.dataDir);
this.logger?.debug?.(`${TAG} extract() scene index synced: ${Date.now() - syncStartMs}ms`);
// Phase 7: Update persona.md navigation (GAP-4 fix)
const navStartMs = Date.now();
try {
await this.updateSceneNavigation();
this.logger?.debug?.(`${TAG} extract() persona.md navigation updated: ${Date.now() - navStartMs}ms`);
} catch (navErr) {
// Non-fatal — log and continue
this.logger?.warn(`${TAG} extract() failed to update persona navigation: ${navErr instanceof Error ? navErr.message : String(navErr)}`);
}
// Phase 8: Parse LLM output for out-of-band persona update signal
if (llmOutput) {
const signal = parsePersonaUpdateSignal(llmOutput);
if (signal) {
await cpManager.setPersonaUpdateRequest(signal.reason);
this.logger?.debug?.(`${TAG} extract() persona update requested by LLM: ${signal.reason}`);
}
}
const totalMs = Date.now() - extractStartMs;
this.logger?.info(`${TAG} extract() completed: ${memories.length} memories processed in ${totalMs}ms`);
// ── l2_extraction metric ──
if (this.instanceId && this.logger) {
// Read updated scene index to report final state + diff against pre-extract snapshot
let resultScenes: Array<{ title: string; summary: string; content: string; status: "created" | "updated" }> = [];
let scenesCreated = 0;
let scenesUpdated = 0;
let scenesDeleted = 0;
try {
const finalIndex = await readSceneIndex(this.dataDir);
const postFilenames = new Set<string>();
for (const e of finalIndex) {
postFilenames.add(e.filename);
const oldSummary = preExtractIndex.get(e.filename);
// Read scene block content from disk
let content = "";
try {
const blockPath = path.join(sceneBlocksDir, e.filename);
const raw = await fs.readFile(blockPath, "utf-8");
const block = parseSceneBlock(raw, e.filename);
content = block.content;
} catch { /* file read failure is non-fatal */ }
if (oldSummary === undefined) {
// New scene
scenesCreated++;
resultScenes.push({
title: e.filename.replace(/\.md$/, ""),
summary: e.summary,
content,
status: "created",
});
} else {
// Existing scene — check if content actually changed (not just metadata)
const oldContent = preExtractContent.get(e.filename) ?? "";
if (content !== oldContent) {
scenesUpdated++;
resultScenes.push({
title: e.filename.replace(/\.md$/, ""),
summary: e.summary,
content,
status: "updated",
});
}
// If only metadata (summary/heat) changed but content is the same, skip
}
}
// Scenes in pre-extract but missing from post-extract = deleted
for (const [filename] of preExtractIndex) {
if (!postFilenames.has(filename)) {
scenesDeleted++;
}
}
} catch { /* non-fatal */ }
report("l2_extraction", {
inputMemoryCount: memories.length,
resultSceneCount: resultScenes.length,
resultScenes,
scenesCreated,
scenesUpdated,
scenesDeleted,
llmDurationMs,
totalDurationMs: totalMs,
success: true,
error: null,
});
}
return { memoriesProcessed: memories.length, success: true };
}
/**
* Build human-readable scene summaries for the prompt,
* and collect the list of existing scene filenames (relative).
*
* Includes a capacity counter at the top (e.g. "当前场景总数:5 / 15")
* so the LLM can immediately see how close it is to the limit.
*/
private buildSceneSummaries(
index: SceneIndexEntry[],
): { summaries: string; filenames: string[] } {
if (index.length === 0) return { summaries: "", filenames: [] };
const lines: string[] = [];
const filenames: string[] = [];
// Inject capacity counter at the top — LLM sees this first
lines.push(`**当前场景总数:${index.length} / ${this.maxScenes}**`);
lines.push("");
for (const entry of index) {
filenames.push(entry.filename);
lines.push(`### ${entry.filename}`);
lines.push(`**热度**: ${entry.heat} | **更新**: ${entry.updated}`);
lines.push(`**summary**: ${entry.summary}`);
lines.push("");
}
return { summaries: lines.join("\n"), filenames };
}
/**
* Update the scene navigation section at the end of persona.md.
*
* Reads the current scene index, generates the navigation block, then
* strips any existing navigation from persona.md and appends the new one.
*
* IMPORTANT: If the persona body is empty (PersonaGenerator hasn't run yet),
* we skip writing to avoid creating a persona.md that only contains the
* scene navigation. PersonaGenerator.generate() will write the full
* persona + navigation when it runs.
*/
private async updateSceneNavigation(): Promise<void> {
const personaPath = path.join(this.dataDir, "persona.md");
const index = await readSceneIndex(this.dataDir);
const nav = generateSceneNavigation(index);
let existing = "";
try {
existing = await fs.readFile(personaPath, "utf-8");
} catch {
// No persona file yet — PersonaGenerator will create it with navigation.
// Don't write a navigation-only file.
this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: no persona file yet, waiting for PersonaGenerator`);
return;
}
if (!existing.trim() && !nav) return;
const stripped = stripSceneNavigation(existing).trimEnd();
// If the persona body is empty (only navigation existed), don't overwrite
// with a navigation-only file. Let PersonaGenerator handle full generation.
if (!stripped) {
this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: persona body is empty, waiting for PersonaGenerator`);
return;
}
const updated = nav ? `${stripped}\n\n${nav}\n` : `${stripped}\n`;
// persona.md is at dataDir root, no subdir needed
await fs.writeFile(personaPath, updated, "utf-8");
}
}
function formatTimestamp(d: Date): string {
return d.toISOString();
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Scene Block file format: parse and format the META-delimited Markdown files.
*/
export interface SceneBlockMeta {
created: string;
updated: string;
summary: string;
heat: number;
}
export interface SceneBlock {
filename: string;
meta: SceneBlockMeta;
content: string;
}
const META_START = "-----META-START-----";
const META_END = "-----META-END-----";
/**
* Parse a Scene Block file into structured data.
*/
export function parseSceneBlock(raw: string, filename: string): SceneBlock {
const startIdx = raw.indexOf(META_START);
const endIdx = raw.indexOf(META_END);
if (startIdx === -1 || endIdx === -1) {
// No META section — treat entire file as content
return {
filename,
meta: { created: "", updated: "", summary: "", heat: 0 },
content: raw.trim(),
};
}
const metaBlock = raw.slice(startIdx + META_START.length, endIdx).trim();
const content = raw.slice(endIdx + META_END.length).trim();
const meta: SceneBlockMeta = {
created: extractMetaField(metaBlock, "created"),
updated: extractMetaField(metaBlock, "updated"),
summary: extractMetaField(metaBlock, "summary"),
heat: parseInt(extractMetaField(metaBlock, "heat"), 10) || 0,
};
return { filename, meta, content };
}
/**
* Format a Scene Block back into file content.
*/
export function formatSceneBlock(meta: SceneBlockMeta, content: string): string {
return `${formatMeta(meta)}\n\n${content}`;
}
/**
* Format the META section.
*/
export function formatMeta(meta: SceneBlockMeta): string {
return [
META_START,
`created: ${meta.created}`,
`updated: ${meta.updated}`,
`summary: ${meta.summary}`,
`heat: ${meta.heat}`,
META_END,
].join("\n");
}
function extractMetaField(metaBlock: string, field: string): string {
const re = new RegExp(`^${field}:\\s*(.*)$`, "m");
const m = metaBlock.match(re);
return m ? m[1]!.trim() : "";
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Scene Index: maintains a JSON index of all scene blocks for quick lookup.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { parseSceneBlock } from "./scene-format.js";
export interface SceneIndexEntry {
filename: string;
summary: string;
heat: number;
created: string;
updated: string;
}
/**
* Read the scene index from disk.
*
* The index is written exclusively by syncSceneIndex() (engineering side).
* The LLM is sandboxed to scene_blocks/ and cannot access this file.
*/
export async function readSceneIndex(dataDir: string): Promise<SceneIndexEntry[]> {
const indexPath = path.join(dataDir, ".metadata", "scene_index.json");
try {
const raw = await fs.readFile(indexPath, "utf-8");
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed)) return [];
const entries: SceneIndexEntry[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object") continue;
const filename = typeof item.filename === "string" ? item.filename : "";
if (!filename) continue;
entries.push({
filename,
summary: typeof item.summary === "string" ? item.summary : "",
heat: typeof item.heat === "number" ? item.heat : 0,
created: typeof item.created === "string" ? item.created : "",
updated: typeof item.updated === "string" ? item.updated : "",
});
}
return entries;
} catch {
return [];
}
}
/**
* Write the scene index to disk.
*/
export async function writeSceneIndex(
dataDir: string,
entries: SceneIndexEntry[],
): Promise<void> {
const indexPath = path.join(dataDir, ".metadata", "scene_index.json");
await fs.mkdir(path.dirname(indexPath), { recursive: true });
await fs.writeFile(indexPath, JSON.stringify(entries, null, 2), "utf-8");
}
/**
* Rebuild scene index by scanning all .md files in the scene_blocks directory.
*/
export async function syncSceneIndex(dataDir: string): Promise<SceneIndexEntry[]> {
const blocksDir = path.join(dataDir, "scene_blocks");
let files: string[];
try {
files = (await fs.readdir(blocksDir)).filter((f) => f.endsWith(".md"));
} catch {
files = [];
}
const entries: SceneIndexEntry[] = [];
for (const file of files) {
try {
const raw = await fs.readFile(path.join(blocksDir, file), "utf-8");
const block = parseSceneBlock(raw, file);
entries.push({
filename: file,
summary: block.meta.summary,
heat: block.meta.heat,
created: block.meta.created,
updated: block.meta.updated,
});
} catch {
// File may have been deleted between readdir and readFile (e.g. by concurrent
// SceneExtractor soft-delete). Skip it and continue syncing the rest.
continue;
}
}
await writeSceneIndex(dataDir, entries);
return entries;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Scene navigation: generates a summary navigation section appended to persona.md.
*
* The navigation includes file paths so the agent can use read_file to load
* scene details on demand (progressive disclosure).
*/
import type { SceneIndexEntry } from "./scene-index.js";
const NAV_HEADER = "---\n## 🗺️ Scene Navigation (Scene Index)";
const NAV_FOOTER = `📌 使用说明:
- Path 即 scene block 的相对路径,可按需使用 read_file 读取完整内容
- 热度:该场景被记忆命中的累计次数,越高越重要
- Summary:场景的核心要点摘要`;
/**
* Build a fire-emoji string based on heat value (visual priority cue for the agent).
*/
function heatEmoji(heat: number): string {
if (heat >= 1000) return " 🔥🔥🔥🔥🔥";
if (heat >= 500) return " 🔥🔥🔥🔥";
if (heat >= 200) return " 🔥🔥🔥";
if (heat >= 100) return " 🔥🔥";
if (heat >= 50) return " 🔥";
return "";
}
/**
* Generate the scene navigation Markdown section.
*
* Output format mirrors the v1 Python version so the agent can identify file
* paths and invoke read_file for on-demand scene loading.
*/
export function generateSceneNavigation(entries: SceneIndexEntry[]): string {
if (entries.length === 0) return "";
const sorted = [...entries].sort((a, b) => b.heat - a.heat);
const blocks = sorted.map((e) => {
const pathLine = `### Path: scene_blocks/${e.filename}`;
const heatLine = `**热度**: ${e.heat}${heatEmoji(e.heat)}${e.updated ? ` | **更新**: ${e.updated}` : ""}`;
const summaryLine = `Summary: ${e.summary}`;
return `${pathLine}\n${heatLine}\n${summaryLine}`;
});
return `${NAV_HEADER}\n*以下是当前场景记忆的索引,可根据需要 read_file 读取详细内容。*\n\n${blocks.join("\n\n")}\n\n${NAV_FOOTER}`;
}
/**
* Strip the scene navigation section from persona content.
*/
export function stripSceneNavigation(personaContent: string): string {
const idx = personaContent.indexOf(NAV_HEADER);
if (idx === -1) return personaContent;
return personaContent.slice(0, idx).trimEnd();
}
+598
View File
@@ -0,0 +1,598 @@
/**
* Embedding Service: converts text to vector embeddings.
*
* Supports two providers:
* - "openai": OpenAI-compatible embedding APIs (OpenAI, Azure OpenAI, self-hosted)
* - "local": node-llama-cpp with embeddinggemma-300m GGUF model (fully offline)
*
* When no remote embedding is configured, automatically falls back to local provider.
*
* Design:
* - Single `embed()` for one text, `embedBatch()` for multiple.
* - `getDimensions()` returns configured vector dimensions.
* - Throws on failure; callers decide fallback strategy.
*/
// ============================
// Types
// ============================
export interface OpenAIEmbeddingConfig {
/** Provider identifier — any value other than "local" (e.g. "openai", "deepseek", "azure", "qclaw") */
provider: string;
/** API base URL (required — must be specified by user, e.g. "https://api.openai.com/v1") */
baseUrl: string;
/** API Key (required) */
apiKey: string;
/** Model name (required — must be specified by user) */
model: string;
/** Output dimensions (required — must match the chosen model) */
dimensions: number;
/** Local proxy URL (only for provider="qclaw") — requests are forwarded through this proxy with Remote-URL header */
proxyUrl?: string;
/** Max input text length in characters before truncation (default: 5000). */
maxInputChars?: number;
/** Timeout per API call in milliseconds (default: 10000). */
timeoutMs?: number;
}
export interface LocalEmbeddingConfig {
provider: "local";
/** Custom GGUF model path (default: embeddinggemma-300m from HuggingFace) */
modelPath?: string;
/** Model cache directory (default: node-llama-cpp default cache) */
modelCacheDir?: string;
}
export type EmbeddingConfig = OpenAIEmbeddingConfig | LocalEmbeddingConfig;
/** Identifies the embedding provider + model for change detection. */
export interface EmbeddingProviderInfo {
/** Provider identifier (e.g. "local", "openai", "deepseek") */
provider: string;
/** Model identifier (e.g. "embeddinggemma-300m", "text-embedding-3-large") */
model: string;
}
export interface EmbeddingService {
/** Get embedding for a single text */
embed(text: string): Promise<Float32Array>;
/** Get embeddings for multiple texts (batched API call) */
embedBatch(texts: string[]): Promise<Float32Array[]>;
/** Return the configured vector dimensions */
getDimensions(): number;
/** Return provider + model identifiers for change detection */
getProviderInfo(): EmbeddingProviderInfo;
/**
* Whether the service is ready to serve embed requests.
* For remote providers (OpenAI), always true (stateless HTTP).
* For local providers, true only after model download + load completes.
*/
isReady(): boolean;
/**
* Start background warmup (model download + load).
* For remote providers, this is a no-op.
* For local providers, triggers async initialization without blocking.
* Safe to call multiple times (idempotent).
*/
startWarmup(): void;
/** Optional: release resources (model memory, GPU, etc.) on shutdown */
close?(): void | Promise<void>;
}
/**
* Error thrown when embed() / embedBatch() is called before the local
* embedding model has finished downloading and loading.
* Callers should catch this and fall back to keyword-only mode.
*/
export class EmbeddingNotReadyError extends Error {
constructor(message?: string) {
super(message ?? "Local embedding model is not ready yet (still downloading or loading)");
this.name = "EmbeddingNotReadyError";
}
}
// ============================
// Logger interface
// ============================
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
const TAG = "[memory-tdai][embedding]";
// ============================
// Local (node-llama-cpp) implementation
// ============================
/** Default model: Google's embeddinggemma-300m, quantized Q8_0 (~300MB) */
const DEFAULT_LOCAL_MODEL =
"hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf";
/** embeddinggemma-300m outputs 768-dimensional vectors */
const LOCAL_DIMENSIONS = 768;
/**
* embeddinggemma-300m has a 256-token context window.
* As a safe heuristic, we limit input to ~600 chars for CJK text
* (CJK characters typically tokenize to 1-2 tokens each,
* so 600 chars ≈ 200-400 tokens, keeping well within 256-token limit
* after accounting for special tokens).
* For Latin text, ~800 chars is a safe limit (~200 tokens).
* We use 512 chars as a conservative universal limit.
*/
const LOCAL_MAX_INPUT_CHARS = 512;
/**
* Sanitize NaN/Inf values and L2-normalize the vector.
* Matches OpenClaw's own sanitizeAndNormalizeEmbedding().
*/
function sanitizeAndNormalize(vec: number[] | Float32Array): Float32Array {
const arr = Array.from(vec).map((v) => (Number.isFinite(v) ? v : 0));
const magnitude = Math.sqrt(arr.reduce((sum, v) => sum + v * v, 0));
if (magnitude < 1e-10) {
return new Float32Array(arr);
}
return new Float32Array(arr.map((v) => v / magnitude));
}
/**
* Initialization state for LocalEmbeddingService.
* - "idle": not started yet
* - "initializing": model download / load is in progress (background)
* - "ready": model is loaded and ready to serve
* - "failed": initialization failed (will retry on next startWarmup)
*/
type LocalInitState = "idle" | "initializing" | "ready" | "failed";
export class LocalEmbeddingService implements EmbeddingService {
private readonly modelPath: string;
private readonly modelCacheDir?: string;
private readonly logger?: Logger;
// Initialization state machine
private initState: LocalInitState = "idle";
private initPromise: Promise<void> | null = null;
private initError: Error | null = null;
private embeddingContext: {
getEmbeddingFor: (text: string) => Promise<{ vector: Float32Array | number[] }>;
} | null = null;
constructor(config?: LocalEmbeddingConfig, logger?: Logger) {
this.modelPath = config?.modelPath?.trim() || DEFAULT_LOCAL_MODEL;
this.modelCacheDir = config?.modelCacheDir?.trim();
this.logger = logger;
}
getDimensions(): number {
return LOCAL_DIMENSIONS;
}
getProviderInfo(): EmbeddingProviderInfo {
return { provider: "local", model: this.modelPath };
}
/**
* Whether the local model is fully loaded and ready to serve requests.
*/
isReady(): boolean {
return this.initState === "ready" && this.embeddingContext !== null;
}
/**
* Start background warmup: download model (if needed) and load into memory.
* Does NOT block the caller — returns immediately.
* Safe to call multiple times (idempotent); re-triggers on "failed" state.
*/
startWarmup(): void {
if (this.initState === "initializing" || this.initState === "ready") {
return; // already in progress or done
}
this.logger?.info(`${TAG} Starting background warmup for local embedding model...`);
this.initState = "initializing";
this.initError = null;
this.initPromise = this._doInitialize()
.then(() => {
this.initState = "ready";
this.logger?.info(`${TAG} Background warmup complete — local embedding ready`);
})
.catch((err) => {
this.initState = "failed";
this.initError = err instanceof Error ? err : new Error(String(err));
this.logger?.error(
`${TAG} Background warmup failed: ${this.initError.message}. ` +
`embed() calls will throw EmbeddingNotReadyError until retried.`,
);
});
}
/**
* Get embedding for a single text.
* @throws {EmbeddingNotReadyError} if model is not yet ready.
*/
async embed(text: string): Promise<Float32Array> {
this.assertReady();
const truncated = this.truncateInput(text);
const embedding = await this.embeddingContext!.getEmbeddingFor(truncated);
return sanitizeAndNormalize(embedding.vector);
}
/**
* Get embeddings for multiple texts.
* @throws {EmbeddingNotReadyError} if model is not yet ready.
*/
async embedBatch(texts: string[]): Promise<Float32Array[]> {
if (texts.length === 0) return [];
this.assertReady();
const results: Float32Array[] = [];
for (const text of texts) {
const truncated = this.truncateInput(text);
const embedding = await this.embeddingContext!.getEmbeddingFor(truncated);
results.push(sanitizeAndNormalize(embedding.vector));
}
return results;
}
/**
* Release the node-llama-cpp embedding context and model resources.
* Safe to call multiple times (idempotent).
*/
close(): void {
if (this.embeddingContext) {
try {
const ctx = this.embeddingContext as unknown as { dispose?: () => void };
ctx.dispose?.();
} catch {
// best-effort cleanup
}
this.embeddingContext = null;
this.initPromise = null;
this.initState = "idle";
this.initError = null;
this.logger?.info(`${TAG} Local embedding resources released`);
}
}
/**
* Assert the model is ready. Throws EmbeddingNotReadyError if not.
*/
private assertReady(): void {
if (this.initState === "ready" && this.embeddingContext) {
return;
}
if (this.initState === "failed") {
throw new EmbeddingNotReadyError(
`Local embedding model initialization failed: ${this.initError?.message ?? "unknown error"}. ` +
`Call startWarmup() to retry.`,
);
}
if (this.initState === "initializing") {
throw new EmbeddingNotReadyError(
"Local embedding model is still loading (download/initialization in progress). Please try again later.",
);
}
// "idle" — startWarmup() was never called
throw new EmbeddingNotReadyError(
"Local embedding model warmup has not been started. Call startWarmup() first.",
);
}
/**
* Truncate input text to stay within the model's context window.
* embeddinggemma-300m has a 256-token limit; we use a character-based
* heuristic (LOCAL_MAX_INPUT_CHARS) as a safe proxy.
*/
private truncateInput(text: string): string {
if (text.length <= LOCAL_MAX_INPUT_CHARS) return text;
this.logger?.debug?.(
`${TAG} Input truncated from ${text.length} to ${LOCAL_MAX_INPUT_CHARS} chars (model context limit)`,
);
return text.slice(0, LOCAL_MAX_INPUT_CHARS);
}
/**
* Internal: perform the actual model download + load.
* Called by startWarmup(), runs in background.
*/
private async _doInitialize(): Promise<void> {
// Track partially-initialized resources for cleanup on failure
let model: { createEmbeddingContext: () => Promise<unknown>; dispose?: () => void } | undefined;
try {
this.logger?.debug?.(`${TAG} Loading node-llama-cpp for local embedding...`);
// Dynamic import — node-llama-cpp is a peer dependency of OpenClaw
const { getLlama, resolveModelFile, LlamaLogLevel } = await import("node-llama-cpp");
const llama = await getLlama({ logLevel: LlamaLogLevel.error });
this.logger?.debug?.(`${TAG} Llama instance created`);
const resolvedPath = await resolveModelFile(
this.modelPath,
this.modelCacheDir || undefined,
);
this.logger?.debug?.(`${TAG} Model resolved: ${resolvedPath}`);
model = await (llama as unknown as { loadModel: (opts: { modelPath: string }) => Promise<typeof model> }).loadModel({ modelPath: resolvedPath });
this.logger?.debug?.(`${TAG} Model loaded, creating embedding context...`);
this.embeddingContext = await model!.createEmbeddingContext() as typeof this.embeddingContext;
this.logger?.info(`${TAG} Local embedding ready (model=${this.modelPath}, dims=${LOCAL_DIMENSIONS})`);
} catch (err) {
// Clean up partially-initialized resources to prevent leaks
if (model?.dispose) {
try { model.dispose(); } catch { /* best-effort */ }
}
this.embeddingContext = null;
throw err;
}
}
/**
* Wait for ongoing warmup to complete (used internally by tests).
* Returns immediately if already ready or idle.
*/
async waitForReady(): Promise<void> {
if (this.initPromise) {
await this.initPromise;
}
}
}
// ============================
// OpenAI-compatible implementation
// ============================
/** Max texts per batch (OpenAI limit is 2048, we use a safe value) */
const MAX_BATCH_SIZE = 256;
/** Max retries for API calls */
const MAX_RETRIES = 0;
/** Default timeout per API call in milliseconds */
const DEFAULT_API_TIMEOUT_MS = 10_000;
/**
* Custom error class for embedding API errors that carries HTTP status code.
* Used to distinguish non-retryable client errors (4xx except 429) from
* retryable server errors (5xx) and rate limits (429).
*/
class EmbeddingApiError extends Error {
readonly httpStatus: number;
constructor(message: string, httpStatus: number) {
super(message);
this.name = "EmbeddingApiError";
this.httpStatus = httpStatus;
}
/** Returns true for 4xx errors that should NOT be retried (excluding 429). */
isClientError(): boolean {
return this.httpStatus >= 400 && this.httpStatus < 500 && this.httpStatus !== 429;
}
}
interface OpenAIEmbeddingResponse {
data: Array<{
index: number;
embedding: number[];
}>;
usage?: {
prompt_tokens: number;
total_tokens: number;
};
}
export class OpenAIEmbeddingService implements EmbeddingService {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly model: string;
private readonly dims: number;
private readonly providerName: string;
private readonly proxyUrl?: string;
private readonly maxInputChars?: number;
private readonly timeoutMs: number;
private readonly logger?: Logger;
constructor(config: OpenAIEmbeddingConfig, logger?: Logger) {
if (!config.apiKey) {
throw new Error("EmbeddingService: apiKey is required for remote provider");
}
if (!config.baseUrl) {
throw new Error("EmbeddingService: baseUrl is required for remote provider");
}
if (!config.model) {
throw new Error("EmbeddingService: model is required for remote provider");
}
if (!config.dimensions || config.dimensions <= 0) {
throw new Error("EmbeddingService: dimensions is required for remote provider (must be a positive integer)");
}
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.apiKey = config.apiKey;
this.model = config.model;
this.dims = config.dimensions;
this.providerName = config.provider || "openai";
this.proxyUrl = config.proxyUrl?.trim() || undefined;
this.maxInputChars = config.maxInputChars && config.maxInputChars > 0 ? config.maxInputChars : undefined;
this.timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_API_TIMEOUT_MS;
this.logger = logger;
}
getDimensions(): number {
return this.dims;
}
getProviderInfo(): EmbeddingProviderInfo {
return { provider: this.providerName, model: this.model };
}
/** Remote embedding is always ready (stateless HTTP). */
isReady(): boolean {
return true;
}
/** No-op for remote embedding (no local model to warm up). */
startWarmup(): void {
// nothing to do — remote API is stateless
}
async embed(text: string): Promise<Float32Array> {
const [result] = await this.embedBatch([text]);
return result;
}
async embedBatch(texts: string[]): Promise<Float32Array[]> {
if (texts.length === 0) return [];
// Truncate texts exceeding maxInputChars limit
const processedTexts = this.maxInputChars
? texts.map((t) => this.truncateInput(t))
: texts;
// Split into sub-batches if needed
if (processedTexts.length > MAX_BATCH_SIZE) {
const results: Float32Array[] = [];
for (let i = 0; i < processedTexts.length; i += MAX_BATCH_SIZE) {
const chunk = processedTexts.slice(i, i + MAX_BATCH_SIZE);
const chunkResults = await this._callApi(chunk);
results.push(...chunkResults);
}
return results;
}
return this._callApi(processedTexts);
}
/**
* Truncate input text to stay within the configured maxInputChars limit.
* Logs a warning when truncation occurs.
*/
private truncateInput(text: string): string {
if (!this.maxInputChars || text.length <= this.maxInputChars) return text;
this.logger?.warn?.(
`${TAG} Input truncated from ${text.length} to ${this.maxInputChars} chars (maxInputChars limit)`,
);
return text.slice(0, this.maxInputChars);
}
private async _callApi(texts: string[]): Promise<Float32Array[]> {
const body: Record<string, unknown> = {
input: texts,
model: this.model,
dimensions: this.dims,
};
// Determine fetch URL and headers based on proxy mode
const useProxy = this.providerName === "qclaw" && !!this.proxyUrl;
const fetchUrl = useProxy ? this.proxyUrl! : `${this.baseUrl}/embeddings`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
};
if (useProxy) {
headers["Remote-URL"] = `${this.baseUrl}/embeddings`;
this.logger?.debug?.(
`${TAG} [qclaw-proxy] Forwarding embedding request via proxy: ${fetchUrl}, Remote-URL: ${headers["Remote-URL"]}`,
);
}
// Retry loop with timeout
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const resp = await fetch(fetchUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!resp.ok) {
const errBody = await resp.text().catch(() => "(unable to read body)");
const err = new EmbeddingApiError(
`Embedding API error: HTTP ${resp.status} ${resp.statusText}${errBody.slice(0, 500)}`,
resp.status,
);
// Don't retry on 4xx client errors (except 429 rate limit)
if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) {
throw err;
}
lastError = err;
continue;
}
const json = (await resp.json()) as OpenAIEmbeddingResponse;
if (!json.data || !Array.isArray(json.data)) {
throw new Error("Embedding API returned unexpected format: missing 'data' array");
}
// Sort by index to ensure correct order, then sanitize+normalize for consistency with local provider
const sorted = [...json.data].sort((a, b) => a.index - b.index);
return sorted.map((d) => sanitizeAndNormalize(d.embedding));
} finally {
clearTimeout(timeoutId);
}
} catch (err) {
// Non-retryable errors (4xx client errors) — rethrow immediately
if (err instanceof EmbeddingApiError && err.isClientError()) {
throw err;
}
lastError = err instanceof Error ? err : new Error(String(err));
// AbortError = timeout, retry
if (attempt < MAX_RETRIES) {
// Exponential backoff: 500ms, 1000ms
const delay = 500 * (attempt + 1);
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw lastError ?? new Error("Embedding API call failed after retries");
}
}
// ============================
// Factory
// ============================
/**
* Create an EmbeddingService from config.
*
* Strategy:
* - If config has provider != "local" with valid apiKey, model, and dimensions → use remote OpenAI-compatible embedding
* - If config has provider="local" → use node-llama-cpp local embedding
* - If config is undefined or missing required fields → fall back to local embedding
*
* NOTE: For local providers, `startWarmup()` is NOT called here.
* The caller is responsible for calling `startWarmup()` at the right time
* (e.g. on first conversation) to avoid triggering model download during
* short-lived CLI commands like `gateway stop` or `agents list`.
*/
export function createEmbeddingService(
config: EmbeddingConfig | undefined,
logger?: Logger,
): EmbeddingService {
// Remote OpenAI-compatible provider: any provider value other than "local"
if (config && config.provider !== "local" && "apiKey" in config && config.apiKey) {
logger?.info(`${TAG} Using remote embedding (provider=${config.provider}, model=${config.model})`);
return new OpenAIEmbeddingService(config as OpenAIEmbeddingConfig, logger);
}
// Explicit local config
if (config && config.provider === "local") {
const localConfig = config as LocalEmbeddingConfig;
logger?.info(`${TAG} Using local embedding (node-llama-cpp, model=${localConfig.modelPath ?? DEFAULT_LOCAL_MODEL})`);
return new LocalEmbeddingService(localConfig, logger);
}
// Fallback: no config or empty apiKey → use local
logger?.info(`${TAG} No remote embedding configured, falling back to local embedding (node-llama-cpp)`);
return new LocalEmbeddingService(undefined, logger);
}
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
/**
* conversation_search tool: Agent-callable tool for searching L0 conversation records.
*
* Supports three search strategies with automatic degradation:
* 1. **hybrid** (default) — FTS5 keyword + vector embedding in parallel,
* merged via Reciprocal Rank Fusion (RRF).
* 2. **embedding** — pure vector similarity (when FTS5 is unavailable).
* 3. **fts** — pure FTS5 keyword search (when embedding is unavailable).
*
* The tool is registered via `api.registerTool()` in index.ts.
*/
import type { VectorStore, L0VectorSearchResult } from "../store/vector-store.js";
import { buildFtsQuery } from "../store/vector-store.js";
import type { EmbeddingService } from "../store/embedding.js";
// ============================
// Types
// ============================
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export interface ConversationSearchResultItem {
id: string;
session_key: string;
/** Role of the message sender: "user" or "assistant" */
role: string;
/** Text content of this single message */
content: string;
score: number;
recorded_at: string;
}
export interface ConversationSearchResult {
results: ConversationSearchResultItem[];
total: number;
/** Actual search strategy used: "hybrid", "embedding", "fts", or "none". */
strategy: string;
/** Optional message, e.g. when embedding is not configured. */
message?: string;
}
const TAG = "[memory-tdai][tdai_conversation_search]";
// ============================
// RRF (Reciprocal Rank Fusion)
// ============================
/** Standard RRF constant from the original RRF paper. */
const RRF_K = 60;
/**
* Merge multiple ranked lists of `ConversationSearchResultItem` via Reciprocal
* Rank Fusion. Items appearing in multiple lists get their RRF scores summed.
*
* Returns items sorted by descending RRF score. The `score` field of each
* returned item is replaced by the RRF score for consistent ranking semantics.
*/
function rrfMergeL0(...lists: ConversationSearchResultItem[][]): ConversationSearchResultItem[] {
const map = new Map<string, { item: ConversationSearchResultItem; rrfScore: number }>();
for (const list of lists) {
for (let rank = 0; rank < list.length; rank++) {
const item = list[rank];
const score = 1 / (RRF_K + rank + 1);
const existing = map.get(item.id);
if (existing) {
existing.rrfScore += score;
} else {
map.set(item.id, { item, rrfScore: score });
}
}
}
return [...map.values()]
.sort((a, b) => b.rrfScore - a.rrfScore)
.map(({ item, rrfScore }) => ({ ...item, score: rrfScore }));
}
// ============================
// Search implementation
// ============================
export async function executeConversationSearch(params: {
query: string;
limit: number;
sessionKey?: string;
vectorStore?: VectorStore;
embeddingService?: EmbeddingService;
logger?: Logger;
}): Promise<ConversationSearchResult> {
const {
query,
limit,
sessionKey: sessionFilter,
vectorStore,
embeddingService,
logger,
} = params;
logger?.debug?.(
`${TAG} CALLED: query="${query.slice(0, 100)}", limit=${limit}, ` +
`sessionFilter=${sessionFilter ?? "(none)"}, ` +
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`,
);
if (!query || query.trim().length === 0) {
logger?.debug?.(`${TAG} Empty query, returning empty`);
return { results: [], total: 0, strategy: "none" };
}
if (!vectorStore) {
logger?.warn?.(`${TAG} VectorStore not available`);
return { results: [], total: 0, strategy: "none" };
}
// ── Determine available capabilities ──
const hasEmbedding = !!embeddingService;
const hasFts = vectorStore.isFtsAvailable();
if (!hasEmbedding && !hasFts) {
logger?.warn?.(`${TAG} Neither EmbeddingService nor FTS5 available — cannot search`);
return {
results: [],
total: 0,
strategy: "none",
message:
"Embedding service is not configured and FTS is not available. " +
"Conversation search requires an embedding provider or FTS5 support. " +
"Please configure an embedding provider in the embedding.provider setting (e.g. openai_compatible).",
};
}
// ── Over-retrieve for later filtering and RRF merging ──
const candidateK = sessionFilter ? limit * 4 : limit * 3;
// ── Run available search strategies in parallel ──
const [ftsItems, vecItems] = await Promise.all([
// FTS5 keyword search on L0
(async (): Promise<ConversationSearchResultItem[]> => {
if (!hasFts) return [];
try {
const ftsQuery = buildFtsQuery(query);
if (!ftsQuery) {
logger?.debug?.(`${TAG} [hybrid-fts] No usable FTS tokens from query`);
return [];
}
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 query: "${ftsQuery}"`);
const ftsResults = vectorStore.ftsSearchL0(ftsQuery, candidateK);
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 returned ${ftsResults.length} candidates`);
return ftsResults.map((r) => ({
id: r.record_id,
session_key: r.session_key,
role: r.role,
content: r.message_text,
score: r.score,
recorded_at: r.recorded_at,
}));
} catch (err) {
logger?.warn?.(
`${TAG} [hybrid-fts] FTS5 search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
})(),
// Vector embedding search on L0
(async (): Promise<ConversationSearchResultItem[]> => {
if (!hasEmbedding) return [];
try {
logger?.debug?.(`${TAG} [hybrid-vec] Generating query embedding...`);
const queryEmbedding = await embeddingService!.embed(query);
logger?.debug?.(
`${TAG} [hybrid-vec] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
);
const vecResults: L0VectorSearchResult[] = vectorStore.searchL0(queryEmbedding, candidateK);
logger?.debug?.(`${TAG} [hybrid-vec] Vector search returned ${vecResults.length} candidates`);
return vecResults.map((r) => ({
id: r.record_id,
session_key: r.session_key,
role: r.role,
content: r.message_text,
score: r.score,
recorded_at: r.recorded_at,
}));
} catch (err) {
logger?.warn?.(
`${TAG} [hybrid-vec] Embedding search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
})(),
]);
// ── Determine effective strategy ──
const ftsOk = ftsItems.length > 0;
const vecOk = vecItems.length > 0;
let strategy: string;
if (ftsOk && vecOk) {
strategy = "hybrid";
} else if (vecOk) {
strategy = "embedding";
} else if (ftsOk) {
strategy = "fts";
} else {
logger?.debug?.(`${TAG} Both search paths returned 0 results`);
return { results: [], total: 0, strategy: hasEmbedding ? "embedding" : "fts" };
}
// ── Merge results ──
let results: ConversationSearchResultItem[];
if (strategy === "hybrid") {
results = rrfMergeL0(ftsItems, vecItems);
logger?.debug?.(
`${TAG} [hybrid] RRF merged: fts=${ftsItems.length}, vec=${vecItems.length}${results.length} unique`,
);
} else {
// Single-source: use whichever list has results (already sorted by score)
results = ftsOk ? ftsItems : vecItems;
}
// ── Apply session key filter ──
if (sessionFilter) {
const preFilterCount = results.length;
results = results.filter((r) => r.session_key === sessionFilter);
logger?.debug?.(`${TAG} After session filter "${sessionFilter}": ${results.length}/${preFilterCount}`);
}
// ── Trim to requested limit ──
const trimmed = results.slice(0, limit);
logger?.debug?.(
`${TAG} RESULT (strategy=${strategy}): returning ${trimmed.length} messages ` +
`(scores: [${trimmed.map((r) => r.score.toFixed(3)).join(", ")}])`,
);
return {
results: trimmed,
total: trimmed.length,
strategy,
};
}
// ============================
// Tool response formatter
// ============================
export function formatConversationSearchResponse(result: ConversationSearchResult): string {
if (result.message) {
return result.message;
}
if (result.results.length === 0) {
return "No matching conversation messages found.";
}
const lines: string[] = [
`Found ${result.total} matching message(s):`,
"",
];
for (const item of result.results) {
const scoreStr = typeof item.score === "number" ? ` (score: ${item.score.toFixed(3)})` : "";
const dateStr = item.recorded_at ? ` [${item.recorded_at}]` : "";
lines.push(`---`);
lines.push(`**[${item.role}]** Session: ${item.session_key}${dateStr}${scoreStr}`);
lines.push("");
lines.push(item.content);
lines.push("");
}
return lines.join("\n");
}
+290
View File
@@ -0,0 +1,290 @@
/**
* memory_search tool: Agent-callable tool for searching L1 memory records.
*
* Supports three search strategies with automatic degradation:
* 1. **hybrid** (default) — FTS5 keyword + vector embedding in parallel,
* merged via Reciprocal Rank Fusion (RRF).
* 2. **embedding** — pure vector similarity (when FTS5 is unavailable).
* 3. **fts** — pure FTS5 keyword search (when embedding is unavailable).
*
* The tool is registered via `api.registerTool()` in index.ts.
*/
import type { VectorStore, VectorSearchResult } from "../store/vector-store.js";
import { buildFtsQuery } from "../store/vector-store.js";
import type { EmbeddingService } from "../store/embedding.js";
// ============================
// Types
// ============================
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export interface MemorySearchResultItem {
id: string;
content: string;
type: string;
priority: number;
scene_name: string;
score: number;
created_at: string;
updated_at: string;
}
export interface MemorySearchResult {
results: MemorySearchResultItem[];
total: number;
strategy: string;
/** Optional message, e.g. when embedding is not configured. */
message?: string;
}
const TAG = "[memory-tdai][tdai_memory_search]";
// ============================
// RRF (Reciprocal Rank Fusion)
// ============================
/** Standard RRF constant from the original RRF paper. */
const RRF_K = 60;
/**
* Merge multiple ranked lists of `MemorySearchResultItem` via Reciprocal Rank
* Fusion. Items appearing in multiple lists get their RRF scores summed.
*
* Returns items sorted by descending RRF score. The `score` field of each
* returned item is replaced by the RRF score for consistent ranking semantics.
*/
function rrfMergeL1(...lists: MemorySearchResultItem[][]): MemorySearchResultItem[] {
const map = new Map<string, { item: MemorySearchResultItem; rrfScore: number }>();
for (const list of lists) {
for (let rank = 0; rank < list.length; rank++) {
const item = list[rank];
const score = 1 / (RRF_K + rank + 1);
const existing = map.get(item.id);
if (existing) {
existing.rrfScore += score;
} else {
map.set(item.id, { item, rrfScore: score });
}
}
}
return [...map.values()]
.sort((a, b) => b.rrfScore - a.rrfScore)
.map(({ item, rrfScore }) => ({ ...item, score: rrfScore }));
}
// ============================
// Search implementation
// ============================
export async function executeMemorySearch(params: {
query: string;
limit: number;
type?: string;
scene?: string;
vectorStore?: VectorStore;
embeddingService?: EmbeddingService;
logger?: Logger;
}): Promise<MemorySearchResult> {
const {
query,
limit,
type: typeFilter,
scene: sceneFilter,
vectorStore,
embeddingService,
logger,
} = params;
logger?.debug?.(
`${TAG} CALLED: query="${query.slice(0, 100)}", limit=${limit}, ` +
`typeFilter=${typeFilter ?? "(none)"}, sceneFilter=${sceneFilter ?? "(none)"}, ` +
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`,
);
if (!query || query.trim().length === 0) {
logger?.debug?.(`${TAG} Empty query, returning empty`);
return { results: [], total: 0, strategy: "none" };
}
if (!vectorStore) {
logger?.warn?.(`${TAG} VectorStore not available`);
return { results: [], total: 0, strategy: "none" };
}
// ── Determine available capabilities ──
const hasEmbedding = !!embeddingService;
const hasFts = vectorStore.isFtsAvailable();
if (!hasEmbedding && !hasFts) {
logger?.warn?.(`${TAG} Neither EmbeddingService nor FTS5 available — cannot search`);
return {
results: [],
total: 0,
strategy: "none",
message:
"Embedding service is not configured and FTS is not available. " +
"Memory search requires an embedding provider or FTS5 support. " +
"Please configure an embedding provider in the embedding.provider setting (e.g. openai_compatible).",
};
}
// ── Over-retrieve for later filtering and RRF merging ──
const candidateK = limit * 3;
// ── Run available search strategies in parallel ──
const [ftsItems, vecItems] = await Promise.all([
// FTS5 keyword search
(async (): Promise<MemorySearchResultItem[]> => {
if (!hasFts) return [];
try {
const ftsQuery = buildFtsQuery(query);
if (!ftsQuery) {
logger?.debug?.(`${TAG} [hybrid-fts] No usable FTS tokens from query`);
return [];
}
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 query: "${ftsQuery}"`);
const ftsResults = vectorStore.ftsSearchL1(ftsQuery, candidateK);
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 returned ${ftsResults.length} candidates`);
return ftsResults.map((r) => ({
id: r.record_id,
content: r.content,
type: r.type,
priority: r.priority,
scene_name: r.scene_name,
score: r.score,
created_at: r.timestamp_start,
updated_at: r.timestamp_end,
}));
} catch (err) {
logger?.warn?.(
`${TAG} [hybrid-fts] FTS5 search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
})(),
// Vector embedding search
(async (): Promise<MemorySearchResultItem[]> => {
if (!hasEmbedding) return [];
try {
logger?.debug?.(`${TAG} [hybrid-vec] Generating query embedding...`);
const queryEmbedding = await embeddingService!.embed(query);
logger?.debug?.(
`${TAG} [hybrid-vec] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
);
const vecResults: VectorSearchResult[] = vectorStore.search(queryEmbedding, candidateK);
logger?.debug?.(`${TAG} [hybrid-vec] Vector search returned ${vecResults.length} candidates`);
return vecResults.map((r) => ({
id: r.record_id,
content: r.content,
type: r.type,
priority: r.priority,
scene_name: r.scene_name,
score: r.score,
created_at: r.timestamp_start,
updated_at: r.timestamp_end,
}));
} catch (err) {
logger?.warn?.(
`${TAG} [hybrid-vec] Embedding search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
})(),
]);
// ── Determine effective strategy ──
const ftsOk = ftsItems.length > 0;
const vecOk = vecItems.length > 0;
let strategy: string;
if (ftsOk && vecOk) {
strategy = "hybrid";
} else if (vecOk) {
strategy = "embedding";
} else if (ftsOk) {
strategy = "fts";
} else {
logger?.debug?.(`${TAG} Both search paths returned 0 results`);
return { results: [], total: 0, strategy: hasEmbedding ? "embedding" : "fts" };
}
// ── Merge results ──
let results: MemorySearchResultItem[];
if (strategy === "hybrid") {
results = rrfMergeL1(ftsItems, vecItems);
logger?.debug?.(
`${TAG} [hybrid] RRF merged: fts=${ftsItems.length}, vec=${vecItems.length}${results.length} unique`,
);
} else {
// Single-source: use whichever list has results (already sorted by score)
results = ftsOk ? ftsItems : vecItems;
}
// ── Apply secondary filters (type, scene) ──
const preFilterCount = results.length;
if (typeFilter) {
results = results.filter((r) => r.type === typeFilter);
logger?.debug?.(`${TAG} After type filter "${typeFilter}": ${results.length}/${preFilterCount}`);
}
if (sceneFilter) {
const normalizedScene = sceneFilter.toLowerCase();
results = results.filter((r) =>
r.scene_name.toLowerCase().includes(normalizedScene),
);
logger?.debug?.(`${TAG} After scene filter "${sceneFilter}": ${results.length}/${preFilterCount}`);
}
// ── Trim to requested limit ──
const trimmed = results.slice(0, limit);
logger?.debug?.(
`${TAG} RESULT (strategy=${strategy}): returning ${trimmed.length} memories ` +
`(scores: [${trimmed.map((r) => r.score.toFixed(3)).join(", ")}])`,
);
return {
results: trimmed,
total: trimmed.length,
strategy,
};
}
// ============================
// Tool response formatter
// ============================
export function formatSearchResponse(result: MemorySearchResult): string {
if (result.message) {
return result.message;
}
if (result.results.length === 0) {
return "No matching memories found.";
}
const lines: string[] = [
`Found ${result.total} matching memories:`,
"",
];
for (const item of result.results) {
const scoreStr = typeof item.score === "number" ? ` (score: ${item.score.toFixed(3)})` : "";
const sceneStr = item.scene_name ? ` [scene: ${item.scene_name}]` : "";
const priorityStr = item.priority >= 0 ? ` (priority: ${item.priority})` : " (global instruction)";
lines.push(`- **[${item.type}]**${priorityStr}${sceneStr}${scoreStr}`);
lines.push(` ${item.content}`);
lines.push("");
}
return lines.join("\n");
}
+156
View File
@@ -0,0 +1,156 @@
/**
* BackupManager: generic file/directory backup utility.
*
* Provides two backup modes:
* - `backupFile(src, category, tag, maxKeep)` — copy a single file
* - `backupDirectory(src, category, tag, maxKeep)` — copy an entire directory
*
* All backups land under `<backupRoot>/<category>/` with timestamped names.
* After each backup, entries beyond `maxKeep` are automatically pruned
* (oldest first, by lexicographic order on the timestamp-embedded name).
*/
import fs from "node:fs/promises";
import path from "node:path";
export class BackupManager {
private backupRoot: string;
/**
* @param backupRoot - Absolute path to the root backup directory
* (e.g. `<dataDir>/.backup`).
*/
constructor(backupRoot: string) {
this.backupRoot = backupRoot;
}
/**
* Backup a single file.
*
* Destination: `<backupRoot>/<category>/<category>_<timestamp>_<tag>.<ext>`
*
* @param srcFile - Absolute path to the source file
* @param category - Logical grouping (e.g. "persona")
* @param tag - Additional identifier (e.g. "offset42")
* @param maxKeep - Max backup files to retain in this category (0 = unlimited)
*/
async backupFile(
srcFile: string,
category: string,
tag: string,
maxKeep: number,
): Promise<void> {
try {
await fs.access(srcFile);
} catch {
return; // Source file doesn't exist, nothing to backup
}
const destDir = path.join(this.backupRoot, category);
await fs.mkdir(destDir, { recursive: true });
const ext = path.extname(srcFile); // e.g. ".md"
const timestamp = formatTimestamp(new Date());
const destName = `${category}_${timestamp}_${tag}${ext}`;
await fs.copyFile(srcFile, path.join(destDir, destName));
if (maxKeep > 0) {
await pruneOldEntries(destDir, maxKeep, "file");
}
}
/**
* Backup an entire directory (shallow copy of all files).
*
* Destination: `<backupRoot>/<category>/<category>_<timestamp>_<tag>/`
*
* @param srcDir - Absolute path to the source directory
* @param category - Logical grouping (e.g. "scene_blocks")
* @param tag - Additional identifier (e.g. "offset42")
* @param maxKeep - Max backup directories to retain in this category (0 = unlimited)
*/
async backupDirectory(
srcDir: string,
category: string,
tag: string,
maxKeep: number,
): Promise<void> {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(srcDir, { withFileTypes: true });
} catch {
return; // Source directory doesn't exist
}
// Only backup regular files (skip subdirectories to avoid EISDIR errors)
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
if (files.length === 0) return;
const parentDir = path.join(this.backupRoot, category);
const timestamp = formatTimestamp(new Date());
const destDir = path.join(parentDir, `${category}_${timestamp}_${tag}`);
await fs.mkdir(destDir, { recursive: true });
for (const file of files) {
await fs.copyFile(path.join(srcDir, file), path.join(destDir, file));
}
if (maxKeep > 0) {
await pruneOldEntries(parentDir, maxKeep, "directory");
}
}
}
// ============================
// Helpers
// ============================
function formatTimestamp(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return [
d.getFullYear(),
pad(d.getMonth() + 1),
pad(d.getDate()),
"_",
pad(d.getHours()),
pad(d.getMinutes()),
pad(d.getSeconds()),
].join("");
}
/**
* Keep only the newest `maxKeep` entries in a directory.
* Entries are sorted by name ascending (oldest first) since backup names
* embed timestamps, so lexicographic order = chronological order.
*
* @param dir - Directory containing the backup entries
* @param maxKeep - Number of entries to retain
* @param kind - "file" to unlink, "directory" to rm -rf
*/
async function pruneOldEntries(
dir: string,
maxKeep: number,
kind: "file" | "directory",
): Promise<void> {
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
return;
}
entries.sort(); // ascending — oldest first
const toRemove = entries.slice(0, Math.max(0, entries.length - maxKeep));
for (const name of toRemove) {
try {
if (kind === "file") {
await fs.unlink(path.join(dir, name));
} else {
await fs.rm(path.join(dir, name), { recursive: true, force: true });
}
} catch {
// best-effort
}
}
}
+543
View File
@@ -0,0 +1,543 @@
/**
* Checkpoint management for tracking memory processing progress.
*
* ## Split-state design
*
* Per-session state is split into two independent namespaces to prevent
* the PipelineManager and L0/L1 runners from overwriting each other's fields:
*
* - **runner_states** (`RunnerSessionState`): owned by CheckpointManager methods
* (markL1*, advanceSession*). Contains L0 capture cursor, L1 cursor, scene name.
*
* - **pipeline_states** (`PipelineSessionState`): owned exclusively by
* PipelineManager via `mergePipelineStates()`. Contains conversation_count,
* extraction times, L2 tracking fields.
*
* Each side only reads/writes its own namespace, eliminating the split-brain
* overwrite bug where pipeline persistStates() could clobber runner-written fields.
*
* ## Concurrency safety
*
* All mutating methods (read-modify-write) are serialized via a per-file async lock.
* Multiple CheckpointManager instances sharing the same file path automatically share
* the same lock, so callers can freely `new CheckpointManager()` without coordination.
* Writes use atomic tmp+rename to prevent corruption on crash.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { randomBytes } from "node:crypto";
// ============================
// Types
// ============================
/**
* Per-session state managed by L0/L1 runners (written directly to checkpoint).
* These fields are ONLY written by CheckpointManager methods (markL1*, advanceSession*, etc.)
* and are NEVER touched by the PipelineManager's persistStates().
*/
export interface RunnerSessionState {
// ═══ L0 — per-session capture cursor ═══
/** Epoch ms of the newest message captured for THIS session.
* Used instead of the global `Checkpoint.last_captured_timestamp` so that
* concurrent sessions don't advance each other's cursors and cause missed messages. */
last_captured_timestamp: number;
// ═══ L1 — cursor & continuity ═══
/** L0 JSONL cursor: epoch ms of last message processed by L1 */
last_l1_cursor: number;
/** Last scene name from the most recent L1 extraction (for cross-batch continuity) */
last_scene_name: string;
}
/**
* Per-session state managed exclusively by PipelineManager (written via mergePipelineStates).
* These fields are ONLY written by the pipeline's persistStates() callback
* and are NEVER touched by CheckpointManager's L0/L1 methods.
*/
export interface PipelineSessionState {
/** Conversation rounds since last L1 trigger */
conversation_count: number;
/** ISO timestamp of the last extraction completion */
last_extraction_time: string;
/** ISO timestamp cursor for incremental extraction reads */
last_extraction_updated_time: string;
/** Epoch ms of the last notifyConversation call */
last_active_time: number;
/** Mirrors conversation_count at L1 completion time (for L2 tracking) */
l2_pending_l1_count: number;
/**
* Current warm-up threshold for L1 triggering.
* Starts at 1 for new sessions and doubles after each L1 completion
* (1 → 2 → 4 → 8 → ...) until it reaches everyNConversations.
* 0 means warm-up is complete (use everyNConversations directly).
*/
warmup_threshold: number;
/** ISO timestamp of last L2 extraction completion */
l2_last_extraction_time: string;
}
export interface Checkpoint {
// ═══ Global counters ═══
/** Epoch ms of the newest message successfully uploaded. Messages with ts > this are new. */
last_captured_timestamp: number;
/** Total messages processed across all time */
total_processed: number;
last_persona_at: number;
last_persona_time: string;
request_persona_update: boolean;
persona_update_reason: string;
memories_since_last_persona: number;
scenes_processed: number;
// ═══ Per-session split state ═══
/** Runner-managed per-session state (L0 capture cursor, L1 cursor, scene name).
* Written ONLY by CheckpointManager methods. */
runner_states: Record<string, RunnerSessionState>;
/** Pipeline-managed per-session state (conversation_count, extraction times, etc.).
* Written ONLY by the pipeline's mergePipelineStates(). */
pipeline_states: Record<string, PipelineSessionState>;
// ═══ L0 ═══
/** Total L0 conversation files recorded */
l0_conversations_count: number;
// ═══ L1 ═══
/** Total L1 memories extracted across all time */
total_memories_extracted: number;
}
const DEFAULT_RUNNER_STATE: RunnerSessionState = {
last_captured_timestamp: 0,
last_l1_cursor: 0,
last_scene_name: "",
};
const DEFAULT_PIPELINE_STATE: PipelineSessionState = {
conversation_count: 0,
last_extraction_time: "",
last_extraction_updated_time: "",
last_active_time: 0,
l2_pending_l1_count: 0,
warmup_threshold: 0, // 0 = graduated (safe default for old sessions missing this field)
l2_last_extraction_time: "",
};
const DEFAULT_CHECKPOINT: Checkpoint = {
last_captured_timestamp: 0,
total_processed: 0,
last_persona_at: 0,
last_persona_time: "",
request_persona_update: false,
persona_update_reason: "",
memories_since_last_persona: 0,
scenes_processed: 0,
runner_states: {},
pipeline_states: {},
l0_conversations_count: 0,
total_memories_extracted: 0,
};
export interface CheckpointLogger {
info(msg: string): void;
warn?(msg: string): void;
}
const noopLogger: CheckpointLogger = { info() {} };
// ============================
// Per-file async lock
// ============================
// Keyed by resolved file path. Multiple CheckpointManager instances pointing
// to the same file automatically share the same lock — callers don't need to
// coordinate instance creation.
const fileLocks = new Map<string, Promise<void>>();
/**
* Serialize async critical sections per file path.
* Under no contention the overhead is a single resolved-promise await.
*/
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
// Chain after whatever is currently queued for this path
const prev = fileLocks.get(filePath) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
fileLocks.set(filePath, gate);
await prev;
try {
return await fn();
} finally {
release();
// Clean up the map entry if we're the tail of the chain
if (fileLocks.get(filePath) === gate) {
fileLocks.delete(filePath);
}
}
}
export class CheckpointManager {
private filePath: string;
private logger: CheckpointLogger;
constructor(dataDir: string, logger?: CheckpointLogger) {
this.filePath = path.join(dataDir, ".metadata", "recall_checkpoint.json");
this.logger = logger ?? noopLogger;
}
// ============================
// Low-level I/O (internal)
// ============================
private async readRaw(): Promise<Checkpoint> {
try {
const raw = await fs.readFile(this.filePath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
// Merge with defaults for backward compat (old checkpoints lack new fields).
// structuredClone avoids shallow-copy pitfall: without it, the nested
// runner_states/pipeline_states objects in DEFAULT_CHECKPOINT would be
// shared across all callers and mutated in place — corrupting the default.
const cp = { ...structuredClone(DEFAULT_CHECKPOINT), ...parsed } as Checkpoint;
// Migrate from old session_states format (pre-split)
const oldStates = parsed.session_states as Record<string, Record<string, unknown>> | undefined;
if (oldStates && !parsed.runner_states && !parsed.pipeline_states) {
cp.runner_states = {};
cp.pipeline_states = {};
for (const [key, state] of Object.entries(oldStates)) {
cp.runner_states[key] = {
...DEFAULT_RUNNER_STATE,
last_captured_timestamp: (state.last_captured_timestamp as number) ?? 0,
last_l1_cursor: (state.last_l1_cursor as number) ?? 0,
last_scene_name: (state.last_scene_name as string) ?? "",
};
cp.pipeline_states[key] = {
...DEFAULT_PIPELINE_STATE,
conversation_count: (state.conversation_count as number) ?? 0,
last_extraction_time: (state.last_extraction_time as string) ?? "",
last_extraction_updated_time: (state.last_extraction_updated_time as string) ?? "",
last_active_time: (state.last_active_time as number) ?? 0,
l2_pending_l1_count: (state.l2_pending_l1_count as number) ?? 0,
l2_last_extraction_time: (state.l2_last_extraction_time as string) ?? "",
};
}
} else {
// Ensure per-session states have all fields with defaults
if (cp.runner_states) {
for (const [key, state] of Object.entries(cp.runner_states)) {
cp.runner_states[key] = { ...DEFAULT_RUNNER_STATE, ...state };
}
}
if (cp.pipeline_states) {
for (const [key, state] of Object.entries(cp.pipeline_states)) {
cp.pipeline_states[key] = { ...DEFAULT_PIPELINE_STATE, ...state };
}
}
}
return cp;
} catch {
return structuredClone(DEFAULT_CHECKPOINT);
}
}
/** Atomic write: write to tmp file, then rename into place. */
private async writeRaw(checkpoint: Checkpoint): Promise<void> {
const dir = path.dirname(this.filePath);
await fs.mkdir(dir, { recursive: true });
const tmp = `${this.filePath}.tmp.${randomBytes(4).toString("hex")}`;
await fs.writeFile(tmp, JSON.stringify(checkpoint, null, 2), "utf-8");
await fs.rename(tmp, this.filePath);
}
// ============================
// Locked read-modify-write helper
// ============================
/**
* Execute a mutating operation under the per-file lock.
* `fn` receives the current checkpoint and may modify it in place;
* the updated checkpoint is atomically written back.
*/
private async mutate(fn: (cp: Checkpoint) => void | Promise<void>): Promise<Checkpoint> {
return withFileLock(this.filePath, async () => {
const cp = await this.readRaw();
await fn(cp);
await this.writeRaw(cp);
return cp;
});
}
// ============================
// Public API — read-only
// ============================
/**
* Read the current checkpoint (unlocked snapshot).
*
* NOTE: This does NOT acquire the file lock. The returned snapshot may be
* stale if a concurrent `mutate()` is in progress. This is acceptable for
* read-only uses (status display, deciding whether to run a pipeline step).
*
* For read-then-write patterns, always use `mutate()` instead — it acquires
* the lock and re-reads from disk inside the critical section, ensuring the
* update is based on the latest state.
*/
async read(): Promise<Checkpoint> {
return this.readRaw();
}
/** Write a full checkpoint (acquires lock + atomic write). */
async write(checkpoint: Checkpoint): Promise<void> {
return withFileLock(this.filePath, () => this.writeRaw(checkpoint));
}
// ============================
// Public API — mutating (all serialized via file lock)
// ============================
/**
* Advance the captured timestamp after successful upload/recording.
* Also updates total_processed and persona counters.
*
* NOTE: This advances the GLOBAL cursor (`Checkpoint.last_captured_timestamp`).
* For per-session cursor advancement, use `advanceSessionCapturedTimestamp()`.
* The global cursor is kept for aggregate stats / backward compat, but should
* NOT be used as the L0 incremental-capture filter (use per-session instead).
*/
async advanceCapturedTimestamp(maxTimestamp: number, messageCount: number): Promise<void> {
const cp = await this.mutate((cp) => {
cp.last_captured_timestamp = maxTimestamp;
cp.total_processed += messageCount;
cp.memories_since_last_persona += messageCount;
});
this.logger.info(
`[checkpoint] advanceCapturedTimestamp: -> ${maxTimestamp} (+${messageCount} msgs), ` +
`total_processed=${cp.total_processed}, memories_since_last_persona=${cp.memories_since_last_persona}`,
);
}
/**
* Advance the per-session L0 capture cursor after recording messages.
* This is the **primary** cursor for incremental L0 recording — each session
* tracks its own progress independently, preventing cross-session cursor drift.
*
* Also updates the global cursor / total_processed for aggregate stats.
*/
async advanceSessionCapturedTimestamp(
sessionKey: string,
maxTimestamp: number,
messageCount: number,
): Promise<void> {
const cp = await this.mutate((cp) => {
// Per-session cursor (runner-owned)
const state = this.getRunnerState(cp, sessionKey);
state.last_captured_timestamp = maxTimestamp;
// Global stats (aggregate only — not used for filtering)
cp.last_captured_timestamp = Math.max(cp.last_captured_timestamp, maxTimestamp);
cp.total_processed += messageCount;
cp.memories_since_last_persona += messageCount;
});
this.logger.info(
`[checkpoint] advanceSessionCapturedTimestamp session=${sessionKey}: -> ${maxTimestamp} ` +
`(+${messageCount} msgs), total_processed=${cp.total_processed}`,
);
}
/**
* Increment L0 conversation count.
*/
async incrementL0ConversationCount(): Promise<void> {
await this.mutate((cp) => {
cp.l0_conversations_count += 1;
});
}
// ============================
// Persona methods (L3)
// ============================
async markPersonaGenerated(totalProcessed: number): Promise<void> {
await this.mutate((cp) => {
cp.last_persona_at = totalProcessed;
cp.last_persona_time = new Date().toISOString();
cp.memories_since_last_persona = 0;
cp.request_persona_update = false;
cp.persona_update_reason = "";
});
}
async clearPersonaRequest(): Promise<void> {
await this.mutate((cp) => {
cp.request_persona_update = false;
cp.persona_update_reason = "";
});
}
async setPersonaUpdateRequest(reason: string): Promise<void> {
await this.mutate((cp) => {
cp.request_persona_update = true;
cp.persona_update_reason = reason;
});
}
async incrementScenesProcessed(): Promise<void> {
const cp = await this.mutate((cp) => {
cp.scenes_processed += 1;
});
this.logger.info(`[checkpoint] incrementScenesProcessed: scenes_processed=${cp.scenes_processed}`);
}
// ============================
// Per-session helpers — runner state (L0/L1 owned)
// ============================
/**
* Get or create runner session state for a session.
*/
getRunnerState(cp: Checkpoint, sessionKey: string): RunnerSessionState {
if (!cp.runner_states) {
cp.runner_states = {};
}
let state = cp.runner_states[sessionKey];
if (!state) {
state = { ...DEFAULT_RUNNER_STATE };
cp.runner_states[sessionKey] = state;
}
return state;
}
// ============================
// Per-session helpers — pipeline state (PipelineManager owned)
// ============================
/**
* Get or create pipeline session state for a session.
*/
getPipelineState(cp: Checkpoint, sessionKey: string): PipelineSessionState {
if (!cp.pipeline_states) {
cp.pipeline_states = {};
}
let state = cp.pipeline_states[sessionKey];
if (!state) {
state = { ...DEFAULT_PIPELINE_STATE, last_active_time: Date.now() };
cp.pipeline_states[sessionKey] = state;
}
return state;
}
/**
* Get all pipeline states from checkpoint.
*/
getAllPipelineStates(cp: Checkpoint): Record<string, PipelineSessionState> {
return cp.pipeline_states ?? {};
}
/**
* Merge pipeline session states into the checkpoint (used by pipeline persister).
* Acquires the file lock so this is safe against concurrent mutations.
*
* This writes ONLY to `pipeline_states`, never touching `runner_states`.
* This is the core guarantee that eliminates the split-brain overwrite bug.
*/
async mergePipelineStates(states: Record<string, PipelineSessionState>): Promise<void> {
await this.mutate((cp) => {
if (!cp.pipeline_states) cp.pipeline_states = {};
for (const [key, pState] of Object.entries(states)) {
cp.pipeline_states[key] = {
...cp.pipeline_states[key],
...pState,
};
}
});
}
// ============================
// L1-specific methods
// ============================
/**
* Mark L1 extraction completed: reset sinceL1 counter, advance L1 cursor,
* and optionally save the last scene name for cross-batch continuity.
*/
async markL1ExtractionComplete(
sessionKey: string,
memoriesExtracted: number,
cursorTimestamp?: number,
lastSceneName?: string,
): Promise<void> {
await this.mutate((cp) => {
const state = this.getRunnerState(cp, sessionKey);
if (cursorTimestamp) {
state.last_l1_cursor = cursorTimestamp;
}
if (lastSceneName !== undefined) {
state.last_scene_name = lastSceneName;
}
cp.total_memories_extracted += memoriesExtracted;
cp.memories_since_last_persona += memoriesExtracted;
});
this.logger.info(
`[checkpoint] markL1ExtractionComplete session=${sessionKey}: ` +
`extracted=${memoriesExtracted}, cursor=${cursorTimestamp ?? "(unchanged)"}, ` +
`lastScene="${lastSceneName ?? "(unchanged)"}"`,
);
}
// ============================
// Atomic capture (race-condition fix)
// ============================
/**
* Atomically read the per-session cursor, execute the capture callback,
* and advance the cursor — all within a single file-lock critical section.
*
* This eliminates the race window that existed when `read()` (unlocked) and
* `advanceSessionCapturedTimestamp()` (locked) were separate calls:
* two concurrent `agent_end` events could both read the same stale cursor
* and record duplicate messages.
*
* The callback receives `afterTimestamp` (the current per-session cursor)
* and must return either:
* - `{ maxTimestamp, messageCount }` to advance the cursor, or
* - `null` to leave the cursor unchanged (nothing captured).
*
* L0 conversation count is also incremented inside the lock when messages
* are captured, removing the need for a separate `incrementL0ConversationCount()` call.
*
* @param sessionKey Per-session identifier
* @param pluginStartTimestamp Cold-start floor (used when no cursor exists yet)
* @param fn Async callback that performs the actual capture (recordConversation, etc.)
*/
async captureAtomically(
sessionKey: string,
pluginStartTimestamp: number | undefined,
fn: (afterTimestamp: number) => Promise<{ maxTimestamp: number; messageCount: number } | null>,
): Promise<void> {
await this.mutate(async (cp) => {
// Read the per-session cursor inside the lock
const state = this.getRunnerState(cp, sessionKey);
let afterTimestamp = state.last_captured_timestamp || 0;
// Cold-start guard (same logic that was previously in auto-capture.ts)
if (afterTimestamp === 0 && pluginStartTimestamp && pluginStartTimestamp > 0) {
afterTimestamp = pluginStartTimestamp;
}
const result = await fn(afterTimestamp);
if (result) {
// Advance per-session cursor (runner-owned)
state.last_captured_timestamp = result.maxTimestamp;
// Global stats (aggregate only — not used for filtering)
cp.last_captured_timestamp = Math.max(cp.last_captured_timestamp, result.maxTimestamp);
cp.total_processed += result.messageCount;
cp.memories_since_last_persona += result.messageCount;
// Increment L0 conversation count (was a separate mutate() call before)
cp.l0_conversations_count += 1;
}
});
}
}
+451
View File
@@ -0,0 +1,451 @@
/**
* CleanContextRunner: executes LLM calls in a fully isolated context
* using runEmbeddedPiAgent (same mechanism as the llm-task extension).
*
* Guarantees:
* 1. Blank conversation history (temporary session file)
* 2. Independent system prompt (only the task prompt)
* 3. No tool calls (tools restricted to minimal read-only set to avoid empty tools[] rejection by some providers)
* 4. No contamination from the main agent's context
*/
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
import { report } from "../report/reporter.js";
/**
* Resolve a preferred temporary directory for memory-tdai operations.
*
* Previously imported from `openclaw/plugin-sdk` as `resolvePreferredOpenClawTmpDir`,
* but that export was removed in openclaw 2026.2.23+. This local implementation
* provides equivalent behavior:
* 1. Try `/tmp/openclaw` (if writable)
* 2. Fall back to `os.tmpdir()/openclaw-<uid>`
*/
function resolveOpenClawTmpDir(): string {
const POSIX_DIR = "/tmp/openclaw";
try {
if (fsSync.existsSync(POSIX_DIR)) {
fsSync.accessSync(POSIX_DIR, fsSync.constants.W_OK | fsSync.constants.X_OK);
return POSIX_DIR;
}
// Try to create it
fsSync.mkdirSync(POSIX_DIR, { recursive: true, mode: 0o700 });
return POSIX_DIR;
} catch {
// Fall back to os.tmpdir()
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
const suffix = uid === undefined ? "openclaw" : `openclaw-${uid}`;
const fallback = path.join(os.tmpdir(), suffix);
fsSync.mkdirSync(fallback, { recursive: true });
return fallback;
}
}
const TAG = "[memory-tdai] [runner]";
interface RunnerLogger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
// Dynamic import type — runEmbeddedPiAgent is an internal API
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
// ── Core import (mirrors voice-call/core-bridge.ts — dist/ only, no jiti) ──
let _rootCache: string | null = null;
function findPackageRoot(startDir: string, name: string): string | null {
let dir = startDir;
for (;;) {
const pkgPath = path.join(dir, "package.json");
try {
if (fsSync.existsSync(pkgPath)) {
const raw = fsSync.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { name?: string };
if (pkg.name === name) return dir;
}
} catch { /* ignore */ }
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function resolveOpenClawRoot(): string {
if (_rootCache) return _rootCache;
const override = process.env.OPENCLAW_ROOT?.trim();
if (override) { _rootCache = override; return override; }
const candidates = new Set<string>();
if (process.argv[1]) candidates.add(path.dirname(process.argv[1]));
candidates.add(process.cwd());
try { candidates.add(path.dirname(fileURLToPath(import.meta.url))); } catch { /* ignore */ }
for (const start of candidates) {
const found = findPackageRoot(start, "openclaw");
if (found) { _rootCache = found; return found; }
}
throw new Error("Unable to resolve OpenClaw root. Set OPENCLAW_ROOT or run `pnpm build`.");
}
let _loadPromise: Promise<RunEmbeddedPiAgentFn> | null = null;
function loadRunEmbeddedPiAgent(logger?: RunnerLogger): Promise<RunEmbeddedPiAgentFn> {
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
const t0 = Date.now();
const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
if (!fsSync.existsSync(distPath)) {
throw new Error(`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`);
}
const mod = await import(pathToFileURL(distPath).href);
if (typeof mod.runEmbeddedPiAgent !== "function") {
throw new Error("runEmbeddedPiAgent not exported from dist/extensionAPI.js");
}
logger?.info(`${TAG} loadRunEmbeddedPiAgent: dist/ import OK (${Date.now() - t0}ms)`);
return mod.runEmbeddedPiAgent as RunEmbeddedPiAgentFn;
})();
_loadPromise.catch(() => { _loadPromise = null; });
return _loadPromise;
}
/**
* Pre-warm the embedded agent import. Call this during plugin init to avoid
* the cold-start penalty on the first actual extraction run.
* Returns immediately (fire-and-forget) — errors are swallowed.
*/
export function prewarmEmbeddedAgent(logger?: RunnerLogger): void {
loadRunEmbeddedPiAgent(logger).catch((err) => {
logger?.warn(`${TAG} prewarmEmbeddedAgent: failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
});
}
function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string {
const texts = (payloads ?? [])
.filter((p) => !p.isError && typeof p.text === "string")
.map((p) => p.text ?? "");
return texts.join("\n").trim();
}
// ── Model resolution utilities ──
/** Parsed model reference: { provider, model } */
export interface ModelRef {
provider: string;
model: string;
}
/**
* Parse a "provider/model" string into its components.
* Returns undefined if the input is empty or doesn't contain a "/".
*
* Examples:
* "azure/gpt-5.2-chat" → { provider: "azure", model: "gpt-5.2-chat" }
* "custom-host/org/model-v2" → { provider: "custom-host", model: "org/model-v2" }
* "" → undefined
* "bare-model-name" → undefined (no "/" — may be an alias)
*/
export function parseModelRef(raw: string | undefined): ModelRef | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const slashIdx = trimmed.indexOf("/");
if (slashIdx <= 0 || slashIdx === trimmed.length - 1) return undefined;
return {
provider: trimmed.slice(0, slashIdx),
model: trimmed.slice(slashIdx + 1),
};
}
/**
* Resolve the user's default model from the main OpenClaw config.
*
* Resolution order:
* 1. Read `agents.defaults.model` (string or { primary })
* 2. If the value contains "/", parse directly
* 3. If not (may be an alias), look up in `agents.defaults.models` alias table
* 4. Return undefined if nothing resolves — let the core use its built-in default
*/
export function resolveModelFromMainConfig(config: unknown): ModelRef | undefined {
if (!config || typeof config !== "object") return undefined;
const cfg = config as Record<string, unknown>;
const agents = cfg.agents as Record<string, unknown> | undefined;
if (!agents || typeof agents !== "object") return undefined;
const defaults = agents.defaults as Record<string, unknown> | undefined;
if (!defaults || typeof defaults !== "object") return undefined;
// Step 1: extract raw model value (string | { primary?: string })
const modelCfg = defaults.model;
let raw: string | undefined;
if (typeof modelCfg === "string") {
raw = modelCfg.trim();
} else if (modelCfg && typeof modelCfg === "object") {
const primary = (modelCfg as Record<string, unknown>).primary;
raw = typeof primary === "string" ? primary.trim() : undefined;
}
if (!raw) return undefined;
// Step 2: try direct "provider/model" parse
const direct = parseModelRef(raw);
if (direct) return direct;
// Step 3: alias lookup — raw doesn't contain "/", check agents.defaults.models
const models = defaults.models as Record<string, unknown> | undefined;
if (!models || typeof models !== "object") return undefined;
const rawLower = raw.toLowerCase();
for (const [key, entry] of Object.entries(models)) {
if (!entry || typeof entry !== "object") continue;
const alias = (entry as Record<string, unknown>).alias;
if (typeof alias !== "string") continue;
if (alias.trim().toLowerCase() !== rawLower) continue;
// key is "provider/model" format
const resolved = parseModelRef(key);
if (resolved) return resolved;
}
return undefined;
}
export interface CleanContextRunnerOptions {
config: unknown; // OpenClawConfig
provider?: string;
model?: string;
/**
* Convenience field: full "provider/model" string.
* Takes precedence over separate `provider`/`model` fields.
* When all three (modelRef, provider, model) are omitted,
* automatically falls back to the main config's `agents.defaults.model`.
*/
modelRef?: string;
/** Allow the LLM to use tools (read_file, write_to_file, etc). Default: false */
enableTools?: boolean;
/** Logger instance for detailed tracing */
logger?: RunnerLogger;
}
// Stable empty directory used as default workspaceDir so that:
// 1. Bootstrap/skills scans find nothing → clean LLM context
// 2. The path is constant → plugin cacheKey stays stable (no re-registration)
let _cleanWorkspaceDir: string | undefined;
async function getCleanWorkspaceDir(): Promise<string> {
if (_cleanWorkspaceDir) return _cleanWorkspaceDir;
const dir = path.join(resolveOpenClawTmpDir(), "memory-tdai-clean-workspace");
await fs.mkdir(dir, { recursive: true });
_cleanWorkspaceDir = dir;
return dir;
}
export class CleanContextRunner {
private options: CleanContextRunnerOptions;
private logger: RunnerLogger | undefined;
/** Resolved provider after modelRef / config fallback */
private resolvedProvider: string | undefined;
/** Resolved model after modelRef / config fallback */
private resolvedModel: string | undefined;
constructor(options: CleanContextRunnerOptions) {
this.options = options;
this.logger = options.logger;
// Model resolution priority:
// 1. modelRef ("provider/model" string) — highest
// 2. explicit provider + model fields
// 3. main config agents.defaults.model — automatic fallback
// 4. undefined (let core use built-in default)
const fromRef = parseModelRef(options.modelRef);
if (fromRef) {
this.resolvedProvider = fromRef.provider;
this.resolvedModel = fromRef.model;
} else if (options.provider || options.model) {
this.resolvedProvider = options.provider;
this.resolvedModel = options.model;
} else {
// No explicit model specified — fall back to main config
const fromConfig = resolveModelFromMainConfig(options.config);
if (fromConfig) {
this.resolvedProvider = fromConfig.provider;
this.resolvedModel = fromConfig.model;
this.logger?.debug?.(
`${TAG} Using model from main config: ${fromConfig.provider}/${fromConfig.model}`,
);
}
// else: both undefined → core will use its built-in default (anthropic/claude-opus-4-6)
}
}
/**
* Run a prompt in a fully isolated clean context.
* Returns the LLM's text output.
*
* When `workspaceDir` is provided it overrides the default `process.cwd()`,
* letting the LLM's file-tool calls resolve paths relative to a custom root.
*/
async run(params: {
prompt: string;
/** Optional system prompt. When provided, `prompt` is used as the user message. */
systemPrompt?: string;
taskId: string;
timeoutMs?: number;
maxTokens?: number;
workspaceDir?: string;
/** Plugin instance ID for llm_call metric (optional) */
instanceId?: string;
}): Promise<string> {
const runStartMs = Date.now();
this.logger?.debug?.(`${TAG} run() start: taskId=${params.taskId}, timeout=${params.timeoutMs ?? 120_000}ms, tools=${this.options.enableTools ? "enabled" : "disabled"}, workspaceDir=${params.workspaceDir ?? "(default)"}`);
const tmpDir = await fs.mkdtemp(
path.join(resolveOpenClawTmpDir(), `memory-tdai-${params.taskId}-`),
);
const cleanWorkspace = params.workspaceDir ?? await getCleanWorkspaceDir();
this.logger?.debug?.(`${TAG} run() tmpDir=${tmpDir}, cleanWorkspace=${cleanWorkspace}`);
try {
const sessionFile = path.join(tmpDir, "session.json");
// Phase 1: Load runEmbeddedPiAgent (fast if dist/ exists or already cached)
const importStartMs = Date.now();
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent(this.logger);
const importElapsedMs = Date.now() - importStartMs;
this.logger?.debug?.(`${TAG} run() dynamic import phase: ${importElapsedMs}ms`);
// Derive a config with plugins disabled to prevent loadOpenClawPlugins
// from re-registering plugins when the workspaceDir differs from the
// gateway's original workspace (cacheKey mismatch triggers full reload).
//
// Security: restrict available tools to the minimal set needed for
// scene extraction (read/write/edit). This prevents the LLM from
// accessing exec, sessions, browser, cron, or any other powerful tools.
// File deletion is handled via "soft-delete" (write empty) + cleanup afterward.
const cleanConfig = {
...(this.options.config as Record<string, unknown>),
plugins: {
...((this.options.config as Record<string, unknown>)?.plugins as Record<string, unknown> | undefined),
enabled: false,
},
tools: {
...((this.options.config as Record<string, unknown>)?.tools as Record<string, unknown> | undefined),
// When enableTools=false we still keep one lightweight read-only tool
// so that the tools array sent to the API is non-empty.
// Some providers (e.g. qwencode) reject tools:[] with minItems:1 validation.
allow: this.options.enableTools ? ["read", "write", "edit"] : ["read"],
},
};
// Build the effective prompt:
// If systemPrompt is provided, pass it as a separate parameter to the agent
// and use `prompt` as the user message. Fallback: prepend to prompt if the
// embedded agent doesn't support systemPrompt natively.
const effectivePrompt = params.systemPrompt
? `${params.systemPrompt}\n\n---\n\n${params.prompt}`
: params.prompt;
const ts = Date.now();
const sessionId = `memory-${params.taskId}-session-${ts}`;
const runId = `memory-${params.taskId}-run-${ts}`;
this.logger?.debug?.(`${TAG} run() starting embedded agent: sessionId=${sessionId}, runId=${runId}, provider=${this.resolvedProvider ?? "(default)"}, model=${this.resolvedModel ?? "(default)"}`);
// Phase 2: Embedded agent run (LLM call + tool calls)
const agentStartMs = Date.now();
const result = await runEmbeddedPiAgent({
sessionId,
sessionFile,
workspaceDir: cleanWorkspace,
config: cleanConfig,
prompt: effectivePrompt,
systemPrompt: params.systemPrompt,
timeoutMs: params.timeoutMs ?? 120_000,
runId,
provider: this.resolvedProvider,
model: this.resolvedModel,
// Do NOT pass disableTools:true — that produces tools:[] which some
// providers (qwencode) reject with "[] is too short - 'tools'".
// Instead rely on cleanConfig.tools.allow to restrict the tool set
// to a minimal read-only tool (when enableTools=false).
disableTools: false,
streamParams: {
maxTokens: params.maxTokens,
},
});
const agentElapsedMs = Date.now() - agentStartMs;
this.logger?.debug?.(`${TAG} run() embedded agent completed: ${agentElapsedMs}ms`);
// Phase 3: Collect output
const text = collectText((result as Record<string, unknown>).payloads as Array<{ text?: string; isError?: boolean }> | undefined);
const totalMs = Date.now() - runStartMs;
if (!text) {
// Empty output is normal when the LLM decides there is nothing to
// extract (e.g. trivial greetings). Log a warning instead of
// throwing so the caller can handle it gracefully.
this.logger?.warn?.(`${TAG} run() empty output after ${totalMs}ms (import=${importElapsedMs}ms, agent=${agentElapsedMs}ms) — treating as empty result`);
// llm_call metric (empty output)
if (params.instanceId && this.logger) {
report("llm_call", {
taskId: params.taskId,
provider: this.resolvedProvider ?? "default",
model: this.resolvedModel ?? "default",
inputLength: params.prompt.length,
outputLength: 0,
totalDurationMs: totalMs,
success: true,
error: "empty_output",
});
}
return "";
}
this.logger?.debug?.(`${TAG} run() completed: ${totalMs}ms total (import=${importElapsedMs}ms, agent=${agentElapsedMs}ms), output=${text.length} chars`);
// ── llm_call metric (success) ──
if (params.instanceId && this.logger) {
report("llm_call", {
taskId: params.taskId,
provider: this.resolvedProvider ?? "default",
model: this.resolvedModel ?? "default",
inputLength: params.prompt.length,
outputLength: text.length,
totalDurationMs: totalMs,
success: true,
error: null,
});
}
return text;
} catch (err) {
const totalMs = Date.now() - runStartMs;
this.logger?.error(`${TAG} run() failed after ${totalMs}ms: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
// ── llm_call metric (failure) ──
if (params.instanceId && this.logger) {
report("llm_call", {
taskId: params.taskId,
provider: this.resolvedProvider ?? "default",
model: this.resolvedModel ?? "default",
inputLength: params.prompt.length,
outputLength: 0,
totalDurationMs: totalMs,
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
throw err;
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* ManagedTimer: a named, lifecycle-managed wrapper around setTimeout.
*
* Eliminates repetitive clear→set→fire→clean patterns by providing:
* - `schedule(delayMs, cb)` — cancel any pending timer, set a new one
* - `scheduleAt(epochMs, cb)` — schedule by absolute time point
* - `tryAdvanceTo(epochMs, cb)` — only reschedule if new time is *earlier*
* - `cancel()` — cancel without triggering
* - `flush()` — trigger immediately (for graceful shutdown)
* - `pending` — whether a timer is waiting
*
* The optional `isDestroyed` guard prevents firing after the owner is torn down.
*/
type TimerHandle = ReturnType<typeof setTimeout>;
export class ManagedTimer {
private handle: TimerHandle | null = null;
private callback: (() => void) | null = null;
/** Absolute epoch-ms when the current timer is scheduled to fire. */
private scheduledAt = 0;
constructor(
/** Human-readable name for logging. */
public readonly name: string,
/** If provided, checked before firing — skips callback when true. */
private readonly isDestroyed?: () => boolean,
) {}
// ── Core operations ──────────────────────────────────
/**
* Cancel any pending timer and schedule a new one after `delayMs`.
* The callback fires once; the timer auto-clears after firing.
*/
schedule(delayMs: number, callback: () => void): void {
this.cancelInternal();
this.callback = callback;
this.scheduledAt = Date.now() + delayMs;
this.handle = setTimeout(() => this.fire(), delayMs);
// Don't let pipeline timers keep the process alive in CLI mode.
// In gateway mode the server listener holds the event loop anyway.
this.handle.unref();
}
/**
* Cancel any pending timer and schedule to fire at an absolute epoch-ms.
* If `epochMs` is in the past, fires on next tick (delay = 0).
*/
scheduleAt(epochMs: number, callback: () => void): void {
this.cancelInternal();
this.callback = callback;
this.scheduledAt = epochMs;
const delay = Math.max(0, epochMs - Date.now());
this.handle = setTimeout(() => this.fire(), delay);
this.handle.unref();
}
/**
* Only reschedule if `epochMs` is *earlier* than the current scheduled time.
* This implements the "downward-only" timer pattern (L2 scheduling).
* If no timer is pending, behaves like `scheduleAt()`.
*
* @returns true if the timer was actually advanced (or newly set).
*/
tryAdvanceTo(epochMs: number, callback: () => void): boolean {
if (this.handle === null) {
// No pending timer → set it
this.scheduleAt(epochMs, callback);
return true;
}
if (epochMs < this.scheduledAt) {
// New time is earlier → reschedule
this.scheduleAt(epochMs, callback);
return true;
}
// Current timer is already earlier or equal → keep it
return false;
}
/**
* Cancel the pending timer without triggering the callback.
*/
cancel(): void {
this.cancelInternal();
}
/**
* Immediately trigger the callback (if pending) and clear the timer.
* Used for graceful shutdown to flush pending work.
*
* Note: Unlike `fire()`, this method intentionally does NOT check `isDestroyed`.
* This is by design — during shutdown, `destroy()` sets `destroyed = true` first,
* then calls `flush()` to drain pending work. The `isDestroyed` guard only applies
* to natural timer expiration via `fire()`, not to explicit shutdown flushes.
*/
flush(): void {
if (this.handle === null) return;
const cb = this.callback;
this.cancelInternal();
if (cb) cb();
}
// ── Accessors ────────────────────────────────────────
/** Whether a timer is currently pending. */
get pending(): boolean {
return this.handle !== null;
}
/** The epoch-ms when the current timer is scheduled to fire (0 if none). */
get scheduledTime(): number {
return this.handle !== null ? this.scheduledAt : 0;
}
// ── Internals ────────────────────────────────────────
private fire(): void {
const cb = this.callback;
this.handle = null;
this.callback = null;
this.scheduledAt = 0;
if (this.isDestroyed?.()) return;
if (cb) cb();
}
private cancelInternal(): void {
if (this.handle !== null) {
clearTimeout(this.handle);
this.handle = null;
}
this.callback = null;
this.scheduledAt = 0;
}
}
+331
View File
@@ -0,0 +1,331 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { VectorStore } from "../store/vector-store.js";
import { ManagedTimer } from "./managed-timer.js";
interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export interface MemoryCleanerOptions {
baseDir: string;
retentionDays: number;
cleanTime: string;
logger?: Logger;
vectorStore?: VectorStore;
}
interface CleanupStats {
scannedFiles: number;
changedFiles: number;
skippedNonShardFiles: number;
deleteFailedFiles: number;
}
const TAG = "[memory-tdai][cleaner]";
const L0_DIR_NAME = "conversations";
const L1_DIR_NAME = "records";
export class LocalMemoryCleaner {
private readonly timer: ManagedTimer;
private destroyed = false;
private vectorStore?: VectorStore;
constructor(private readonly opts: MemoryCleanerOptions) {
this.timer = new ManagedTimer("memory-tdai-cleaner", () => this.destroyed);
this.vectorStore = opts.vectorStore;
}
setVectorStore(vectorStore: VectorStore | undefined): void {
this.vectorStore = vectorStore;
}
start(): void {
if (this.destroyed) return;
const now = new Date();
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown";
const utcOffset = formatUtcOffset(-now.getTimezoneOffset());
this.opts.logger?.info(
`${TAG} Enabled: retentionDays=${this.opts.retentionDays}, cleanTime=${this.opts.cleanTime}, dirs=[${L0_DIR_NAME}, ${L1_DIR_NAME}]`,
);
this.opts.logger?.info(
`${TAG} Runtime clock: nowLocal=${formatLocalDateTime(now)}, nowIso=${now.toISOString()}, tz=${tz}, utcOffset=${utcOffset}`,
);
this.scheduleNext();
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.timer.cancel();
this.opts.logger?.info(`${TAG} Stopped`);
}
async runOnce(nowMs = Date.now()): Promise<void> {
if (this.destroyed) return;
const retentionDays = this.opts.retentionDays;
if (!(retentionDays > 0)) {
this.opts.logger?.debug?.(`${TAG} Skip run: invalid retentionDays=${retentionDays}`);
return;
}
// 按“本地自然日”保留策略计算截止时间。
// 例如 retentionDays=2,今天是 03-15,则保留 03-14/03-15,删除早于 03-14 00:00:00.000 的记录。
const cutoffMs = computeCutoffMsByLocalDay(nowMs, retentionDays);
const targetDirs = [
path.join(this.opts.baseDir, L0_DIR_NAME),
path.join(this.opts.baseDir, L1_DIR_NAME),
];
const total: CleanupStats = {
scannedFiles: 0,
changedFiles: 0,
skippedNonShardFiles: 0,
deleteFailedFiles: 0,
};
for (const dirPath of targetDirs) {
const stats = await this.cleanDirectory(dirPath, cutoffMs);
total.scannedFiles += stats.scannedFiles;
total.changedFiles += stats.changedFiles;
total.skippedNonShardFiles += stats.skippedNonShardFiles;
total.deleteFailedFiles += stats.deleteFailedFiles;
}
if (this.vectorStore) {
const vectorStore = this.vectorStore;
const cutoffIso = new Date(cutoffMs).toISOString();
let removedL0 = 0;
let removedL1 = 0;
let failedL0DbCleanup = 0;
let failedL1DbCleanup = 0;
try {
removedL0 = vectorStore.deleteL0ExpiredByRecordedAt(cutoffIso);
} catch (err) {
failedL0DbCleanup = 1;
this.opts.logger?.warn(
`${TAG} SQLite cleanup L0 failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
removedL1 = vectorStore.deleteL1ExpiredByUpdatedTime(cutoffIso);
} catch (err) {
failedL1DbCleanup = 1;
this.opts.logger?.warn(
`${TAG} SQLite cleanup L1 failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (removedL1 > 0 || removedL0 > 0) {
total.changedFiles += 1;
}
this.opts.logger?.info(
`${TAG} SQLite cleanup done: removedL1Records=${removedL1}, removedL0Records=${removedL0}, failedL1DbCleanup=${failedL1DbCleanup}, failedL0DbCleanup=${failedL0DbCleanup}, cutoffIso=${cutoffIso}`,
);
}
this.opts.logger?.info(
`${TAG} Cleanup done: scannedFiles=${total.scannedFiles}, changedFiles=${total.changedFiles}, skippedNonShardFiles=${total.skippedNonShardFiles}, deleteFailedFiles=${total.deleteFailedFiles}`,
);
}
private scheduleNext(): void {
const nowMs = Date.now();
const now = new Date(nowMs);
const next = nextRunAt(this.opts.cleanTime, nowMs);
const targetToday = buildTodayRunTime(this.opts.cleanTime, nowMs);
const passedToday = targetToday <= nowMs;
const delayMs = Math.max(0, next - nowMs);
this.opts.logger?.info(
`${TAG} Schedule next run: nowLocal=${formatLocalDateTime(now)}, cleanTime=${this.opts.cleanTime}, targetTodayLocal=${formatLocalDateTime(new Date(targetToday))}, passedToday=${passedToday}, nextRunLocal=${formatLocalDateTime(new Date(next))}, nextRunIso=${new Date(next).toISOString()}, delayMs=${delayMs}`,
);
this.timer.scheduleAt(next, () => {
const firedAtMs = Date.now();
this.opts.logger?.info(
`${TAG} Timer fired: scheduledLocal=${formatLocalDateTime(new Date(next))}, firedLocal=${formatLocalDateTime(new Date(firedAtMs))}, driftMs=${firedAtMs - next}`,
);
void this.runAndReschedule();
});
}
private async runAndReschedule(): Promise<void> {
if (this.destroyed) return;
const runStart = new Date();
this.opts.logger?.info(
`${TAG} Cleanup tick start: nowLocal=${formatLocalDateTime(runStart)}, nowIso=${runStart.toISOString()}`,
);
try {
await this.runOnce();
} catch (err) {
this.opts.logger?.error(`${TAG} Cleanup failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
} finally {
if (!this.destroyed) {
this.scheduleNext();
}
}
}
private async cleanDirectory(dirPath: string, cutoffMs: number): Promise<CleanupStats> {
const stats: CleanupStats = {
scannedFiles: 0,
changedFiles: 0,
skippedNonShardFiles: 0,
deleteFailedFiles: 0,
};
let entries;
try {
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
this.opts.logger?.debug?.(`${TAG} Directory not found, skip: ${dirPath}`);
return stats;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!isJsonLikeFile(entry.name)) continue;
const filePath = path.join(dirPath, entry.name);
stats.scannedFiles += 1;
// 仅支持日期分片文件:YYYY-MM-DD(.jsonl/.json)
const shard = extractShardDateFromFileName(entry.name);
if (!shard) {
stats.skippedNonShardFiles += 1;
this.opts.logger?.debug?.(`${TAG} Skip non-shard file: ${filePath}`);
continue;
}
const dayEndMs = localDayEndMs(shard.year, shard.month, shard.day);
if (dayEndMs < cutoffMs) {
try {
await fs.unlink(filePath);
stats.changedFiles += 1;
this.opts.logger?.info(`${TAG} Removed expired file by name: ${filePath}`);
} catch (err) {
stats.deleteFailedFiles += 1;
this.opts.logger?.warn(
`${TAG} Failed to delete expired shard file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
} else {
this.opts.logger?.debug?.(`${TAG} Keep shard file by name: ${filePath}`);
}
}
return stats;
}
}
function isJsonLikeFile(name: string): boolean {
return name.endsWith(".jsonl") || name.endsWith(".json");
}
function extractShardDateFromFileName(
fileName: string,
): { year: number; month: number; day: number } | undefined {
// Supported format: YYYY-MM-DD.jsonl | YYYY-MM-DD.json
const m = /^(\d{4})-(\d{2})-(\d{2})\.(?:jsonl|json)$/.exec(fileName);
if (!m) return undefined;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return undefined;
}
if (month < 1 || month > 12 || day < 1 || day > 31) {
return undefined;
}
const probe = new Date(year, month - 1, day);
if (
probe.getFullYear() !== year
|| probe.getMonth() !== month - 1
|| probe.getDate() !== day
) {
return undefined;
}
return { year, month, day };
}
function localDayEndMs(year: number, month: number, day: number): number {
const end = new Date(year, month - 1, day, 23, 59, 59, 999);
return end.getTime();
}
function formatLocalDateTime(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
}
function formatUtcOffset(offsetMinutes: number): string {
const sign = offsetMinutes >= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
const mm = String(abs % 60).padStart(2, "0");
return `${sign}${hh}:${mm}`;
}
function computeCutoffMsByLocalDay(nowMs: number, retentionDays: number): number {
// 自然日策略,保留“今天 + 往前 retentionDays-1 天”
// 删除阈值为 keepStart 当天 00:00:00.000(本地时区)
const now = new Date(nowMs);
const keepStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
keepStart.setDate(keepStart.getDate() - (retentionDays - 1));
return keepStart.getTime();
}
function buildTodayRunTime(cleanTime: string, nowMs: number): number {
const [hRaw, mRaw] = cleanTime.split(":");
const hour = Number(hRaw);
const minute = Number(mRaw);
const target = new Date(nowMs);
target.setHours(hour, minute, 0, 0);
return target.getTime();
}
function nextRunAt(cleanTime: string, nowMs: number): number {
const [hRaw, mRaw] = cleanTime.split(":");
const hour = Number(hRaw);
const minute = Number(mRaw);
const now = new Date(nowMs);
const next = new Date(nowMs);
next.setHours(hour, minute, 0, 0);
if (next.getTime() <= now.getTime()) {
next.setDate(next.getDate() + 1);
}
return next.getTime();
}
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
/**
* Text sanitization for memory pipeline (capture & recall).
* Removes injected tags, gateway metadata, media noise, etc.
*/
/**
* Clean text for the memory pipeline: remove injected tags, metadata,
* timestamps, media markers and base64 image data.
*
* Used by both capture (L0 recording) and recall (query cleaning) paths.
*/
export function sanitizeText(text: string): string {
let cleaned = text;
// Remove injected memory context tags (prevent feedback loops)
cleaned = cleaned.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/g, "");
cleaned = cleaned.replace(/<user-persona>[\s\S]*?<\/user-persona>/g, "");
cleaned = cleaned.replace(/<relevant-scenes>[\s\S]*?<\/relevant-scenes>/g, "");
cleaned = cleaned.replace(/<scene-navigation>[\s\S]*?<\/scene-navigation>/g, "");
// Remove framework-injected inbound metadata blocks (from inbound-meta.ts buildInboundUserContextPrefix).
// These are "label:\n```json\n...\n```" blocks that the framework prepends to user messages.
// Pattern matches all known block labels:
// - Conversation info (untrusted metadata):
// - Sender (untrusted metadata):
// - Thread starter (untrusted, for context):
// - Replied message (untrusted, for context):
// - Forwarded message context (untrusted metadata):
// - Chat history since last reply (untrusted, for context):
cleaned = cleaned.replace(
/(?:Conversation info|Sender|Thread starter|Replied message|Forwarded message context|Chat history since last reply)\s*\(untrusted[\s\S]*?\):\s*```json\s*[\s\S]*?```/g,
"",
);
// Remove conversation metadata JSON blocks (legacy pattern)
cleaned = cleaned.replace(/```json\s*\{[\s\S]*?"session[\s\S]*?\}\s*```/g, "");
// Remove framework reply directive tags: [[reply_to_current]], [[reply_to_xxx]], etc.
cleaned = cleaned.replace(/\[\[reply_to[^\]]*\]\]\s*/g, "");
// Remove line-leading timestamps, e.g. "[Tue 2026-03-24 03:48 UTC]"
// or "[Tue 2026-03-24 20:21 GMT+8]", "[Thu 2026-03-24 01:51 GMT+5:30]"
// Matches brackets containing word chars, digits, hyphens, colons, plus signs,
// and spaces — the '+' is needed for timezone offsets like GMT+8, GMT+5:30.
cleaned = cleaned.replace(/^\[[\w\d\-:+ ]+\]\s*/gm, "");
// Remove gateway media-attachment markers:
// [media attached: /path/to/file.png (image/png) | /path/to/file.png]
cleaned = cleaned.replace(/\[media attached:[^\]]*\]\s*/g, "");
// Remove gateway image-reply instructions injected after media attachments.
// Starts with "To send an image back" and ends before the next real content.
cleaned = cleaned.replace(
/To send an image back,[\s\S]*?(?:Keep caption in the text body\.)\s*/g,
"",
);
// Remove "System: [timestamp] Exec completed ..." blocks appended by the framework.
cleaned = cleaned.replace(/^System:\s*\[[\s\S]*?$/gm, "");
// Remove inline base64 image data URIs (e.g. data:image/png;base64,iVBOR...)
// Replace with empty string (not a placeholder) so that pure-image messages
// become empty after sanitization and are naturally filtered by length checks.
cleaned = cleaned.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "");
// Remove null chars + compress whitespace
cleaned = cleaned.replace(/\0/g, "").replace(/\n{3,}/g, "\n\n").trim();
return cleaned;
}
/**
* Strip fenced code blocks from assistant replies before L0 capture.
*
* AI responses often contain large code snippets (```...```) that dilute
* the semantic signal for embedding and memory extraction. This function
* removes only the code block content while preserving surrounding
* natural-language explanations.
*
* Only applied to `role=assistant` messages in the L0 capture path —
* user messages and recall queries are NOT affected.
*/
export function stripCodeBlocks(text: string): string {
return text.replace(/```[^\n]*\n[\s\S]*?```/g, "").replace(/\n{3,}/g, "\n\n").trim();
}
// ============================
// L0 / L1 Capture & Extraction Filters
// ============================
/**
* L0 capture filter — intentionally **permissive**.
*
* L0 is the raw conversation archive. We want to preserve as much user input
* as possible so that downstream stages (L1 extraction, search, analytics)
* have the full picture. Only messages that are *structurally* useless are
* dropped here:
* - Empty / whitespace-only text
* - Framework-internal noise (bootstrap, session reset, NO_REPLY, …)
* - Slash commands (/new, /reset, …)
*
* Content-quality filters (length, symbols, prompt injection) are deferred
* to {@link shouldExtractL1}.
*/
export function shouldCaptureL0(text: string): boolean {
if (!text || !text.trim()) return false;
// Filter framework-internal / bootstrap noise messages
if (isFrameworkNoise(text)) return false;
// Slash commands are framework directives, not user content
if (text.startsWith("/")) return false;
return true;
}
/**
* L1 extraction filter — **strict** quality gate.
*
* Applied when L0 messages are fed into the LLM extraction pipeline.
* Filters out content that is too short, too long, purely symbolic,
* or looks like a prompt-injection attack — none of which should
* become structured memories.
*
* This function is a superset of {@link shouldCaptureL0}: anything
* rejected by L0 is also rejected here, plus additional quality checks.
*/
export function shouldExtractL1(text: string): boolean {
// First apply the same structural filters as L0
if (!shouldCaptureL0(text)) return false;
// ── Length filters ──
// const isCJK = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(text);
// if (isCJK && text.length < 2) return false;
// if (!isCJK && text.length < 2) return false;
// if (text.length > 5000) return false;
// ── Content-quality filters ──
// Match strings composed entirely of non-word, non-space, non-CJK characters (15 chars).
if (/^[^\w\s\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]{1,5}$/.test(text)) return false;
if (/^[?]+$/.test(text)) return false;
// ── Security filters ──
// Reject prompt-injection payloads — prevent malicious content from being
// persisted into structured memory and re-injected on future recalls.
// if (looksLikePromptInjection(text)) return false;
return true;
}
/**
* @deprecated Use {@link shouldExtractL1} (strict) or {@link shouldCaptureL0} (permissive) instead.
*
* Kept as an alias of `shouldExtractL1` for backward compatibility.
*/
export const shouldCapture = shouldExtractL1;
// ============================
// Prompt Injection Detection
// ============================
/**
* Known prompt-injection / jailbreak patterns.
*
* Covers:
* 1. Instruction override — "ignore all previous instructions", etc.
* 2. Role hijack — "you are now DAN", "act as root", etc.
* 3. System/developer boundary probing — "system prompt", "developer message"
* 4. XML/tag injection — opening tags that match our context boundaries
* 5. Tool/command invocation tricks — "run command X", "execute tool Y"
* 6. Multi-language variants — Chinese prompt-injection patterns
*/
const PROMPT_INJECTION_PATTERNS: RegExp[] = [
// ── Instruction override ──
/ignore\b.{0,30}\b(instructions|rules|guidelines)/i,
/disregard\b.{0,30}\b(instructions|rules|guidelines)/i,
/forget\b.{0,30}\b(instructions|rules|context)/i,
/override\b.{0,30}\b(instructions|rules|guidelines|safety)/i,
// ── Role hijack ──
/you are now (?!going|about|ready)/i, // "you are now DAN" but not "you are now going to..."
/act as (?:if you are |if you were )?(?:a |an )?(?:root|admin|unrestricted|unfiltered|jailbroken)/i,
/enter (?:DAN|jailbreak|god|sudo|developer|dev|debug|unrestricted|unfiltered) mode/i,
/switch to (?:DAN|jailbreak|god|sudo|developer|dev|debug|unrestricted|unfiltered) mode/i,
// ── System boundary probing ──
/(?:show|reveal|print|output|display|repeat|leak|dump|give)\b.{0,20}\bsystem prompt/i,
/reveal (?:your |the )?(system|hidden|secret|internal) (?:prompt|instructions|rules)/i,
/what (?:are|is) your (?:system|hidden|original|initial) (?:prompt|instructions|rules)/i,
// ── XML/tag injection (our context boundaries) ──
/<\s*(system|assistant|developer|tool|function|relevant-memories)\b/i,
// ── Tool/command invocation tricks ──
/\b(run|execute|call|invoke)\b.{0,40}\b(tool|command|function|shell)\b/i,
// ── Chinese variants ──
/忽略(?:所有|之前|以上|先前)?(?:的)?(?:指令|规则|指示|说明)/,
/无视(?:所有|之前|以上)?(?:的)?(?:指令|规则|限制)/,
/(?:显示|输出|告诉我|给我看)(?:你的)?(?:系统|初始|隐藏)?(?:提示词|指令|规则|prompt)/,
/你(?:现在|从现在开始)是/, // "你现在是 DAN"
];
/**
* Detect likely prompt-injection / jailbreak attempts.
*
* Normalises whitespace before matching to defeat trivial obfuscation
* (e.g. extra spaces / newlines between keywords).
*/
export function looksLikePromptInjection(text: string): boolean {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return false;
return PROMPT_INJECTION_PATTERNS.some((pattern) => pattern.test(normalized));
}
/**
* Detect framework-injected noise messages that should never be captured.
*
* These include:
* - "(session bootstrap)" — synthetic user turn for Google turn-order compliance
* - Session startup instructions from /new or /reset
* - "✅ New session started" — AI's ack of session startup (no user-meaningful content)
* - Pre-compaction memory flush prompts (system-to-agent instructions, not user content)
* - AI's NO_REPLY ack of memory flush (no user-meaningful content)
*/
function isFrameworkNoise(text: string): boolean {
const t = text.trim();
// Google turn-order bootstrap placeholder
if (t === "(session bootstrap)") return true;
// Framework session-reset instruction (starts with "A new session was started via /new or /reset")
if (t.startsWith("A new session was started via")) return true;
// AI's pure ack of session startup: "✅ New session started · model: ..."
if (/^✅\s*New session started/.test(t)) return true;
// Pre-compaction memory flush prompt injected by the framework as a synthetic
// user turn. This is an internal system-to-agent instruction, NOT real user
// content. Capturing it would pollute L0/L1 memories with framework directives.
if (t.startsWith("Pre-compaction memory flush")) return true;
// AI's NO_REPLY response to memory flush (or other silent-reply scenarios).
// A bare "NO_REPLY" (with optional whitespace) carries no user-meaningful content.
if (/^NO_REPLY\s*$/.test(t)) return true;
return false;
}
/**
* Pick up to `max` recent unique texts.
*/
export function pickRecentUnique(texts: string[], max: number): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (let i = texts.length - 1; i >= 0 && result.length < max; i--) {
const t = texts[i]!;
if (!seen.has(t)) {
seen.add(t);
result.push(t);
}
}
return result.reverse();
}
// ============================
// LLM Safety Utilities
// ============================
/**
* Escape XML-like tags in text to prevent tag injection attacks.
*
* When memory content or persona text is injected into XML-delimited sections
* (e.g. `<user-persona>...</user-persona>`), a malicious user could craft content
* containing `</user-persona>` to break out of the section boundary.
*
* This function escapes `<` and `>` in known dangerous patterns (closing tags
* that match our injection boundaries) so the content cannot prematurely close
* the XML section.
*/
export function escapeXmlTags(text: string): string {
// Escape closing tags that match our injection section boundaries
return text.replace(
/<\/?(?:user-persona|relevant-memories|scene-navigation|relevant-scenes|memory-tools-guide|system|assistant)>/gi,
(match) => match.replace(/</g, "&lt;").replace(/>/g, "&gt;"),
);
}
// ============================
// JSON Sanitization for LLM Output
// ============================
/**
* Sanitize a raw JSON string from LLM output so that `JSON.parse` won't throw
* "Bad control character in string literal".
*
* Per RFC 8259 §7, U+0000U+001F MUST be escaped inside JSON string literals.
* LLMs sometimes produce unescaped control characters (raw newlines, tabs, etc.)
* inside string values.
*
* Strategy (two-phase):
* 1. **Precise pass** — walk through JSON string literals (delimited by `"`)
* and escape any unescaped U+0000U+001F inside them to `\uXXXX` form,
* while leaving structural whitespace (between values) untouched.
* 2. **Fallback** — if the precise pass still fails `JSON.parse`, fall back to
* a simple global strip of rare control chars (\x00\x08, \x0b, \x0c,
* \x0e\x1f) which are almost never meaningful in natural-language content.
*/
export function sanitizeJsonForParse(raw: string): string {
// Phase 1: Escape control characters inside JSON string literals.
// We walk the string character-by-character to properly handle escape sequences.
const escaped = escapeControlCharsInJsonStrings(raw);
try {
JSON.parse(escaped);
return escaped;
} catch {
// Phase 1 didn't fully fix it — fall through to phase 2
}
// Phase 2: Brute-force strip of rare control chars that have no textual meaning.
// Preserves \t (\x09), \n (\x0a), \r (\x0d) which are common structural whitespace.
// NOTE: We strip from `escaped` (Phase 1 result) rather than `raw`, so that any
// control-character escaping Phase 1 performed is preserved even when the JSON has
// other issues (e.g. trailing commas) that cause the Phase 1 parse to fail.
const stripped = escaped.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
return stripped;
}
/**
* Walk through a JSON text and escape U+0000U+001F control characters that
* appear *inside* JSON string literals (between unescaped `"` delimiters).
*
* Characters that already have short escape sequences (\n, \r, \t, \b, \f)
* are mapped to those; others become \uXXXX.
*
* Structural whitespace outside string literals is left untouched.
*/
function escapeControlCharsInJsonStrings(text: string): string {
const SHORT_ESCAPES: Record<number, string> = {
0x08: "\\b", // backspace
0x09: "\\t", // tab
0x0a: "\\n", // line feed
0x0c: "\\f", // form feed
0x0d: "\\r", // carriage return
};
const out: string[] = [];
let inString = false;
let i = 0;
while (i < text.length) {
const ch = text[i]!;
const code = ch.charCodeAt(0);
if (inString) {
if (ch === "\\" && i + 1 < text.length) {
// Already-escaped sequence — copy both characters verbatim
out.push(ch, text[i + 1]!);
i += 2;
continue;
}
if (ch === '"') {
// End of string literal
out.push(ch);
inString = false;
i++;
continue;
}
if (code <= 0x1f) {
// Unescaped control character inside string — escape it
const short = SHORT_ESCAPES[code];
if (short) {
out.push(short);
} else {
out.push("\\u" + code.toString(16).padStart(4, "0"));
}
i++;
continue;
}
// Normal character inside string
out.push(ch);
i++;
} else {
// Outside string literal
if (ch === '"') {
out.push(ch);
inString = true;
i++;
continue;
}
// Structural character (including whitespace) — pass through
out.push(ch);
i++;
}
}
return out.join("");
}
+120
View File
@@ -0,0 +1,120 @@
/**
* SerialQueue: a lightweight task queue with concurrency=1.
*
* Equivalent to `new PQueue({ concurrency: 1 })` but with zero external
* dependencies. Supports:
* - Serial execution (FIFO)
* - `add(fn)` to enqueue a task (returns the task's result promise)
* - `onIdle()` to wait until all queued tasks have completed
* - `pause()` / `start()` to suspend/resume execution
* - `size` to check pending task count
* - Optional debug logger for enqueue/dequeue/complete diagnostics
*/
type Task<T = unknown> = () => Promise<T>;
interface QueueEntry {
task: Task;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
export class SerialQueue {
/** Human-readable name for logging / diagnostics. */
public readonly name: string;
private queue: QueueEntry[] = [];
private running = false;
private paused = false;
private idleResolvers: Array<() => void> = [];
/** Optional debug logger — receives diagnostic messages for enqueue/dequeue/complete. */
private debugFn?: (msg: string) => void;
constructor(name = "unnamed") {
this.name = name;
}
/** Set a debug logger for queue diagnostics. */
setDebugLogger(fn: (msg: string) => void): void {
this.debugFn = fn;
}
/** Number of tasks waiting to be executed. */
get size(): number {
return this.queue.length;
}
/** Whether a task is currently executing. */
get pending(): boolean {
return this.running;
}
/** Add a task to the queue. Returns the task's result promise. */
add<T>(task: Task<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({
task: task as Task,
resolve: resolve as (value: unknown) => void,
reject,
});
this.debugFn?.(`[queue:${this.name}] enqueued, pending=${this.queue.length}, running=${this.running}`);
this.drain();
});
}
/** Pause the queue. Currently running task will finish, but no new tasks start. */
pause(): void {
this.paused = true;
}
/** Resume the queue after pause(). */
start(): void {
this.paused = false;
this.drain();
}
/** Returns a promise that resolves when all queued tasks have completed. */
onIdle(): Promise<void> {
if (this.queue.length === 0 && !this.running) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.idleResolvers.push(resolve);
});
}
/** Clear all pending (not yet started) tasks. */
clear(): void {
for (const entry of this.queue) {
entry.reject(new Error("Queue cleared"));
}
this.queue = [];
}
private drain(): void {
if (this.running || this.paused || this.queue.length === 0) return;
const entry = this.queue.shift()!;
this.running = true;
this.debugFn?.(`[queue:${this.name}] dequeued, starting execution (remaining=${this.queue.length})`);
entry
.task()
.then((result) => entry.resolve(result))
.catch((err) => entry.reject(err))
.finally(() => {
this.running = false;
this.debugFn?.(`[queue:${this.name}] task completed (remaining=${this.queue.length})`);
if (this.queue.length === 0) {
// Notify idle waiters
const resolvers = this.idleResolvers;
this.idleResolvers = [];
for (const resolve of resolvers) resolve();
} else {
this.drain();
}
});
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Session filtering for memory-tdai.
*
* Decides whether a session should be ignored by the memory plugin
* (capture, recall, pipeline scheduling). All skip rules are compiled
* into a flat list of matchers at construction time — zero per-call overhead.
*/
// ============================
// Types
// ============================
export interface AgentHookContext {
sessionKey?: string;
sessionId?: string;
trigger?: string;
}
type SessionKeyMatcher = (sessionKey: string) => boolean;
// ============================
// Non-interactive trigger detection
// ============================
const SKIP_TRIGGERS = new Set(["cron", "heartbeat", "automation", "schedule"]);
/**
* Returns true when the hook was fired by a non-interactive trigger
* (heartbeat, cron job, automation, etc.) — these produce no meaningful
* user conversation and should not be captured or counted.
*/
export function isNonInteractiveTrigger(trigger?: string, sessionKey?: string): boolean {
if (trigger && SKIP_TRIGGERS.has(trigger.toLowerCase())) return true;
if (sessionKey) {
if (/:cron:/i.test(sessionKey) || /:heartbeat:/i.test(sessionKey)) return true;
}
return false;
}
// ============================
// Built-in skip rules (always active)
// ============================
/**
* Hard-coded matchers that identify internal / non-user sessions.
* These are always applied regardless of user configuration.
*/
const BUILTIN_MATCHERS: SessionKeyMatcher[] = [
// Scene extraction runner sessions
(key) => key.includes(":memory-scene-extract-"),
// OpenClaw subagent sessions
(key) => key.includes(":subagent:"),
// Temporary / internal utility sessions (e.g. temp:slug-generator)
(key) => key.startsWith("temp:"),
];
// ============================
// Glob → matcher compiler
// ============================
/**
* Turn a simple glob pattern (only `*` supported) into a matcher
* that tests the full sessionKey.
*
* Since sessionKeys look like `agent:<agentId>:...`, we match the
* glob against the whole key so users can write patterns like
* `bench-judge-*` (matched anywhere) or more specific ones.
*/
function globToMatcher(pattern: string): SessionKeyMatcher {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
const re = new RegExp(escaped);
return (key) => re.test(key);
}
// ============================
// SessionFilter
// ============================
/**
* Unified filter: construct once at plugin startup, then call
* `shouldSkip(sessionKey)` or `shouldSkipCtx(ctx)` at each gate.
*/
export class SessionFilter {
private readonly matchers: SessionKeyMatcher[];
constructor(excludeAgents: string[] = []) {
// Merge built-in rules + user-configured exclude patterns into one flat list
const userMatchers = excludeAgents
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map(globToMatcher);
this.matchers = [...BUILTIN_MATCHERS, ...userMatchers];
}
/** Should this sessionKey be skipped? */
shouldSkip(sessionKey: string): boolean {
return this.matchers.some((m) => m(sessionKey));
}
/** Should this hook context be skipped? */
shouldSkipCtx(ctx: AgentHookContext): boolean {
if (!ctx.sessionKey) return true;
if (ctx.sessionId?.startsWith("memory-")) return true;
if (isNonInteractiveTrigger(ctx.trigger, ctx.sessionKey)) return true;
return this.shouldSkip(ctx.sessionKey);
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Shared text utility functions for the memory-tdai plugin.
*/
/**
* Extract meaningful words from text (supports CJK and Latin).
*
* Used by both auto-recall (keyword search) and l1-dedup (keyword candidate recall).
* Extracted to a shared module to prevent implementation drift.
*/
export function extractWords(text: string): Set<string> {
const words = new Set<string>();
// Latin words (2+ chars)
const latinWords = text.toLowerCase().match(/[a-z0-9]{2,}/g);
if (latinWords) {
for (const w of latinWords) words.add(w);
}
// CJK characters (each char as a "word", plus 2-gram)
const cjkChars = text.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g);
if (cjkChars) {
for (const c of cjkChars) words.add(c);
// 2-grams for better matching
for (let i = 0; i < cjkChars.length - 1; i++) {
words.add(cjkChars[i] + cjkChars[i + 1]);
}
}
return words;
}