From 40cc606b6e1e43bd8cffaef80b31085b99a4cd92 Mon Sep 17 00:00:00 2001 From: Clawdie AI Date: Mon, 20 Apr 2026 12:17:55 +0200 Subject: [PATCH] feat(compaction): session compaction with LLM summarization, /compact command, sanitize utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pi session JSONL exceeds AGENT_SESSION_MAX_BYTES, the runner now compacts instead of silently dropping the session. Old turns are summarized via pi --print --no-session (with concatenation fallback), stored in mevy_brain at importance=4, and the session file is rewritten with a compaction header + last N recent turns at full fidelity. If compaction fails, a memory handoff is injected into the system prompt via so the fresh session has context carryover. Also: - Extract sanitizeInboundText/truncateUtf8ByBytes to src/sanitize.ts - Fix mojibake (double-encoded U+2500 box-drawing) in telegram.ts - Add /compact Telegram command (admin-gated, inline keyboard) - Delete superseded docs/internal/compact-implementation-plan.md - Add AGENT_SESSION_COMPACT_ENABLED/KEEP_TURNS/MIN_ENTRIES config vars --- Build: pass | Tests: pending — FreeBSD validation needed (Sam & Claude) --- .env.example | 10 +- AGENTS.md | 50 +++ docs/internal/compact-implementation-plan.md | 90 ----- src/agent-runner.ts | 167 ++++++--- src/channels/telegram.ts | 47 ++- src/config.ts | 23 +- src/sanitize.test.ts | 109 ++++++ src/sanitize.ts | 24 ++ src/session-compaction.test.ts | 338 +++++++++++++++++++ src/session-compaction.ts | 325 ++++++++++++++++++ src/telegram-commands.ts | 106 ++++++ 11 files changed, 1118 insertions(+), 171 deletions(-) delete mode 100644 docs/internal/compact-implementation-plan.md create mode 100644 src/sanitize.test.ts create mode 100644 src/sanitize.ts create mode 100644 src/session-compaction.test.ts create mode 100644 src/session-compaction.ts diff --git a/.env.example b/.env.example index 8ea54e8..0be0614 100644 --- a/.env.example +++ b/.env.example @@ -78,9 +78,15 @@ AGENT_MAX_INBOUND_BYTES=64000 AGENT_MAX_BACKLOG_MESSAGES=20 AGENT_MAX_BACKLOG_CHARS=25000 AGENT_MAX_PROMPT_CHARS=60000 -# Session files exceeding this size trigger an automatic fresh session -# (prevents model context_window_exceeded errors). +# Session files exceeding this size trigger compaction (or fresh session if +# compaction is disabled). Prevents model context_window_exceeded errors. AGENT_SESSION_MAX_BYTES=200000 +# Session compaction — when a session grows past the byte limit, old turns are +# summarized into a compaction header and the summary is stored in memory DB. +# The last N turns are kept at full fidelity. +AGENT_SESSION_COMPACT_ENABLED=YES +AGENT_SESSION_COMPACT_KEEP_TURNS=20 +AGENT_SESSION_COMPACT_MIN_ENTRIES=30 # Crowdin — Translation management for multi-language docs # Free for open source: https://crowdin.com/open-source diff --git a/AGENTS.md b/AGENTS.md index 8296f02..90b7379 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -637,6 +637,56 @@ The FreeBSD agent MUST: --- +## Session Compaction + +When a pi session JSONL file exceeds `AGENT_SESSION_MAX_BYTES` (default 2MB), +the agent runner compacts it instead of silently starting a fresh session. + +### How it works + +1. **Trigger**: `isSessionOversize()` in `agent-runner.ts` detects the file is too large +2. **Split**: Old entries (first ~70%) are separated from recent entries (last N turns, configurable) +3. **Summarize**: A `pi --print --no-session` call generates a narrative summary of old entries +4. **Fallback**: If LLM summarization fails or times out (30s), old entries are concatenated as a fallback summary +5. **Write**: A compaction header entry + recent entries are written atomically (temp file → rename) +6. **Store**: The summary is stored in `mevy_brain` via `storeMemory()` with `topics: ['session-compaction']`, `importance: 3` +7. **Resume**: The agent resumes with `--session` pointing to the compacted file + +### Result + +The session file shrinks from potentially thousands of entries to: + +- 1 compaction header (narrative summary of all old turns) +- N recent turns at full fidelity (default 20) + +### Config + +| Var | Default | Purpose | +| ----------------------------------- | --------- | ---------------------------------------- | +| `AGENT_SESSION_MAX_BYTES` | 2,000,000 | Byte limit that triggers compaction | +| `AGENT_SESSION_COMPACT_ENABLED` | `YES` | Feature flag | +| `AGENT_SESSION_COMPACT_KEEP_TURNS` | `20` | Recent turns preserved at full fidelity | +| `AGENT_SESSION_COMPACT_MIN_ENTRIES` | `30` | Don't compact sessions shorter than this | + +### Files + +| File | Role | +| -------------------------------- | ------------------------------------------------------------------------- | +| `src/session-compaction.ts` | Core logic: parse, split, summarize, write, store | +| `src/session-compaction.test.ts` | Unit tests (pure functions + mocked DB) | +| `src/agent-runner.ts` | Oversize handler replaced with compaction call | +| `src/config.ts` | Config exports | +| `src/agent-session.ts` | `SessionEntry` type, `pruneOldEntries()` (still available for direct use) | + +### Security + +- Summarization prompt only includes `task`, `skill`, `result`, and truncated `output` fields — never raw full output +- Atomic write prevents corruption on crash (old file untouched until rename succeeds) +- Compaction summary goes through the same `storeMemory()` pipeline as all other memories +- `storeMemory` failure is non-fatal — compaction succeeds even if memory storage fails + +--- + ## Agentic Harness (FreeBSD Ops) The control plane now pivots to a terminal-first harness (extensions + safety). diff --git a/docs/internal/compact-implementation-plan.md b/docs/internal/compact-implementation-plan.md deleted file mode 100644 index 2eb8374..0000000 --- a/docs/internal/compact-implementation-plan.md +++ /dev/null @@ -1,90 +0,0 @@ -# Session Compaction — Implementation Plan - -> Handoff doc created 2026-04-20. Ready for implementation. - -## Problem - -When pi session JSONL exceeds `AGENT_SESSION_MAX_BYTES` (2MB default), `agent-runner.ts:255` does a **hard reset** — silently drops the session with zero context carryover. The agent amnesias completely. - -Meanwhile, every agent reply is already stored to `mevy_brain` via `storeSessionSummary()` (`index.ts:684`), and `buildMemoryContext()` (`index.ts:301`) pulls 3 relevant + 3 recent memories into the system prompt. But these two systems aren't connected at the reset boundary. - -## What Upstream NanoClaw Does - -Nothing. No compaction on `main` or `v2`. We're ahead. The aspirational skill at `.agent/skills/add-compact/SKILL.md` has a design but is not applied. - -## Implementation Plan - -### 1. Create `src/session-compact.ts` - -```typescript -// Core functions: -extractSessionTail(sessionPath: string, maxBytes?: number): string - // Read last ~4KB of session JSONL as raw text (format-agnostic) - -storeCompactionSummary(tail: string, groupFolder: string, sessionId?: string): Promise - // Store to mevy_brain with importance=4, topics=['session-compaction', groupFolder] - // High importance ensures buildMemoryContext() surfaces it on next turn - -buildCompactionHandoff(summary: string): string - // Returns ... block, max 2000 chars - // Injected into system prompt of fresh session -``` - -### 2. Wire auto-compaction in `agent-runner.ts` - -At `isSessionOversize()` boundary (line ~255), before dropping the session: -1. Call `extractSessionTail()` on the old session file -2. Call `storeCompactionSummary()` to persist to memory DB -3. Pass handoff context into `systemPrompt` for the fresh session -4. Fallback: if memory DB unreachable, proceed with hard reset (current behavior) - -### 3. Add `/compact` Telegram command - -- `telegram-commands.ts`: add `handleCompactCommand()` — admin-gated, confirmation keyboard -- `telegram.ts`: register `bot.command('compact', ...)`, add to `/help` list -- Wire callback router for `cmd:compact:confirm/cancel` -- On confirm: extract session tail, store summary, clear session (reuse `resetSession()` logic) -- Reply: "Session compacted. Context preserved in memory." - -### 4. Connect `pruneOldEntries()` for controlplane sessions - -In `controlplane-heartbeat.ts` or wherever heartbeat runs: call `pruneOldEntries(session, 50)` periodically. Currently defined in `agent-session.ts:83` but never called. - -### 5. Tests — `src/session-compact.test.ts` - -- `extractSessionTail`: normal file, file < maxBytes, empty file, missing file -- `storeCompactionSummary`: success path, DB failure → null -- `buildCompactionHandoff`: normal, truncation at 2000 chars, empty input -- Integration: oversize detection → extract → store → handoff round-trip - -### 6. Update existing tests - -- `agent-runner.test.ts`: add tests for `isSessionOversize()` (currently untested) -- `memory-lifecycle.test.ts`: test high-importance compaction memories surface in search - -## Edge Cases - -| Case | Handling | -|---|---| -| Summarization/DB fails | Fallback to hard reset (current behavior) | -| Summary too large | Truncate to 2000 chars in handoff | -| Rapid messages during compact | GroupQueue serializes per-chat — safe | -| Session file corrupted | `extractSessionTail` returns empty → hard reset | -| `/compact` in non-main group | Admin-gated via `requireAdmin()` | -| Embedding API down | `storeMemory()` already skips embeddings gracefully | - -## Naming Options (for commit/branch) - -1. **`session-compaction`** — straightforward, descriptive -2. **`context-bridge`** — emphasizes the gap it fills between session reset and memory DB -3. **`memory-aware-reset`** — highlights that resets now preserve context through memory - -## Files to Touch - -- `src/session-compact.ts` (new) -- `src/session-compact.test.ts` (new) -- `src/agent-runner.ts` (wire auto-compact at oversize boundary) -- `src/agent-runner.test.ts` (add isSessionOversize tests) -- `src/telegram-commands.ts` (add /compact handler) -- `src/channels/telegram.ts` (register command, update help, wire callback) -- `src/memory-lifecycle.test.ts` (compaction memory recall test) diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 1ff7671..92c19f3 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -32,6 +32,8 @@ import { systemStateSummary } from './system-state.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { markJailRunFinished, markJailRunStarted } from './health.js'; import { incCounter, incLabeledCounter, registerGauge } from './metrics.js'; +import { compactSession } from './session-compaction.js'; +import { getImportantMemories } from './memory-pg.js'; const METRICS_PREFIX = `${AGENT_NAME}_`; import { logger } from './logger.js'; @@ -40,18 +42,9 @@ import { RegisteredGroup } from './types.js'; let promptEstTokens = 0; let sessionBytes = 0; let sessionEstTokens = 0; -registerGauge( - `${METRICS_PREFIX}prompt_est_tokens`, - () => promptEstTokens, -); -registerGauge( - `${METRICS_PREFIX}session_bytes`, - () => sessionBytes, -); -registerGauge( - `${METRICS_PREFIX}session_est_tokens`, - () => sessionEstTokens, -); +registerGauge(`${METRICS_PREFIX}prompt_est_tokens`, () => promptEstTokens); +registerGauge(`${METRICS_PREFIX}session_bytes`, () => sessionBytes); +registerGauge(`${METRICS_PREFIX}session_est_tokens`, () => sessionEstTokens); // ── Types ────────────────────────────────────────────────────────────────── @@ -116,7 +109,8 @@ function readSecrets(): Record { */ function newestSessionFile(sessionDir: string): string | undefined { try { - const files = fs.readdirSync(sessionDir) + const files = fs + .readdirSync(sessionDir) .filter((f) => f.endsWith('.jsonl')) .map((f) => ({ name: f, @@ -232,42 +226,68 @@ export async function runJailAgent( `Rule: Text-to-speech uses the \`edge-tts\` CLI provided by the repo wrapper at \`/home/mevy/mevy-ai/bin/edge-tts\`, which runs from a persistent uv venv at \`/home/mevy/mevy-ai/.venv-edge-tts\`. ` + `Do NOT suggest installing \`edge-tts\` via pip/pkg unless explicitly asked to change the installation method.`; const stateInfo = await systemStateSummary(); - const systemPrompt = [ + let systemPrompt = [ AGENT_IDENTITY.selfIntro, PI_TUI_APPEND_SYSTEM_PROMPT, runtimeInfo, stateInfo, input.systemContext, - ].filter(Boolean).join('\n\n'); - if (systemPrompt) { - args.push('--append-system-prompt', systemPrompt); - } + ] + .filter(Boolean) + .join('\n\n'); promptEstTokens = Math.round((input.prompt.length + systemPrompt.length) / 4); // Session management — per-group session directory for continuity + let needsMemoryHandoff = false; if (PI_TUI_NO_SESSION) { args.push('--no-session'); } else { args.push('--session-dir', sessionDir); - // If the session file got too large, start a fresh session instead of - // continuing until the model hits context_window_exceeded. + // If the session file got too large, compact it (summarize old turns, + // keep recent turns at full fidelity). If compaction fails or is + // disabled, fall back to a fresh session with memory handoff. const oversize = input.sessionId && isSessionOversize(sessionDir, input.sessionId, AGENT_SESSION_MAX_BYTES, { runId, groupFolder: input.groupFolder, }); - if (oversize) { - logger.warn( - { - runId, - groupFolder: input.groupFolder, - sessionId: input.sessionId, - maxBytes: AGENT_SESSION_MAX_BYTES, - }, - 'Session file exceeded size limit; starting fresh session', - ); + if (oversize && input.sessionId) { + const sessionFile = path.join(sessionDir, input.sessionId); + try { + const compactResult = await compactSession(sessionFile); + if (compactResult.compacted) { + logger.info( + { + runId, + groupFolder: input.groupFolder, + sessionId: input.sessionId, + before: compactResult.entriesBefore, + after: compactResult.entriesAfter, + method: compactResult.method, + }, + 'Session compacted; resuming with compressed history', + ); + args.push('--session', sessionFile); + } else { + needsMemoryHandoff = true; + logger.warn( + { + runId, + groupFolder: input.groupFolder, + sessionId: input.sessionId, + }, + 'Compaction skipped; starting fresh session with memory handoff', + ); + } + } catch (err) { + needsMemoryHandoff = true; + logger.warn( + { err, runId, sessionId: input.sessionId }, + 'Compaction failed; starting fresh session with memory handoff', + ); + } } else if (input.sessionId) { // Resume specific session if the file still exists const sessionFile = path.join(sessionDir, input.sessionId); @@ -286,6 +306,36 @@ export async function runJailAgent( } } + // If starting fresh without compaction, inject recent memories as handoff + // so the agent doesn't amnesia completely. + if (needsMemoryHandoff) { + try { + const memories = await getImportantMemories(5); + if (memories.length > 0) { + const handoffLines = memories.map((m) => { + const summary = + m.summary.length > 300 + ? m.summary.slice(0, 300) + '...' + : m.summary; + return `- ${summary}`; + }); + const handoff = [ + '', + 'Previous session was reset. Key context from memory:', + ...handoffLines, + '', + ].join('\n'); + systemPrompt += '\n\n' + handoff; + } + } catch { + logger.debug('Memory handoff failed — starting truly fresh'); + } + } + + if (systemPrompt) { + args.push('--append-system-prompt', systemPrompt); + } + // ── Environment ─────────────────────────────────────────────────────── // Only pass what pi actually needs — avoids E2BIG (ARG_MAX) when process.env // is large after several sessions or env file growth. @@ -303,7 +353,12 @@ export async function runJailAgent( }; logger.debug( - { runId, provider: PI_TUI_PROVIDER, model: PI_TUI_MODEL, groupFolder: input.groupFolder }, + { + runId, + provider: PI_TUI_PROVIDER, + model: PI_TUI_MODEL, + groupFolder: input.groupFolder, + }, 'Spawning pi agent', ); @@ -336,7 +391,10 @@ export async function runJailAgent( // ── Result ──────────────────────────────────────────────────────────── return new Promise((resolve) => { - const finish = async (output: AgentOutput, exitCode?: number | null): Promise => { + const finish = async ( + output: AgentOutput, + exitCode?: number | null, + ): Promise => { clearTimeout(timeoutHandle); const durationMs = Date.now() - startTime; markJailRunFinished(input.groupFolder, input.chatJid, runId, { @@ -361,29 +419,40 @@ export async function runJailAgent( }; proc.on('error', (err: Error) => { - void finish({ - status: 'error', - result: null, - error: `Failed to spawn ${PI_TUI_BIN}: ${err.message}`, - }, null); + void finish( + { + status: 'error', + result: null, + error: `Failed to spawn ${PI_TUI_BIN}: ${err.message}`, + }, + null, + ); }); proc.on('close', (code: number | null) => { - const sessionFile = PI_TUI_NO_SESSION ? undefined : newestSessionFile(sessionDir); + const sessionFile = PI_TUI_NO_SESSION + ? undefined + : newestSessionFile(sessionDir); if (code === 0) { - void finish({ - status: 'success', - result: stdout.trim() || null, - newSessionId: sessionFile, - }, code); + void finish( + { + status: 'success', + result: stdout.trim() || null, + newSessionId: sessionFile, + }, + code, + ); } else { - void finish({ - status: 'error', - result: null, - error: stderr.trim() || `pi exited with code ${code ?? 'null'}`, - newSessionId: sessionFile, - }, code); + void finish( + { + status: 'error', + result: null, + error: stderr.trim() || `pi exited with code ${code ?? 'null'}`, + newSessionId: sessionFile, + }, + code, + ); } }); }); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 1067e3b..d5fa536 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -25,29 +25,7 @@ import { RegisteredGroup, } from '../types.js'; import { registerChannel } from './registry.js'; - -function sanitizeInboundText(raw: string): string { - // Remove characters commonly used to hide or reshape text in prompt injection attempts. - // This is intentionally conservative: it only strips known-invisible controls. - return raw - .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/gu, '') - .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/gu, ''); -} - -function truncateUtf8ByBytes(text: string, maxBytes: number): { head: string; omittedBytes: number } { - const fullBytes = Buffer.byteLength(text, 'utf8'); - if (maxBytes <= 0 || fullBytes <= maxBytes) return { head: text, omittedBytes: 0 }; - - let bytes = 0; - let head = ''; - for (const ch of text) { - const b = Buffer.byteLength(ch, 'utf8'); - if (bytes + b > maxBytes) break; - head += ch; - bytes += b; - } - return { head, omittedBytes: Math.max(0, fullBytes - bytes) }; -} +import { sanitizeInboundText, truncateUtf8ByBytes } from '../sanitize.js'; import { getTranscriptionStatus, isTranscriptionAvailable, @@ -55,6 +33,7 @@ import { } from '../transcription.js'; import { handleActivationCommand, + handleCompactCommand, handleCommandCallback, handleNewCommand, handleStatusCommand, @@ -122,6 +101,7 @@ export class TelegramChannel implements Channel { '/tts — Control voice replies (on/off/status)\n' + '/stop — Stop running agent\n' + '/new — Reset session, start fresh\n' + + '/compact — Compact session (summarize old, keep recent)\n' + '/whoami — Show your Telegram identity\n' + '/activation — Set trigger mode (always/mention)\n' + '/help — Show this message', @@ -156,6 +136,12 @@ export class TelegramChannel implements Channel { await handleNewCommand(ctx, chatJid); }); + this.bot.command('compact', async (ctx) => { + const chatJid = await requireRegistered(ctx); + if (!chatJid) return; + await handleCompactCommand(ctx, chatJid); + }); + this.bot.command('whoami', async (ctx) => { await handleWhoamiCommand(ctx); }); @@ -320,12 +306,19 @@ export class TelegramChannel implements Channel { ctx.from?.username || ctx.from?.id?.toString() || 'Unknown'; - let captionText = typeof ctx.message.caption === 'string' ? ctx.message.caption : ''; + let captionText = + typeof ctx.message.caption === 'string' ? ctx.message.caption : ''; captionText = sanitizeInboundText(captionText); if (AGENT_MAX_INBOUND_BYTES > 0) { - captionText = truncateUtf8ByBytes(captionText, AGENT_MAX_INBOUND_BYTES).head; + captionText = truncateUtf8ByBytes( + captionText, + AGENT_MAX_INBOUND_BYTES, + ).head; } - if (AGENT_MAX_INBOUND_CHARS > 0 && captionText.length > AGENT_MAX_INBOUND_CHARS) { + if ( + AGENT_MAX_INBOUND_CHARS > 0 && + captionText.length > AGENT_MAX_INBOUND_CHARS + ) { captionText = captionText.slice(0, AGENT_MAX_INBOUND_CHARS); } const caption = captionText ? ` ${captionText}` : ''; @@ -763,7 +756,7 @@ export class TelegramChannel implements Channel { // Keep it conservative: only rewrite what Telegram HTML can represent. let out = escapeHtml(s); out = out.replace(/~~([^~]+)~~/g, '$1'); - out = out.replace(/^\s*---+\s*$/gm, '────────'); + out = out.replace(/^\s*---+\s*$/gm, '────────'); return out; }; diff --git a/src/config.ts b/src/config.ts index 1097638..9103fc2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -125,6 +125,10 @@ const envConfig = readEnvFile([ 'AGENT_MAX_BACKLOG_CHARS', 'AGENT_MAX_PROMPT_CHARS', 'AGENT_SESSION_MAX_BYTES', + // Session compaction + 'AGENT_SESSION_COMPACT_ENABLED', + 'AGENT_SESSION_COMPACT_KEEP_TURNS', + 'AGENT_SESSION_COMPACT_MIN_ENTRIES', // Vision (optional helper model for OCR/screenshot reading) 'VISION_PROVIDER', 'VISION_MODEL', @@ -259,9 +263,7 @@ export const TELEGRAM_ADMIN_IDS: number[] = (() => { .filter((n) => Number.isFinite(n) && n > 0); })(); export const TELEGRAM_OPS_CHAT_ID = - process.env.TELEGRAM_OPS_CHAT_ID || - envConfig.TELEGRAM_OPS_CHAT_ID || - ''; + process.env.TELEGRAM_OPS_CHAT_ID || envConfig.TELEGRAM_OPS_CHAT_ID || ''; export const OPENAI_API_KEY = process.env.OPENAI_API_KEY || envConfig.OPENAI_API_KEY || ''; @@ -303,6 +305,21 @@ export const AGENT_SESSION_MAX_BYTES = parseOptionalInt( process.env.AGENT_SESSION_MAX_BYTES || envConfig.AGENT_SESSION_MAX_BYTES, ) ?? 2_000_000; +export const AGENT_SESSION_COMPACT_ENABLED = /^(YES|yes|true|TRUE|1)$/u.test( + process.env.AGENT_SESSION_COMPACT_ENABLED ?? + envConfig.AGENT_SESSION_COMPACT_ENABLED ?? + 'true', +); +export const AGENT_SESSION_COMPACT_KEEP_TURNS = + parseOptionalInt( + process.env.AGENT_SESSION_COMPACT_KEEP_TURNS || + envConfig.AGENT_SESSION_COMPACT_KEEP_TURNS, + ) ?? 20; +export const AGENT_SESSION_COMPACT_MIN_ENTRIES = + parseOptionalInt( + process.env.AGENT_SESSION_COMPACT_MIN_ENTRIES || + envConfig.AGENT_SESSION_COMPACT_MIN_ENTRIES, + ) ?? 30; export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || envConfig.STRIPE_SECRET_KEY || ''; export const STRIPE_KEY_MODE = getStripeKeyMode(STRIPE_SECRET_KEY); diff --git a/src/sanitize.test.ts b/src/sanitize.test.ts new file mode 100644 index 0000000..127ef10 --- /dev/null +++ b/src/sanitize.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeInboundText, truncateUtf8ByBytes } from './sanitize.js'; + +describe('sanitizeInboundText', () => { + it('passes through plain ASCII text unchanged', () => { + expect(sanitizeInboundText('Hello world 123!')).toBe('Hello world 123!'); + }); + + it('passes through normal Unicode (Slovenian, emoji, CJK)', () => { + expect(sanitizeInboundText('čšž ČŠŽ 日本語 🎉')).toBe('čšž ČŠŽ 日本語 🎉'); + }); + + it('strips zero-width characters (U+200B-U+200F)', () => { + const input = 'hello\u200Bworld\u200C\u200D\u200E\u200F'; + expect(sanitizeInboundText(input)).toBe('helloworld'); + }); + + it('strips bidi control characters (U+202A-U+202E)', () => { + const input = 'text\u202A\u202B\u202C\u202D\u202Emore'; + expect(sanitizeInboundText(input)).toBe('textmore'); + }); + + it('strips directional isolate characters (U+2066-U+2069)', () => { + const input = 'a\u2066\u2067\u2068\u2069b'; + expect(sanitizeInboundText(input)).toBe('ab'); + }); + + it('strips BOM (U+FEFF)', () => { + const input = '\uFEFFhello\uFEFF'; + expect(sanitizeInboundText(input)).toBe('hello'); + }); + + it('strips C0 controls (U+0000-U+0008, U+000B, U+000C, U+000E-U+001F)', () => { + const input = + '\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008ok\u000B\u000C\u000E\u001F'; + expect(sanitizeInboundText(input)).toBe('ok'); + }); + + it('strips DEL (U+007F)', () => { + expect(sanitizeInboundText('a\u007Fb')).toBe('ab'); + }); + + it('preserves tabs and newlines', () => { + expect(sanitizeInboundText('hello\tworld\nline2')).toBe( + 'hello\tworld\nline2', + ); + }); + + it('handles empty string', () => { + expect(sanitizeInboundText('')).toBe(''); + }); +}); + +describe('truncateUtf8ByBytes', () => { + it('returns full text when within byte limit', () => { + const result = truncateUtf8ByBytes('hello', 100); + expect(result.head).toBe('hello'); + expect(result.omittedBytes).toBe(0); + }); + + it('returns full text when maxBytes is 0 (disabled)', () => { + const result = truncateUtf8ByBytes('hello', 0); + expect(result.head).toBe('hello'); + expect(result.omittedBytes).toBe(0); + }); + + it('truncates at character boundary for ASCII', () => { + const result = truncateUtf8ByBytes('abcdefghij', 5); + expect(result.head).toBe('abcde'); + expect(result.omittedBytes).toBe(5); + }); + + it('does not split multi-byte UTF-8 characters', () => { + const result = truncateUtf8ByBytes('aačšžbb', 5); + expect(result.head).toBe('aač'); + expect(Buffer.byteLength(result.head, 'utf8')).toBeLessThanOrEqual(5); + }); + + it('handles emoji (4-byte characters) correctly', () => { + const result = truncateUtf8ByBytes('hi🎉🌍', 6); + expect(result.head).toBe('hi'); + expect(result.omittedBytes).toBeGreaterThan(0); + }); + + it('handles CJK characters (3-byte each)', () => { + const result = truncateUtf8ByBytes('日本語テスト', 9); + expect(result.head).toBe('日本語'); + expect(Buffer.byteLength(result.head, 'utf8')).toBe(9); + }); + + it('returns empty head when first character exceeds limit', () => { + const result = truncateUtf8ByBytes('🎉hello', 1); + expect(result.head).toBe(''); + expect(result.omittedBytes).toBeGreaterThan(0); + }); + + it('handles empty string', () => { + const result = truncateUtf8ByBytes('', 10); + expect(result.head).toBe(''); + expect(result.omittedBytes).toBe(0); + }); + + it('calculates omittedBytes correctly', () => { + const text = 'abcdef'; + const fullBytes = Buffer.byteLength(text, 'utf8'); + const result = truncateUtf8ByBytes(text, 3); + expect(result.omittedBytes).toBe(fullBytes - 3); + }); +}); diff --git a/src/sanitize.ts b/src/sanitize.ts new file mode 100644 index 0000000..e4ae14f --- /dev/null +++ b/src/sanitize.ts @@ -0,0 +1,24 @@ +export function sanitizeInboundText(raw: string): string { + return raw + .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/gu, '') + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/gu, ''); +} + +export function truncateUtf8ByBytes( + text: string, + maxBytes: number, +): { head: string; omittedBytes: number } { + const fullBytes = Buffer.byteLength(text, 'utf8'); + if (maxBytes <= 0 || fullBytes <= maxBytes) + return { head: text, omittedBytes: 0 }; + + let bytes = 0; + let head = ''; + for (const ch of text) { + const b = Buffer.byteLength(ch, 'utf8'); + if (bytes + b > maxBytes) break; + head += ch; + bytes += b; + } + return { head, omittedBytes: Math.max(0, fullBytes - bytes) }; +} diff --git a/src/session-compaction.test.ts b/src/session-compaction.test.ts new file mode 100644 index 0000000..7c61f6e --- /dev/null +++ b/src/session-compaction.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'path'; +import fs from 'fs'; + +import { + parseJsonlEntries, + splitEntries, + buildConcatSummary, + buildSummarizationPrompt, + createCompactionHeader, + writeCompactedSession, + compactSession, +} from './session-compaction.js'; +import type { SessionEntry } from './agent-session.js'; + +vi.mock('./memory-pg.js', () => ({ + storeMemory: vi + .fn() + .mockResolvedValue({ memoryId: 'test-mem-id', chunkCount: 1 }), +})); + +vi.mock('./logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +let tmpDir: string; + +beforeEach(() => { + const base = path.resolve(process.cwd(), 'tmp'); + fs.mkdirSync(base, { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(base, 'session-compact-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function makeEntry(overrides: Partial = {}): SessionEntry { + return { + timestamp: new Date().toISOString(), + task: 'Check jail status', + skill: 'jail-status', + result: 'success', + tokens_used: 420, + ...overrides, + }; +} + +function writeJsonl(filePath: string, entries: SessionEntry[]): void { + const lines = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'; + fs.writeFileSync(filePath, lines, 'utf-8'); +} + +function readJsonl(filePath: string): SessionEntry[] { + const raw = fs.readFileSync(filePath, 'utf-8'); + return raw + .split('\n') + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as SessionEntry); +} + +describe('parseJsonlEntries', () => { + it('parses valid JSONL entries', () => { + const filePath = path.join(tmpDir, 'test.jsonl'); + writeJsonl(filePath, [makeEntry(), makeEntry({ task: 'Second' })]); + const entries = parseJsonlEntries(filePath); + expect(entries).toHaveLength(2); + expect(entries[0].task).toBe('Check jail status'); + expect(entries[1].task).toBe('Second'); + }); + + it('returns empty array for missing file', () => { + const entries = parseJsonlEntries(path.join(tmpDir, 'missing.jsonl')); + expect(entries).toHaveLength(0); + }); + + it('skips malformed lines', () => { + const filePath = path.join(tmpDir, 'bad.jsonl'); + fs.writeFileSync( + filePath, + 'not-json\n' + JSON.stringify(makeEntry()) + '\n', + 'utf-8', + ); + const entries = parseJsonlEntries(filePath); + expect(entries).toHaveLength(1); + }); + + it('handles empty file', () => { + const filePath = path.join(tmpDir, 'empty.jsonl'); + fs.writeFileSync(filePath, '', 'utf-8'); + expect(parseJsonlEntries(filePath)).toHaveLength(0); + }); +}); + +describe('splitEntries', () => { + it('splits entries at correct point', () => { + const entries = Array.from({ length: 30 }, (_, i) => + makeEntry({ task: `Task ${i}` }), + ); + const { old, recent } = splitEntries(entries, 10); + expect(old).toHaveLength(20); + expect(recent).toHaveLength(10); + expect(old[0].task).toBe('Task 0'); + expect(recent[0].task).toBe('Task 20'); + }); + + it('returns all recent when entries <= keepCount', () => { + const entries = [makeEntry(), makeEntry()]; + const { old, recent } = splitEntries(entries, 10); + expect(old).toHaveLength(0); + expect(recent).toHaveLength(2); + }); + + it('returns empty old when keepCount equals total', () => { + const entries = Array.from({ length: 5 }, (_, i) => + makeEntry({ task: `Task ${i}` }), + ); + const { old, recent } = splitEntries(entries, 5); + expect(old).toHaveLength(0); + expect(recent).toHaveLength(5); + }); +}); + +describe('buildConcatSummary', () => { + it('produces numbered summary lines', () => { + const entries = [ + makeEntry({ task: 'Task A', result: 'success' }), + makeEntry({ task: 'Task B', result: 'error', output: 'Disk full' }), + ]; + const summary = buildConcatSummary(entries); + expect(summary).toContain('1. [success] Task A'); + expect(summary).toContain('2. [error] Task B: Disk full'); + }); + + it('truncates long output to 200 chars', () => { + const entries = [makeEntry({ output: 'x'.repeat(300) })]; + const summary = buildConcatSummary(entries); + expect(summary).toContain('...'); + expect(summary.length).toBeLessThan(400); + }); + + it('handles entries with no output', () => { + const entries = [makeEntry({ task: 'Simple', output: undefined })]; + const summary = buildConcatSummary(entries); + expect(summary).toContain('Simple'); + expect(summary).not.toContain(': '); + }); +}); + +describe('buildSummarizationPrompt', () => { + it('includes entry details', () => { + const entries = [ + makeEntry({ task: 'Fix DB', skill: 'postgres', output: 'Restarted' }), + ]; + const prompt = buildSummarizationPrompt(entries); + expect(prompt).toContain('Fix DB'); + expect(prompt).toContain('postgres'); + expect(prompt).toContain('Restarted'); + }); + + it('truncates long outputs to 300 chars', () => { + const entries = [makeEntry({ output: 'y'.repeat(500) })]; + const prompt = buildSummarizationPrompt(entries); + expect(prompt).toContain('...'); + expect(prompt.length).toBeLessThan(800); + }); + + it('includes tokens_used when present', () => { + const entries = [makeEntry({ tokens_used: 12345 })]; + const prompt = buildSummarizationPrompt(entries); + expect(prompt).toContain('Tokens: 12345'); + }); +}); + +describe('createCompactionHeader', () => { + it('creates a valid SessionEntry', () => { + const header = createCompactionHeader('Summary text here'); + expect(header.task).toBe('[compaction-summary]'); + expect(header.skill).toBe('session-compaction'); + expect(header.result).toBe('success'); + expect(header.output).toBe('Summary text here'); + expect(header.tokens_used).toBe(0); + expect(header.timestamp).toBeTruthy(); + }); +}); + +describe('writeCompactedSession', () => { + it('writes header followed by recent entries', () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + const header = createCompactionHeader('Test summary'); + const recent = [ + makeEntry({ task: 'Recent 1' }), + makeEntry({ task: 'Recent 2' }), + ]; + writeCompactedSession(filePath, header, recent); + + const entries = readJsonl(filePath); + expect(entries).toHaveLength(3); + expect(entries[0].task).toBe('[compaction-summary]'); + expect(entries[1].task).toBe('Recent 1'); + expect(entries[2].task).toBe('Recent 2'); + }); + + it('uses atomic write (temp file then rename)', () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + writeJsonl( + filePath, + Array.from({ length: 50 }, (_, i) => makeEntry({ task: `Task ${i}` })), + ); + + const header = createCompactionHeader('Compacted'); + const recent = [makeEntry({ task: 'Kept' })]; + writeCompactedSession(filePath, header, recent); + + expect(fs.existsSync(filePath + '.compact.tmp')).toBe(false); + const entries = readJsonl(filePath); + expect(entries).toHaveLength(2); + expect(entries[0].task).toBe('[compaction-summary]'); + }); + + it('preserves file if temp write exists (no clobber)', () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + writeJsonl(filePath, [makeEntry({ task: 'Original' })]); + + const tmpPath = filePath + '.compact.tmp'; + fs.writeFileSync(tmpPath, 'stale', 'utf-8'); + + const header = createCompactionHeader('New'); + const recent = [makeEntry({ task: 'Kept' })]; + writeCompactedSession(filePath, header, recent); + + const entries = readJsonl(filePath); + expect(entries[0].task).toBe('[compaction-summary]'); + expect(fs.existsSync(tmpPath)).toBe(false); + }); +}); + +describe('compactSession', () => { + it('returns compacted=false when disabled', async () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + writeJsonl( + filePath, + Array.from({ length: 50 }, (_, i) => makeEntry({ task: `Task ${i}` })), + ); + + const result = await compactSession(filePath, { + enabled: false, + keepTurns: 10, + minEntries: 5, + }); + expect(result.compacted).toBe(false); + expect(result.method).toBe('none'); + }); + + it('returns compacted=false when file does not exist', async () => { + const result = await compactSession(path.join(tmpDir, 'missing.jsonl'), { + enabled: true, + }); + expect(result.compacted).toBe(false); + }); + + it('returns compacted=false when too few entries', async () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + writeJsonl(filePath, [makeEntry(), makeEntry()]); + + const result = await compactSession(filePath, { + enabled: true, + keepTurns: 10, + minEntries: 30, + }); + expect(result.compacted).toBe(false); + }); + + it('compacts session using concat fallback when LLM unavailable', async () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + const entries = Array.from({ length: 40 }, (_, i) => + makeEntry({ task: `Task ${i}`, output: `Output ${i}` }), + ); + writeJsonl(filePath, entries); + + const result = await compactSession(filePath, { + enabled: true, + keepTurns: 10, + minEntries: 20, + }); + + expect(result.compacted).toBe(true); + expect(result.entriesBefore).toBe(40); + expect(result.entriesAfter).toBe(11); // 1 header + 10 kept + expect(result.summaryLength).toBeGreaterThan(0); + expect(['llm', 'concat']).toContain(result.method); + + const written = readJsonl(filePath); + expect(written).toHaveLength(11); + expect(written[0].task).toBe('[compaction-summary]'); + expect(written[1].task).toBe('Task 30'); + expect(written[10].task).toBe('Task 39'); + }); + + it('does not compact if keepTurns >= total entries', async () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + writeJsonl( + filePath, + Array.from({ length: 10 }, (_, i) => makeEntry({ task: `Task ${i}` })), + ); + + const result = await compactSession(filePath, { + enabled: true, + keepTurns: 20, + minEntries: 5, + }); + expect(result.compacted).toBe(false); + }); + + it('compaction header preserves summary of old entries', async () => { + const filePath = path.join(tmpDir, 'session.jsonl'); + const entries = Array.from({ length: 35 }, (_, i) => + makeEntry({ + task: `Task ${i}`, + skill: `skill-${i}`, + output: `Result ${i}`, + }), + ); + writeJsonl(filePath, entries); + + await compactSession(filePath, { + enabled: true, + keepTurns: 10, + minEntries: 20, + }); + + const written = readJsonl(filePath); + const header = written[0]; + expect(header.task).toBe('[compaction-summary]'); + expect(header.output).toContain('Task 0'); + expect(header.output).toContain('Task 24'); + }); +}); diff --git a/src/session-compaction.ts b/src/session-compaction.ts new file mode 100644 index 0000000..cc17a90 --- /dev/null +++ b/src/session-compaction.ts @@ -0,0 +1,325 @@ +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, + LANG, + LC_ALL, + PI_TUI_BIN, + PI_TUI_MODEL, + PI_TUI_PROVIDER, + TIMEZONE, + TMP_DIR, +} 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 = 30_000; + +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 { + return new Promise((resolve) => { + const args: string[] = ['--print', prompt, '--no-session', '--no-skills']; + if (PI_TUI_PROVIDER) args.push('--provider', PI_TUI_PROVIDER); + if (PI_TUI_MODEL) args.push('--model', PI_TUI_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', + }; + + 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; + } = {}, +): Promise { + 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 { + await storeMemory(summary, { + topics: ['session-compaction'], + 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; +} diff --git a/src/telegram-commands.ts b/src/telegram-commands.ts index 99a01cf..968121e 100644 --- a/src/telegram-commands.ts +++ b/src/telegram-commands.ts @@ -20,6 +20,7 @@ import type { GroupQueue } from './group-queue.js'; import { getAllBudgets } from './controlplane-db.js'; import { getPool } from './db.js'; import { collectSnapshot } from './system-state.js'; +import { compactSession } from './session-compaction.js'; export interface CommandContext { queue: GroupQueue; @@ -268,6 +269,109 @@ async function resetSession(chatJid: string): Promise { } } +// ── /compact ──────────────────────────────────────────────────────────── + +async function findSessionFile(chatJid: string): Promise { + const c = requireCtx(); + const groups = c.registeredGroups(); + const group = groups[chatJid]; + if (!group) return null; + + const sessionDir = path.join( + process.cwd(), + 'groups', + group.folder, + 'sessions', + ); + if (!fs.existsSync(sessionDir)) return null; + + const files = fs + .readdirSync(sessionDir) + .filter((f) => f.endsWith('.jsonl')) + .sort(); + if (files.length === 0) return null; + + return path.join(sessionDir, files[files.length - 1]); +} + +export async function handleCompactCommand( + ctxArg: any, + chatJid: string, +): Promise { + if (!(await requireAdmin(ctxArg))) return; + const sessionFile = await findSessionFile(chatJid); + if (!sessionFile) { + await ctxArg.reply('No session file found for this chat.'); + return; + } + await ctxArg.reply( + 'Compact session? Old turns will be summarized, recent turns kept.', + { + reply_markup: new InlineKeyboard() + .text('Yes, compact', 'cmd:compact:confirm') + .text('Cancel', 'cmd:compact:cancel'), + }, + ); +} + +export async function handleCompactCallback( + ctxArg: any, + chatJid: string, + action: string, +): Promise { + if (action === 'confirm') { + const sessionFile = await findSessionFile(chatJid); + if (!sessionFile) { + try { + await ctxArg.editMessageText('No session file found.'); + } catch { + await ctxArg.reply('No session file found.'); + } + await ctxArg.answerCallbackQuery(); + return; + } + try { + const result = await compactSession(sessionFile); + if (result.compacted) { + const msg = + `Compacted (${result.method}). ` + + `${result.entriesBefore} → ${result.entriesAfter} entries. ` + + `Summary: ${result.summaryLength} chars.` + + (result.memoryStored ? '' : ' (memory DB store failed)'); + try { + await ctxArg.editMessageText(msg); + } catch { + await ctxArg.reply(msg); + } + } else { + try { + await ctxArg.editMessageText( + 'Nothing to compact (session too short or already compact).', + ); + } catch { + await ctxArg.reply( + 'Nothing to compact (session too short or already compact).', + ); + } + } + } catch (err) { + logger.warn({ err, chatJid }, '/compact callback error'); + try { + await ctxArg.editMessageText('Compaction failed.'); + } catch { + await ctxArg.reply('Compaction failed.'); + } + } + } else { + try { + await ctxArg.editMessageText('Cancelled.'); + } catch { + await ctxArg.reply('Cancelled.'); + } + } + await ctxArg.answerCallbackQuery(); +} + // ── /whoami ────────────────────────────────────────────────────────────── export async function handleWhoamiCommand(ctxArg: any): Promise { @@ -487,6 +591,8 @@ export async function handleCommandCallback( if (command === 'stop') await handleStopCallback(ctxArg, chatJid, action); else if (command === 'new') await handleNewCallback(ctxArg, chatJid, action); + else if (command === 'compact') + await handleCompactCallback(ctxArg, chatJid, action); else if (command === 'activation') await handleActivationCallback(ctxArg, chatJid, action); else await ctxArg.answerCallbackQuery({ text: 'Unknown command' });