Skip to content

Commit a97a68d

Browse files
feat(tui): add /compact command with optional summarizer instructions (#1158)
* feat(tui): add /compact command with optional summarizer instructions * fix(compaction): restore extraInstructions after director rebuild The compact record stored operator instructions but a new governor on /model, interrupt, or resume never read them, so later auto-folds used the default structured prompt. * fix(compaction): skip extras on /clear and restore past 32 commits * fix(compaction): do not inherit extras after a failed /clear Stamp last workdir and session id only after a successful rebuild so a failed factory cannot make the retry look like the same session.
1 parent e026c26 commit a97a68d

19 files changed

Lines changed: 841 additions & 27 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h
187187

188188
#### Context compaction (the compaction governor)
189189

190-
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers four cases:
190+
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers five cases:
191191

192192
- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After a compact that remains over the high watermark, the governor uses **growth hysteresis** (wait for usage to grow by ~10% of the window) instead of re-arming on every cycle; dropping under 60% is not required.
193193
- **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it.
@@ -204,6 +204,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen
204204

205205
The `ollama` table key is the bare provider segment; the harness stamps slash-form on `sourceId` (`ollama/default` / `ollama/<instance>`), with `provider: openai-compatible` (`buildOpenAISource`) and a bare model (`llama3` / `qwen3`). Idle-recompress disable matches via `isOllamaProviderId` on that `sourceId` (`src/provider/ollama.ts`), not by looking up slash-form as a table key.
206206

207+
- **Operator `/compact`** — A first-class slash command folds now without waiting for the occupancy governor. Optional trailing instructions go to the summarizer (empty uses the default structured fold) and are stored on the compact record so later auto-folds still see them. Director rebuild (`/model`, interrupt, resume) restores those instructions from the latest compact record's parameters. An in-flight turn uses the same pair-safe compact-then-continue hop as threshold compact; an idle compact shows the fold and does not start a new turn.
207208
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. Overflow ignores hysteresis for the compact itself.
208209

209210
The TTL window is measured from the last `inference.done` (every inference rewrites the provider's prefix cache) with one fire per window per compact of any kind, and it never fires with a tool batch outstanding — stall pings can arrive mid-work. The consecutive-compact cap shared with the threshold path still bounds compact→infer→compact. Prompt-caching the summary call itself is an explicit non-goal: the fold carries no cache options.

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ src/
138138
commands/
139139
registry.ts Extensible slash-command registry
140140
built-in.ts /help, /model, /settings, /permissions, /plugins,
141-
/clear, /new, /mcp (connect providers from /model),
141+
/clear, /new, /compact, /mcp (connect providers from /model),
142142
/yolo
143143
tui/
144144
shell.ts Transcript, header, status line, prompt, overlays

‎docs/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ the file path and parse details.
105105

106106
## Slash Commands (TUI)
107107

108-
The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp` (enable, disable, or remove servers), and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/<name>` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands.
108+
The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/compact` (fold conversation context now, optional trailing instructions to the summarizer; does not wait for the 60% occupancy governor; idle success shows the fold and does not start a new turn), `/mcp` (enable, disable, or remove servers), and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/<name>` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands.
109109

110110
**Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`, `/lexicon`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` classifies the target first, then dispatches a selected fleet; `/pull-request-review` is worktree checkout plus a surface pass, loading `/review` for quality rules only; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop — it does not steal planning from `/plan`. Substantial Builder work consumes a counsel / `/plan` plan first; tiny parent-DIY stays plan-optional. `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). `/lexicon` owns director-prompt drift and size against the agents repo at a pinned commit. There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `native-integration`, `typescript`, `ponytail`, and `opsh` stay `use_skill` only (`user-invocable: false`). Bake-only bars such as `idiot-proof` and `native-runtime` are not slashes and are not listed for `use_skill`. Draper and emil are not slashes; they remain closed directors via `spawn_agent(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `skill_search` (descriptions) then `use_skill` (body). Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone.
111111

‎src/agent/compaction.test.ts‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import type {
88
} from "@intx/types/runtime";
99
import {
1010
COMPACTION_CONTINUATION_EVENT,
11+
OPERATOR_COMPACT_REASON,
12+
compactFloorNoopNotice,
1113
createCompactionGovernor,
14+
stickyExtraInstructionsFromRecords,
1215
} from "./compaction.js";
1316
import {
1417
compactionResumeDeltaFor,
@@ -785,6 +788,150 @@ describe("compaction governor", () => {
785788
]);
786789
expect(continuations).toBe(1);
787790
});
791+
792+
test("manual compact bypasses the occupancy governor on an idle session", () => {
793+
const governor = createCompactionGovernor(undefined);
794+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
795+
796+
expect(governor.requestManual("keep the auth discussion")).toBe("kick");
797+
expect(governor.extraInstructions).toBe("keep the auth discussion");
798+
expect(governor.requestManual("")).toBe("armed");
799+
expect(governor.extraInstructions).toBe("keep the auth discussion");
800+
801+
const actions = governor.interceptIdleContinuation(
802+
emptyMessage(),
803+
capabilities,
804+
);
805+
expect(
806+
actions?.some(
807+
(a) =>
808+
a.type === "compact" &&
809+
"reason" in a &&
810+
a.reason === OPERATOR_COMPACT_REASON,
811+
),
812+
).toBe(true);
813+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
814+
expect(governor.resumeAfterCompact(emptyMessage())).toBeNull();
815+
});
816+
817+
test("manual compact during a tool pause continues the in-flight turn", () => {
818+
const governor = createCompactionGovernor(() => undefined);
819+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
820+
821+
expect(governor.requestManual("", { inFlight: true })).toBe("armed");
822+
const actions = governor.interceptActions(
823+
toolDone(),
824+
inferAction,
825+
capabilities,
826+
);
827+
expect(
828+
actions?.some(
829+
(a) =>
830+
a.type === "compact" &&
831+
"reason" in a &&
832+
a.reason === OPERATOR_COMPACT_REASON,
833+
),
834+
).toBe(true);
835+
expect(actions?.some((a) => a.type === "infer")).toBe(false);
836+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
837+
});
838+
839+
test("manual compact no-ops below the compactor floor", () => {
840+
const governor = createCompactionGovernor(undefined);
841+
governor.noteInferenceDone(inferenceDone(overThreshold), threeTurns);
842+
expect(governor.requestManual("focus on tests")).toBe("noop");
843+
expect(governor.extraInstructions).toBeUndefined();
844+
expect(
845+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
846+
).toBeNull();
847+
});
848+
849+
test("manual compact hydrates from restored turns without a prior decide", () => {
850+
const governor = createCompactionGovernor(undefined);
851+
expect(governor.compactTurnCount).toBe(0);
852+
expect(governor.requestManual("keep the auth discussion")).toBe("noop");
853+
expect(
854+
governor.requestManual("keep the auth discussion", { turns: tenTurns }),
855+
).toBe("kick");
856+
expect(governor.compactTurnCount).toBe(10);
857+
expect(governor.extraInstructions).toBe("keep the auth discussion");
858+
});
859+
860+
test("restoreExtraInstructions hydrates a new governor from a compact record", () => {
861+
const written = {
862+
strategy: "pruning-compactor",
863+
version: "1",
864+
parameters: { extraInstructions: "keep the auth discussion" },
865+
reason: "compacted",
866+
decisions: {},
867+
};
868+
expect(stickyExtraInstructionsFromRecords([written])).toBe(
869+
"keep the auth discussion",
870+
);
871+
872+
const rebuilt = createCompactionGovernor(undefined);
873+
expect(rebuilt.extraInstructions).toBeUndefined();
874+
rebuilt.restoreExtraInstructions(
875+
stickyExtraInstructionsFromRecords([written]),
876+
);
877+
expect(rebuilt.extraInstructions).toBe("keep the auth discussion");
878+
rebuilt.restoreExtraInstructions(undefined);
879+
expect(rebuilt.extraInstructions).toBe("keep the auth discussion");
880+
});
881+
882+
test("sticky extra instructions skip empty values and later non-pruning records", () => {
883+
expect(
884+
stickyExtraInstructionsFromRecords([
885+
{
886+
strategy: "other",
887+
parameters: { extraInstructions: "ignore me" },
888+
},
889+
{
890+
strategy: "pruning-compactor",
891+
parameters: { extraInstructions: " keep tests " },
892+
},
893+
{
894+
strategy: "pruning-compactor",
895+
parameters: { extraInstructions: "older" },
896+
},
897+
]),
898+
).toBe("keep tests");
899+
expect(
900+
stickyExtraInstructionsFromRecords([
901+
{
902+
strategy: "pruning-compactor",
903+
parameters: { extraInstructions: "" },
904+
},
905+
{
906+
strategy: "pruning-compactor",
907+
parameters: { extraInstructions: 12 },
908+
},
909+
]),
910+
).toBeUndefined();
911+
});
912+
913+
test("second /compact during the apply-to-meter window does not double-fold", () => {
914+
const governor = createCompactionGovernor(undefined);
915+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
916+
expect(governor.requestManual("keep the auth discussion")).toBe("kick");
917+
expect(
918+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
919+
).not.toBeNull();
920+
921+
expect(governor.requestManual("also keep the billing notes")).toBe("armed");
922+
expect(governor.extraInstructions).toBe("also keep the billing notes");
923+
expect(
924+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
925+
).toBeNull();
926+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
927+
});
928+
929+
test("noop /compact below the floor tells the operator instructions were not saved", () => {
930+
expect(compactFloorNoopNotice("")).toBe("Nothing to compact yet.");
931+
expect(compactFloorNoopNotice("keep the auth discussion")).toBe(
932+
"Nothing to compact yet. Instructions were not saved.",
933+
);
934+
});
788935
});
789936

790937
describe("provider-aware idle recompress (CL-8745)", () => {

0 commit comments

Comments
 (0)