Skip to content

Commit 6a5044f

Browse files
committed
feat(tui): add /compact command with optional summarizer instructions
1 parent b4fab39 commit 6a5044f

16 files changed

Lines changed: 385 additions & 24 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. 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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from "@intx/types/runtime";
99
import {
1010
COMPACTION_CONTINUATION_EVENT,
11+
OPERATOR_COMPACT_REASON,
1112
createCompactionGovernor,
1213
} from "./compaction.js";
1314
import {
@@ -785,6 +786,74 @@ describe("compaction governor", () => {
785786
]);
786787
expect(continuations).toBe(1);
787788
});
789+
790+
test("manual compact bypasses the occupancy governor on an idle session", () => {
791+
const governor = createCompactionGovernor(undefined);
792+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
793+
794+
expect(governor.requestManual("keep the auth discussion")).toBe("kick");
795+
expect(governor.extraInstructions).toBe("keep the auth discussion");
796+
expect(governor.requestManual("")).toBe("armed");
797+
expect(governor.extraInstructions).toBe("keep the auth discussion");
798+
799+
const actions = governor.interceptIdleContinuation(
800+
emptyMessage(),
801+
capabilities,
802+
);
803+
expect(
804+
actions?.some(
805+
(a) =>
806+
a.type === "compact" &&
807+
"reason" in a &&
808+
a.reason === OPERATOR_COMPACT_REASON,
809+
),
810+
).toBe(true);
811+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
812+
expect(governor.resumeAfterCompact(emptyMessage())).toBeNull();
813+
});
814+
815+
test("manual compact during a tool pause continues the in-flight turn", () => {
816+
const governor = createCompactionGovernor(() => undefined);
817+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
818+
819+
expect(governor.requestManual("", { inFlight: true })).toBe("armed");
820+
const actions = governor.interceptActions(
821+
toolDone(),
822+
inferAction,
823+
capabilities,
824+
);
825+
expect(
826+
actions?.some(
827+
(a) =>
828+
a.type === "compact" &&
829+
"reason" in a &&
830+
a.reason === OPERATOR_COMPACT_REASON,
831+
),
832+
).toBe(true);
833+
expect(actions?.some((a) => a.type === "infer")).toBe(false);
834+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
835+
});
836+
837+
test("manual compact no-ops below the compactor floor", () => {
838+
const governor = createCompactionGovernor(undefined);
839+
governor.noteInferenceDone(inferenceDone(overThreshold), threeTurns);
840+
expect(governor.requestManual("focus on tests")).toBe("noop");
841+
expect(governor.extraInstructions).toBeUndefined();
842+
expect(
843+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
844+
).toBeNull();
845+
});
846+
847+
test("manual compact hydrates from restored turns without a prior decide", () => {
848+
const governor = createCompactionGovernor(undefined);
849+
expect(governor.compactTurnCount).toBe(0);
850+
expect(governor.requestManual("keep the auth discussion")).toBe("noop");
851+
expect(
852+
governor.requestManual("keep the auth discussion", { turns: tenTurns }),
853+
).toBe("kick");
854+
expect(governor.compactTurnCount).toBe(10);
855+
expect(governor.extraInstructions).toBe("keep the auth discussion");
856+
});
788857
});
789858

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

‎src/agent/compaction.ts‎

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ export type CompactionGovernor = ReturnType<typeof createCompactionGovernor>;
5454
// requestContinuation closure delivered. Subscribers that only care about
5555
// provider/connector traffic must ignore this event.
5656
export const COMPACTION_CONTINUATION_EVENT = "custom.compaction.continue";
57+
/** Compact reason for `/compact` (and later operator-triggered folds). */
58+
export const OPERATOR_COMPACT_REASON = "operator-request";
59+
const THRESHOLD_COMPACT_REASON = "context-threshold";
60+
61+
/** How `/compact` armed the shared pipeline. */
62+
export type ManualCompactArming = "kick" | "armed" | "noop";
63+
64+
/** Options for an operator-triggered compact. */
65+
export type ManualCompactOptions = {
66+
inFlight?: boolean;
67+
/** Live or restored turns; used to arm after resume before the first decide. */
68+
turns?: readonly ConversationTurn[];
69+
};
5770

