# Plan: `defaultCompaction` Feature

## Overview

A simple, manually-triggered compaction method stored per-session. Triggered via
`POST /sessions/:id/compact`. Stores a rolling summary and a pointer (`size`) of
how many messages have already been summarized. On each LLM turn the summary is
injected after system prompts so the model always has full context.

---

## What We're NOT Touching (Yet)
- The existing `manageContext` pipeline in `core/compaction.js` (the old threshold-based compaction) — left as-is
- Automated triggering — out of scope for this task; the endpoint is the trigger
- Any other compaction methods from the TODO list

---

## Key Findings From Code Research

| Topic | Location | Notes |
|-------|----------|-------|
| Sessions table | `migrations/001-initial.sql` | Already has `compaction_count` column (unused). Need to add `compact_summary`, `compact_size`, `compact_count` |
| DB patching | `infrastructure/database.js:76-117` | Uses `ensureColumn()` — safe to add columns here and in a migration SQL |
| Message loading | `core/router.js:50-56` | `dbMessages` are loaded from DB, mapped, then passed to `runLoop`. Injection goes here. |
| Sessions API | `api/routes/sessions.js` | POST/GET/DELETE/reset routes. New `POST /:id/compact` goes here. |
| Agent schema | `schemas/agent.json` | Has `additionalProperties: false` — must add `defaultCompaction` property or it will throw |
| Settings fields | `settings/fields.js` | Constants only. Add `DEFAULT_COMPACT_COUNT: 50` |
| LLM call pattern | `core/compaction.js:106-145` | Uses `getModelConfig(settings, F.MODEL_COMPACT)` then `callLLM` |
| Compact prompt | `core/prompt.js:154-173` | `buildCompactionPrompt` exists. We need a new `buildDefaultCompactionPrompt` alongside it |

---

## State Per Session (New DB Columns)

```
compact_summary  TEXT              -- NULL if never compacted; accumulated summary text
compact_size     INTEGER DEFAULT 0 -- count of non-system messages already summarized
compact_count    INTEGER DEFAULT 50 -- percentage threshold; 50 = "compact 50% of context"
```

**Why not store `compact_count` in agent config only?**
Sessions can live longer than agent config changes, so we snapshot it at session creation.
The agent default populates the value; it can later be overridden per-session via the endpoint body.

---

## Algorithm: `runDefaultCompaction`

```
1. Load session → get compact_size, compact_summary, compact_count
2. Load all messages from DB for this session
3. Separate: systemMsgs = role==='system', nonSystemMsgs = everything else (in order)
4. If nonSystemMsgs.length <= compact_size → nothing new, return early (already fully compacted)
5. Calculate total_length:
     = compact_summary.length (or 0)
     + sum of text length of nonSystemMsgs[compact_size .. end]
6. target_length = total_length * (compact_count / 100)
7. Greedily collect messages from nonSystemMsgs[compact_size]:
     keep adding until cumulative_length >= target_length (or we run out)
   → this gives us batch = nonSystemMsgs[compact_size .. cutoff]
   → new_compact_size = compact_size + batch.length
8. If batch is empty → nothing to do, return early
9. Build prompt: existing summary + batch messages
10. Call LLM (compact model, fallback to main)
11. On success: UPDATE session SET compact_summary=newSummary, compact_size=new_compact_size
12. Return { compactedCount: batch.length, newSize: new_compact_size, summary: newSummary }
```

**Text length calculation:**
For each message, count `(msg.content || '').length + JSON.stringify(msg.tool_calls || '').length`.
Same formula already used in `core/compaction.js:estimateContextUsage`.

**Why batch starts at `compact_size`, not 0:**
On second+ runs, we only compact NEW messages (from `compact_size` onwards). The previous
`compact_summary` already covers everything before that.

---

## Injection: `buildMessagesWithSummary`

Called in `core/router.js` after loading DB messages, before passing to `runLoop`.

