bookworm-smart-assistant/scripts/session-memory.js

290 lines
9.0 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* 对话级路由记忆 (v5.3 + P2-1 竞态修复)
*
* context-tracker 的滑动窗口基础上增加会话级别的聚合统计:
* - 技能使用频次 (本次对话中)
* - 技能对共现模式 (AB 频率)
* - 会话偏好加成 (常用技能在本次对话中获得提权)
*
* [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} 个过期会话`);
}