feat(compaction): session compaction with LLM summarization, /compact command, sanitize utils
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 <session-reset-context> 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)
This commit is contained in:
parent
b37c892e56
commit
40cc606b6e
11 changed files with 1118 additions and 171 deletions
10
.env.example
10
.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
|
||||
|
|
|
|||
50
AGENTS.md
50
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).
|
||||
|
|
|
|||
|
|
@ -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<string | null>
|
||||
// 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 <compacted-context>...</compacted-context> 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)
|
||||
|
|
@ -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<string, string> {
|
|||
*/
|
||||
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 = [
|
||||
'<session-reset-context>',
|
||||
'Previous session was reset. Key context from memory:',
|
||||
...handoffLines,
|
||||
'</session-reset-context>',
|
||||
].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<void> => {
|
||||
const finish = async (
|
||||
output: AgentOutput,
|
||||
exitCode?: number | null,
|
||||
): Promise<void> => {
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, '<s>$1</s>');
|
||||
out = out.replace(/^\s*---+\s*$/gm, 'ââââââââ');
|
||||
out = out.replace(/^\s*---+\s*$/gm, '────────');
|
||||
return out;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
109
src/sanitize.test.ts
Normal file
109
src/sanitize.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
24
src/sanitize.ts
Normal file
24
src/sanitize.ts
Normal file
|
|
@ -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) };
|
||||
}
|
||||
338
src/session-compaction.test.ts
Normal file
338
src/session-compaction.test.ts
Normal file
|
|
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
325
src/session-compaction.ts
Normal file
325
src/session-compaction.ts
Normal file
|
|
@ -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<string | null> {
|
||||
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<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 {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── /compact ────────────────────────────────────────────────────────────
|
||||
|
||||
async function findSessionFile(chatJid: string): Promise<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue