#!/usr/bin/env node /** * 对话级路由记忆 (v5.3 + P2-1 竞态修复) * * 在 context-tracker 的滑动窗口基础上,增加会话级别的聚合统计: * - 技能使用频次 (本次对话中) * - 技能对共现模式 (A→B 频率) * - 会话偏好加成 (常用技能在本次对话中获得提权) * * [P2-1] 修复 read-modify-write 竞态: * - 写入前创建 .session-memory.lock 锁文件 (O_EXCL 互斥) * - 锁超时 10 秒自动过期 (检查 lock 文件 mtime) * - 获取锁失败时静默跳过 (不阻断功能) * - saveSessions 改为 temp+rename 原子写入 * * 模块导出: * getSessionId() → string * recordSessionSkill(sessionId, skillName) → void * getSessionBoost(sessionId, candidateSkill) → number (0~0.3) * getSessionPatterns(sessionId) → { pairs: {[string]: number}, topSkills: string[] } * cleanExpiredSessions(maxAge=3600000) → number (cleaned count) */ const fs = require('fs'); const path = require('path'); const detectClaudeRoot = () => require('./paths.config.js').PATHS.root; const ROOT = detectClaudeRoot(); const SESSION_FILE = path.join(ROOT, 'debug', 'session-memory.json'); const LOCK_FILE = path.join(ROOT, 'debug', '.session-memory.lock'); const LOCK_TIMEOUT_MS = 10 * 1000; // 锁超时 10 秒 const MAX_SESSION_AGE = 60 * 60 * 1000; // 1 小时无活动视为过期 const SESSION_BOOST_WEIGHT = 0.15; // 会话偏好最大加成 const PAIR_BOOST_WEIGHT = 0.10; // 技能对共现加成 // ─── P2-1: 文件锁机制 ──────────────────────────────── /** * 尝试获取排他文件锁 * 使用 O_CREAT | O_EXCL 原子创建锁文件,保证互斥 * @returns {boolean} true=获取成功, false=获取失败 */ function acquireLock() { try { const dir = path.dirname(LOCK_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // 检查锁是否已过期 (mtime > LOCK_TIMEOUT_MS) try { const stat = fs.statSync(LOCK_FILE); const age = Date.now() - stat.mtimeMs; if (age > LOCK_TIMEOUT_MS) { // 锁已过期,强制移除后重新获取 try { fs.unlinkSync(LOCK_FILE); } catch {} } } catch { // 文件不存在,正常流程 } // O_CREAT | O_EXCL | O_WRONLY: 仅当文件不存在时创建,否则抛 EEXIST const fd = fs.openSync(LOCK_FILE, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY); // 写入 PID 和时间戳便于调试 fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() })); fs.closeSync(fd); return true; } catch (err) { // EEXIST = 锁被其他进程持有; 其他错误也视为获取失败 return false; } } /** * 释放文件锁 */ function releaseLock() { try { fs.unlinkSync(LOCK_FILE); } catch {} } /** * 加载所有会话数据 * @returns {{ sessions: Object }} */ function loadSessions() { try { if (fs.existsSync(SESSION_FILE)) { return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } } catch {} return { sessions: {} }; } /** * 保存会话数据 (P2-1: temp+rename 原子写入) */ function saveSessions(data) { const dir = path.dirname(SESSION_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // 原子写入: 先写临时文件,再 rename 替换 const tmpFile = SESSION_FILE + '.tmp.' + process.pid; try { fs.writeFileSync(tmpFile, JSON.stringify(data, null, 2) + '\n'); fs.renameSync(tmpFile, SESSION_FILE); } catch (err) { // rename 失败时清理临时文件 try { fs.unlinkSync(tmpFile); } catch {} throw err; } } /** * 获取或创建会话 ID (基于进程启动时间 + PID 的稳定哈希) * 在同一个 Claude Code 会话中保持一致 */ function getSessionId() { // 使用环境变量传递的会话 ID,或基于时间戳生成 if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID; // 回退: 使用当天日期 + 小时窗口 (同一小时内视为同会话) const now = new Date(); return `s-${now.toISOString().slice(0, 13).replace(/[-T:]/g, '')}`; } /** * 记录会话中的技能使用 (P2-1: 带文件锁保护) * @param {string} sessionId * @param {string} skillName */ function recordSessionSkill(sessionId, skillName) { // P2-1: 尝试获取锁,失败时静默跳过 (不阻断功能) if (!acquireLock()) { return; // 锁被占用,跳过本次记录 } try { const data = loadSessions(); if (!data.sessions[sessionId]) { data.sessions[sessionId] = { startedAt: new Date().toISOString(), lastActivity: new Date().toISOString(), skillCounts: {}, recentSkills: [], // 最近 20 个技能调用 (保留顺序) pairs: {}, // "skillA→skillB" 计数 }; } const session = data.sessions[sessionId]; session.lastActivity = new Date().toISOString(); // 更新技能计数 session.skillCounts[skillName] = (session.skillCounts[skillName] || 0) + 1; // 更新技能对共现 if (session.recentSkills.length > 0) { const prev = session.recentSkills[session.recentSkills.length - 1]; if (prev !== skillName) { const pairKey = `${prev}→${skillName}`; session.pairs[pairKey] = (session.pairs[pairKey] || 0) + 1; } } // 维护最近技能序列 session.recentSkills.push(skillName); if (session.recentSkills.length > 20) { session.recentSkills = session.recentSkills.slice(-20); } saveSessions(data); } finally { // P2-1: 确保释放锁 (即使 saveSessions 抛异常) releaseLock(); } } /** * 计算会话偏好加成 * @param {string} sessionId * @param {string} candidateSkill - 候选技能名 * @returns {number} 加成分数 (0~0.3) */ function getSessionBoost(sessionId, candidateSkill) { const data = loadSessions(); const session = data.sessions[sessionId]; if (!session) return 0; let boost = 0; // 1. 技能使用频次加成 (本会话中使用越多,加成越大,上限 0.15) const count = session.skillCounts[candidateSkill] || 0; const totalCalls = Object.values(session.skillCounts).reduce((a, b) => a + b, 0); if (totalCalls > 0 && count > 0) { const freq = count / totalCalls; boost += Math.min(SESSION_BOOST_WEIGHT, freq * 0.3); } // 2. 技能对共现加成 (最近使用的技能 → 候选技能 的模式越强,加成越大) if (session.recentSkills.length > 0) { const lastSkill = session.recentSkills[session.recentSkills.length - 1]; const pairKey = `${lastSkill}→${candidateSkill}`; const pairCount = session.pairs[pairKey] || 0; if (pairCount > 0) { boost += Math.min(PAIR_BOOST_WEIGHT, pairCount * 0.05); } } return Math.round(Math.min(0.3, boost) * 100) / 100; } /** * 获取会话内的技能使用模式 * @param {string} sessionId * @returns {{ pairs: Object, topSkills: string[], totalCalls: number }} */ function getSessionPatterns(sessionId) { const data = loadSessions(); const session = data.sessions[sessionId]; if (!session) return { pairs: {}, topSkills: [], totalCalls: 0 }; const sorted = Object.entries(session.skillCounts) .sort((a, b) => b[1] - a[1]); return { pairs: session.pairs, topSkills: sorted.slice(0, 5).map(([name]) => name), totalCalls: sorted.reduce((sum, [, count]) => sum + count, 0), }; } /** * 清理过期会话 (P2-1: 带文件锁保护) * @param {number} maxAge - 最大存活时间 (ms), 默认 1 小时 * @returns {number} 清理的会话数 */ function cleanExpiredSessions(maxAge = MAX_SESSION_AGE) { // P0-7: 锁-读顺序修正 — 先锁再读,防止覆盖并发写入 if (!acquireLock()) return 0; try { const data = loadSessions(); const now = Date.now(); let cleaned = 0; for (const [id, session] of Object.entries(data.sessions)) { const lastTs = new Date(session.lastActivity).getTime(); if (isNaN(lastTs) || now - lastTs > maxAge) { delete data.sessions[id]; cleaned++; } } if (cleaned > 0) { saveSessions(data); } return cleaned; } finally { releaseLock(); } } // 模块导出 if (typeof module !== 'undefined') { module.exports = { getSessionId, recordSessionSkill, getSessionBoost, getSessionPatterns, cleanExpiredSessions, loadSessions, saveSessions, acquireLock, releaseLock, SESSION_BOOST_WEIGHT, PAIR_BOOST_WEIGHT, }; } // CLI 入口 if (require.main === module) { const sessionId = getSessionId(); const patterns = getSessionPatterns(sessionId); console.log('=== 会话路由记忆 ==='); console.log(`会话 ID: ${sessionId}`); console.log(`总调用: ${patterns.totalCalls}`); console.log(`Top 技能: ${patterns.topSkills.join(', ') || '(空)'}`); console.log(`技能对:`); for (const [pair, count] of Object.entries(patterns.pairs).sort((a, b) => b[1] - a[1]).slice(0, 10)) { console.log(` ${pair}: ${count}`); } // 清理过期会话 const cleaned = cleanExpiredSessions(); if (cleaned > 0) console.log(`\n已清理 ${cleaned} 个过期会话`); }