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:
Clawdie AI 2026-04-20 12:17:55 +02:00
parent b37c892e56
commit 40cc606b6e
11 changed files with 1118 additions and 171 deletions

View file

@ -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

View file

@ -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).

View file

@ -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)

View file

@ -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,
);
}
});
});

View file

@ -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;
};

View file

@ -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
View 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
View 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) };
}

View 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
View 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;
}

View file

@ -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' });