```
Input : rawMessages (from db.getMessages), session (with compact_summary, compact_size)
Output: new messages array for the LLM

Algorithm:
  systemMsgs    = rawMessages where role === 'system'
  nonSystemMsgs = rawMessages where role !== 'system'

  if compact_summary is empty/null:
    return [...systemMsgs, ...nonSystemMsgs]  // unchanged

  return [
    ...systemMsgs,
    { role: 'user',      content: `[Summary of previous conversation]\n\n${compact_summary}` },
    { role: 'assistant', content: 'Understood. I have the context from our earlier conversation.' },
    ...nonSystemMsgs.slice(compact_size)   // only uncompacted messages
  ]
```

This means the LLM always sees: system context → summary → recent uncompacted messages.

---

## Files to Create / Modify

### 1. NEW: `migrations/006-default-compaction.sql`
```sql
-- Add default compaction state columns to sessions
ALTER TABLE sessions ADD COLUMN compact_summary TEXT;
ALTER TABLE sessions ADD COLUMN compact_size     INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN compact_count    INTEGER NOT NULL DEFAULT 50;
```

### 2. MODIFY: `infrastructure/database.js`
In `runMigrations`, add three `ensureColumn` calls (idempotent safety net):
```javascript
ensureColumn(db, 'sessions', 'compact_summary', 'TEXT');
ensureColumn(db, 'sessions', 'compact_size',    'INTEGER NOT NULL DEFAULT 0');
ensureColumn(db, 'sessions', 'compact_count',   'INTEGER NOT NULL DEFAULT 50');
```

Also update `createSession` to accept `compactCount` param:
```javascript
function createSession({ agentName, mode, instanceFolder, model, modelThinking, compactCount }) {
  // ...
  const resolvedCompactCount = compactCount ?? 50;
  db.prepare(`INSERT INTO sessions (..., compact_count, ...) VALUES (..., ?, ...)`).run(..., resolvedCompactCount, ...);
}
```

### 3. NEW: `core/default-compaction.js`
Exports:
- `runDefaultCompaction({ sessionId, settings, cwd })` — performs compaction, updates DB
- `buildMessagesWithSummary({ messages, session })` — injects summary into messages array

