feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行
流式渲染修复: - runId 机制防止 abort 后旧流事件污染新 run - run lock 防止并发 run 污染引擎状态 - abort race 提前退出工具执行等待 - TERMINATED 状态通过 stateChange 发射 - tool_call_delta 流式参数拼接 + pending 占位替换 - 首轮卡片创建路径统一,traceStep 按 ID 精确匹配 - compressed 事件转发为 toast 通知 安全增强: - ConfirmationHook 支持持久化自动执行(跨会话) - 设置面板新增自动执行工具管理 UI - SandboxManager 双重安全校验 fail-closed - 审计日志链式哈希防篡改 - PromptInjectionDefender 中文注入标记清理 - scanCode 28 模式 + base64/$() 检测 - validatePath realpathSync 防符号链接逃逸 - code-search 使用 execFile 防命令注入 新增工具: - file_editor、code_search、task_manager、diff_viewer 其他: - Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试 - MemoryManager TF-IDF 语义检索 - run_command Windows 中文编码修复(chcp 65001) - 版本号 0.2.0 → 0.2.1
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Memory Consolidator — 会话结束记忆固化器
|
||||
*
|
||||
* 每次 Agent 完成一轮对话后,调用 LLM 判断本次对话中哪些内容
|
||||
* 值得持久化到工作空间根目录的 MEMORY.md。
|
||||
*
|
||||
* 工作流程:
|
||||
* 1. 收集本次对话的 user message + assistant final answer + tool calls 摘要
|
||||
* 2. 读取当前 MEMORY.md 内容作为参考(避免重复)
|
||||
* 3. 调用 LLM,要求其返回 JSON 数组 [{section, entry}]
|
||||
* 4. 解析响应,调用 WorkspaceService.appendMemory 追加
|
||||
*
|
||||
* 安全约束:
|
||||
* - 仅追加新条目,不修改已有内容
|
||||
* - section 限定为预定义的 5 个分区
|
||||
* - 单次最多追加 5 条记忆,避免 LLM 过度提取
|
||||
* - LLM 调用失败时静默降级(不影响主流程)
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import log from 'electron-log';
|
||||
import type { IMetonaProviderAdapter } from '../types/metona-adapter';
|
||||
import type { MetonaRequest, MetonaMessage } from '../types';
|
||||
import type { WorkspaceService } from '../../services/workspace.service';
|
||||
import type { IterationStep } from '../agent-loop/types';
|
||||
|
||||
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
|
||||
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
|
||||
type AllowedSection = typeof ALLOWED_SECTIONS[number];
|
||||
|
||||
/** 单次固化最多追加的条目数 */
|
||||
const MAX_ENTRIES_PER_CONSOLIDATION = 5;
|
||||
|
||||
/** 单条记忆最大长度 */
|
||||
const MAX_ENTRY_LENGTH = 500;
|
||||
|
||||
export interface ConsolidationResult {
|
||||
appended: number;
|
||||
entries: Array<{ section: string; entry: string }>;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export class MemoryConsolidator {
|
||||
constructor(
|
||||
private adapter: IMetonaProviderAdapter,
|
||||
private workspaceService: WorkspaceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 热切换 Adapter(配置变更时由 main.ts 调用)
|
||||
*/
|
||||
setAdapter(adapter: IMetonaProviderAdapter): void {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 固化本次对话的重要记忆到 MEMORY.md
|
||||
*
|
||||
* @param userMessage 用户原始消息
|
||||
* @param assistantAnswer Agent 最终回答
|
||||
* @param iterations Agent Loop 迭代步骤(用于提取工具调用摘要)
|
||||
* @returns 固化结果
|
||||
*/
|
||||
async consolidate(
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): Promise<ConsolidationResult> {
|
||||
try {
|
||||
// 1. 构建对话摘要
|
||||
const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations);
|
||||
if (!conversationDigest) {
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 2. 读取当前 MEMORY.md 内容(供 LLM 去重)
|
||||
const currentMemory = this.workspaceService.getFiles().memory;
|
||||
const memoryDigest = this.truncateMemoryForPrompt(currentMemory);
|
||||
|
||||
// 3. 调用 LLM 提取需要持久化的记忆
|
||||
const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest);
|
||||
if (!llmResponse) {
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 4. 解析 JSON 响应
|
||||
const extracted = this.parseExtractionResponse(llmResponse);
|
||||
if (extracted.length === 0) {
|
||||
log.debug('[MemoryConsolidator] No memories worth persisting');
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 5. 过滤 + 截断 + 追加到 MEMORY.md
|
||||
const validEntries: Array<{ section: string; entry: string }> = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const item of extracted.slice(0, MAX_ENTRIES_PER_CONSOLIDATION)) {
|
||||
if (!this.isValidEntry(item)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const truncatedEntry = item.entry.slice(0, MAX_ENTRY_LENGTH);
|
||||
try {
|
||||
this.workspaceService.appendMemory(item.section, truncatedEntry);
|
||||
validEntries.push({ section: item.section, entry: truncatedEntry });
|
||||
} catch (err) {
|
||||
log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (validEntries.length > 0) {
|
||||
log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`);
|
||||
}
|
||||
|
||||
return { appended: validEntries.length, entries: validEntries, skipped };
|
||||
} catch (error) {
|
||||
log.error('[MemoryConsolidator] Consolidation failed:', error);
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建对话摘要(供 LLM 分析)
|
||||
*/
|
||||
private buildConversationDigest(
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 用户消息
|
||||
parts.push(`[USER] ${this.truncate(userMessage, 2000)}`);
|
||||
|
||||
// 工具调用摘要(如果有)
|
||||
const toolSummaries: string[] = [];
|
||||
for (const iter of iterations) {
|
||||
if (!iter.toolCalls?.length) continue;
|
||||
for (let i = 0; i < iter.toolCalls.length; i++) {
|
||||
const tc = iter.toolCalls[i];
|
||||
const result = iter.toolResults?.find((r) => r.toolCallId === tc.id);
|
||||
const status = result?.success ? 'ok' : 'error';
|
||||
const resultPreview = result?.result
|
||||
? this.truncate(JSON.stringify(result.result), 200)
|
||||
: result?.error ?? '';
|
||||
toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`);
|
||||
}
|
||||
}
|
||||
if (toolSummaries.length > 0) {
|
||||
parts.push(`[TOOLS]\n${toolSummaries.join('\n')}`);
|
||||
}
|
||||
|
||||
// Agent 最终回答
|
||||
parts.push(`[ASSISTANT] ${this.truncate(assistantAnswer, 2000)}`);
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断 MEMORY.md 内容用于 prompt(避免过长)
|
||||
*/
|
||||
private truncateMemoryForPrompt(memory: string): string {
|
||||
if (!memory) return '(empty)';
|
||||
// 截取前 3000 字符,保留分区结构概览
|
||||
if (memory.length <= 3000) return memory;
|
||||
return memory.slice(0, 3000) + '\n... (truncated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LLM 提取需要持久化的记忆
|
||||
*/
|
||||
private async callLLMForExtraction(
|
||||
conversationDigest: string,
|
||||
currentMemory: string,
|
||||
): Promise<string | null> {
|
||||
const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', ');
|
||||
|
||||
const request: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: 'memory-consolidation',
|
||||
iteration: 0,
|
||||
requestId: `mc_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.',
|
||||
outputConstraints: [
|
||||
'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:',
|
||||
'1. Long-term value: will be useful in future conversations (not transient task state)',
|
||||
'2. Not already present in the current MEMORY.md (avoid duplicates)',
|
||||
'3. Concrete and actionable (not vague observations)',
|
||||
'',
|
||||
'Good candidates: user preferences, project facts, important decisions, pending todos, known issues.',
|
||||
'Bad candidates: temporary tool results, trivial conversation, already-known facts.',
|
||||
'',
|
||||
`Output a JSON array. Each element: {"section": one of ${sectionsList}, "entry": concise description (max 200 chars, in the conversation language)}`,
|
||||
'If nothing is worth persisting, output an empty array: []',
|
||||
'Output ONLY the JSON array, no markdown fences, no explanation.',
|
||||
].join('\n'),
|
||||
safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
|
||||
},
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
params: {
|
||||
maxTokens: 1024,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.adapter.chat(request);
|
||||
return response.content.trim();
|
||||
} catch (error) {
|
||||
log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 的提取响应
|
||||
*/
|
||||
private parseExtractionResponse(raw: string): Array<{ section: string; entry: string }> {
|
||||
if (!raw) return [];
|
||||
|
||||
// 尝试直接解析 JSON
|
||||
let cleaned = raw.trim();
|
||||
|
||||
// 移除可能的 markdown 代码围栏
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed
|
||||
.filter((item): item is { section: string; entry: string } =>
|
||||
typeof item === 'object' && item !== null &&
|
||||
typeof item.section === 'string' && typeof item.entry === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
section: item.section.trim(),
|
||||
entry: item.entry.trim(),
|
||||
}))
|
||||
.filter((item) => item.section && item.entry);
|
||||
} catch {
|
||||
log.warn('[MemoryConsolidator] Failed to parse LLM response as JSON:', cleaned.slice(0, 200));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验条目是否合法(section 在允许列表内)
|
||||
*/
|
||||
private isValidEntry(item: { section: string; entry: string }): boolean {
|
||||
return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串
|
||||
*/
|
||||
private truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '...';
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
* 基于 SQLite(better-sqlite3)的三层记忆系统。
|
||||
* 表结构由 DatabaseService 统一创建,此处不再重复。
|
||||
*
|
||||
* v0.2.0 增强:
|
||||
* - TF-IDF 语义检索替代 LIKE 关键词搜索
|
||||
* - 时间衰减策略:老旧记忆权重降低
|
||||
* - IDF 缓存:避免每次搜索重新计算
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
|
||||
* @see standard/开发规范.md — 使用 better-sqlite3(禁止自写数据库层)
|
||||
*/
|
||||
@@ -38,17 +43,258 @@ interface MemorySearchOptions {
|
||||
minImportance?: number;
|
||||
}
|
||||
|
||||
/** 分词:将文本拆分为词项(支持中英文) */
|
||||
function tokenize(text: string): string[] {
|
||||
// 转小写,按非字母数字字符分割
|
||||
const lower = text.toLowerCase();
|
||||
// 英文词
|
||||
const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? [];
|
||||
// 中文 bigram(相邻两字组合),覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文
|
||||
const cjkChars = lower.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g) ?? [];
|
||||
const bigrams: string[] = [];
|
||||
for (let i = 0; i < cjkChars.length - 1; i++) {
|
||||
bigrams.push(cjkChars[i] + cjkChars[i + 1]);
|
||||
}
|
||||
// 单字 CJK 补 unigram(避免单字文档无 token)
|
||||
if (cjkChars.length === 1) {
|
||||
bigrams.push(cjkChars[0]);
|
||||
}
|
||||
return [...words, ...bigrams];
|
||||
}
|
||||
|
||||
/** 计算词频(TF) */
|
||||
function computeTF(tokens: string[]): Map<string, number> {
|
||||
const tf = new Map<string, number>();
|
||||
for (const token of tokens) {
|
||||
tf.set(token, (tf.get(token) ?? 0) + 1);
|
||||
}
|
||||
// 归一化
|
||||
const total = tokens.length || 1;
|
||||
for (const [key, val] of tf) {
|
||||
tf.set(key, val / total);
|
||||
}
|
||||
return tf;
|
||||
}
|
||||
|
||||
/** 计算余弦相似度的点积部分 */
|
||||
function dotProduct(tf1: Map<string, number>, tf2: Map<string, number>, idf: Map<string, number>): number {
|
||||
let sum = 0;
|
||||
for (const [term, freq1] of tf1) {
|
||||
const freq2 = tf2.get(term);
|
||||
if (freq2 !== undefined) {
|
||||
const idfVal = idf.get(term) ?? 1;
|
||||
sum += freq1 * freq2 * idfVal * idfVal;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** 计算向量模长 */
|
||||
function vectorNorm(tf: Map<string, number>, idf: Map<string, number>): number {
|
||||
let sum = 0;
|
||||
for (const [term, freq] of tf) {
|
||||
const idfVal = idf.get(term) ?? 1;
|
||||
sum += (freq * idfVal) ** 2;
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
/** 时间衰减权重:30 天半衰期(age=30 时 weight=0.5) */
|
||||
function timeDecayWeight(createdAt: number, now: number = Date.now()): number {
|
||||
const ageDays = Math.max(0, (now - createdAt) / (24 * 60 * 60 * 1000));
|
||||
const halfLifeDays = 30;
|
||||
return Math.pow(0.5, ageDays / halfLifeDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记忆管理器
|
||||
*/
|
||||
export class MemoryManager {
|
||||
/** IDF 缓存:词项 -> 文档频率 */
|
||||
private idfCache = new Map<string, number>();
|
||||
/** 缓存的记忆总数 */
|
||||
private cachedDocCount = 0;
|
||||
/** 缓存最后更新时间 */
|
||||
private cacheUpdatedAt = 0;
|
||||
/** 缓存有效期(5 分钟) */
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
constructor(private getDB: () => Database.Database) {}
|
||||
|
||||
/**
|
||||
* 初始化(表结构由 DatabaseService 创建)
|
||||
*/
|
||||
initialize(): void {
|
||||
log.info('MemoryManager initialized');
|
||||
log.info('MemoryManager initialized (v0.2.0: TF-IDF enabled)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 IDF 缓存
|
||||
*/
|
||||
private updateIdfCache(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.cacheUpdatedAt < this.CACHE_TTL && this.cachedDocCount > 0) {
|
||||
return; // 缓存未过期
|
||||
}
|
||||
|
||||
const db = this.getDB();
|
||||
this.idfCache.clear();
|
||||
|
||||
// 获取所有记忆内容(episodic + semantic + working)
|
||||
const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>;
|
||||
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>;
|
||||
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>;
|
||||
|
||||
const allDocs = [
|
||||
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
|
||||
...semanticRows.map((r) => r.value),
|
||||
...workingRows.map((r) => r.value),
|
||||
];
|
||||
|
||||
this.cachedDocCount = allDocs.length;
|
||||
const docFreq = new Map<string, number>();
|
||||
|
||||
for (const doc of allDocs) {
|
||||
const tokens = new Set(tokenize(doc));
|
||||
for (const token of tokens) {
|
||||
docFreq.set(token, (docFreq.get(token) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// IDF = log((N+1)/(df+1)) + 1(Sklearn 风格平滑),确保非负
|
||||
for (const [term, df] of docFreq) {
|
||||
this.idfCache.set(term, Math.log((this.cachedDocCount + 1) / (df + 1)) + 1);
|
||||
}
|
||||
|
||||
this.cacheUpdatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* TF-IDF 相似度搜索
|
||||
*/
|
||||
private tfidfSearch(query: string, options: MemorySearchOptions): SearchResult[] {
|
||||
const db = this.getDB();
|
||||
this.updateIdfCache();
|
||||
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
|
||||
const queryTF = computeTF(queryTokens);
|
||||
const queryNorm = vectorNorm(queryTF, this.idfCache);
|
||||
if (queryNorm === 0) return [];
|
||||
|
||||
const { topK = 5, type, minImportance = 0 } = options;
|
||||
const results: SearchResult[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// 搜索 episodic 记忆
|
||||
if (!type || type === 'episodic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM episodic_memories WHERE importance >= ?
|
||||
ORDER BY importance DESC, created_at DESC LIMIT ?
|
||||
`).all(minImportance, topK * 3) as Array<{
|
||||
id: string; session_id: string | null; content: string; summary: string | null;
|
||||
source: string; importance: number; created_at: number; expires_at: number | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const docText = row.content + ' ' + (row.summary ?? '');
|
||||
const docTokens = tokenize(docText);
|
||||
const docTF = computeTF(docTokens);
|
||||
const docNorm = vectorNorm(docTF, this.idfCache);
|
||||
|
||||
if (docNorm === 0) continue;
|
||||
|
||||
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
|
||||
const cosineSim = dotProd / (queryNorm * docNorm);
|
||||
|
||||
// 时间衰减
|
||||
const decayWeight = timeDecayWeight(row.created_at, now);
|
||||
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
|
||||
const finalScore = cosineSim * decayWeight * (0.5 + row.importance * 0.5);
|
||||
|
||||
if (finalScore > 0) {
|
||||
results.push({
|
||||
id: row.id, type: 'episodic', content: row.content,
|
||||
summary: row.summary ?? undefined, source: row.source as MemorySource,
|
||||
importance: row.importance, sessionId: row.session_id ?? undefined,
|
||||
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
|
||||
score: finalScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索 semantic 记忆
|
||||
if (!type || type === 'semantic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM semantic_memories WHERE confidence >= ?
|
||||
ORDER BY confidence DESC, access_count DESC LIMIT ?
|
||||
`).all(minImportance, Math.ceil(topK * 1.5)) as Array<{
|
||||
id: string; key: string; value: string; category: string | null;
|
||||
confidence: number; source_session: string | null; created_at: number;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const docText = row.key + ' ' + row.value;
|
||||
const docTokens = tokenize(docText);
|
||||
const docTF = computeTF(docTokens);
|
||||
const docNorm = vectorNorm(docTF, this.idfCache);
|
||||
|
||||
if (docNorm === 0) continue;
|
||||
|
||||
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
|
||||
const cosineSim = dotProd / (queryNorm * docNorm);
|
||||
|
||||
const decayWeight = timeDecayWeight(row.created_at, now);
|
||||
const finalScore = cosineSim * decayWeight * (0.5 + row.confidence * 0.5);
|
||||
|
||||
if (finalScore > 0) {
|
||||
results.push({
|
||||
id: row.id, type: 'semantic', content: row.value,
|
||||
source: 'imported', importance: row.confidence,
|
||||
sessionId: row.source_session ?? undefined,
|
||||
createdAt: row.created_at, score: finalScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索 working 记忆
|
||||
if (!type || type === 'working') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?
|
||||
`).all(topK * 3) as Array<{
|
||||
id: string; session_id: string; task_id: string;
|
||||
key: string; value: string; updated_at: number;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const docText = row.key + ' ' + row.value;
|
||||
const docTokens = tokenize(docText);
|
||||
const docTF = computeTF(docTokens);
|
||||
const docNorm = vectorNorm(docTF, this.idfCache);
|
||||
|
||||
if (docNorm === 0) continue;
|
||||
|
||||
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
|
||||
const cosineSim = dotProd / (queryNorm * docNorm);
|
||||
|
||||
const decayWeight = timeDecayWeight(row.updated_at, now);
|
||||
const finalScore = cosineSim * decayWeight * 0.5;
|
||||
|
||||
if (finalScore > 0) {
|
||||
results.push({
|
||||
id: row.id, type: 'working', content: row.value,
|
||||
source: 'agent_thought', importance: 0.5,
|
||||
sessionId: row.session_id, createdAt: row.updated_at,
|
||||
score: finalScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, topK);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,26 +327,44 @@ export class MemoryManager {
|
||||
break;
|
||||
}
|
||||
|
||||
// 使 IDF 缓存失效
|
||||
this.cacheUpdatedAt = 0;
|
||||
|
||||
log.debug(`Memory stored: ${id} (${item.type})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索记忆(关键词搜索)
|
||||
* 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减)
|
||||
*
|
||||
* v0.2.0 变更:
|
||||
* - 使用 TF-IDF 余弦相似度替代 LIKE 关键词搜索
|
||||
* - 支持中英文分词(英文按词,中文按 bigram)
|
||||
* - 时间衰减:30 天半衰期,老旧记忆权重降低
|
||||
* - IDF 缓存:5 分钟有效期,避免重复计算
|
||||
*/
|
||||
search(query: string, options: MemorySearchOptions = {}): SearchResult[] {
|
||||
const db = this.getDB();
|
||||
const { topK = 5, type, minImportance = 0 } = options;
|
||||
if (!query) return [];
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
// v0.2.0: 优先使用 TF-IDF 语义搜索
|
||||
const tfidfResults = this.tfidfSearch(query, options);
|
||||
if (tfidfResults.length > 0) {
|
||||
return tfidfResults;
|
||||
}
|
||||
|
||||
// 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索
|
||||
// 转义 LIKE 通配符,避免用户输入的 % 和 _ 影响匹配
|
||||
const escapedQuery = query.replace(/[%_]/g, '\\$&');
|
||||
const pattern = `%${escapedQuery}%`;
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// 搜索情节记忆
|
||||
if (!type || type === 'episodic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM episodic_memories
|
||||
WHERE (content LIKE ? OR summary LIKE ?) AND importance >= ?
|
||||
WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ?
|
||||
ORDER BY importance DESC, created_at DESC LIMIT ?
|
||||
`).all(pattern, pattern, minImportance, topK) as Array<{
|
||||
id: string; session_id: string | null; content: string; summary: string | null;
|
||||
@@ -112,7 +376,7 @@ export class MemoryManager {
|
||||
summary: row.summary ?? undefined, source: row.source as MemorySource,
|
||||
importance: row.importance, sessionId: row.session_id ?? undefined,
|
||||
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
|
||||
score: row.importance,
|
||||
score: row.importance * timeDecayWeight(row.created_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -121,7 +385,7 @@ export class MemoryManager {
|
||||
if (!type || type === 'semantic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM semantic_memories
|
||||
WHERE (key LIKE ? OR value LIKE ?) AND confidence >= ?
|
||||
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ?
|
||||
ORDER BY confidence DESC, access_count DESC LIMIT ?
|
||||
`).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
|
||||
id: string; key: string; value: string; category: string | null;
|
||||
@@ -132,7 +396,7 @@ export class MemoryManager {
|
||||
id: row.id, type: 'semantic', content: row.value,
|
||||
source: 'imported', importance: row.confidence,
|
||||
sessionId: row.source_session ?? undefined,
|
||||
createdAt: row.created_at, score: row.confidence,
|
||||
createdAt: row.created_at, score: row.confidence * timeDecayWeight(row.created_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,7 +405,7 @@ export class MemoryManager {
|
||||
if (type === 'working') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM working_memories
|
||||
WHERE (key LIKE ? OR value LIKE ?)
|
||||
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')
|
||||
ORDER BY updated_at DESC LIMIT ?
|
||||
`).all(pattern, pattern, topK) as Array<{
|
||||
id: string; session_id: string; task_id: string;
|
||||
@@ -152,7 +416,7 @@ export class MemoryManager {
|
||||
id: row.id, type: 'working', content: row.value,
|
||||
source: 'agent_thought', importance: 0.5,
|
||||
sessionId: row.session_id, createdAt: row.updated_at,
|
||||
score: 0.3, // 工作记忆权重较低
|
||||
score: 0.3 * timeDecayWeight(row.updated_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user