detached 31e85d1

Make auto-compact threshold configurable

commit
31e85d198e3c5611ff1e337875300f9ba03916cd
author
mi-skam · · 2026-07-28 11:20 UTC
parent
6b2855e
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The key is unknown
9 files · +523 -11
M README.md +3 -2
@@ -282,7 +282,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
282282 | `/unjail` | Allow tools to touch paths outside again. |
283283 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
284284 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
285-| `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
285+| `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
286286 | `/clear` | Clear the chat transcript. |
287287 | `/exit` | Exit zot. |
288288
@@ -378,6 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
378378
379379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
380380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
381+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses.
381382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
382383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
383384 - **show loaded resources at startup**: list loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -394,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
394395
395396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
396397
397-zot also auto-compacts in the background: after any turn that leaves context usage at or above **85%** of the model's window, the agent kicks off a condense pass on its own. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript.
398+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response.
398399
399400 ### `/jail`
400401
@@ -282,7 +282,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
282 | `/unjail` | Allow tools to touch paths outside again. |282 | `/unjail` | Allow tools to touch paths outside again. |
283 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |283 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
284 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |284 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
285-| `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |285+| `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
286 | `/clear` | Clear the chat transcript. |286 | `/clear` | Clear the chat transcript. |
287 | `/exit` | Exit zot. |287 | `/exit` | Exit zot. |
288 288
@@ -378,6 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
378 378
379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
381+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses.
381 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
382 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
383 - **show loaded resources at startup**: list loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.384 - **show loaded resources at startup**: list loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -394,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
394 395
395 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
396 397
397-zot also auto-compacts in the background: after any turn that leaves context usage at or above **85%** of the model's window, the agent kicks off a condense pass on its own. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript.398+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response.
398 399
399 ### `/jail`400 ### `/jail`
400 401
A docs/superpowers/plans/2026-07-28-configurable-auto-compact-threshold.md +239 -0
@@ -0,0 +1,239 @@
1+# Configurable Auto-Compact Threshold Implementation Plan
2+
3+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+**Goal:** Add persisted auto-compaction threshold presets to the interactive TUI settings.
6+
7+**Architecture:** Keep automatic triggering in `packages/agent/modes`. Thread an optional integer percentage from persisted `agent.Config` into `modes.InteractiveConfig`, normalize missing or invalid values to 85%, and let zero disable only percentage-based triggers. Reuse the settings dialog's existing fixed-option picker and settings-store persistence path.
8+
9+**Tech Stack:** Go, standard library, Zot's existing TUI settings dialog, JSON configuration.
10+
11+## Global Constraints
12+
13+- Supported values are `0` (off), `70`, `80`, `85`, and `90`.
14+- Missing or invalid persisted values resolve to `85`.
15+- Disabling the threshold does not disable manual compaction or HTTP 413 recovery.
16+- Do not add dependencies or change non-interactive compaction behavior.
17+- Preserve unrelated working-tree changes and stage only this feature.
18+
19+---
20+
21+### Task 1: Configuration and threshold decision
22+
23+**Files:**
24+- Modify: `packages/agent/config.go`
25+- Modify: `packages/agent/modes/interactive.go`
26+- Create: `packages/agent/modes/auto_compact_test.go`
27+- Modify: `packages/agent/cli.go`
28+
29+**Interfaces:**
30+- Produces: `Config.AutoCompactThreshold *int`
31+- Produces: `InteractiveConfig.AutoCompactThreshold *int`
32+- Produces: `normalizeAutoCompactThreshold(*int) int`
33+- Produces: `shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool`
34+
35+- [ ] **Step 1: Write failing normalization and decision tests**
36+
37+Add table-driven tests proving nil and invalid values resolve to 85, supported presets survive unchanged, zero disables percentage checks, and 70/80/85/90 trigger exactly at their boundaries.
38+
39+```go
40+func TestNormalizeAutoCompactThreshold(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ value *int
44+ want int
45+ }{
46+ {name: "missing", value: nil, want: 85},
47+ {name: "off", value: intPtr(0), want: 0},
48+ {name: "seventy", value: intPtr(70), want: 70},
49+ {name: "invalid", value: intPtr(42), want: 85},
50+ }
51+ // Assert normalizeAutoCompactThreshold(test.value) == test.want.
52+}
53+
54+func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
55+ if shouldAutoCompact(84, 100, 85) {
56+ t.Fatal("84% must remain below an 85% threshold")
57+ }
58+ if !shouldAutoCompact(85, 100, 85) {
59+ t.Fatal("85% must trigger an 85% threshold")
60+ }
61+ if shouldAutoCompact(100, 100, 0) {
62+ t.Fatal("off must disable percentage-triggered compaction")
63+ }
64+}
65+```
66+
67+- [ ] **Step 2: Run tests and verify RED**
68+
69+Run:
70+
71+```sh
72+go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1
73+```
74+
75+Expected: compilation failure because the normalization and decision helpers do not exist.
76+
77+- [ ] **Step 3: Add optional configuration and minimal helpers**
78+
79+Add `AutoCompactThreshold *int` with JSON name `auto_compact_threshold` to both configuration structs. Implement normalization for exactly `0`, `70`, `80`, `85`, and `90`, defaulting all other values to `85`. Replace the hard-coded fraction check with the normalized percentage helper and pass the config field through `cli.go`.
80+
81+- [ ] **Step 4: Run focused tests and verify GREEN**
82+
83+Run:
84+
85+```sh
86+go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1
87+```
88+
89+Expected: PASS.
90+
91+### Task 2: Persistence and TUI preset picker
92+
93+**Files:**
94+- Modify: `packages/agent/settings_store.go`
95+- Modify: `packages/agent/settings_store_test.go`
96+- Modify: `packages/agent/modes/interactive.go`
97+- Modify: `packages/agent/modes/auto_compact_test.go`
98+
99+**Interfaces:**
100+- Extends: `SettingsStore.SetAutoCompactThreshold(percent int) error`
101+- Consumes: `normalizeAutoCompactThreshold(*int) int`
102+
103+- [ ] **Step 1: Write failing persistence and settings-item tests**
104+
105+Add a settings-store test that writes `70` into an isolated `$ZOT_HOME` and verifies `auto_compact_threshold` plus an unrelated config field. Add a modes test that opens settings with missing configuration and verifies the option values are `0`, `70`, `80`, `85`, `90` with `85` selected.
106+
107+```go
108+func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) {
109+ t.Setenv("ZOT_HOME", t.TempDir())
110+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
111+ t.Fatal(err)
112+ }
113+ if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil {
114+ t.Fatal(err)
115+ }
116+ cfg, err := LoadConfig()
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 {
121+ t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold)
122+ }
123+ if cfg.Theme != "dark" {
124+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
125+ }
126+}
127+```
128+
129+- [ ] **Step 2: Run tests and verify RED**
130+
131+Run:
132+
133+```sh
134+go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1
135+```
136+
137+Expected: compilation or assertion failure because persistence and the picker row do not exist.
138+
139+- [ ] **Step 3: Implement persistence and live setting application**
140+
141+Add the settings-store method, fixed picker options, choice resolution, action routing, integer parsing, live `InteractiveConfig` update, and status message. Persist only values exposed by the picker; normalize the live value defensively before use.
142+
143+- [ ] **Step 4: Run focused tests and verify GREEN**
144+
145+Run:
146+
147+```sh
148+go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1
149+```
150+
151+Expected: PASS.
152+
153+### Task 3: Documentation and full verification
154+
155+**Files:**
156+- Modify: `README.md`
157+- Review: `docs/superpowers/specs/2026-07-28-configurable-auto-compact-threshold-design.md`
158+- Review: `docs/superpowers/plans/2026-07-28-configurable-auto-compact-threshold.md`
159+
160+**Interfaces:**
161+- Documents: `auto_compact_threshold`
162+- Documents: `/settings` presets and `off` semantics
163+
164+- [ ] **Step 1: Update user documentation**
165+
166+Document `off`, `70%`, `80%`, `85%` default, and `90%` in the `/settings` section. Update `/compact` to state that threshold-based auto-compaction is configurable and that payload-too-large recovery remains active when the percentage trigger is off.
167+
168+- [ ] **Step 2: Format changed Go files**
169+
170+Run:
171+
172+```sh
173+gofmt -w packages/agent/config.go packages/agent/settings_store.go packages/agent/settings_store_test.go packages/agent/cli.go packages/agent/modes/interactive.go packages/agent/modes/auto_compact_test.go
174+```
175+
176+- [ ] **Step 3: Run the complete test suite**
177+
178+Run:
179+
180+```sh
181+go test ./...
182+```
183+
184+Expected: PASS.
185+
186+- [ ] **Step 4: Run static analysis**
187+
188+Run:
189+
190+```sh
191+go vet ./...
192+```
193+
194+Expected: PASS.
195+
196+- [ ] **Step 5: Inspect the final change**
197+
198+Run:
199+
200+```sh
201+git diff --check
202+git status --short
203+git diff
204+```
205+
206+Confirm only the configuration, TUI, tests, README, design, and plan belong to the feature.
207+
208+### Task 4: Publish the pull request
209+
210+**Files:**
211+- Stage only the files listed in Tasks 1-3.
212+
213+**Interfaces:**
214+- Produces: branch `codex/configurable-auto-compact-threshold`
215+- Produces: one feature commit and a ready GitHub pull request
216+
217+- [ ] **Step 1: Create the feature branch**
218+
219+```sh
220+git switch -c codex/configurable-auto-compact-threshold
221+```
222+
223+- [ ] **Step 2: Stage explicit feature paths and review**
224+
225+Stage only the implementation, tests, README, design, and plan. Review `git diff --cached --check` and `git diff --cached`.
226+
227+- [ ] **Step 3: Commit**
228+
229+```sh
230+git commit -m "Make auto-compact threshold configurable"
231+```
232+
233+- [ ] **Step 4: Push the branch**
234+
235+Push to the appropriate writable GitHub remote with upstream tracking.
236+
237+- [ ] **Step 5: Open a ready pull request**
238+
239+Target the upstream default branch. Include the behavior, compatibility, user impact, and exact validation commands in the PR body.
@@ -0,0 +1,239 @@
1+# Configurable Auto-Compact Threshold Implementation Plan
2+
3+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+**Goal:** Add persisted auto-compaction threshold presets to the interactive TUI settings.
6+
7+**Architecture:** Keep automatic triggering in `packages/agent/modes`. Thread an optional integer percentage from persisted `agent.Config` into `modes.InteractiveConfig`, normalize missing or invalid values to 85%, and let zero disable only percentage-based triggers. Reuse the settings dialog's existing fixed-option picker and settings-store persistence path.
8+
9+**Tech Stack:** Go, standard library, Zot's existing TUI settings dialog, JSON configuration.
10+
11+## Global Constraints
12+
13+- Supported values are `0` (off), `70`, `80`, `85`, and `90`.
14+- Missing or invalid persisted values resolve to `85`.
15+- Disabling the threshold does not disable manual compaction or HTTP 413 recovery.
16+- Do not add dependencies or change non-interactive compaction behavior.
17+- Preserve unrelated working-tree changes and stage only this feature.
18+
19+---
20+
21+### Task 1: Configuration and threshold decision
22+
23+**Files:**
24+- Modify: `packages/agent/config.go`
25+- Modify: `packages/agent/modes/interactive.go`
26+- Create: `packages/agent/modes/auto_compact_test.go`
27+- Modify: `packages/agent/cli.go`
28+
29+**Interfaces:**
30+- Produces: `Config.AutoCompactThreshold *int`
31+- Produces: `InteractiveConfig.AutoCompactThreshold *int`
32+- Produces: `normalizeAutoCompactThreshold(*int) int`
33+- Produces: `shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool`
34+
35+- [ ] **Step 1: Write failing normalization and decision tests**
36+
37+Add table-driven tests proving nil and invalid values resolve to 85, supported presets survive unchanged, zero disables percentage checks, and 70/80/85/90 trigger exactly at their boundaries.
38+
39+```go
40+func TestNormalizeAutoCompactThreshold(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ value *int
44+ want int
45+ }{
46+ {name: "missing", value: nil, want: 85},
47+ {name: "off", value: intPtr(0), want: 0},
48+ {name: "seventy", value: intPtr(70), want: 70},
49+ {name: "invalid", value: intPtr(42), want: 85},
50+ }
51+ // Assert normalizeAutoCompactThreshold(test.value) == test.want.
52+}
53+
54+func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
55+ if shouldAutoCompact(84, 100, 85) {
56+ t.Fatal("84% must remain below an 85% threshold")
57+ }
58+ if !shouldAutoCompact(85, 100, 85) {
59+ t.Fatal("85% must trigger an 85% threshold")
60+ }
61+ if shouldAutoCompact(100, 100, 0) {
62+ t.Fatal("off must disable percentage-triggered compaction")
63+ }
64+}
65+```
66+
67+- [ ] **Step 2: Run tests and verify RED**
68+
69+Run:
70+
71+```sh
72+go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1
73+```
74+
75+Expected: compilation failure because the normalization and decision helpers do not exist.
76+
77+- [ ] **Step 3: Add optional configuration and minimal helpers**
78+
79+Add `AutoCompactThreshold *int` with JSON name `auto_compact_threshold` to both configuration structs. Implement normalization for exactly `0`, `70`, `80`, `85`, and `90`, defaulting all other values to `85`. Replace the hard-coded fraction check with the normalized percentage helper and pass the config field through `cli.go`.
80+
81+- [ ] **Step 4: Run focused tests and verify GREEN**
82+
83+Run:
84+
85+```sh
86+go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1
87+```
88+
89+Expected: PASS.
90+
91+### Task 2: Persistence and TUI preset picker
92+
93+**Files:**
94+- Modify: `packages/agent/settings_store.go`
95+- Modify: `packages/agent/settings_store_test.go`
96+- Modify: `packages/agent/modes/interactive.go`
97+- Modify: `packages/agent/modes/auto_compact_test.go`
98+
99+**Interfaces:**
100+- Extends: `SettingsStore.SetAutoCompactThreshold(percent int) error`
101+- Consumes: `normalizeAutoCompactThreshold(*int) int`
102+
103+- [ ] **Step 1: Write failing persistence and settings-item tests**
104+
105+Add a settings-store test that writes `70` into an isolated `$ZOT_HOME` and verifies `auto_compact_threshold` plus an unrelated config field. Add a modes test that opens settings with missing configuration and verifies the option values are `0`, `70`, `80`, `85`, `90` with `85` selected.
106+
107+```go
108+func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) {
109+ t.Setenv("ZOT_HOME", t.TempDir())
110+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
111+ t.Fatal(err)
112+ }
113+ if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil {
114+ t.Fatal(err)
115+ }
116+ cfg, err := LoadConfig()
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 {
121+ t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold)
122+ }
123+ if cfg.Theme != "dark" {
124+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
125+ }
126+}
127+```
128+
129+- [ ] **Step 2: Run tests and verify RED**
130+
131+Run:
132+
133+```sh
134+go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1
135+```
136+
137+Expected: compilation or assertion failure because persistence and the picker row do not exist.
138+
139+- [ ] **Step 3: Implement persistence and live setting application**
140+
141+Add the settings-store method, fixed picker options, choice resolution, action routing, integer parsing, live `InteractiveConfig` update, and status message. Persist only values exposed by the picker; normalize the live value defensively before use.
142+
143+- [ ] **Step 4: Run focused tests and verify GREEN**
144+
145+Run:
146+
147+```sh
148+go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1
149+```
150+
151+Expected: PASS.
152+
153+### Task 3: Documentation and full verification
154+
155+**Files:**
156+- Modify: `README.md`
157+- Review: `docs/superpowers/specs/2026-07-28-configurable-auto-compact-threshold-design.md`
158+- Review: `docs/superpowers/plans/2026-07-28-configurable-auto-compact-threshold.md`
159+
160+**Interfaces:**
161+- Documents: `auto_compact_threshold`
162+- Documents: `/settings` presets and `off` semantics
163+
164+- [ ] **Step 1: Update user documentation**
165+
166+Document `off`, `70%`, `80%`, `85%` default, and `90%` in the `/settings` section. Update `/compact` to state that threshold-based auto-compaction is configurable and that payload-too-large recovery remains active when the percentage trigger is off.
167+
168+- [ ] **Step 2: Format changed Go files**
169+
170+Run:
171+
172+```sh
173+gofmt -w packages/agent/config.go packages/agent/settings_store.go packages/agent/settings_store_test.go packages/agent/cli.go packages/agent/modes/interactive.go packages/agent/modes/auto_compact_test.go
174+```
175+
176+- [ ] **Step 3: Run the complete test suite**
177+
178+Run:
179+
180+```sh
181+go test ./...
182+```
183+
184+Expected: PASS.
185+
186+- [ ] **Step 4: Run static analysis**
187+
188+Run:
189+
190+```sh
191+go vet ./...
192+```
193+
194+Expected: PASS.
195+
196+- [ ] **Step 5: Inspect the final change**
197+
198+Run:
199+
200+```sh
201+git diff --check
202+git status --short
203+git diff
204+```
205+
206+Confirm only the configuration, TUI, tests, README, design, and plan belong to the feature.
207+
208+### Task 4: Publish the pull request
209+
210+**Files:**
211+- Stage only the files listed in Tasks 1-3.
212+
213+**Interfaces:**
214+- Produces: branch `codex/configurable-auto-compact-threshold`
215+- Produces: one feature commit and a ready GitHub pull request
216+
217+- [ ] **Step 1: Create the feature branch**
218+
219+```sh
220+git switch -c codex/configurable-auto-compact-threshold
221+```
222+
223+- [ ] **Step 2: Stage explicit feature paths and review**
224+
225+Stage only the implementation, tests, README, design, and plan. Review `git diff --cached --check` and `git diff --cached`.
226+
227+- [ ] **Step 3: Commit**
228+
229+```sh
230+git commit -m "Make auto-compact threshold configurable"
231+```
232+
233+- [ ] **Step 4: Push the branch**
234+
235+Push to the appropriate writable GitHub remote with upstream tracking.
236+
237+- [ ] **Step 5: Open a ready pull request**
238+
239+Target the upstream default branch. Include the behavior, compatibility, user impact, and exact validation commands in the PR body.
A docs/superpowers/specs/2026-07-28-configurable-auto-compact-threshold-design.md +73 -0
@@ -0,0 +1,73 @@
1+# Configurable Auto-Compact Threshold
2+
3+## Goal
4+
5+Let users choose when Zot automatically compacts an interactive transcript from the existing `/settings` dialog, while preserving the current 85% behavior by default.
6+
7+## User Experience
8+
9+Add a top-level `/settings` option named **auto-compact threshold** with these choices:
10+
11+- `off`
12+- `70%`
13+- `80%`
14+- `85%` (default)
15+- `90%`
16+
17+Changing the option takes effect for the next turn without restarting Zot.
18+
19+The `off` choice disables context-percentage triggers before and after turns. It does not disable:
20+
21+- manual `/compact`
22+- explicit RPC or SDK compaction
23+- automatic compaction and retry after an HTTP 413 or equivalent payload-too-large response
24+
25+## Configuration
26+
27+Persist the selected percentage in `$ZOT_HOME/config.json` as:
28+
29+```json
30+{
31+ "auto_compact_threshold": 85
32+}
33+```
34+
35+The field is an optional integer percentage:
36+
37+- A missing field resolves to `85` for backward compatibility.
38+- `0` represents `off`.
39+- Valid non-zero values are `70`, `80`, `85`, and `90`.
40+- Any other persisted value resolves to `85` at runtime.
41+- When a user selects a setting in the TUI, Zot writes only one of the supported values.
42+
43+## Implementation Boundaries
44+
45+The interactive host continues to own automatic triggering. `core.Agent.Compact`, RPC, SDK, bot, print, and JSON mode behavior remain unchanged.
46+
47+Replace the hard-coded `0.85` comparison with a helper that resolves the configured integer percentage and compares the most recent context usage against the model's advertised context window. A resolved value of `0` returns false for percentage-based checks.
48+
49+The payload-too-large recovery path continues to invoke automatic compaction independently of the configured percentage.
50+
51+The settings dialog uses its existing fixed-option picker. No free-form numeric input or new dialog control is introduced.
52+
53+## Error Handling
54+
55+Configuration writes use the existing settings-store error path and show failures in the TUI status area. An invalid value already present in `config.json` does not prevent startup; Zot falls back to 85%.
56+
57+## Tests
58+
59+Add focused tests covering:
60+
61+- missing configuration resolves to 85%
62+- every supported preset resolves correctly
63+- invalid persisted values resolve to 85%
64+- `off` suppresses percentage-triggered compaction
65+- threshold comparison uses the selected percentage
66+- the settings store persists the selected integer
67+- the settings dialog exposes all presets with 85% selected by default
68+
69+Run the focused package tests during development, then `gofmt`, `go test ./...`, `go vet ./...`, `git diff --check`, and final status/diff inspection.
70+
71+## Documentation
72+
73+Update the README sections for `/settings` and `/compact` to list the presets, default, immediate application, and the fact that `off` does not disable payload-too-large recovery.
@@ -0,0 +1,73 @@
1+# Configurable Auto-Compact Threshold
2+
3+## Goal
4+
5+Let users choose when Zot automatically compacts an interactive transcript from the existing `/settings` dialog, while preserving the current 85% behavior by default.
6+
7+## User Experience
8+
9+Add a top-level `/settings` option named **auto-compact threshold** with these choices:
10+
11+- `off`
12+- `70%`
13+- `80%`
14+- `85%` (default)
15+- `90%`
16+
17+Changing the option takes effect for the next turn without restarting Zot.
18+
19+The `off` choice disables context-percentage triggers before and after turns. It does not disable:
20+
21+- manual `/compact`
22+- explicit RPC or SDK compaction
23+- automatic compaction and retry after an HTTP 413 or equivalent payload-too-large response
24+
25+## Configuration
26+
27+Persist the selected percentage in `$ZOT_HOME/config.json` as:
28+
29+```json
30+{
31+ "auto_compact_threshold": 85
32+}
33+```
34+
35+The field is an optional integer percentage:
36+
37+- A missing field resolves to `85` for backward compatibility.
38+- `0` represents `off`.
39+- Valid non-zero values are `70`, `80`, `85`, and `90`.
40+- Any other persisted value resolves to `85` at runtime.
41+- When a user selects a setting in the TUI, Zot writes only one of the supported values.
42+
43+## Implementation Boundaries
44+
45+The interactive host continues to own automatic triggering. `core.Agent.Compact`, RPC, SDK, bot, print, and JSON mode behavior remain unchanged.
46+
47+Replace the hard-coded `0.85` comparison with a helper that resolves the configured integer percentage and compares the most recent context usage against the model's advertised context window. A resolved value of `0` returns false for percentage-based checks.
48+
49+The payload-too-large recovery path continues to invoke automatic compaction independently of the configured percentage.
50+
51+The settings dialog uses its existing fixed-option picker. No free-form numeric input or new dialog control is introduced.
52+
53+## Error Handling
54+
55+Configuration writes use the existing settings-store error path and show failures in the TUI status area. An invalid value already present in `config.json` does not prevent startup; Zot falls back to 85%.
56+
57+## Tests
58+
59+Add focused tests covering:
60+
61+- missing configuration resolves to 85%
62+- every supported preset resolves correctly
63+- invalid persisted values resolve to 85%
64+- `off` suppresses percentage-triggered compaction
65+- threshold comparison uses the selected percentage
66+- the settings store persists the selected integer
67+- the settings dialog exposes all presets with 85% selected by default
68+
69+Run the focused package tests during development, then `gofmt`, `go test ./...`, `go vet ./...`, `git diff --check`, and final status/diff inspection.
70+
71+## Documentation
72+
73+Update the README sections for `/settings` and `/compact` to list the presets, default, immediate application, and the fact that `off` does not disable payload-too-large recovery.
M packages/agent/cli.go +1 -0
@@ -989,6 +989,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
989989 Theme: theme,
990990 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
991991 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
992+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
992993 JailByDefault: initialCfg.JailByDefault,
993994 QuickModelShortcuts: quickModelShortcuts,
994995 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
@@ -989,6 +989,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
989 Theme: theme,989 Theme: theme,
990 InlineImagesEnabled: initialCfg.InlineImagesEnabled,990 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
991 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,991 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
992+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
992 JailByDefault: initialCfg.JailByDefault,993 JailByDefault: initialCfg.JailByDefault,
993 QuickModelShortcuts: quickModelShortcuts,994 QuickModelShortcuts: quickModelShortcuts,
994 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,995 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
M packages/agent/config.go +5 -0
@@ -58,6 +58,11 @@ type Config struct {
5858 // default; nil/missing means disabled. Toggle from /settings.
5959 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
6060
61+ // AutoCompactThreshold is the percentage of the model context window
62+ // that triggers automatic transcript compaction in interactive mode.
63+ // nil/missing means 85; zero disables percentage-based triggers.
64+ AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"`
65+
6166 // JailByDefault confines tools to the session working directory when
6267 // a new agent starts. Off by default; nil/missing means disabled.
6368 // The live session can still override this with /jail or /unjail.
@@ -58,6 +58,11 @@ type Config struct {
58 // default; nil/missing means disabled. Toggle from /settings.58 // default; nil/missing means disabled. Toggle from /settings.
59 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`59 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
60 60
61+ // AutoCompactThreshold is the percentage of the model context window
62+ // that triggers automatic transcript compaction in interactive mode.
63+ // nil/missing means 85; zero disables percentage-based triggers.
64+ AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"`
65+
61 // JailByDefault confines tools to the session working directory when66 // JailByDefault confines tools to the session working directory when
62 // a new agent starts. Off by default; nil/missing means disabled.67 // a new agent starts. Off by default; nil/missing means disabled.
63 // The live session can still override this with /jail or /unjail.68 // The live session can still override this with /jail or /unjail.
A packages/agent/modes/auto_compact_test.go +90 -0
@@ -0,0 +1,90 @@
1+package modes
2+
3+import "testing"
4+
5+func TestNormalizeAutoCompactThreshold(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ value *int
9+ want int
10+ }{
11+ {name: "missing", value: nil, want: 85},
12+ {name: "off", value: autoCompactIntPtr(0), want: 0},
13+ {name: "seventy", value: autoCompactIntPtr(70), want: 70},
14+ {name: "eighty", value: autoCompactIntPtr(80), want: 80},
15+ {name: "eighty five", value: autoCompactIntPtr(85), want: 85},
16+ {name: "ninety", value: autoCompactIntPtr(90), want: 90},
17+ {name: "invalid low", value: autoCompactIntPtr(42), want: 85},
18+ {name: "invalid high", value: autoCompactIntPtr(100), want: 85},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ if got := normalizeAutoCompactThreshold(tt.value); got != tt.want {
24+ t.Fatalf("normalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want)
25+ }
26+ })
27+ }
28+}
29+
30+func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ used int
34+ window int
35+ threshold int
36+ want bool
37+ }{
38+ {name: "below threshold", used: 84, window: 100, threshold: 85, want: false},
39+ {name: "at threshold", used: 85, window: 100, threshold: 85, want: true},
40+ {name: "above threshold", used: 86, window: 100, threshold: 85, want: true},
41+ {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true},
42+ {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true},
43+ {name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true},
44+ {name: "off", used: 100, window: 100, threshold: 0, want: false},
45+ {name: "missing usage", used: 0, window: 100, threshold: 85, want: false},
46+ {name: "missing window", used: 85, window: 0, threshold: 85, want: false},
47+ }
48+
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ if got := shouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want {
52+ t.Fatalf("shouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want)
53+ }
54+ })
55+ }
56+}
57+
58+func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) {
59+ interactive := NewInteractive(InteractiveConfig{})
60+ interactive.openSettingsDialog()
61+
62+ var thresholdItem *settingsItem
63+ for idx := range interactive.settingsDialog.items {
64+ item := &interactive.settingsDialog.items[idx]
65+ if item.key == "auto_compact_threshold" {
66+ thresholdItem = item
67+ break
68+ }
69+ }
70+ if thresholdItem == nil {
71+ t.Fatal("settings dialog is missing auto-compact threshold")
72+ }
73+
74+ wantValues := []string{"0", "70", "80", "85", "90"}
75+ if len(thresholdItem.options) != len(wantValues) {
76+ t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues))
77+ }
78+ for idx, want := range wantValues {
79+ if got := thresholdItem.options[idx].value; got != want {
80+ t.Fatalf("auto-compact option %d = %q, want %q", idx, got, want)
81+ }
82+ }
83+ if got := thresholdItem.options[thresholdItem.choice].value; got != "85" {
84+ t.Fatalf("default auto-compact choice = %q, want 85", got)
85+ }
86+}
87+
88+func autoCompactIntPtr(value int) *int {
89+ return &value
90+}
@@ -0,0 +1,90 @@
1+package modes
2+
3+import "testing"
4+
5+func TestNormalizeAutoCompactThreshold(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ value *int
9+ want int
10+ }{
11+ {name: "missing", value: nil, want: 85},
12+ {name: "off", value: autoCompactIntPtr(0), want: 0},
13+ {name: "seventy", value: autoCompactIntPtr(70), want: 70},
14+ {name: "eighty", value: autoCompactIntPtr(80), want: 80},
15+ {name: "eighty five", value: autoCompactIntPtr(85), want: 85},
16+ {name: "ninety", value: autoCompactIntPtr(90), want: 90},
17+ {name: "invalid low", value: autoCompactIntPtr(42), want: 85},
18+ {name: "invalid high", value: autoCompactIntPtr(100), want: 85},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ if got := normalizeAutoCompactThreshold(tt.value); got != tt.want {
24+ t.Fatalf("normalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want)
25+ }
26+ })
27+ }
28+}
29+
30+func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ used int
34+ window int
35+ threshold int
36+ want bool
37+ }{
38+ {name: "below threshold", used: 84, window: 100, threshold: 85, want: false},
39+ {name: "at threshold", used: 85, window: 100, threshold: 85, want: true},
40+ {name: "above threshold", used: 86, window: 100, threshold: 85, want: true},
41+ {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true},
42+ {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true},
43+ {name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true},
44+ {name: "off", used: 100, window: 100, threshold: 0, want: false},
45+ {name: "missing usage", used: 0, window: 100, threshold: 85, want: false},
46+ {name: "missing window", used: 85, window: 0, threshold: 85, want: false},
47+ }
48+
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ if got := shouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want {
52+ t.Fatalf("shouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want)
53+ }
54+ })
55+ }
56+}
57+
58+func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) {
59+ interactive := NewInteractive(InteractiveConfig{})
60+ interactive.openSettingsDialog()
61+
62+ var thresholdItem *settingsItem
63+ for idx := range interactive.settingsDialog.items {
64+ item := &interactive.settingsDialog.items[idx]
65+ if item.key == "auto_compact_threshold" {
66+ thresholdItem = item
67+ break
68+ }
69+ }
70+ if thresholdItem == nil {
71+ t.Fatal("settings dialog is missing auto-compact threshold")
72+ }
73+
74+ wantValues := []string{"0", "70", "80", "85", "90"}
75+ if len(thresholdItem.options) != len(wantValues) {
76+ t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues))
77+ }
78+ for idx, want := range wantValues {
79+ if got := thresholdItem.options[idx].value; got != want {
80+ t.Fatalf("auto-compact option %d = %q, want %q", idx, got, want)
81+ }
82+ }
83+ if got := thresholdItem.options[thresholdItem.choice].value; got != "85" {
84+ t.Fatalf("default auto-compact choice = %q, want 85", got)
85+ }
86+}
87+
88+func autoCompactIntPtr(value int) *int {
89+ return &value
90+}
M packages/agent/modes/interactive.go +82 -9
@@ -62,6 +62,11 @@ type InteractiveConfig struct {
6262 // re-reading config.json on every open.
6363 AutoSwarmEnabled *bool
6464
65+ // AutoCompactThreshold is the context-window percentage that triggers
66+ // automatic compaction. nil means 85; zero disables percentage-based
67+ // triggers while preserving payload-too-large recovery.
68+ AutoCompactThreshold *int
69+
6570 // JailByDefault mirrors the persisted jail_by_default preference.
6671 // The current sandbox may differ after a session-scoped /jail or /unjail.
6772 JailByDefault *bool
@@ -322,6 +327,10 @@ type showInstructionsSettingsStore interface {
322327 SetShowInstructionsAtStartup(enabled bool) error
323328 }
324329
330+type autoCompactThresholdSettingsStore interface {
331+ SetAutoCompactThreshold(percent int) error
332+}
333+
325334 type Interactive struct {
326335 cfg InteractiveConfig
327336 view *tui.View
@@ -3151,6 +3160,22 @@ func (i *Interactive) openSettingsDialog() {
31513160 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
31523161 quickItems := i.quickModelSettingItems()
31533162
3163+ autoCompactOptions := []settingsOption{
3164+ {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
3165+ {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
3166+ {value: "80", label: "80%", desc: "compact with moderate context headroom"},
3167+ {value: "85", label: "85%", desc: "default balance between history and headroom"},
3168+ {value: "90", label: "90%", desc: "retain more history before compacting"},
3169+ }
3170+ autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
3171+ autoCompactChoice := 0
3172+ for idx, opt := range autoCompactOptions {
3173+ if opt.value == strconv.Itoa(autoCompactThreshold) {
3174+ autoCompactChoice = idx
3175+ break
3176+ }
3177+ }
3178+
31543179 reasoningOptions := []settingsOption{
31553180 {value: "", label: "off", desc: "no reasoning"},
31563181 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
@@ -3251,6 +3276,13 @@ func (i *Interactive) openSettingsDialog() {
32513276 disabled: autoSwarmDisabled,
32523277 hint: autoSwarmHint,
32533278 },
3279+ {
3280+ key: "auto_compact_threshold",
3281+ label: "auto-compact threshold",
3282+ desc: "choose how full the model context can get before zot condenses conversation history",
3283+ options: autoCompactOptions,
3284+ choice: autoCompactChoice,
3285+ },
32543286 {
32553287 key: "jail_by_default",
32563288 label: "jail new sessions by default",
@@ -3344,6 +3376,8 @@ func (i *Interactive) applySettingChange(act settingsAction) {
33443376 i.applyReasoningSetting(act.StringValue)
33453377 case act.Key == "theme":
33463378 i.applyThemeSetting(act.StringValue)
3379+ case act.Key == "auto_compact_threshold":
3380+ i.applyAutoCompactThresholdSetting(act.StringValue)
33473381 case act.Key == "tui_input_style":
33483382 i.applyTUIInputStyleSetting(act.StringValue)
33493383 case act.Key == "tui_status_position":
@@ -3355,6 +3389,32 @@ func (i *Interactive) applySettingChange(act settingsAction) {
33553389 }
33563390 }
33573391
3392+func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
3393+ threshold, err := strconv.Atoi(value)
3394+ if err != nil {
3395+ return
3396+ }
3397+ threshold = normalizeAutoCompactThreshold(&threshold)
3398+ i.cfg.AutoCompactThreshold = &threshold
3399+ if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
3400+ if err := store.SetAutoCompactThreshold(threshold); err != nil {
3401+ i.mu.Lock()
3402+ i.statusErr = "settings: " + err.Error()
3403+ i.mu.Unlock()
3404+ return
3405+ }
3406+ }
3407+ i.mu.Lock()
3408+ if threshold == 0 {
3409+ i.statusOK = "auto-compact threshold off"
3410+ } else {
3411+ i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
3412+ }
3413+ i.statusErr = ""
3414+ i.mu.Unlock()
3415+ i.invalidate()
3416+}
3417+
33583418 func (i *Interactive) quickModelSettingItems() []settingsItem {
33593419 if len(i.cfg.QuickModelShortcuts) < 9 {
33603420 next := make([]QuickModelShortcut, 9)
@@ -5561,11 +5621,26 @@ func isPayloadTooLargeError(err error) bool {
55615621 return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")
55625622 }
55635623
5564-// autoCompactThreshold is the context-window fraction at which the
5565-// agent will auto-compact after a turn ends. 0.85 leaves enough
5566-// headroom for one more user prompt + response before we bump the
5567-// hard limit.
5568-const autoCompactThreshold = 0.85
5624+const defaultAutoCompactThreshold = 85
5625+
5626+func normalizeAutoCompactThreshold(threshold *int) int {
5627+ if threshold == nil {
5628+ return defaultAutoCompactThreshold
5629+ }
5630+ switch *threshold {
5631+ case 0, 70, 80, 85, 90:
5632+ return *threshold
5633+ default:
5634+ return defaultAutoCompactThreshold
5635+ }
5636+}
5637+
5638+func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
5639+ if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
5640+ return false
5641+ }
5642+ return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
5643+}
55695644
55705645 // shouldAutoCompactLocked reports whether the last turn pushed context
55715646 // usage past the auto-compact threshold. Must be called with i.mu
@@ -5581,10 +5656,8 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
55815656 if err != nil || m.ContextWindow <= 0 {
55825657 return false
55835658 }
5584- if i.lastCtxInput <= 0 {
5585- return false
5586- }
5587- return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
5659+ threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
5660+ return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
55885661 }
55895662
55905663 func (i *Interactive) handleEvent(ev core.AgentEvent) {
@@ -62,6 +62,11 @@ type InteractiveConfig struct {
62 // re-reading config.json on every open.62 // re-reading config.json on every open.
63 AutoSwarmEnabled *bool63 AutoSwarmEnabled *bool
64 64
65+ // AutoCompactThreshold is the context-window percentage that triggers
66+ // automatic compaction. nil means 85; zero disables percentage-based
67+ // triggers while preserving payload-too-large recovery.
68+ AutoCompactThreshold *int
69+
65 // JailByDefault mirrors the persisted jail_by_default preference.70 // JailByDefault mirrors the persisted jail_by_default preference.
66 // The current sandbox may differ after a session-scoped /jail or /unjail.71 // The current sandbox may differ after a session-scoped /jail or /unjail.
67 JailByDefault *bool72 JailByDefault *bool
@@ -322,6 +327,10 @@ type showInstructionsSettingsStore interface {
322 SetShowInstructionsAtStartup(enabled bool) error327 SetShowInstructionsAtStartup(enabled bool) error
323 }328 }
324 329
330+type autoCompactThresholdSettingsStore interface {
331+ SetAutoCompactThreshold(percent int) error
332+}
333+
325 type Interactive struct {334 type Interactive struct {
326 cfg InteractiveConfig335 cfg InteractiveConfig
327 view *tui.View336 view *tui.View
@@ -3151,6 +3160,22 @@ func (i *Interactive) openSettingsDialog() {
3151 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)3160 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
3152 quickItems := i.quickModelSettingItems()3161 quickItems := i.quickModelSettingItems()
3153 3162
3163+ autoCompactOptions := []settingsOption{
3164+ {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
3165+ {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
3166+ {value: "80", label: "80%", desc: "compact with moderate context headroom"},
3167+ {value: "85", label: "85%", desc: "default balance between history and headroom"},
3168+ {value: "90", label: "90%", desc: "retain more history before compacting"},
3169+ }
3170+ autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
3171+ autoCompactChoice := 0
3172+ for idx, opt := range autoCompactOptions {
3173+ if opt.value == strconv.Itoa(autoCompactThreshold) {
3174+ autoCompactChoice = idx
3175+ break
3176+ }
3177+ }
3178+
3154 reasoningOptions := []settingsOption{3179 reasoningOptions := []settingsOption{
3155 {value: "", label: "off", desc: "no reasoning"},3180 {value: "", label: "off", desc: "no reasoning"},
3156 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},3181 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
@@ -3251,6 +3276,13 @@ func (i *Interactive) openSettingsDialog() {
3251 disabled: autoSwarmDisabled,3276 disabled: autoSwarmDisabled,
3252 hint: autoSwarmHint,3277 hint: autoSwarmHint,
3253 },3278 },
3279+ {
3280+ key: "auto_compact_threshold",
3281+ label: "auto-compact threshold",
3282+ desc: "choose how full the model context can get before zot condenses conversation history",
3283+ options: autoCompactOptions,
3284+ choice: autoCompactChoice,
3285+ },
3254 {3286 {
3255 key: "jail_by_default",3287 key: "jail_by_default",
3256 label: "jail new sessions by default",3288 label: "jail new sessions by default",
@@ -3344,6 +3376,8 @@ func (i *Interactive) applySettingChange(act settingsAction) {
3344 i.applyReasoningSetting(act.StringValue)3376 i.applyReasoningSetting(act.StringValue)
3345 case act.Key == "theme":3377 case act.Key == "theme":
3346 i.applyThemeSetting(act.StringValue)3378 i.applyThemeSetting(act.StringValue)
3379+ case act.Key == "auto_compact_threshold":
3380+ i.applyAutoCompactThresholdSetting(act.StringValue)
3347 case act.Key == "tui_input_style":3381 case act.Key == "tui_input_style":
3348 i.applyTUIInputStyleSetting(act.StringValue)3382 i.applyTUIInputStyleSetting(act.StringValue)
3349 case act.Key == "tui_status_position":3383 case act.Key == "tui_status_position":
@@ -3355,6 +3389,32 @@ func (i *Interactive) applySettingChange(act settingsAction) {
3355 }3389 }
3356 }3390 }
3357 3391
3392+func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
3393+ threshold, err := strconv.Atoi(value)
3394+ if err != nil {
3395+ return
3396+ }
3397+ threshold = normalizeAutoCompactThreshold(&threshold)
3398+ i.cfg.AutoCompactThreshold = &threshold
3399+ if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
3400+ if err := store.SetAutoCompactThreshold(threshold); err != nil {
3401+ i.mu.Lock()
3402+ i.statusErr = "settings: " + err.Error()
3403+ i.mu.Unlock()
3404+ return
3405+ }
3406+ }
3407+ i.mu.Lock()
3408+ if threshold == 0 {
3409+ i.statusOK = "auto-compact threshold off"
3410+ } else {
3411+ i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
3412+ }
3413+ i.statusErr = ""
3414+ i.mu.Unlock()
3415+ i.invalidate()
3416+}
3417+
3358 func (i *Interactive) quickModelSettingItems() []settingsItem {3418 func (i *Interactive) quickModelSettingItems() []settingsItem {
3359 if len(i.cfg.QuickModelShortcuts) < 9 {3419 if len(i.cfg.QuickModelShortcuts) < 9 {
3360 next := make([]QuickModelShortcut, 9)3420 next := make([]QuickModelShortcut, 9)
@@ -5561,11 +5621,26 @@ func isPayloadTooLargeError(err error) bool {
5561 return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")5621 return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")
5562 }5622 }
5563 5623
5564-// autoCompactThreshold is the context-window fraction at which the5624+const defaultAutoCompactThreshold = 85
5565-// agent will auto-compact after a turn ends. 0.85 leaves enough5625+
5566-// headroom for one more user prompt + response before we bump the5626+func normalizeAutoCompactThreshold(threshold *int) int {
5567-// hard limit.5627+ if threshold == nil {
5568-const autoCompactThreshold = 0.855628+ return defaultAutoCompactThreshold
5629+ }
5630+ switch *threshold {
5631+ case 0, 70, 80, 85, 90:
5632+ return *threshold
5633+ default:
5634+ return defaultAutoCompactThreshold
5635+ }
5636+}
5637+
5638+func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
5639+ if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
5640+ return false
5641+ }
5642+ return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
5643+}
5569 5644
5570 // shouldAutoCompactLocked reports whether the last turn pushed context5645 // shouldAutoCompactLocked reports whether the last turn pushed context
5571 // usage past the auto-compact threshold. Must be called with i.mu5646 // usage past the auto-compact threshold. Must be called with i.mu
@@ -5581,10 +5656,8 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
5581 if err != nil || m.ContextWindow <= 0 {5656 if err != nil || m.ContextWindow <= 0 {
5582 return false5657 return false
5583 }5658 }
5584- if i.lastCtxInput <= 0 {5659+ threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
5585- return false5660+ return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
5586- }
5587- return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
5588 }5661 }
5589 5662
5590 func (i *Interactive) handleEvent(ev core.AgentEvent) {5663 func (i *Interactive) handleEvent(ev core.AgentEvent) {
M packages/agent/settings_store.go +9 -0
@@ -50,6 +50,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
5050 return SaveConfig(cfg)
5151 }
5252
53+func (configSettingsStore) SetAutoCompactThreshold(percent int) error {
54+ cfg, err := LoadConfig()
55+ if err != nil {
56+ return err
57+ }
58+ cfg.AutoCompactThreshold = &percent
59+ return SaveConfig(cfg)
60+}
61+
5362 func (configSettingsStore) SetJailByDefault(enabled bool) error {
5463 cfg, err := LoadConfig()
5564 if err != nil {
@@ -50,6 +50,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
50 return SaveConfig(cfg)50 return SaveConfig(cfg)
51 }51 }
52 52
53+func (configSettingsStore) SetAutoCompactThreshold(percent int) error {
54+ cfg, err := LoadConfig()
55+ if err != nil {
56+ return err
57+ }
58+ cfg.AutoCompactThreshold = &percent
59+ return SaveConfig(cfg)
60+}
61+
53 func (configSettingsStore) SetJailByDefault(enabled bool) error {62 func (configSettingsStore) SetJailByDefault(enabled bool) error {
54 cfg, err := LoadConfig()63 cfg, err := LoadConfig()
55 if err != nil {64 if err != nil {
M packages/agent/settings_store_test.go +21 -0
@@ -43,3 +43,24 @@ func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
4343 t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
4444 }
4545 }
46+
47+func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) {
48+ t.Setenv("ZOT_HOME", t.TempDir())
49+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
50+ t.Fatal(err)
51+ }
52+
53+ if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil {
54+ t.Fatal(err)
55+ }
56+ cfg, err := LoadConfig()
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 {
61+ t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold)
62+ }
63+ if cfg.Theme != "dark" {
64+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
65+ }
66+}
@@ -43,3 +43,24 @@ func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
43 t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)43 t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
44 }44 }
45 }45 }
46+
47+func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) {
48+ t.Setenv("ZOT_HOME", t.TempDir())
49+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
50+ t.Fatal(err)
51+ }
52+
53+ if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil {
54+ t.Fatal(err)
55+ }
56+ cfg, err := LoadConfig()
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 {
61+ t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold)
62+ }
63+ if cfg.Theme != "dark" {
64+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
65+ }
66+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close