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

290 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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} 个过期会话`);
}