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