```javascript
'use strict';

const { callLLM, extractMessage } = require('../llm/client');
const { getModelConfig } = require('../utils/settings');
const db = require('../infrastructure/database');
const F = require('../settings/fields');

function msgTextLength(msg) {
  return (msg.content || '').length + (msg.tool_calls ? JSON.stringify(msg.tool_calls).length : 0);
}

function buildDefaultCompactionPrompt({ previousSummary, messages }) {
  const formatted = messages.map(m => {
    const role = m.role.toUpperCase();
    const body = m.content || (m.tool_calls ? JSON.stringify(m.tool_calls) : '');
    return `[${role}]: ${body}`;
  }).join('\n\n');

  return [
    'You are updating a running conversation summary to reduce context length.',
    previousSummary
      ? `EXISTING SUMMARY:\n${previousSummary}`
      : 'EXISTING SUMMARY: None (this is the first compaction).',
    `NEW MESSAGES TO INCORPORATE:\n${formatted}`,
    'Produce an updated comprehensive summary that preserves:',
    '- Key decisions and conclusions',
    '- Important findings and facts',
    '- Files read or modified (with paths)',
    '- Active problems and unresolved issues',
    '- Any errors encountered and how they were handled',
    '- Current state and what step comes next',
    'Be concise but complete. Return ONLY the summary text, no preamble or labels.',
  ].join('\n\n');
}

async function runDefaultCompaction({ sessionId, settings, cwd }) {
  const session = db.getSession(sessionId);
  if (!session) throw new Error(`Session not found: ${sessionId}`);

  const compactCount  = session.compact_count  ?? 50;
  const compactSize   = session.compact_size   ?? 0;
  const compactSummary = session.compact_summary || '';

  const allMessages    = db.getMessages(sessionId);
  const nonSystemMsgs  = allMessages.filter(m => m.role !== 'system');

  if (nonSystemMsgs.length <= compactSize) {
    return { compactedCount: 0, newSize: compactSize, alreadyUpToDate: true };
  }

  const uncompactedMsgs = nonSystemMsgs.slice(compactSize);

  // Compute total length
  const summaryLen      = compactSummary.length;
  const uncompactedLen  = uncompactedMsgs.reduce((s, m) => s + msgTextLength(m), 0);
  const totalLength     = summaryLen + uncompactedLen;
  const targetLength    = Math.floor(totalLength * (compactCount / 100));

  // Greedily collect batch up to targetLength
  let batchLen = 0;
  let cutoff = 0;
  for (let i = 0; i < uncompactedMsgs.length; i++) {
    batchLen += msgTextLength(uncompactedMsgs[i]);
    cutoff = i + 1;
    if (batchLen >= targetLength) break;
  }

  const batch = uncompactedMsgs.slice(0, cutoff);
  if (batch.length === 0) {
    return { compactedCount: 0, newSize: compactSize, alreadyUpToDate: true };
  }

  // Call LLM
  const modelConfig = getModelConfig(settings, F.MODEL_COMPACT);
  if (!modelConfig[F.MODEL_API_KEY] || !modelConfig[F.MODEL_NAME]) {
    throw new Error('No compact model configured (set models.compact in settings)');
  }

  const prompt = buildDefaultCompactionPrompt({ previousSummary: compactSummary, messages: batch });

  const response = await callLLM({
    baseUrl: modelConfig[F.MODEL_BASE_URL],
    apiKey:  modelConfig[F.MODEL_API_KEY],
    model:   modelConfig[F.MODEL_NAME],
    messages: [{ role: 'user', content: prompt }],
    tools: [],
  });

  const { content: newSummary } = extractMessage(response);
  if (!newSummary || !newSummary.trim()) {
    throw new Error('Compaction LLM returned empty summary');
  }

  const newSize = compactSize + batch.length;
  db.updateSession(sessionId, { compactSummary: newSummary.trim(), compactSize: newSize });

  return { compactedCount: batch.length, newSize, summary: newSummary.trim() };
}

function buildMessagesWithSummary({ messages, session }) {
  if (!session) return messages;

  const compactSummary = session.compact_summary || '';
  const compactSize    = session.compact_size    ?? 0;

  if (!compactSummary || compactSummary.length === 0) return messages;

  const systemMsgs    = messages.filter(m => m.role === 'system');
  const nonSystemMsgs = messages.filter(m => m.role !== 'system');

  const summaryInjection = [
    { role: 'user',      content: `[Summary of previous conversation]\n\n${compactSummary}` },
    { role: 'assistant', content: 'Understood. I have the context from our earlier conversation.' },
  ];

  return [
    ...systemMsgs,
    ...summaryInjection,
    ...nonSystemMsgs.slice(compactSize),
  ];
}

module.exports = { runDefaultCompaction, buildMessagesWithSummary };
```

### 4. MODIFY: `settings/fields.js`
Add:
```javascript
DEFAULT_COMPACT_COUNT: 50,
```

### 5. MODIFY: `schemas/agent.json`
Add to root `properties` object (alongside `memory`, `temperature`, etc.):
```json
"defaultCompaction": {
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "compactionCount": { "type": "number", "minimum": 1, "maximum": 99 }
  }
}
```

### 6. MODIFY: `api/routes/sessions.js`
Add route (before `module.exports`):
```javascript
const { runDefaultCompaction } = require('../../core/default-compaction');

// POST /sessions/:id/compact
router.post('/:id/compact', async (req, res, next) => {
  try {
    const session = db.getSession(req.params.id);
    if (!session) return sendError(res, 404, 'SESSION_NOT_FOUND', `Session not found: ${req.params.id}`);
    if (session.status === 'closed') return sendError(res, 400, 'SESSION_CLOSED', 'Cannot compact a closed session');

    const cwd      = context.getCwd();
    const settings = loadSettings({ cwd });

    const result = await runDefaultCompaction({ sessionId: req.params.id, settings, cwd });

    const updatedSession = db.getSession(req.params.id);
    res.json({
      sessionId:      req.params.id,
      compactedCount: result.compactedCount,
      newSize:        result.newSize,
      alreadyUpToDate: result.alreadyUpToDate || false,
      session:        updatedSession,
    });
  } catch (err) {
    next(err);
  }
});
```

