149 lines
5.2 KiB
JavaScript
149 lines
5.2 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ContextCompressor — automatic context window compression
|
||
// Monitors session token usage and produces structured summaries
|
||
// when token budgets are exceeded. Supports iterative compression
|
||
// (compressed sessions can be further compressed).
|
||
class ContextCompressor {
|
||
constructor(config) {
|
||
this.config = config || {};
|
||
this.maxTokensBeforeCompress = config.maxTokensBeforeCompress || 20000;
|
||
this.minCompressionGain = config.minCompressionGain || 0.1; // 10% minimum gain
|
||
this.summaryTemplate = config.summaryTemplate || this._defaultTemplate();
|
||
}
|
||
|
||
_defaultTemplate() {
|
||
return {
|
||
activeTask: '',
|
||
goal: '',
|
||
progress: [],
|
||
decisions: [],
|
||
blockers: [],
|
||
openQuestions: [],
|
||
keyFiles: [],
|
||
remainingWork: [],
|
||
notes: ''
|
||
};
|
||
}
|
||
|
||
// Check if a session needs compression
|
||
shouldCompress(sessionStore, sessionId) {
|
||
var tokenCount = sessionStore.getTotalTokenCount(sessionId);
|
||
return tokenCount > this.maxTokensBeforeCompress;
|
||
}
|
||
|
||
// Compress a session: produce summary and mark as compressed
|
||
async compress(sessionStore, sessionId, childSessionId) {
|
||
var session = sessionStore.getSession(sessionId);
|
||
if (!session) return null;
|
||
|
||
var messages = sessionStore.getMessages(sessionId);
|
||
var summary = this._buildSummary(session, messages);
|
||
|
||
// Mark current session as compressed
|
||
sessionStore.compressSession(sessionId, summary.summary, childSessionId);
|
||
|
||
// If there was a previous compression, propagate context
|
||
if (session.parent_id) {
|
||
var parentChain = sessionStore.getSessionChain(sessionId);
|
||
summary.compressionChain = parentChain.length;
|
||
}
|
||
|
||
return summary;
|
||
}
|
||
|
||
// Build a structured summary from session data and messages
|
||
_buildSummary(session, messages) {
|
||
var summary = JSON.parse(JSON.stringify(this.summaryTemplate));
|
||
|
||
// Extract from session metadata
|
||
summary.activeTask = session.task || '';
|
||
if (session.summary) {
|
||
summary.notes = session.summary;
|
||
}
|
||
|
||
// Extract from messages (last N messages for most recent context)
|
||
var recentMsgs = messages.slice(-20);
|
||
|
||
for (var i = 0; i < recentMsgs.length; i++) {
|
||
var msg = recentMsgs[i];
|
||
var content = msg.content || '';
|
||
|
||
// Look for decision patterns
|
||
var decisionMatch = content.match(/决定|选择|采用|使用|改用|确定|确认.*方案/i);
|
||
if (decisionMatch) {
|
||
var snippet = content.slice(Math.max(0, decisionMatch.index - 20), decisionMatch.index + 40);
|
||
summary.decisions.push(snippet);
|
||
}
|
||
|
||
// Look for blocker patterns
|
||
var blockerMatch = content.match(/阻塞|卡住|问题|错误|失败|报错|无法|不能|不行/i);
|
||
if (blockerMatch) {
|
||
var snippet = content.slice(Math.max(0, blockerMatch.index - 20), blockerMatch.index + 40);
|
||
summary.blockers.push(snippet);
|
||
}
|
||
|
||
// Look for file references
|
||
var fileMatch = content.match(/[A-Za-z]:\\[^\s,,。;;::!!??()()\[\]【】{}"']+/g);
|
||
if (fileMatch) {
|
||
for (var j = 0; j < fileMatch.length; j++) {
|
||
if (summary.keyFiles.indexOf(fileMatch[j]) < 0) {
|
||
summary.keyFiles.push(fileMatch[j]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for progress markers
|
||
if (content.includes('完成') || content.includes('成功') || content.includes('通过')) {
|
||
summary.progress.push(content.slice(0, 80));
|
||
}
|
||
}
|
||
|
||
// Deduplicate and trim
|
||
summary.decisions = this._unique(summary.decisions).slice(0, 10);
|
||
summary.blockers = this._unique(summary.blockers).slice(0, 10);
|
||
summary.keyFiles = this._unique(summary.keyFiles).slice(0, 15);
|
||
summary.progress = this._unique(summary.progress).slice(0, 10);
|
||
|
||
// Build condensed summary string
|
||
var summaryStr = 'Task: ' + (summary.activeTask || 'N/A');
|
||
if (summary.progress.length) summaryStr += ' | Progress: ' + summary.progress.length + ' items';
|
||
if (summary.decisions.length) summaryStr += ' | Decisions: ' + summary.decisions.length;
|
||
if (summary.blockers.length) summaryStr += ' | Blockers: ' + summary.blockers.length;
|
||
if (summary.keyFiles.length) summaryStr += ' | Files: ' + summary.keyFiles.length;
|
||
summary.summary = summaryStr;
|
||
|
||
return summary;
|
||
}
|
||
|
||
// Estimate token count for a string (approx: 1 token ~= 2 CJK chars or 4 ASCII chars)
|
||
estimateTokens(text) {
|
||
if (!text) return 0;
|
||
var cjkCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
|
||
var asciiCount = text.length - cjkCount;
|
||
return Math.ceil(cjkCount / 1.5) + Math.ceil(asciiCount / 4);
|
||
}
|
||
|
||
// Check compression effectiveness (anti-thrash)
|
||
compressionGain(sessionStore, sessionId) {
|
||
var before = sessionStore.getTotalTokenCount(sessionId);
|
||
var session = sessionStore.getSession(sessionId);
|
||
if (!before || !session) return 0;
|
||
|
||
var summaryLen = this.estimateTokens(session.summary || '');
|
||
if (before === 0) return 0;
|
||
|
||
return (before - summaryLen) / before;
|
||
}
|
||
|
||
_unique(arr) {
|
||
var result = [];
|
||
for (var i = 0; i < arr.length; i++) {
|
||
if (result.indexOf(arr[i]) < 0) result.push(arr[i]);
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
|
||
module.exports = ContextCompressor; |