Files
cc-web/lib/session-history-store.js

674 lines
30 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.

'use strict';
const nativeFs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { createJsonReader, readJsonSync, writeJsonSync, cloneJsonValue, jsonValueEqual } = require('./json-persistence');
// 跨存储实例保护仍在使用的旧修订;弱引用不延长会话或消息的寿命。
const liveBaselines = new Set();
const SESSION_RUNTIME_KEYS = new Set([
'ws', 'tailer', 'codexAppStateTimer', 'codexAppStateDirty', 'codexAppStateCleaned',
'toolOutputDeltas', 'agentMessageItems',
]);
function createSessionHistoryStore(options = {}) {
const fs = options.fs || nativeFs;
const historyDir = path.join(options.sessionsDir, '_history');
const turnsDir = path.join(historyDir, '_turns');
// 身份只留在当前存储实例,不能通过 JSON 快照伪造可信完整加载或修订。
const loadedStates = new WeakMap();
const removedIds = new Set();
const chunkMaxBytes = options.chunkMaxBytes || 2 * 1024 * 1024;
const chunkMaxMessages = options.chunkMaxMessages || 64;
let commits = 0;
function validId(value) {
const id = String(value || '');
if (!/^[a-zA-Z0-9_-]+$/.test(id) || id === '_turns') throw Object.assign(new Error('会话归档 ID 无效'), { code: 'history_invalid_id' });
return id;
}
function filePath(id) {
return path.join(historyDir, `${validId(id)}.json`);
}
function archiveDir(id) {
return path.join(historyDir, validId(id));
}
function manifestPath(id) {
return path.join(archiveDir(id), 'manifest.json');
}
function chunkPath(id, file) {
if (!/^[a-f0-9]{64}\.json$/.test(file)) throw new Error('完整会话归档块引用无效');
return path.join(archiveDir(id), 'chunks', file);
}
function stringify(value) {
return JSON.stringify(value, (key, item) => {
if (typeof item === 'bigint') return String(item);
if (item instanceof Map) return Object.fromEntries(item);
if (item instanceof Set) return [...item];
return item;
});
}
function sessionMetadata(session) {
const metadata = Object.create(null);
for (const key of Object.keys(session)) {
if (key === 'messages' || key === 'historySnapshotBaseIndex' || key === 'historySnapshotCount'
|| key === 'historyArchiveRevision' || SESSION_RUNTIME_KEYS.has(key)) continue;
Object.defineProperty(metadata, key, { value: session[key], enumerable: true });
}
return cloneJsonValue(metadata);
}
function chunkWriter(id) {
const entries = [];
let pending = [];
let bytes = 2;
function flush() {
if (!pending.length) return;
const json = `[${pending.map(item => item.json).join(',')}]`;
const file = `${crypto.createHash('sha256').update(json).digest('hex')}.json`;
const target = chunkPath(id, file);
if (!fs.existsSync(target)) writeAtomicJson(target, null, json);
pending.forEach((item, index) => {
Object.assign(item.entry, { id: item.id || null, role: item.role, digest: item.digest, file, index, bytes: item.bytes });
entries.push(item.entry);
});
pending = [];
bytes = 2;
}
function add(message) {
const json = stringify(message);
if (json === undefined) throw new Error('完整会话消息不可序列化');
const size = Buffer.byteLength(json);
if (pending.length && (bytes + size + 1 > chunkMaxBytes || pending.length >= chunkMaxMessages)) flush();
const entry = {};
pending.push({ json, id: message?.id, role: message?.role || null,
digest: crypto.createHash('sha256').update(json).digest('hex'), bytes: size, entry });
bytes += size + 1;
// 单条超大消息仍完整保存,不能按预览限额截断。
if (bytes >= chunkMaxBytes || pending.length >= chunkMaxMessages) flush();
return entry;
}
return { add, finish() { flush(); return entries; } };
}
function migrateLegacy(id) {
if (!fs.existsSync(filePath(id))) return null;
const reader = createJsonReader(fs, filePath(id));
const record = Object.create(null);
const metadata = Object.create(null);
const writer = chunkWriter(id);
let hasMessages = false;
try {
reader.object(key => {
if (key !== 'session') { record[key] = reader.value(); return; }
reader.object(field => {
if (field === 'messages') {
hasMessages = true;
reader.array(message => { if (message?.ccwebPersistenceNotice !== true) writer.add(message); });
} else Object.defineProperty(metadata, field, { value: reader.value(), enumerable: true, configurable: true });
});
});
reader.end();
} finally {
reader.close();
}
if (record.version !== 1 || typeof record.revision !== 'string' || !record.revision
|| metadata.id !== id || !hasMessages || typeof record.complete !== 'boolean') {
throw Object.assign(new Error('完整会话归档格式无效'), { code: 'history_invalid_archive' });
}
const entries = writer.finish();
const manifest = { version: 2, revision: record.revision, complete: record.complete,
resetRevision: record.resetRevision || null, session: sessionMetadata(metadata), entries };
// 旧单文件保留为迁移来源;所有块成功落盘后,清单才成为唯一提交点。
writeAtomicJson(manifestPath(id), manifest);
return manifest;
}
function readRecord(id) {
let record;
try {
record = JSON.parse(fs.readFileSync(manifestPath(id), 'utf8'));
} catch (error) {
if (error?.code === 'ENOENT') return migrateLegacy(id);
throw error;
}
if (record?.version !== 2 || typeof record.revision !== 'string' || !record.revision
|| record.session?.id !== id || !Array.isArray(record.entries) || typeof record.complete !== 'boolean'
|| record.entries.some(entry => !/^[a-f0-9]{64}\.json$/.test(entry.file)
|| !/^[a-f0-9]{64}$/.test(entry.digest) || !Number.isSafeInteger(entry.index) || entry.index < 0)) {
throw Object.assign(new Error('完整会话归档格式无效'), { code: 'history_invalid_archive' });
}
return record;
}
function state(session) {
const value = session && loadedStates.get(session);
if (!value) return null;
return { id: value.id, revision: value.revision, complete: value.complete, source: value.source,
resetRevision: value.resetRevision || null, start: value.start || 0,
end: value.end, total: value.manifest?.entries.length, partial: value.partial === true };
}
function register(session, manifest, start, end, partial) {
const baseline = { id: session.id, revision: manifest.revision, complete: manifest.complete,
source: 'archive', resetRevision: manifest.resetRevision || null, manifest, start, end, partial, directory: archiveDir(session.id) };
loadedStates.set(session, baseline);
liveBaselines.add(new WeakRef(baseline));
}
function entryReader(id) {
const cache = new Map();
return entry => {
let messages = cache.get(entry.file);
if (!messages) {
const json = fs.readFileSync(chunkPath(id, entry.file), 'utf8');
if (crypto.createHash('sha256').update(json).digest('hex') !== entry.file.slice(0, -5)) {
throw new Error('完整会话归档块校验失败');
}
messages = JSON.parse(json);
if (!Array.isArray(messages)) throw new Error('完整会话归档块格式无效');
cache.set(entry.file, messages);
if (cache.size > 2) cache.delete(cache.keys().next().value);
}
const message = messages[entry.index];
if (message === undefined) throw new Error('完整会话归档消息引用无效');
return message;
};
}
function loadRange(id, record, start, end, partial) {
const session = cloneJsonValue(record.session);
const read = entryReader(id);
session.messages = record.entries.slice(start, end).map(read);
session.historySnapshotBaseIndex = start;
session.historySnapshotCount = session.messages.length;
register(session, record, start, end, partial);
return session;
}
function load(id) {
id = validId(id);
const record = readRecord(id);
return record ? loadRange(id, record, 0, record.entries.length, false) : null;
}
function loadTail(id, count = 180, limits = {}) {
id = validId(id);
const record = readRecord(id);
if (!record) return null;
const end = record.entries.length;
let start = Math.max(0, end - Math.max(0, Math.trunc(count)));
if (Number.isFinite(limits.maxBytes) && limits.maxBytes > 0) {
let bytes = 0;
for (let index = end - 1; index >= start; index -= 1) {
const size = record.entries[index].bytes || 0;
if (bytes && bytes + size > limits.maxBytes) { start = index + 1; break; }
bytes += size;
}
}
return loadRange(id, record, start, end, start > 0);
}
function loadMetadata(id) {
return loadTail(id, 0);
}
function loadWindow(id, start, end = start + 1) {
id = validId(id);
const record = readRecord(id);
if (!record) return null;
const total = record.entries.length;
start = Math.max(0, Math.min(total, Math.trunc(start)));
end = Math.max(start, Math.min(total, Math.trunc(end)));
return loadRange(id, record, start, end, start > 0 || end < total);
}
function forEachMessage(id, callback) {
id = validId(id);
const record = readRecord(id);
if (!record) return;
const read = entryReader(id);
for (let index = 0; index < record.entries.length; index += 1) callback(read(record.entries[index]), index);
}
function findMessage(id, predicate, before = Infinity) {
id = validId(id);
const record = readRecord(id);
if (!record) return null;
const read = entryReader(id);
for (let index = Math.min(record.entries.length, before) - 1; index >= 0; index -= 1) {
const message = read(record.entries[index]);
if (predicate(message, index)) return { message, index };
}
return null;
}
function loadHistoryPage(id, before, count = 24) {
id = validId(id);
const record = readRecord(id);
if (!record) return null;
const total = record.entries.length;
const end = Number.isFinite(before) ? Math.max(0, Math.min(total, Math.trunc(before))) : total;
const start = Math.max(0, end - Math.max(0, Math.trunc(count)));
const read = entryReader(id);
return { messages: record.entries.slice(start, end).map(read), total, start, end,
complete: record.complete, revision: record.revision };
}
function messageIndex(id, messageId) {
const record = readRecord(validId(id));
return record ? record.entries.findIndex(entry => entry.id === messageId) : -1;
}
function countMessages(id, role) {
const record = readRecord(validId(id));
return record ? record.entries.filter(entry => !role || entry.role === role).length : null;
}
function markLegacy(session) {
validId(session?.id);
loadedStates.set(session, {
id: session.id, revision: null, source: 'legacy',
complete: !session.messages?.some(message => message?.ccwebPersistenceNotice === true)
&& !(Number(session.historySnapshotBaseIndex) > 0),
});
return session;
}
function markComplete(session, options = {}) {
const previous = loadedStates.get(session);
if (!previous || previous.id !== session.id) {
throw Object.assign(new Error('只能确认可信加载会话的历史状态'), { code: 'history_untrusted_source' });
}
const confirmed = { ...previous, complete: true, reset: options.reset === true, partial: false };
loadedStates.set(session, confirmed);
if (confirmed.manifest) liveBaselines.add(new WeakRef(confirmed));
session.historySnapshotBaseIndex = 0;
session.historySnapshotCount = session.messages.length;
return () => loadedStates.set(session, previous);
}
function equal(left, right) {
return jsonValueEqual(left, right);
}
function plainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
function identifiedArray(value) {
return Array.isArray(value) && value.every(item => item && typeof item.id === 'string' && item.id);
}
function mergeIdentifiedArray(baseline, incoming, current) {
const baseById = new Map(baseline.map(item => [item.id, item]));
const incomingById = new Map(incoming.map(item => [item.id, item]));
const currentById = new Map(current.map(item => [item.id, item]));
const output = [];
const seen = new Set();
for (const item of current) {
if (seen.has(item.id)) continue;
seen.add(item.id);
const base = baseById.get(item.id);
const next = incomingById.get(item.id);
if (base && !next && equal(item, base)) continue;
output.push(next ? mergeValue(base, next, item) : item);
}
// 只补本次相对加载基线新增的气泡;后来已经删除的旧气泡不能复活。
for (let index = 0; index < incoming.length; index += 1) {
const item = incoming[index];
if (baseById.has(item.id) || currentById.has(item.id) || output.some(existing => existing.id === item.id)) continue;
let position = -1;
for (let next = index + 1; next < incoming.length; next += 1) {
position = output.findIndex(existing => existing.id === incoming[next].id);
if (position >= 0) break;
}
if (position < 0) {
for (let previous = index - 1; previous >= 0; previous -= 1) {
const anchor = output.findIndex(existing => existing.id === incoming[previous].id);
if (anchor >= 0) { position = anchor + 1; break; }
}
}
output.splice(position < 0 ? output.length : position, 0, item);
}
return output;
}
function mergeValue(baseline, incoming, current) {
if (equal(incoming, baseline)) return current;
if (equal(current, baseline) || equal(incoming, current)) return incoming;
if ((baseline === undefined || identifiedArray(baseline)) && identifiedArray(incoming) && identifiedArray(current)) {
return mergeIdentifiedArray(baseline || [], incoming, current);
}
if (plainObject(incoming) && plainObject(current) && (baseline === undefined || plainObject(baseline))) {
const output = Object.create(null);
const base = baseline || {};
for (const key of new Set([...Object.keys(base), ...Object.keys(incoming), ...Object.keys(current)])) {
const own = (value) => Object.prototype.hasOwnProperty.call(value, key) ? value[key] : undefined;
const value = mergeValue(own(base), own(incoming), own(current));
if (value !== undefined) output[key] = value;
}
return output;
}
// 同字段冲突保留已经提交的值;互不重叠的正文、工具与元数据修改会逐字段合并。
return current;
}
function synchronizeValue(target, persisted, root = false) {
if (Array.isArray(target) && Array.isArray(persisted)) {
const identified = identifiedArray(target) && identifiedArray(persisted);
const byId = identified ? new Map(target.map(item => [item.id, item])) : null;
const updated = persisted.map((item, index) => synchronizeValue(byId ? byId.get(item.id) : target[index], item));
target.length = updated.length;
for (let index = 0; index < updated.length; index += 1) target[index] = updated[index];
return target;
}
const prototype = target && typeof target === 'object' ? Object.getPrototypeOf(target) : undefined;
if ((prototype === Object.prototype || prototype === null) && plainObject(persisted)) {
for (const key of Object.keys(target)) {
if ((!root || !SESSION_RUNTIME_KEYS.has(key)) && !Object.prototype.hasOwnProperty.call(persisted, key)) delete target[key];
}
for (const [key, value] of Object.entries(persisted)) {
const existing = Object.prototype.hasOwnProperty.call(target, key) ? target[key] : undefined;
Object.defineProperty(target, key, {
value: synchronizeValue(existing, value), enumerable: true, writable: true, configurable: true,
});
}
return target;
}
return persisted;
}
function writeAtomicJson(target, record, json) {
fs.mkdirSync(path.dirname(target), { recursive: true });
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
let fd;
try {
fd = fs.openSync(temporary, 'wx', 0o600);
if (json === undefined) writeJsonSync(fs, fd, record);
else fs.writeFileSync(fd, json, 'utf8');
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.renameSync(temporary, target);
} finally {
if (fd !== undefined) {
try { fs.closeSync(fd); } catch {}
}
try { fs.unlinkSync(temporary); } catch {}
}
}
function turnPath(id) {
return path.join(turnsDir, `${validId(id)}.json`);
}
function saveTurn(id, state) {
writeAtomicJson(turnPath(id), state);
}
function loadTurn(id) {
try {
const state = readJsonSync(fs, turnPath(id));
if (state?.sessionId !== id || state.agent !== 'codexapp') throw new Error('完整运行态归档无效');
return state;
} catch (error) {
if (error?.code === 'ENOENT') return null;
throw error;
}
}
function removeTurn(id) {
try { fs.unlinkSync(turnPath(id)); } catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
function listTurnIds() {
try {
return fs.readdirSync(turnsDir).filter(name => /^[a-zA-Z0-9_-]+\.json$/.test(name)).map(name => name.slice(0, -5));
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
}
function save(session, options = {}) {
const id = validId(session?.id);
const previous = loadedStates.get(session);
if (removedIds.has(id)) throw Object.assign(new Error('已删除会话不能由旧对象重新保存'), { code: 'history_removed' });
if (previous?.source === 'archive' && !fs.existsSync(manifestPath(id))) {
throw Object.assign(new Error('完整会话归档已移除,拒绝旧对象重新创建'), { code: 'history_removed' });
}
// 同修订只核对文件头,可信清单已经是不可变基线;旧修订才重新读取当前清单。
const current = previous?.source === 'archive' && revision(id) === previous.revision
? previous.manifest : readRecord(id);
if (current) {
if (previous?.source !== 'archive' || previous.id !== id) {
throw Object.assign(new Error('受限预览不能覆盖完整会话归档'), { code: 'history_untrusted_source' });
}
} else if (previous?.source === 'archive') {
throw Object.assign(new Error('完整会话归档已移除,拒绝旧对象重新创建'), { code: 'history_removed' });
}
const complete = current?.complete || (previous ? previous.complete
: !session.messages?.some(message => message?.ccwebPersistenceNotice === true)
&& !(Number(session.historySnapshotBaseIndex) > 0));
if (!Array.isArray(session.messages)) throw new Error('完整会话消息必须为数组');
let metadata = sessionMetadata(session);
let taskTrackingPreserved = null;
const currentTracking = current?.session.taskTracking;
if (!options.taskBoardMetadata && currentTracking && typeof currentTracking === 'object') {
const persistedVersion = Number.isSafeInteger(currentTracking.version) ? currentTracking.version : 0;
const incomingVersion = Number.isSafeInteger(metadata.taskTracking?.version) ? metadata.taskTracking.version : -1;
if (incomingVersion < persistedVersion) {
metadata.taskTracking = cloneJsonValue(currentTracking);
taskTrackingPreserved = { incomingVersion, persistedVersion };
}
}
const rebased = !!current && previous.revision !== current.revision;
const baseEntries = previous?.manifest?.entries || [];
const viewedEntries = previous?.partial ? baseEntries.slice(previous.start, previous.end) : baseEntries;
const descriptors = [];
const positions = new Map();
for (const message of session.messages) {
if (message?.ccwebPersistenceNotice === true) continue;
const json = stringify(message);
if (json === undefined) throw new Error('完整会话消息不可序列化');
const descriptor = { id: message?.id || null, digest: crypto.createHash('sha256').update(json).digest('hex'), message };
// 同 ID 的最新片段保留最初位置,不同气泡即使同 turn 也分别保存。
if (descriptor.id && positions.has(descriptor.id)) descriptors[positions.get(descriptor.id)] = descriptor;
else {
if (descriptor.id) positions.set(descriptor.id, descriptors.length);
descriptors.push(descriptor);
}
}
let ordered = descriptors;
let start = previous?.partial ? previous.start : 0;
let end;
const read = entryReader(id);
const currentEntries = current?.entries || [];
const resetChanged = rebased && (current.resetRevision || null) !== (previous.resetRevision || null);
if (rebased && !resetChanged) {
if ([viewedEntries, descriptors, currentEntries].some(entries => !entries.every(entry => typeof entry.id === 'string' && entry.id))) {
throw Object.assign(new Error('旧修订缺少可靠消息 ID,拒绝覆盖新历史'), { code: 'history_stale_unidentified' });
}
if (!previous.partial && baseEntries.length > 0 && descriptors.length === 0
&& !equal(currentEntries.map(entry => entry.digest), baseEntries.map(entry => entry.digest))) {
throw Object.assign(new Error('清空前历史已经更新,请重新加载'), { code: 'history_stale_clear' });
}
const baseById = new Map(viewedEntries.map(entry => [entry.id, entry]));
const incomingById = new Map(descriptors.map(entry => [entry.id, entry]));
const currentIds = new Set(currentEntries.map(entry => entry.id));
ordered = [];
for (const entry of currentEntries) {
const base = baseById.get(entry.id);
const next = incomingById.get(entry.id);
if (!base) { ordered.push(entry); continue; }
if (!next) {
if (entry.digest !== base.digest) ordered.push(entry);
continue;
}
if (next.digest === base.digest || next.digest === entry.digest) ordered.push(entry);
else if (entry.digest === base.digest) ordered.push(next);
else ordered.push({ id: next.id, incoming: next.message, base, current: entry });
}
const orderedIds = new Set(ordered.map(entry => entry.id));
for (let index = 0; index < descriptors.length; index += 1) {
const item = descriptors[index];
if (baseById.has(item.id) || currentIds.has(item.id) || orderedIds.has(item.id)) continue;
let position = -1;
for (let next = index + 1; next < descriptors.length; next += 1) {
position = ordered.findIndex(entry => entry.id === descriptors[next].id);
if (position >= 0) break;
}
if (position < 0) {
for (let anchor = index - 1; anchor >= 0; anchor -= 1) {
const found = ordered.findIndex(entry => entry.id === descriptors[anchor].id);
if (found >= 0) { position = found + 1; break; }
}
}
ordered.splice(position < 0 ? ordered.length : position, 0, item);
orderedIds.add(item.id);
}
if (previous.partial) {
// 只协调原加载窗口及窗口内的并发新增,不能把未加载的后缀变成内存基线。
const prefixIds = new Set(baseEntries.slice(0, previous.start).map(entry => entry.id));
const suffixIds = new Set(baseEntries.slice(previous.end).map(entry => entry.id));
const lastPrefix = ordered.reduce((found, entry, index) => prefixIds.has(entry.id) ? index : found, -1);
start = lastPrefix >= 0 ? lastPrefix + 1 : Math.min(previous.start, ordered.length);
const firstSuffix = ordered.findIndex(entry => suffixIds.has(entry.id));
end = firstSuffix >= 0 ? firstSuffix : ordered.length;
if (previous.start === previous.end && descriptors.length === 0) start = end;
}
} else if (resetChanged) {
// 清空后的旧对象只同步元数据,旧线程及输出不能复活。
ordered = currentEntries;
start = previous.partial ? currentEntries.length : 0;
end = currentEntries.length;
for (const key of ['claudeSessionId', 'codexThreadId', 'codexAppThreadId']) {
if (Object.prototype.hasOwnProperty.call(previous.manifest.session, key)) metadata[key] = previous.manifest.session[key];
else delete metadata[key];
}
} else if (previous?.partial) {
const prefix = currentEntries.slice(0, previous.start);
const suffix = currentEntries.slice(previous.end);
const unseenIds = new Set([...prefix, ...suffix].filter(entry => entry.id).map(entry => entry.id));
// 重试的稳定 ID 若已存在于未加载区间,保留已提交原文和位置。
ordered = [...prefix, ...descriptors.filter(entry => !entry.id || !unseenIds.has(entry.id)), ...suffix];
end = ordered.length - suffix.length;
}
if (rebased) metadata = mergeValue(previous.manifest.session, metadata, current.session);
const writer = chunkWriter(id);
const currentById = new Map(currentEntries.filter(entry => entry.id).map(entry => [entry.id, entry]));
const entries = ordered.map((item, index) => {
if (item.file) return item;
const same = item.id ? currentById.get(item.id) : currentEntries[index];
if (same && same.digest === item.digest) return same;
const message = item.current
? mergeValue(read(item.base), cloneJsonValue(item.incoming), read(item.current))
: item.message;
return writer.add(message);
});
writer.finish();
const nextRevision = crypto.randomUUID();
const resetRevision = previous?.reset ? nextRevision : current?.resetRevision || null;
const record = { version: 2, revision: nextRevision, complete, resetRevision, session: metadata, entries };
end ??= entries.length;
let updated;
let synchronizedMetadata;
if (rebased || end - start !== session.messages.length) {
const byId = new Map(session.messages.filter(message => message?.id).map(message => [message.id, message]));
// 提交前完成所有可能失败的块读取;提交失败不修改活跃消息和可信基线。
updated = entries.slice(start, end).map(entry => ({ target: byId.get(entry.id), persisted: read(entry) }));
// 活跃元数据可以原位更新,不能与不可变清单基线共用新增的对象引用。
synchronizedMetadata = cloneJsonValue(metadata);
}
const synchronizedTracking = taskTrackingPreserved && !updated ? cloneJsonValue(metadata.taskTracking) : null;
writeAtomicJson(manifestPath(id), record);
if (updated) {
session.messages.length = updated.length;
updated.forEach((item, index) => { session.messages[index] = synchronizeValue(item.target, item.persisted); });
synchronizeValue(session, { ...synchronizedMetadata, messages: session.messages }, true);
}
if (synchronizedTracking) session.taskTracking = synchronizeValue(session.taskTracking, synchronizedTracking);
session.historySnapshotBaseIndex = start;
session.historySnapshotCount = session.messages.length;
register(session, record, start, end, start > 0 || end < entries.length);
if (++commits % 32 === 0) {
// 只回收当前清单和活跃旧修订都不再引用的块,清理失败不改变已提交结果。
try { collectChunks(id, record); } catch {}
}
return { revision: nextRevision, complete, session, rebased, taskTrackingPreserved };
}
function collectChunks(id, current) {
const files = new Set(current.entries.map(entry => entry.file));
for (const reference of liveBaselines) {
const baseline = reference.deref();
if (!baseline) { liveBaselines.delete(reference); continue; }
if (baseline.id === id && baseline.directory === archiveDir(id)) baseline.manifest.entries.forEach(entry => files.add(entry.file));
}
const directory = path.join(archiveDir(id), 'chunks');
for (const file of fs.readdirSync(directory)) {
if (/^[a-f0-9]{64}\.json$/.test(file) && !files.has(file)) fs.unlinkSync(path.join(directory, file));
}
}
function listIds() {
try {
const ids = new Set();
for (const name of fs.readdirSync(historyDir)) {
// 运行态归档属于保留目录,不参与会话归档枚举。
if (name === '_turns' || name === '_turns.json') continue;
if (/^[a-zA-Z0-9_-]+\.json$/.test(name)) ids.add(name.slice(0, -5));
else if (/^[a-zA-Z0-9_-]+$/.test(name) && fs.existsSync(manifestPath(name))) ids.add(name);
}
return [...ids];
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
}
function revision(id) {
let fd;
try {
const recordPath = fs.existsSync(manifestPath(id)) ? manifestPath(id) : filePath(id);
fd = fs.openSync(recordPath, 'r');
const head = Buffer.alloc(256);
const count = fs.readSync(fd, head, 0, head.length, 0);
const matched = /^\{"version":[12],"revision":"([0-9a-f-]{36})",/.exec(head.toString('utf8', 0, count));
if (!matched) throw new Error('完整会话归档修订无效');
return matched[1];
} catch (error) {
if (error?.code === 'ENOENT') return null;
throw error;
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
function remove(id) {
id = validId(id);
try { fs.unlinkSync(filePath(id)); } catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
fs.rmSync(archiveDir(id), { recursive: true, force: true });
removedIds.add(id);
removeTurn(id);
}
return { load, loadTail, loadMetadata, loadWindow, forEachMessage, findMessage, loadHistoryPage, messageIndex, countMessages, save, state, markLegacy, markComplete,
listIds, revision, remove, saveTurn, loadTurn, removeTurn, listTurnIds, historyDir };
}
module.exports = { createSessionHistoryStore };