345 lines
9.2 KiB
TypeScript
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;
|
|
}
|