clawdie-ai/src/session-compaction.ts
Operator & Codex a3aa29b2ca Fix DeepSeek compaction key handling
---
Build: pass | Tests: FAIL — Tests  1 failed | 2029 passed (2030)
2026-04-29 00:36:02 +02:00

345 lines
9.2 KiB
TypeScript

import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { storeMemory } from './memory-pg.js';
import { logger } from './logger.js';
import {
AGENT_SESSION_COMPACT_ENABLED,
AGENT_SESSION_COMPACT_KEEP_TURNS,
AGENT_SESSION_COMPACT_MIN_ENTRIES,
AGENT_SESSION_COMPACT_TIMEOUT_MS,
AGENT_COMPACTION_PROVIDER,
AGENT_COMPACTION_MODEL,
LANG,
LC_ALL,
PI_TUI_BIN,
PI_TUI_MODEL,
PI_TUI_PROVIDER,
TIMEZONE,
TMP_DIR,
resolveProviderApiKey,
} from './config.js';
import { SessionEntry } from './agent-session.js';
export interface CompactionResult {
compacted: boolean;
entriesBefore: number;
entriesAfter: number;
summaryLength: number;
memoryStored: boolean;
method: 'llm' | 'concat' | 'none';
}
const COMPACT_TIMEOUT_MS = AGENT_SESSION_COMPACT_TIMEOUT_MS;
const SUMMARIZATION_SYSTEM_PROMPT = [
'You are a session compactor for an AI assistant platform.',
'Given a sequence of task entries from a conversation session, produce a concise summary that preserves:',
'1. What was being worked on (the narrative arc)',
'2. Key decisions made',
'3. Important facts or context that would be needed to continue the work',
'4. Any unresolved issues or open tasks',
'',
'Be specific — include file names, config values, error messages where relevant.',
'Keep the summary under 500 words.',
'Output ONLY the summary text, no preamble or formatting.',
].join('\n');
export function parseJsonlEntries(filePath: string): SessionEntry[] {
const entries: SessionEntry[] = [];
if (!fs.existsSync(filePath)) return entries;
const raw = fs.readFileSync(filePath, 'utf-8');
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
entries.push(JSON.parse(trimmed) as SessionEntry);
} catch {
logger.warn(
{ line: trimmed.substring(0, 100) },
'compaction: skipping malformed line',
);
}
}
return entries;
}
export function splitEntries(
entries: SessionEntry[],
keepCount: number,
): { old: SessionEntry[]; recent: SessionEntry[] } {
if (entries.length <= keepCount) {
return { old: [], recent: entries };
}
return {
old: entries.slice(0, entries.length - keepCount),
recent: entries.slice(entries.length - keepCount),
};
}
export function buildConcatSummary(old: SessionEntry[]): string {
const MAX_ENTRY_SUMMARY = 200;
const lines = old.map((e, i) => {
const task = e.task || '(no task)';
const result = e.result || '?';
const output = e.output
? e.output.length > MAX_ENTRY_SUMMARY
? e.output.slice(0, MAX_ENTRY_SUMMARY) + '...'
: e.output
: '';
return `${i + 1}. [${result}] ${task}${output ? ': ' + output : ''}`;
});
return lines.join('\n');
}
export function buildSummarizationPrompt(old: SessionEntry[]): string {
const MAX_ENTRY_DETAIL = 300;
const entryLines = old.map((e, i) => {
const parts = [`[${e.result}] Task: ${e.task || '(no task)'}`];
if (e.skill) parts.push(`Skill: ${e.skill}`);
if (e.output) {
const truncated =
e.output.length > MAX_ENTRY_DETAIL
? e.output.slice(0, MAX_ENTRY_DETAIL) + '...'
: e.output;
parts.push(`Output: ${truncated}`);
}
if (e.tokens_used) parts.push(`Tokens: ${e.tokens_used}`);
return `--- Entry ${i + 1} ---\n${parts.join('\n')}`;
});
return [
`Summarize the following ${old.length} conversation entries from an AI assistant session.`,
'Preserve key decisions, file names, config values, errors, and unresolved tasks.',
'',
entryLines.join('\n\n'),
].join('\n');
}
export async function summarizeWithLlm(prompt: string): Promise<string | null> {
if (process.env.VITEST || process.env.NODE_ENV === 'test') {
return null;
}
return new Promise((resolve) => {
const args: string[] = ['--print', prompt, '--no-session', '--no-skills'];
const provider = AGENT_COMPACTION_PROVIDER || PI_TUI_PROVIDER;
const model = AGENT_COMPACTION_MODEL || PI_TUI_MODEL;
if (provider) args.push('--provider', provider);
if (model) args.push('--model', model);
const env: NodeJS.ProcessEnv = {
HOME: process.env.HOME ?? '/root',
PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin',
TMPDIR: process.env.TMPDIR ?? TMP_DIR,
TERM: process.env.TERM ?? 'xterm',
LANG,
LC_ALL,
TZ: TIMEZONE,
NODE_ENV: process.env.NODE_ENV ?? 'production',
};
if (provider) {
const apiKey = resolveProviderApiKey(provider);
if (apiKey?.value) {
env[apiKey.name] = apiKey.value;
}
}
const proc = spawn(
PI_TUI_BIN,
['--append-system-prompt', SUMMARIZATION_SYSTEM_PROMPT, ...args],
{
cwd: TMP_DIR,
env,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let stdout = '';
let stderr = '';
let settled = false;
const finish = (result: string | null) => {
if (settled) return;
settled = true;
resolve(result);
};
const timer = setTimeout(() => {
logger.warn('compaction: LLM summarization timed out');
proc.kill('SIGTERM');
finish(null);
}, COMPACT_TIMEOUT_MS);
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
proc.on('error', (err) => {
clearTimeout(timer);
logger.warn({ err }, 'compaction: LLM summarization spawn error');
finish(null);
});
proc.on('close', (code) => {
clearTimeout(timer);
if (code === 0 && stdout.trim()) {
finish(stdout.trim());
} else {
logger.warn(
{ code, stderr: stderr.substring(0, 200) },
'compaction: LLM summarization failed',
);
finish(null);
}
});
});
}
export function createCompactionHeader(summary: string): SessionEntry {
return {
timestamp: new Date().toISOString(),
task: '[compaction-summary]',
skill: 'session-compaction',
result: 'success',
output: summary,
tokens_used: 0,
};
}
export function writeCompactedSession(
filePath: string,
header: SessionEntry,
recent: SessionEntry[],
): void {
const tmpPath = filePath + '.compact.tmp';
const lines = [
JSON.stringify(header),
...recent.map((e) => JSON.stringify(e)),
'',
].join('\n');
fs.writeFileSync(tmpPath, lines, 'utf-8');
fs.renameSync(tmpPath, filePath);
}
export async function compactSession(
sessionFilePath: string,
options: {
keepTurns?: number;
minEntries?: number;
enabled?: boolean;
groupFolder?: string;
} = {},
): Promise<CompactionResult> {
const {
keepTurns = AGENT_SESSION_COMPACT_KEEP_TURNS,
minEntries = AGENT_SESSION_COMPACT_MIN_ENTRIES,
enabled = AGENT_SESSION_COMPACT_ENABLED,
} = options;
const noop: CompactionResult = {
compacted: false,
entriesBefore: 0,
entriesAfter: 0,
summaryLength: 0,
memoryStored: false,
method: 'none',
};
if (!enabled) {
logger.debug('compaction: disabled by config');
return noop;
}
if (!fs.existsSync(sessionFilePath)) {
logger.debug(
{ path: sessionFilePath },
'compaction: session file not found',
);
return noop;
}
const allEntries = parseJsonlEntries(sessionFilePath);
noop.entriesBefore = allEntries.length;
noop.entriesAfter = allEntries.length;
if (allEntries.length < minEntries) {
logger.debug(
{ count: allEntries.length, min: minEntries },
'compaction: too few entries to compact',
);
return noop;
}
const { old, recent } = splitEntries(allEntries, keepTurns);
if (old.length === 0) {
logger.debug('compaction: nothing to compact after split');
return noop;
}
logger.info(
{
oldCount: old.length,
recentCount: recent.length,
file: path.basename(sessionFilePath),
},
'compaction: starting',
);
const summarizationPrompt = buildSummarizationPrompt(old);
let summary = await summarizeWithLlm(summarizationPrompt);
let method: 'llm' | 'concat' = 'llm';
if (!summary) {
summary = buildConcatSummary(old);
method = 'concat';
logger.info('compaction: LLM summary failed, using concatenation fallback');
}
const header = createCompactionHeader(summary);
writeCompactedSession(sessionFilePath, header, recent);
let memoryStored = false;
try {
const topics = ['session-compaction'];
if (options.groupFolder) topics.push(options.groupFolder);
await storeMemory(summary, {
topics,
importance: 4,
keyFacts: old
.filter((e) => e.result === 'success' && e.task)
.map((e) => e.task.slice(0, 100)),
});
memoryStored = true;
} catch (err) {
logger.warn({ err }, 'compaction: failed to store summary in memory DB');
}
const result: CompactionResult = {
compacted: true,
entriesBefore: allEntries.length,
entriesAfter: 1 + recent.length,
summaryLength: summary.length,
memoryStored,
method,
};
logger.info(
{
before: result.entriesBefore,
after: result.entriesAfter,
method: result.method,
memoryStored: result.memoryStored,
summaryLen: result.summaryLength,
},
'compaction: complete',
);
return result;
}