UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

693 lines (692 loc) 31.6 kB
/** * SessionStore — Bootstrap 会话级存储 (合并 EpisodicMemory + ToolResultCache) * * 内部子系统: * 1. DimensionReports — 跨维度分析报告 + 结构化证据 + 交叉引用 (from EpisodicMemory) * 2. ReadOnlyCache — 只读工具结果缓存 (from ToolResultCache, 排除副作用工具 B3 fix) * * 替代关系: * EpisodicMemory.js → 全部维度报告/证据/反思逻辑 * ToolResultCache.js → LRU 缓存逻辑 (仅只读工具) * * 新增能力 (vs 原模块): * - getDistilledForProducer(dimId): Producer 专用蒸馏上下文 (B2 fix) * - NON_CACHEABLE 内置: 副作用工具自动排除 (B3 fix) * - buildContextForDimension 增强: 消费 workingMemoryDistilled (B1 fix, 已在 EpisodicMemory 修复) * - 统一的 getStats(): 合并维度 + 缓存统计 * * 生命周期: 与 Bootstrap 会话一致。 * 持久化: 通过 saveCheckpoint / loadCheckpoint 实现断点续传。 * * @module SessionStore */ import fs from 'node:fs'; import path from 'node:path'; import Logger from '#infra/logging/Logger.js'; import { CACHE } from '#shared/constants.js'; import { validateSessionStoreShape } from './session-store-schema.js'; // ── 类型定义 ── /** 副作用工具 — 不缓存结果 (B3 fix) */ const NON_CACHEABLE = new Set([ 'submit_knowledge', 'submit_with_check', 'note_finding', 'get_previous_analysis', 'get_previous_evidence', ]); /** 缓存上限 */ const MAX_FILE_CACHE = CACHE.MAX_FILE_ENTRIES; const MAX_SEARCH_CACHE = CACHE.MAX_SEARCH_ENTRIES; const DEFAULT_TTL_MS = CACHE.DEFAULT_TTL_MS; // ═══════════════════════════════════════════════════════════ export class SessionStore { // ── 子系统 1: DimensionReports (from EpisodicMemory) ── #dimensionReports = new Map(); /** filePath → Evidence[] */ #evidenceStore = new Map(); #crossReferences = []; #tierReflections = []; /** dimId → candidates */ #submittedCandidates = new Map(); #projectContext; // ── 子系统 2: ReadOnlyCache (from ToolResultCache) ── #searchCache = new Map(); #fileCache = new Map(); /** } */ #cacheStats = { hits: 0, misses: 0, evictions: 0 }; #ttlMs; #cleanupTimer = null; #logger; constructor(config = {}) { this.#projectContext = config.projectContext || {}; this.#ttlMs = config.ttlMs ?? DEFAULT_TTL_MS; this.#logger = Logger.getInstance(); // 定期清理过期缓存条目 const cleanupInterval = config.cleanupIntervalMs ?? 5 * 60 * 1000; if (this.#ttlMs > 0 && cleanupInterval > 0) { this.#cleanupTimer = setInterval(() => this.#evictExpired(), cleanupInterval); if (this.#cleanupTimer.unref) { this.#cleanupTimer.unref(); } } } // ═══════════════════════════════════════════════════════ // §1: 维度报告 (from EpisodicMemory) // ═══════════════════════════════════════════════════════ /** 维度完成后存储完整报告 */ storeDimensionReport(dimId, report) { // findings 统一形状: { finding: string, evidence: string, importance: number } // P0 Fix: evidence 可能是 array/object,强制 string const findings = (report.findings || []).map((f) => ({ finding: f.finding || '', evidence: typeof f.evidence === 'string' ? f.evidence : Array.isArray(f.evidence) ? f.evidence.join(', ') : f.evidence ? String(f.evidence) : '', importance: f.importance || 5, })); this.#dimensionReports.set(dimId, { dimId, completedAt: Date.now(), analysisText: report.analysisText || '', findings, referencedFiles: report.referencedFiles || [], candidatesSummary: report.candidatesSummary || [], workingMemoryDistilled: report.workingMemoryDistilled || null, digest: report.digest || null, }); // 自动提取文件级 Evidence for (const f of findings) { if (f.evidence) { const ev = typeof f.evidence === 'string' ? f.evidence : String(f.evidence); const filePath = ev.split(':')[0]; this.addEvidence(filePath, { dimId, finding: f.finding, importance: f.importance, }); } } // 从 digest 中提取 crossRefs if (report.digest?.crossRefs) { for (const [targetDim, detail] of Object.entries(report.digest.crossRefs)) { if (detail) { this.#crossReferences.push({ from: dimId, to: targetDim, relation: 'suggests', detail: String(detail), }); } } } this.#logger.info(`[SessionStore] Stored report for "${dimId}": ` + `${report.findings?.length || 0} findings, ` + `${report.referencedFiles?.length || 0} files`); } getDimensionReport(dimId) { return this.#dimensionReports.get(dimId); } getCompletedDimensions() { return [...this.#dimensionReports.keys()]; } // ═══════════════════════════════════════════════════════ // §2: Evidence Store // ═══════════════════════════════════════════════════════ addEvidence(filePath, evidence) { if (!this.#evidenceStore.has(filePath)) { this.#evidenceStore.set(filePath, []); } this.#evidenceStore.get(filePath).push({ ...evidence, timestamp: Date.now(), }); } getEvidenceForFile(filePath) { return this.#evidenceStore.get(filePath) || []; } /** @returns >} */ searchEvidence(query, dimId) { const results = []; const lowerQuery = query.toLowerCase(); for (const [filePath, evidences] of this.#evidenceStore) { for (const ev of evidences) { if (dimId && ev.dimId !== dimId) { continue; } const matchesFile = filePath.toLowerCase().includes(lowerQuery); const matchesFinding = (ev.finding || '').toLowerCase().includes(lowerQuery); if (matchesFile || matchesFinding) { results.push({ filePath, evidence: ev }); } } } return results.sort((a, b) => (b.evidence.importance || 5) - (a.evidence.importance || 5)); } // ═══════════════════════════════════════════════════════ // §3: 已提交候选 // ═══════════════════════════════════════════════════════ addSubmittedCandidate(dimId, candidate) { if (!this.#submittedCandidates.has(dimId)) { this.#submittedCandidates.set(dimId, []); } this.#submittedCandidates.get(dimId).push({ dimId, title: candidate.title || '', subTopic: candidate.subTopic || '', summary: candidate.summary || '', }); } // ═══════════════════════════════════════════════════════ // §4: DimensionDigest 兼容层 // ═══════════════════════════════════════════════════════ addDimensionDigest(dimId, digest) { const existing = this.#dimensionReports.get(dimId); if (existing) { existing.digest = digest; } else { this.#dimensionReports.set(dimId, { dimId, completedAt: Date.now(), analysisText: digest.summary || '', findings: (digest.keyFindings || []).map((f) => ({ finding: typeof f === 'string' ? f : f.finding || '', evidence: '', importance: 5, })), referencedFiles: [], candidatesSummary: [], workingMemoryDistilled: null, digest, }); } // 提取 crossRefs if (digest.crossRefs) { for (const [targetDim, detail] of Object.entries(digest.crossRefs)) { if (detail) { const exists = this.#crossReferences.some((cr) => cr.from === dimId && cr.to === targetDim); if (!exists) { this.#crossReferences.push({ from: dimId, to: targetDim, relation: 'suggests', detail: String(detail), }); } } } } } // ═══════════════════════════════════════════════════════ // §5: Tier Reflection // ═══════════════════════════════════════════════════════ addTierReflection(tierIndex, reflection) { this.#tierReflections.push(reflection); this.#logger.info(`[SessionStore] Tier ${tierIndex + 1} reflection: ` + `${reflection.topFindings?.length || 0} top findings, ` + `${reflection.crossDimensionPatterns?.length || 0} patterns`); } /** 获取所有 TierReflection (F17: EpisodicConsolidator 需要) */ getTierReflections() { return [...this.#tierReflections]; } getRelevantReflections(currentDimId) { if (this.#tierReflections.length === 0) { return null; } const parts = []; for (const ref of this.#tierReflections) { parts.push(`### Tier ${ref.tierIndex + 1} 综合洞察`); if (ref.topFindings?.length > 0) { parts.push('**核心发现**:'); for (const f of ref.topFindings.slice(0, 5)) { parts.push(`- [${f.importance || 5}/10] ${f.finding}`); } } if (ref.crossDimensionPatterns?.length > 0) { parts.push('**跨维度模式**:'); for (const p of ref.crossDimensionPatterns) { parts.push(`- ${p}`); } } if (ref.suggestionsForNextTier?.length > 0) { parts.push('**对后续维度的建议**:'); for (const s of ref.suggestionsForNextTier) { parts.push(`- ${s}`); } } } return parts.length > 0 ? parts.join('\n') : null; } // ═══════════════════════════════════════════════════════ // §6: 上下文构建 (核心: 替代 DimensionContext) // ═══════════════════════════════════════════════════════ /** * 构建给 Analyst 的跨维度上下文 * * @param [focusKeywordsOrOpts] 关键词数组或 options 对象 */ buildContextForDimension(currentDimId, focusKeywordsOrOpts = []) { // 兼容两种调用方式: (dimId, keywords[]) 或 (dimId, { focusKeywords, tokenBudget }) let focusKeywords = []; let tokenBudget = Infinity; if (Array.isArray(focusKeywordsOrOpts)) { focusKeywords = focusKeywordsOrOpts; } else if (typeof focusKeywordsOrOpts === 'object') { focusKeywords = focusKeywordsOrOpts.focusKeywords || []; tokenBudget = focusKeywordsOrOpts.tokenBudget || Infinity; } const parts = []; const completedDims = [...this.#dimensionReports.entries()].filter(([id]) => id !== currentDimId); if (completedDims.length === 0 && this.#tierReflections.length === 0) { return ''; } parts.push('## 前序维度分析成果(避免重复探索)'); // §1: 前序维度的关键发现 for (const [dimId, report] of completedDims) { parts.push(`### ${dimId}`); if (report.digest?.summary) { parts.push(report.digest.summary); } else if (report.analysisText) { parts.push(`${report.analysisText.substring(0, 300)}…`); } let findings = report.findings; if ((!findings || findings.length === 0) && report.workingMemoryDistilled?.keyFindings) { findings = report.workingMemoryDistilled.keyFindings.map((f) => ({ finding: f.finding || '', evidence: f.evidence || '', importance: f.importance || 5, })); } const relevantFindings = this.#selectRelevantFindings(findings, focusKeywords, 5); if (relevantFindings.length > 0) { parts.push('**具体发现**:'); for (const f of relevantFindings) { let line = `- [${f.importance}/10] ${f.finding}`; if (f.evidence) { line += ` _(${f.evidence})_`; } parts.push(line); } } const candidates = this.#submittedCandidates.get(dimId) || []; if (candidates.length > 0) { parts.push(`已提交 ${candidates.length} 个候选: ${candidates.map((c) => c.title).join(', ')}`); } } // §2: 已读文件汇总 const allReadFiles = this.getAllReferencedFiles(); if (allReadFiles.size > 0) { parts.push(`### 前序维度已扫描的文件 (${allReadFiles.size} 个)`); const fileList = [...allReadFiles].slice(0, 30).join(', '); parts.push(fileList); if (allReadFiles.size > 30) { parts.push(`…还有 ${allReadFiles.size - 30} 个文件`); } } // §3: 跨维度引用建议 const relevantCrossRefs = this.#crossReferences.filter((cr) => cr.to === currentDimId); if (relevantCrossRefs.length > 0) { parts.push(`### 其他维度对 ${currentDimId} 的建议`); for (const cr of relevantCrossRefs) { parts.push(`- [来自 ${cr.from}] ${cr.detail}`); } } // §4: Tier Reflection const reflections = this.getRelevantReflections(currentDimId); if (reflections) { parts.push(reflections); } // Token 预算裁剪 let result = parts.join('\n'); if (tokenBudget < Infinity) { const estimatedTokens = Math.ceil(result.length / 4); if (estimatedTokens > tokenBudget) { // 粗略裁剪 const maxChars = tokenBudget * 4; result = `${result.substring(0, maxChars)}\n…(truncated due to budget)`; } } return result; } /** 兼容 DimensionContext.buildContextForDimension 返回格式 */ buildContextSnapshot(currentDimId) { const previousDimensions = {}; for (const [dimId, report] of this.#dimensionReports) { if (dimId === currentDimId) { continue; } previousDimensions[dimId] = report.digest || { summary: report.analysisText?.substring(0, 300) || '', candidateCount: report.candidatesSummary?.length || 0, keyFindings: report.findings?.map((f) => f.finding) || [], crossRefs: {}, gaps: [], }; } const submittedCandidates = []; for (const [, candidates] of this.#submittedCandidates) { submittedCandidates.push(...candidates); } return { previousDimensions, submittedCandidates }; } // ═══════════════════════════════════════════════════════ // §7: 蒸馏上下文 (for PipelineStrategy produce 阶段) // ═══════════════════════════════════════════════════════ /** * 获取维度的蒸馏上下文 (供 Producer 使用) * @returns |null} */ getDistilledForProducer(dimId) { const report = this.#dimensionReports.get(dimId); if (!report) { return null; } return { keyFindings: report.workingMemoryDistilled?.keyFindings || [], toolCallSummary: report.workingMemoryDistilled?.toolCallSummary || [], referencedFiles: report.referencedFiles || [], }; } // ═══════════════════════════════════════════════════════ // §8: 只读缓存 (from ToolResultCache, B3 fix) // ═══════════════════════════════════════════════════════ /** 获取缓存的工具结果 */ getCachedResult(toolName, args) { if (NON_CACHEABLE.has(toolName)) { return null; } if (toolName === 'search_project_code') { const pattern = args?.pattern || ''; if (pattern) { const entry = this.#searchCache.get(pattern); if (entry) { if (this.#ttlMs > 0 && Date.now() - entry.cachedAt > this.#ttlMs) { this.#searchCache.delete(pattern); this.#cacheStats.evictions++; this.#cacheStats.misses++; return null; } entry.hitCount++; this.#cacheStats.hits++; return entry.result; } } } if (toolName === 'read_project_file') { const filePath = args?.filePath || ''; if (filePath) { const entry = this.#fileCache.get(filePath); if (entry) { if (this.#ttlMs > 0 && Date.now() - entry.cachedAt > this.#ttlMs) { this.#fileCache.delete(filePath); this.#cacheStats.evictions++; this.#cacheStats.misses++; return null; } entry.hitCount++; this.#cacheStats.hits++; return { content: entry.content, path: filePath, cached: true }; } } } this.#cacheStats.misses++; return null; } /** 缓存工具结果 (自动排除副作用工具) */ cacheToolResult(toolName, args, result) { if (NON_CACHEABLE.has(toolName)) { return; } if (toolName === 'search_project_code') { const pattern = args?.pattern || ''; if (pattern) { if (this.#searchCache.size >= MAX_SEARCH_CACHE) { const oldestKey = this.#searchCache.keys().next().value; if (oldestKey) { this.#searchCache.delete(oldestKey); } } this.#searchCache.set(pattern, { result, cachedAt: Date.now(), hitCount: 0 }); } } if (toolName === 'read_project_file') { const filePath = args?.filePath || ''; const content = typeof result === 'object' && result !== null ? result.content : String(result); if (filePath && content) { if (this.#fileCache.size >= MAX_FILE_CACHE) { const oldestKey = this.#fileCache.keys().next().value; if (oldestKey) { this.#fileCache.delete(oldestKey); } } this.#fileCache.set(filePath, { content: String(content), cachedAt: Date.now(), hitCount: 0, }); } } } /** 兼容 ToolResultCache.get() */ get(toolName, args) { return this.getCachedResult(toolName, args); } /** 兼容 ToolResultCache.set() */ set(toolName, args, result) { this.cacheToolResult(toolName, args, result); } // ═══════════════════════════════════════════════════════ // §9: 持久化 (断点续传) // ═══════════════════════════════════════════════════════ async saveCheckpoint(projectRoot) { const checkpointDir = path.join(projectRoot, '.autosnippet', 'bootstrap-checkpoint'); try { fs.mkdirSync(checkpointDir, { recursive: true }); const data = { version: 2, savedAt: Date.now(), dimensionReports: Object.fromEntries([...this.#dimensionReports].map(([k, v]) => [ k, { ...v, analysisText: v.analysisText?.substring(0, 500) || '', }, ])), crossReferences: this.#crossReferences, tierReflections: this.#tierReflections, submittedCandidates: Object.fromEntries(this.#submittedCandidates), evidenceIndex: [...this.#evidenceStore.keys()], }; fs.writeFileSync(path.join(checkpointDir, 'session-store.json'), JSON.stringify(data, null, 2), 'utf-8'); this.#logger.info(`[SessionStore] Checkpoint saved: ${this.#dimensionReports.size} reports`); } catch (err) { this.#logger.warn(`[SessionStore] Failed to save checkpoint: ${err.message}`); } } async loadCheckpoint(projectRoot) { // Try new format first, then legacy const newPath = path.join(projectRoot, '.autosnippet', 'bootstrap-checkpoint', 'session-store.json'); const legacyPath = path.join(projectRoot, '.autosnippet', 'bootstrap-checkpoint', 'episodic-memory.json'); const checkpointPath = fs.existsSync(newPath) ? newPath : legacyPath; try { if (!fs.existsSync(checkpointPath)) { return false; } const raw = fs.readFileSync(checkpointPath, 'utf-8'); const data = JSON.parse(raw); if (data.version !== 1 && data.version !== 2) { this.#logger.warn(`[SessionStore] Unsupported checkpoint version: ${data.version}`); return false; } if (Date.now() - data.savedAt > 3600_000) { this.#logger.info(`[SessionStore] Checkpoint expired (>1h), ignoring`); return false; } if (data.dimensionReports) { for (const [dimId, report] of Object.entries(data.dimensionReports)) { this.#dimensionReports.set(dimId, report); } } if (data.crossReferences) { this.#crossReferences = data.crossReferences; } if (data.tierReflections) { this.#tierReflections = data.tierReflections; } if (data.submittedCandidates) { for (const [dimId, candidates] of Object.entries(data.submittedCandidates)) { this.#submittedCandidates.set(dimId, candidates); } } this.#logger.info(`[SessionStore] Checkpoint loaded: ${this.#dimensionReports.size} reports`); return true; } catch (err) { this.#logger.warn(`[SessionStore] Failed to load checkpoint: ${err.message}`); return false; } } // ═══════════════════════════════════════════════════════ // §10: 序列化 // ═══════════════════════════════════════════════════════ toJSON() { return { dimensionReports: Object.fromEntries(this.#dimensionReports), crossReferences: this.#crossReferences, tierReflections: this.#tierReflections, submittedCandidates: Object.fromEntries(this.#submittedCandidates), projectContext: this.#projectContext, }; } static fromJSON(json) { const validated = validateSessionStoreShape(json); const store = new SessionStore({ projectContext: validated.projectContext, }); for (const [k, v] of Object.entries(validated.dimensionReports)) { store.#dimensionReports.set(k, v); } store.#crossReferences = validated.crossReferences; store.#tierReflections = validated.tierReflections; for (const [k, v] of Object.entries(validated.submittedCandidates)) { store.#submittedCandidates.set(k, v); } return store; } // ═══════════════════════════════════════════════════════ // §11: 统计 + 查询 // ═══════════════════════════════════════════════════════ /** 获取所有已引用文件 (去重, F10) */ getAllReferencedFiles() { const files = new Set(); for (const report of this.#dimensionReports.values()) { for (const f of report.referencedFiles) { files.add(f); } } return files; } /** 获取统计数据 (合并维度 + 缓存统计, F12) */ getStats() { const totalFindings = [...this.#dimensionReports.values()].reduce((sum, r) => sum + r.findings.length, 0); const totalEvidence = [...this.#evidenceStore.values()].reduce((sum, arr) => sum + arr.length, 0); const totalCandidates = [...this.#submittedCandidates.values()].reduce((sum, arr) => sum + arr.length, 0); const { hits, misses } = this.#cacheStats; return { completedDimensions: this.#dimensionReports.size, totalFindings, totalEvidence, totalCandidates, crossReferences: this.#crossReferences.length, tierReflections: this.#tierReflections.length, referencedFiles: this.getAllReferencedFiles().size, cache: { ...this.#cacheStats, hitRate: hits + misses > 0 ? `${((hits / (hits + misses)) * 100).toFixed(1)}%` : '0%', searchCacheSize: this.#searchCache.size, fileCacheSize: this.#fileCache.size, }, }; } // ═══════════════════════════════════════════════════════ // §12: 清理 // ═══════════════════════════════════════════════════════ /** 清空所有缓存 */ clearCache() { this.#searchCache.clear(); this.#fileCache.clear(); this.#cacheStats = { hits: 0, misses: 0, evictions: 0 }; } /** 销毁实例,释放定时器 */ dispose() { this.clearCache(); this.#dimensionReports.clear(); this.#evidenceStore.clear(); this.#crossReferences.length = 0; this.#tierReflections.length = 0; this.#submittedCandidates.clear(); if (this.#cleanupTimer) { clearInterval(this.#cleanupTimer); this.#cleanupTimer = null; } } // ═══════════════════════════════════════════════════════ // 私有方法 // ═══════════════════════════════════════════════════════ /** 从 findings 中选择与当前焦点最相关的 */ #selectRelevantFindings(findings, focusKeywords, limit) { if (!findings || findings.length === 0) { return []; } if (!focusKeywords || focusKeywords.length === 0) { return [...findings] .sort((a, b) => (b.importance || 5) - (a.importance || 5)) .slice(0, limit); } return [...findings] .map((f) => { const relevance = focusKeywords.some((kw) => (f.finding || '').toLowerCase().includes(kw.toLowerCase())) ? 1 : 0; return { ...f, _score: relevance * 10 + (f.importance || 5) }; }) .sort((a, b) => b._score - a._score) .slice(0, limit) .map(({ _score, ...rest }) => rest); } /** 清理过期缓存条目 (F13) */ #evictExpired() { if (this.#ttlMs <= 0) { return; } const now = Date.now(); let evicted = 0; for (const [key, entry] of this.#searchCache) { if (now - entry.cachedAt > this.#ttlMs) { this.#searchCache.delete(key); evicted++; } } for (const [key, entry] of this.#fileCache) { if (now - entry.cachedAt > this.#ttlMs) { this.#fileCache.delete(key); evicted++; } } if (evicted > 0) { this.#cacheStats.evictions += evicted; this.#logger.debug(`[SessionStore] evicted ${evicted} expired cache entries`); } } } export default SessionStore;