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

167 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Session Pin - 钉住已命名(renamed)的会话,防止 auto-cleanup 清理
* 创建: 2026-04-15 | 部署: deploy-session-pin.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
let ROOT;
try { ROOT = require('../hooks/lib/root.js'); }
catch { ROOT = path.join(process.env.USERPROFILE || process.env.HOME || '', '.claude'); }
const PIN_FILE = path.join(ROOT, 'pinned-sessions.json');
const SESSIONS_DIR = path.join(ROOT, 'sessions');
const ARCHIVES_DIR = path.join(ROOT, 'archives');
const PIN_TTL_DAYS = 90;
function readPins() {
try {
if (!fs.existsSync(PIN_FILE)) return { version: 1, pins: {} };
const raw = fs.readFileSync(PIN_FILE, 'utf8');
const data = JSON.parse(raw);
if (!data || typeof data.pins !== 'object') throw new Error('invalid schema');
return data;
} catch {
// 备份损坏文件而非静默丢弃,防止覆盖导致数据丢失
try { fs.copyFileSync(PIN_FILE, PIN_FILE + '.corrupt.' + Date.now()); } catch {}
return { version: 1, pins: {} };
}
}
function writePins(data) {
const tmp = PIN_FILE + '.tmp.' + process.pid;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
fs.renameSync(tmp, PIN_FILE);
}
function findSessionMeta(sessionId) {
if (!fs.existsSync(SESSIONS_DIR)) return null;
try {
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const meta = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
if (meta.sessionId === sessionId && meta.name) {
return { name: meta.name, startedAt: meta.startedAt, pid: meta.pid };
}
} catch {}
}
} catch {}
return null;
}
function extractSummary(transcriptPath) {
const result = { messageCount: 0, firstPrompt: '', lastPrompt: '' };
try {
if (!transcriptPath || !fs.existsSync(transcriptPath)) return result;
const content = fs.readFileSync(transcriptPath, 'utf8');
const lines = content.split('\n').filter(l => l.trim());
result.messageCount = lines.length;
const userMessages = [];
for (const line of lines) {
try {
const obj = JSON.parse(line);
const isHuman = (obj.type === 'human') ||
(obj.message && obj.message.role === 'human') ||
(obj.role === 'human');
if (isHuman) {
let text = '';
if (typeof obj.message === 'string') text = obj.message;
else if (obj.message && obj.message.content) {
if (typeof obj.message.content === 'string') text = obj.message.content;
else if (Array.isArray(obj.message.content)) {
text = obj.message.content.filter(c => c.type === 'text').map(c => c.text).join(' ');
}
} else if (obj.content && typeof obj.content === 'string') text = obj.content;
if (text.trim()) userMessages.push(text.trim().slice(0, 200));
}
} catch {}
}
if (userMessages.length > 0) {
result.firstPrompt = userMessages[0];
result.lastPrompt = userMessages[userMessages.length - 1];
}
} catch {}
return result;
}
function saveArchive(sessionId, name, summary, startedAt) {
try {
if (!fs.existsSync(ARCHIVES_DIR)) fs.mkdirSync(ARCHIVES_DIR, { recursive: true });
const date = new Date().toISOString().slice(0, 10);
const safeName = (name
.replace(/[\x00-\x1f<>:"\/\\|?*\[\]]/g, '_')
.replace(/^(CON|PRN|AUX|NUL|COM\d|LPT\d)$/i, '_$1')
.trim()
.slice(0, 50)) || 'unnamed';
const archiveFile = path.join(ARCHIVES_DIR, date + '_' + safeName + '.json');
const archive = {
sessionId, name,
archivedAt: new Date().toISOString(),
startedAt: startedAt ? new Date(startedAt).toISOString() : null,
messageCount: summary.messageCount,
firstPrompt: summary.firstPrompt,
lastPrompt: summary.lastPrompt,
};
fs.writeFileSync(archiveFile, JSON.stringify(archive, null, 2), 'utf8');
return archiveFile;
} catch { return null; }
}
function cleanExpiredPins() {
const data = readPins();
const now = Date.now();
let cleaned = 0;
for (const [sid, entry] of Object.entries(data.pins)) {
const expiry = entry.expiresAt ? new Date(entry.expiresAt).getTime() : 0;
if (!entry.expiresAt || isNaN(expiry) || expiry < now) {
delete data.pins[sid]; cleaned++;
}
}
if (cleaned > 0) writePins(data);
return cleaned;
}
function pinSession(input) {
const sessionId = input && input.session_id;
if (!sessionId) return;
const meta = findSessionMeta(sessionId);
if (!meta || !meta.name) return;
const data = readPins();
if (data.pins[sessionId]) {
data.pins[sessionId].name = meta.name;
writePins(data);
return;
}
const now = new Date();
const expiresAt = new Date(now.getTime() + PIN_TTL_DAYS * 24 * 60 * 60 * 1000);
let transcriptRel = '';
if (input.transcript_path) {
const normalized = input.transcript_path.replace(/\\/g, '/');
const idx = normalized.indexOf('projects/');
transcriptRel = idx >= 0 ? normalized.slice(idx) : normalized;
}
data.pins[sessionId] = {
name: meta.name,
pinnedAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
ttlDays: PIN_TTL_DAYS,
transcriptRel,
};
// 内联过期清理,避免双重写盘 [P2-1 fixed]
const _now = Date.now();
for (const [_sid, _entry] of Object.entries(data.pins)) {
if (_sid === sessionId) continue; // 刚添加的不清理
const _exp = _entry.expiresAt ? new Date(_entry.expiresAt).getTime() : 0;
if (!_entry.expiresAt || isNaN(_exp) || _exp < _now) delete data.pins[_sid];
}
writePins(data);
const summary = extractSummary(input.transcript_path);
saveArchive(sessionId, meta.name, summary, meta.startedAt);
}
module.exports = { pinSession, readPins, cleanExpiredPins, PIN_FILE, ARCHIVES_DIR };