167 lines
5.8 KiB
JavaScript
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 };
|