5871
/** Build the continuation re-entry action for a compacted governor cycle. */
5972
export function compactionContinuationAction(
@@ -70,6 +83,11 @@ export function createCompactionGovernor(
7083
) {
7184
let pending = false;
7285
let idlePending = false;
86+
let manualPending = false;
87+
// Sticky operator instructions from `/compact …`. Empty `/compact` still
88+
// uses the default structured fold; a non-empty argument is kept for later
89+
// auto-folds and written into the compact record.
90+
let extraInstructions: string | undefined;
7391
let postCompactInfer = false;
7492
// Idle empty compact needs a post-compact decide cycle to adopt the shrunk
7593
// turns for the meter, but must not start a new inference (there is no
@@ -157,6 +175,16 @@ export function createCompactionGovernor(
157175
noteCompactIssued();
158176
}
159177

178+
function compactReason(operator: boolean): string {
179+
return operator ? OPERATOR_COMPACT_REASON : THRESHOLD_COMPACT_REASON;
180+
}
181+
182+
function clearManualArming(): void {
183+
manualPending = false;
184+
idlePending = false;
185+
pending = false;
186+
}
187+
160188
// Continuation re-entry for a compact-bearing return. The legacy subagent
161189
// path still holds a host closure and drives its stall-ping loop through
162190
// it; the chat path holds none and gets an emit action the host answers
@@ -246,16 +274,22 @@ export function createCompactionGovernor(
246274
): ReactorAction[] | null {
247275
if (event.type !== "tool.done") return null;
248276
if (outstandingToolCalls > 0) outstandingToolCalls -= 1;
249-
if (!pending && !(usingEstimate && isOverThreshold(estimate.tokens)))
277+
const operator = manualPending;
278+
if (
279+
!operator &&
280+
!pending &&
281+
!(usingEstimate && isOverThreshold(estimate.tokens))
282+
)
250283
return null;
251284
if (!actions.some((a) => a.type === "infer")) return null;
252-
if (atThresholdCompactCap()) return null;
253-
pending = false;
285+
if (!operator && atThresholdCompactCap()) return null;
286+
clearManualArming();
254287
postCompactInfer = true;
255-
issueThresholdCompact();
288+
if (operator) noteCompactIssued();
289+
else issueThresholdCompact();
256290
return [
257291
...actions.filter((a) => a.type !== "infer"),
258-
capabilities.compact(COMPACTOR_NAME, "context-threshold"),
292+
capabilities.compact(COMPACTOR_NAME, compactReason(operator)),
259293
...continuationActions(capabilities),
260294
];
261295
}
@@ -276,8 +310,10 @@ export function createCompactionGovernor(
276310
event: ReactorInboundEvent,
277311
actions: ReactorAction[],
278312
): boolean {
279-
if (!pending || idlePending) return false;
280-
if (atThresholdCompactCap()) return false;
313+
if (idlePending) return false;
314+
const operator = manualPending;
315+
if (!operator && !pending) return false;
316+
if (!operator && atThresholdCompactCap()) return false;
281317
if (!onTurnBoundary(event)) return false;
282318
if (isSpacerEchoTerminal(event, actions)) return false;
283319
const terminal =
@@ -352,26 +388,35 @@ export function createCompactionGovernor(
352388
): ReactorAction[] | null {
353389
if (event.type !== "message.received") return null;
354390
if (idlePending) {
355-
if (atThresholdCompactCap()) {
391+
const operator = manualPending;
392+
if (!operator && atThresholdCompactCap()) {
356393
idlePending = false;
357394
return null;
358395
}
359-
idlePending = false;
360-
pending = false;
396+
clearManualArming();
397+
const content = inboundText(event);
361398
// The reactor delivers no event after compact, so always request a
362399
// continuation to re-enter decide against the shrunk turns:
363400
// - raced operator content → re-infer to answer it
364401
// - empty synthetic continuation → meter-only sync (no infer)
365-
return issueIdleFold(
366-
inboundText(event),
367-
capabilities,
368-
"context-threshold",
369-
);
402+
if (operator) {
403+
if (content.length > 0) postCompactInfer = true;
404+
else postCompactMeter = true;
405+
noteCompactIssued();
406+
return [
407+
capabilities.compact(COMPACTOR_NAME, OPERATOR_COMPACT_REASON),
408+
...continuationActions(capabilities),
409+
];
410+
}
411+
return issueIdleFold(content, capabilities, THRESHOLD_COMPACT_REASON);
370412
}
371413
// Unarmed idle re-entry past the provider TTL: same fold, same
372414
// keep-recent tail, same cap — but a "cache-ttl-recompress" reason so the
373415
// fold is attributable. No arming: every live re-entry re-checks the
374416
// window, so a sub-agent stall ping or operator message is the trigger.
417+
// In-flight `/compact` (manualPending without idlePending) waits on
418+
// interceptActions; do not steal that hop with a TTL fold.
419+
if (manualPending) return null;
375420
if (!isTtlRecompressDue(now())) return null;
376421
return issueIdleFold(
377422
inboundText(event),
@@ -443,6 +488,28 @@ export function createCompactionGovernor(
443488
return postCompactInfer || postCompactMeter;
444489
}
445490

491+
// `/compact` bypasses the occupancy governor. Same pair-safe compact pipeline
492+
// as auto-compact: in-flight turns wait for interceptActions / noteIdleTurn;
493+
// idle sessions arm an empty continuation so the host can kick decide().
494+
function requestManual(
495+
instructions: string,
496+
options?: ManualCompactOptions,
497+
): ManualCompactArming {
498+
if (options?.turns !== undefined) syncFromTurns(options.turns);
499+
if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop";
500+
const trimmed = instructions.trim();
501+
if (trimmed.length > 0) extraInstructions = trimmed;
502+
manualPending = true;
503+
if (options?.inFlight === true) return "armed";
504+
if (idlePending) return "armed";
505+
idlePending = true;
506+
if (requestContinuation !== undefined) {
507+
requestContinuation();
508+
return "armed";
509+
}
510+
return "kick";
511+
}
512+
446513
return {
447514
get estimatedTokens(): number {
448515
return estimate.tokens;
@@ -453,6 +520,13 @@ export function createCompactionGovernor(
453520
get usingEstimate(): boolean {
454521
return usingEstimate;
455522
},
523+
get extraInstructions(): string | undefined {
524+
return extraInstructions;
525+
},
526+
get compactTurnCount(): number {
527+
return turnCount;
528+
},
529+
requestManual,
456530
syncFromTurns,
457531
noteInferenceDone,
458532
notePostCompact,

0 commit comments

Comments
 (0)