### 7. MODIFY: `core/router.js`
In `runChat`, after loading `dbMessages`:
```javascript
// Existing code:
const dbMessages = db.getMessages(sid);
messages = dbMessages.map(m => { ... });

// ADD after the map:
const { buildMessagesWithSummary } = require('./default-compaction');
const sessionForCompact = db.getSession(sid);
messages = buildMessagesWithSummary({ messages, session: sessionForCompact });
```

Same injection needed in `runTask` (existing session branch, line ~144), `resumeTask` (line ~227).
`runSubagent` and `runDaemonTick` create fresh sessions — no injection needed there.

### 8. MODIFY: `core/router.js` — session creation
In `runChat`, when creating a new session (line ~59), pass agent's default:
```javascript
sid = db.createSession({
  agentName,
  mode: 'chat',
  instanceFolder: cwd,
  model: agent.model || modelConfig.model,
  modelThinking: thinking,
  compactCount: agent.defaultCompaction?.compactionCount ?? 50,  // NEW
});
```

Same for `runTask` and `runSubagent` session creation blocks.

Also in `api/routes/sessions.js` `POST /sessions` (manual session creation):
```javascript
const compactCount = agent.defaultCompaction?.compactionCount ?? 50;
const sessionId = db.createSession({ agentName, mode, instanceFolder: cwd, model: resolvedModel, modelThinking: resolvedThinking, compactCount });
```

---

## Response Shape for `POST /sessions/:id/compact`

```json
{
  "sessionId": "sess_...",
  "compactedCount": 47,
  "newSize": 47,
  "alreadyUpToDate": false,
  "session": { ... full session object ... }
}
```

If nothing was compacted (already up-to-date or empty):
```json
{
  "sessionId": "sess_...",
  "compactedCount": 0,
  "newSize": 0,
  "alreadyUpToDate": true,
  "session": { ... }
}
```

---

## `updateSession` Compatibility Note

`database.js:updateSession` uses `camelToSnake` to convert field names to snake_case.
So `{ compactSummary: '...' }` → `compact_summary = '...'` and `{ compactSize: 50 }` → `compact_size = 50`.
This means no custom SQL needed for the update — use `db.updateSession(sessionId, { compactSummary, compactSize })`.

---

## Implementation Order (to avoid breaking things)

1. `migrations/006-default-compaction.sql` + `ensureColumn` in `database.js` → DB is ready
2. `database.js:createSession` — accept `compactCount`
3. `settings/fields.js` — add constant
4. `schemas/agent.json` — add `defaultCompaction` property
5. `core/default-compaction.js` — full implementation
6. `core/router.js` — inject summary when loading messages, pass `compactCount` on session create
7. `api/routes/sessions.js` — add `POST /:id/compact` endpoint

---

## What's NOT Included (For Later)

- Auto-trigger based on message count / token threshold (agent param → will be added separately)
- `POST /sessions` accepting `compactCount` override from request body (trivial to add later)
- Fallback behavior when no compact model is configured (currently throws — could be changed to use main model)
- Emitting a `context.defaultCompacted` event to SSE/WS clients

---

## Open Questions / Decisions Made

| Question | Decision |
|----------|----------|
| What if no compact model? | Throw error with clear message (caller can catch in endpoint) |
| Include system messages in size calculation? | No — `compact_size` only counts non-system messages |
| What if `compact_size` points past the end of messages? | Guard: if `nonSystemMsgs.length <= compactSize` return early |
| Re-run compaction on already-compacted range? | No — always start from `compact_size` |
| LLM message format for compaction call | Single `user` turn with the full prompt (no system) — simple and reliable |
