@pi-unipi/unipi 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -2
  3. package/packages/autocomplete/README.md +1 -0
  4. package/packages/autocomplete/src/provider.ts +17 -2
  5. package/packages/compactor/README.md +290 -73
  6. package/packages/compactor/skills/compactor/SKILL.md +2 -3
  7. package/packages/compactor/skills/compactor-detail/SKILL.md +49 -64
  8. package/packages/compactor/skills/compactor-doctor/SKILL.md +28 -31
  9. package/packages/compactor/skills/compactor-stats/SKILL.md +22 -20
  10. package/packages/compactor/src/commands/index.ts +4 -1
  11. package/packages/compactor/src/compaction/auto-trigger.ts +306 -0
  12. package/packages/compactor/src/config/manager.ts +1 -0
  13. package/packages/compactor/src/config/presets.ts +26 -0
  14. package/packages/compactor/src/config/schema.ts +7 -0
  15. package/packages/compactor/src/index.ts +74 -1
  16. package/packages/compactor/src/tools/context-budget.ts +18 -2
  17. package/packages/compactor/src/tools/register.ts +19 -11
  18. package/packages/compactor/src/tui/settings-overlay.ts +142 -3
  19. package/packages/compactor/src/types.ts +17 -0
  20. package/packages/core/events.ts +2 -0
  21. package/packages/footer/README.md +12 -0
  22. package/packages/footer/src/config.ts +9 -1
  23. package/packages/footer/src/rendering/renderer.ts +14 -1
  24. package/packages/footer/src/rendering/theme.ts +187 -6
  25. package/packages/footer/src/types.ts +5 -0
  26. package/packages/notify/README.md +2 -2
  27. package/packages/notify/commands.ts +9 -4
  28. package/packages/notify/events.ts +12 -2
  29. package/packages/notify/platforms/focus-win.ts +123 -0
  30. package/packages/notify/platforms/focus.ts +33 -0
  31. package/packages/notify/platforms/native.ts +33 -1
  32. package/packages/notify/settings.ts +1 -0
  33. package/packages/notify/tui/settings-overlay.ts +33 -7
  34. package/packages/notify/types.ts +8 -0
  35. package/packages/utility/README.md +3 -0
  36. package/packages/utility/src/display/capabilities.ts +29 -15
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: compactor-detail
3
- description: Full compactor reference — tool parameters, anti-patterns, sandbox languages, FTS5 modes, workflows.
3
+ description: Full compactor reference — tool parameters, anti-patterns, sandbox languages, context budget, workflows.
4
4
  ---
5
5
 
6
6
  # Compactor — Full Reference
@@ -9,70 +9,65 @@ description: Full compactor reference — tool parameters, anti-patterns, sandbo
9
9
 
10
10
  ### compact
11
11
  ```
12
- compact()
12
+ compact(dryRun?: boolean)
13
13
  ```
14
- Trigger manual context compaction. Zero LLM pure regex/text processing.
15
- Returns stats after next `session_compact` event.
14
+ Agent-facing compaction request. Use `dryRun: true` to preview estimated message reduction without compacting.
15
+
16
+ For user-triggered immediate compaction, prefer the slash command `/unipi:lossless-compact`.
16
17
 
17
18
  ### session_recall
18
19
  ```
19
20
  session_recall(query: string, mode?: "bm25" | "regex", limit?: number, offset?: number, expand?: boolean)
20
21
  ```
21
- Search session history. BM25 is default (TF-IDF ranked). Regex is fallback.
22
+ Search current session history. BM25 is default; regex is fallback. The recall path prefers Pi's append-only session branch so it can find messages that are no longer present in live LLM context after compaction.
23
+
24
+ Deprecated alias: `vcc_recall`.
22
25
 
23
26
  ### sandbox
24
27
  ```
25
28
  sandbox(language: string, code: string, timeout?: number)
26
29
  ```
27
- Run code in sandboxed env. Only stdout enters context. 100MB output cap. 30s default timeout.
30
+ Run code in a sandboxed environment. Only stdout enters context. 100MB output cap. 30s default timeout.
31
+
32
+ Deprecated alias: `ctx_execute`.
28
33
 
29
34
  ### sandbox_file
30
35
  ```
31
36
  sandbox_file(language: string, path: string, timeout?: number)
32
37
  ```
33
- Execute file. Content injected as `FILE_CONTENT` variable.
38
+ Execute a file. File content is injected as `FILE_CONTENT`.
34
39
 
35
- ### sandbox_batch
36
- ```
37
- sandbox_batch(items: Array<{type: "execute", language, code} | {type: "search", query}>)
38
- ```
39
- Atomic batch — all items run, results returned together.
40
+ Deprecated alias: `ctx_execute_file`.
40
41
 
41
- ### content_index
42
+ ### sandbox_batch
42
43
  ```
43
- content_index(label: string, content?: string, filePath?: string, contentType?: "markdown"|"json"|"plain", chunkSize?: number)
44
+ sandbox_batch(items: Array<{ type: "execute", language: string, code: string, timeout?: number }>)
44
45
  ```
45
- Index content into FTS5. Provide either `content` or `filePath`. Auto-chunks by type.
46
+ Run multiple sandbox executions and return a single combined result.
46
47
 
47
- ### content_search
48
- ```
49
- content_search(query: string, limit?: number, offset?: number)
50
- ```
51
- Search FTS5 index. Returns ranked results with title, content, source, rank.
52
-
53
- ### content_fetch
54
- ```
55
- content_fetch(url: string, label?: string, chunkSize?: number)
56
- ```
57
- Fetch URL → markdown → index. Auto-indexes for later search.
48
+ Deprecated alias: `ctx_batch_execute`.
58
49
 
59
50
  ### compactor_stats
60
51
  ```
61
52
  compactor_stats()
62
53
  ```
63
- Dashboard: session events, compactions, tokens saved, indexed docs, sandbox runs, search queries.
54
+ Dashboard: session events, compactions, tokens saved, compression ratio, sandbox runs, and search queries.
55
+
56
+ Deprecated alias: `ctx_stats`.
64
57
 
65
58
  ### compactor_doctor
66
59
  ```
67
60
  compactor_doctor()
68
61
  ```
69
- Diagnostics: config file, session DB, content store, runtimes (node, python3, bash).
62
+ Diagnostics: config file, session DB, and available runtimes (node, python3, bash).
63
+
64
+ Deprecated alias: `ctx_doctor`.
70
65
 
71
66
  ### context_budget
72
67
  ```
73
68
  context_budget()
74
69
  ```
75
- Estimate remaining context tokens and % full. Returns guidance on whether to compact.
70
+ Estimate remaining context tokens and percent full. Uses Pi's live context usage when available and includes percentage auto-compaction guidance when enabled.
76
71
 
77
72
  ## Sandbox Language Reference
78
73
 
@@ -90,44 +85,34 @@ Estimate remaining context tokens and % full. Returns guidance on whether to com
90
85
  | r | Rscript | 30s | - |
91
86
  | elixir | elixir | 30s | - |
92
87
 
93
- ## FTS5 Search Modes
94
-
95
- | Mode | When To Use |
96
- |------|-------------|
97
- | **porter** | Exact term matching with stemming |
98
- | **trigram** | Fuzzy/spelling errors, partial matches |
99
- | **rrf** | Best overall (Reciprocal Rank Fusion of porter+trigram) |
100
- | **fuzzy** | Auto-correction of misspellings from vocabulary |
101
-
102
- Default: `rrf` (best general-purpose).
103
-
104
88
  ## Anti-Patterns
105
89
 
106
- 1. **Don't call `compact` in a tight loop.** It triggers the full compaction pipeline. Call once before complex work.
107
- 2. **Don't search without indexing.** `content_search` has nothing to search until you `content_index` or `content_fetch`.
108
- 3. **Don't use `sandbox` for file ops.** Use bash instead. Sandbox is for computation.
109
- 4. **Don't use `session_recall` with empty query.** It needs meaningful search terms.
110
- 5. **Don't index node_modules.** Stick to source files and documentation.
111
- 6. **Don't compact mid-task.** Wait for a natural break point.
90
+ 1. **Don't compact in a tight loop.** Use `context_budget` first and compact at natural break points.
91
+ 2. **Don't use `session_recall` for project-wide code search.** It searches the conversation/session. Use CocoIndex (`cocoindex_search`) for indexed files when installed.
92
+ 3. **Don't use `sandbox` for file operations.** Use normal file tools for reads/writes; sandbox is for computation or quick scripts.
93
+ 4. **Don't use empty or vague recall queries.** Search for specific filenames, issue numbers, commands, or decisions.
94
+ 5. **Don't compact mid-thought if avoidable.** Finish the current step, then compact.
112
95
 
113
96
  ## Workflow Patterns
114
97
 
115
- ### Research → Index → Search → Test
116
- 1. `content_fetch(url)` — index reference docs
117
- 2. `content_search(query)` — find relevant sections
118
- 3. `sandbox(lang, code)` — test hypotheses
119
-
120
- ### Diagnose → Fix → Verify
121
- 1. `compactor_doctor` — check system health
122
- 2. Fix issues (install runtimes, rebuild index)
123
- 3. `compactor_stats` — verify metrics
124
-
125
98
  ### Before Complex Work
126
- 1. `compact` — free up context
127
- 2. `content_index` — index relevant files
128
- 3. `session_recall("goals")` — load context
129
-
130
- ### After Long Session
131
- 1. `compactor_stats` check savings
132
- 2. `compact` — compact if needed
133
- 3. `session_recall(topic)` verify recall quality
99
+ 1. `context_budget` — check pressure.
100
+ 2. `session_recall("goal files decisions")` — recover relevant prior context.
101
+ 3. `compact(dryRun: true)` — preview if context is tight.
102
+ 4. `/unipi:lossless-compact` or `compact` at a clean boundary.
103
+
104
+ ### Diagnose Compactor Health
105
+ 1. `compactor_doctor` — check config, DB, runtimes.
106
+ 2. Fix reported issues.
107
+ 3. `compactor_stats` — verify counters and savings.
108
+
109
+ ### Long Session Recovery
110
+ 1. `session_recall(query)` — find older details hidden by compaction.
111
+ 2. `context_budget` — decide whether another compaction is needed.
112
+ 3. `compactor_stats` — confirm compactions/savings are recorded.
113
+
114
+ ### Project Search
115
+ Compactor no longer owns project content indexing. Use the CocoIndex package if installed:
116
+ 1. `/unipi:cocoindex-init`
117
+ 2. `/unipi:cocoindex-update`
118
+ 3. `cocoindex_search(query)`
@@ -1,55 +1,53 @@
1
1
  ---
2
2
  name: compactor-doctor
3
- description: Diagnostics — validate config, DB, FTS5, runtimes, troubleshoot issues.
3
+ description: Diagnostics — validate config, session DB, runtimes, and troubleshoot compactor issues.
4
4
  ---
5
5
 
6
6
  # Compactor Doctor
7
7
 
8
8
  Run diagnostics and troubleshoot compactor issues.
9
9
 
10
- ## Commands
10
+ ## Commands and Tools
11
11
 
12
- - `/unipi:compact-doctor` — run all checks
13
- - `ctx_doctor` tool — agent-callable diagnostics
12
+ - `/unipi:compact-doctor` — user-facing diagnostics
13
+ - `compactor_doctor` tool — agent-callable diagnostics
14
+ - `ctx_doctor` tool — deprecated alias
14
15
 
15
16
  ## Checks Performed
16
17
 
17
18
  | Check | What It Validates |
18
19
  |-------|-------------------|
19
- | **Config file** | `~/.unipi/config/compactor/config.json` exists and is valid |
20
- | **Session DB** | SQLite connection works, schema correct |
21
- | **Content Store** | FTS5 index accessible, tables exist |
22
- | **Runtime: node** | Node.js available for sandbox |
23
- | **Runtime: python3** | Python 3 available for sandbox |
24
- | **Runtime: bash** | Bash available for sandbox |
20
+ | **Config file** | `~/.unipi/config/compactor/config.json` exists or defaults can be used |
21
+ | **Session DB** | SQLite connection works and session schema is usable |
22
+ | **Runtime: node** | Node.js available for sandbox/runtime helpers |
23
+ | **Runtime: python3** | Python 3 available for Python sandbox execution |
24
+ | **Runtime: bash** | Bash available for shell sandbox execution |
25
25
 
26
26
  ## Status Icons
27
27
 
28
28
  - ✅ **pass** — check succeeded
29
- - ⚠️ **warn** — non-critical issue (e.g., optional runtime missing)
30
- - ❌ **fail** — critical issue, feature may not work
29
+ - ⚠️ **warn** — non-critical issue, such as an optional runtime missing
30
+ - ❌ **fail** — critical issue; a feature may not work
31
31
 
32
32
  ## Common Issues
33
33
 
34
34
  ### "Config file: Using defaults"
35
- - Normal on first run
36
- - Config auto-created on next settings save
37
- - Fix: `/unipi:compact-settings` save
35
+
36
+ - Normal on first run.
37
+ - Config is created by the extension or on settings save.
38
+ - Fix: open `/unipi:compact-settings`, adjust if desired, then save.
38
39
 
39
40
  ### "Session DB: Connection failed"
40
- - SQLite not available
41
- - Check if `better-sqlite3` is installed
42
- - Fix: `npm install better-sqlite3`
43
41
 
44
- ### "Content Store: FTS5 error"
45
- - SQLite FTS5 extension not available
46
- - Requires SQLite 3.9+ with FTS5
47
- - Fix: Update system SQLite
42
+ - SQLite or `better-sqlite3` may be unavailable.
43
+ - Compaction can still degrade gracefully, but stats/recall may be limited.
44
+ - Fix: verify dependencies and run package install/build steps.
48
45
 
49
46
  ### "Runtime: python3 Not found"
50
- - Python not installed or not in PATH
51
- - Only needed for Python sandbox execution
52
- - Fix: Install Python 3 or ignore if not needed
47
+
48
+ - Python is not installed or not in `PATH`.
49
+ - Only needed for Python sandbox execution.
50
+ - Fix: install Python 3 or ignore if not needed.
53
51
 
54
52
  ## Manual Diagnostics
55
53
 
@@ -63,12 +61,11 @@ cat ~/.unipi/config/compactor/config.json
63
61
  ls -la ~/.unipi/db/compactor/
64
62
  ```
65
63
 
66
- ### Check SQLite version
64
+ ### Check runtimes
67
65
  ```bash
68
- sqlite3 --version
66
+ node --version
67
+ python3 --version
68
+ bash --version
69
69
  ```
70
70
 
71
- ### Test FTS5
72
- ```bash
73
- sqlite3 ':memory:' "CREATE VIRTUAL TABLE t USING fts5(x); SELECT 1;"
74
- ```
71
+ Project content indexing diagnostics live in the CocoIndex package, not compactor.
@@ -1,49 +1,51 @@
1
1
  ---
2
2
  name: compactor-stats
3
- description: Stats display — context savings, session metrics, index health.
3
+ description: Stats display — context savings, session metrics, compactions, sandbox and recall/search counters.
4
4
  ---
5
5
 
6
6
  # Compactor Stats
7
7
 
8
8
  Display and interpret compactor statistics.
9
9
 
10
- ## Commands
10
+ ## Commands and Tools
11
11
 
12
- - `/unipi:compact-stats` — quick dashboard
13
- - `ctx_stats` tool — detailed stats (agent-callable)
12
+ - `/unipi:compact-stats` — user-facing dashboard
13
+ - `compactor_stats` tool — agent-callable stats
14
+ - `ctx_stats` tool — deprecated alias
14
15
 
15
16
  ## Metrics Explained
16
17
 
17
18
  | Metric | Description |
18
19
  |--------|-------------|
19
- | **Session events** | Total events tracked in current session |
20
- | **Compactions** | Number of times context was compacted |
21
- | **Tokens saved** | Estimated tokens freed by compaction |
22
- | **Indexed docs** | Number of files/sources in FTS5 index |
23
- | **Indexed chunks** | Total searchable chunks |
24
- | **Sandbox runs** | Code executions in sandbox |
25
- | **Search queries** | FTS5 searches performed |
20
+ | **Session events** | Events tracked for the current session in the compactor session DB |
21
+ | **Compactions** | Number of compaction events recorded |
22
+ | **Tokens saved** | Estimated tokens freed by compaction, using Pi token counts when available and DB fallbacks otherwise |
23
+ | **Compression ratio** | Approximate before/kept ratio from stored compaction stats |
24
+ | **Sandbox runs** | Agent sandbox executions recorded in runtime counters/DB |
25
+ | **Search queries** | Session recall/search queries recorded in runtime counters/DB |
26
26
 
27
27
  ## Health Indicators
28
28
 
29
- - **Compactions > 0** on long sessions = working correctly
30
- - **Indexed chunks > 0** = search available
31
- - **Sandbox runs** = code execution active
29
+ - **Compactions > 0** on long sessions = compaction is happening.
30
+ - **Tokens saved > 0** after compaction = savings were recorded.
31
+ - **Sandbox runs** increasing = sandbox tool usage is tracked.
32
+ - **Search queries** increasing = recall/search activity is tracked.
32
33
 
33
34
  ## Reading Stats
34
35
 
35
- ```
36
+ ```text
36
37
  📊 Compactor Stats
37
38
  Session events: 42
38
39
  Compactions: 3
39
40
  Tokens saved: 15000
40
- Indexed docs: 12 (48 chunks)
41
41
  Sandbox runs: 7
42
42
  Search queries: 15
43
43
  ```
44
44
 
45
45
  This means:
46
- - 42 tool calls/events tracked
47
- - 3 compactions saved ~15K tokens
48
- - 12 files indexed into 48 searchable chunks
49
- - 7 code executions, 15 searches performed
46
+
47
+ - 42 events are tracked for the session.
48
+ - 3 compactions saved roughly 15K tokens.
49
+ - 7 sandbox executions and 15 recall/search queries were recorded.
50
+
51
+ Project content indexing stats are not owned by compactor anymore; use the CocoIndex package for indexed file search/status.
@@ -229,9 +229,12 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
229
229
  " /unipi:session-recall <query> — search session history\n" +
230
230
  " /unipi:compact-stats — view stats\n" +
231
231
  " /unipi:compact-doctor — run diagnostics\n" +
232
- " /unipi:compact-settings — TUI settings\n" +
232
+ " /unipi:compact-settings — TUI settings, including optional % auto-compaction\n" +
233
233
  " /unipi:compact-preset <name> — apply preset\n" +
234
234
  "\n" +
235
+ "Percentage trigger:\n" +
236
+ " Disabled by default. Enable in /unipi:compact-settings to compact at a context % before Pi's reserve-token limit.\n" +
237
+ "\n" +
235
238
  "Content indexing has moved to @pi-unipi/cocoindex:\n" +
236
239
  " /unipi:cocoindex-init — initialize pipeline\n" +
237
240
  " /unipi:cocoindex-update — index project files\n" +
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Pure percentage auto-compaction trigger decisions.
3
+ *
4
+ * The runtime wiring owns Pi ctx access; this module only decides whether a
5
+ * known context-usage sample should trigger UniPi's zero-LLM compaction.
6
+ */
7
+
8
+ import type { AutoCompactionConfig } from "../types.js";
9
+
10
+ export interface AutoCompactionUsage {
11
+ tokens?: number | null;
12
+ percent?: number | null;
13
+ contextWindow?: number | null;
14
+ }
15
+
16
+ export interface KnownAutoCompactionUsage {
17
+ tokens: number;
18
+ percent: number;
19
+ contextWindow?: number;
20
+ }
21
+
22
+ export interface AutoCompactionState {
23
+ /** Previous known context percentage sample. Null before first known usage. */
24
+ previousPercent: number | null;
25
+ /** Previous known context token sample. Null before first known usage. */
26
+ previousTokens: number | null;
27
+ /** True after UniPi calls ctx.compact() and before Pi reports completion/error. */
28
+ inFlight: boolean;
29
+ /** Timestamp of the last UniPi-triggered compaction attempt. */
30
+ lastTriggerAt: number | null;
31
+ /** Context tokens reported at the last UniPi-triggered compaction attempt. */
32
+ lastTriggerTokens: number | null;
33
+ /** Baseline used to require meaningful token growth for repeat high-usage triggers. */
34
+ repeatBaselineTokens: number | null;
35
+ /** After a successful compaction, consume one known usage sample as a fresh baseline. */
36
+ awaitingPostCompactionSample: boolean;
37
+ }
38
+
39
+ export type AutoCompactionDecisionReason =
40
+ | "disabled"
41
+ | "unknown_usage"
42
+ | "in_flight"
43
+ | "below_threshold"
44
+ | "post_compaction_baseline"
45
+ | "threshold_reached"
46
+ | "threshold_crossed"
47
+ | "cooldown_active"
48
+ | "repeat_growth_needed"
49
+ | "repeat_growth_reached";
50
+
51
+ export interface AutoCompactionDecision {
52
+ shouldTrigger: boolean;
53
+ reason: AutoCompactionDecisionReason;
54
+ state: AutoCompactionState;
55
+ thresholdPercent: number;
56
+ usage?: KnownAutoCompactionUsage;
57
+ cooldownRemainingMs?: number;
58
+ tokenGrowth?: number;
59
+ tokensUntilRepeat?: number;
60
+ }
61
+
62
+ export interface AutoCompactionDecisionInput {
63
+ config?: Partial<AutoCompactionConfig> | null;
64
+ usage?: AutoCompactionUsage | null;
65
+ state: AutoCompactionState;
66
+ nowMs?: number;
67
+ }
68
+
69
+ export const AUTO_COMPACTION_DEFAULTS: AutoCompactionConfig = {
70
+ enabled: false,
71
+ thresholdPercent: 80,
72
+ cooldownMs: 60_000,
73
+ repeatMinGrowthTokens: 4_000,
74
+ notify: true,
75
+ };
76
+
77
+ const clamp = (value: unknown, fallback: number, min: number, max: number): number => {
78
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
79
+ return Math.min(max, Math.max(min, value));
80
+ };
81
+
82
+ export function normalizeAutoCompactionConfig(config?: Partial<AutoCompactionConfig> | null): AutoCompactionConfig {
83
+ return {
84
+ enabled: config?.enabled ?? AUTO_COMPACTION_DEFAULTS.enabled,
85
+ thresholdPercent: clamp(
86
+ config?.thresholdPercent,
87
+ AUTO_COMPACTION_DEFAULTS.thresholdPercent,
88
+ 1,
89
+ 99,
90
+ ),
91
+ cooldownMs: Math.round(clamp(
92
+ config?.cooldownMs,
93
+ AUTO_COMPACTION_DEFAULTS.cooldownMs,
94
+ 0,
95
+ 24 * 60 * 60 * 1000,
96
+ )),
97
+ repeatMinGrowthTokens: Math.round(clamp(
98
+ config?.repeatMinGrowthTokens,
99
+ AUTO_COMPACTION_DEFAULTS.repeatMinGrowthTokens,
100
+ 0,
101
+ 10_000_000,
102
+ )),
103
+ notify: config?.notify ?? AUTO_COMPACTION_DEFAULTS.notify,
104
+ };
105
+ }
106
+
107
+ export function createAutoCompactionState(): AutoCompactionState {
108
+ return {
109
+ previousPercent: null,
110
+ previousTokens: null,
111
+ inFlight: false,
112
+ lastTriggerAt: null,
113
+ lastTriggerTokens: null,
114
+ repeatBaselineTokens: null,
115
+ awaitingPostCompactionSample: false,
116
+ };
117
+ }
118
+
119
+ function knownUsage(usage?: AutoCompactionUsage | null): KnownAutoCompactionUsage | null {
120
+ if (!usage) return null;
121
+ const { tokens, percent, contextWindow } = usage;
122
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens < 0) return null;
123
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) return null;
124
+ const known: KnownAutoCompactionUsage = { tokens, percent };
125
+ if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
126
+ known.contextWindow = contextWindow;
127
+ }
128
+ return known;
129
+ }
130
+
131
+ function updatePrevious(state: AutoCompactionState, usage: KnownAutoCompactionUsage): AutoCompactionState {
132
+ return {
133
+ ...state,
134
+ previousPercent: usage.percent,
135
+ previousTokens: usage.tokens,
136
+ };
137
+ }
138
+
139
+ function trigger(
140
+ reason: Extract<AutoCompactionDecisionReason, "threshold_reached" | "threshold_crossed" | "repeat_growth_reached">,
141
+ state: AutoCompactionState,
142
+ usage: KnownAutoCompactionUsage,
143
+ thresholdPercent: number,
144
+ nowMs: number,
145
+ tokenGrowth?: number,
146
+ ): AutoCompactionDecision {
147
+ return {
148
+ shouldTrigger: true,
149
+ reason,
150
+ thresholdPercent,
151
+ usage,
152
+ tokenGrowth,
153
+ state: {
154
+ ...updatePrevious(state, usage),
155
+ inFlight: true,
156
+ lastTriggerAt: nowMs,
157
+ lastTriggerTokens: usage.tokens,
158
+ repeatBaselineTokens: usage.tokens,
159
+ awaitingPostCompactionSample: false,
160
+ },
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Decide whether the current usage sample should trigger auto-compaction.
166
+ *
167
+ * Loop safeguards:
168
+ * - unknown/null usage never triggers and does not erase the previous baseline;
169
+ * - in-flight compactions suppress further triggers;
170
+ * - every repeat trigger after a prior attempt observes cooldown;
171
+ * - if usage remains above threshold after compaction, the first known sample is
172
+ * only a baseline, and a repeat requires sufficient token growth.
173
+ */
174
+ export function decideAutoCompaction(input: AutoCompactionDecisionInput): AutoCompactionDecision {
175
+ const config = normalizeAutoCompactionConfig(input.config);
176
+ const nowMs = input.nowMs ?? Date.now();
177
+
178
+ if (!config.enabled) {
179
+ return {
180
+ shouldTrigger: false,
181
+ reason: "disabled",
182
+ thresholdPercent: config.thresholdPercent,
183
+ state: input.state,
184
+ };
185
+ }
186
+
187
+ if (input.state.inFlight) {
188
+ return {
189
+ shouldTrigger: false,
190
+ reason: "in_flight",
191
+ thresholdPercent: config.thresholdPercent,
192
+ state: input.state,
193
+ };
194
+ }
195
+
196
+ const usage = knownUsage(input.usage);
197
+ if (!usage) {
198
+ return {
199
+ shouldTrigger: false,
200
+ reason: "unknown_usage",
201
+ thresholdPercent: config.thresholdPercent,
202
+ state: input.state,
203
+ };
204
+ }
205
+
206
+ let nextState = updatePrevious(input.state, usage);
207
+
208
+ if (input.state.awaitingPostCompactionSample) {
209
+ nextState = {
210
+ ...nextState,
211
+ awaitingPostCompactionSample: false,
212
+ repeatBaselineTokens: usage.percent >= config.thresholdPercent ? usage.tokens : null,
213
+ };
214
+ return {
215
+ shouldTrigger: false,
216
+ reason: usage.percent >= config.thresholdPercent ? "post_compaction_baseline" : "below_threshold",
217
+ thresholdPercent: config.thresholdPercent,
218
+ usage,
219
+ state: nextState,
220
+ };
221
+ }
222
+
223
+ if (usage.percent < config.thresholdPercent) {
224
+ return {
225
+ shouldTrigger: false,
226
+ reason: "below_threshold",
227
+ thresholdPercent: config.thresholdPercent,
228
+ usage,
229
+ state: {
230
+ ...nextState,
231
+ repeatBaselineTokens: null,
232
+ },
233
+ };
234
+ }
235
+
236
+ if (input.state.lastTriggerAt !== null) {
237
+ const elapsedMs = Math.max(0, nowMs - input.state.lastTriggerAt);
238
+ if (elapsedMs < config.cooldownMs) {
239
+ return {
240
+ shouldTrigger: false,
241
+ reason: "cooldown_active",
242
+ thresholdPercent: config.thresholdPercent,
243
+ usage,
244
+ cooldownRemainingMs: config.cooldownMs - elapsedMs,
245
+ state: nextState,
246
+ };
247
+ }
248
+ }
249
+
250
+ const previousPercent = input.state.previousPercent;
251
+ if (previousPercent === null) {
252
+ return trigger("threshold_reached", input.state, usage, config.thresholdPercent, nowMs);
253
+ }
254
+ if (previousPercent < config.thresholdPercent) {
255
+ return trigger("threshold_crossed", input.state, usage, config.thresholdPercent, nowMs);
256
+ }
257
+
258
+ // We were already above threshold. Re-trigger only after enough growth.
259
+ if (input.state.lastTriggerAt === null) {
260
+ return trigger("threshold_reached", input.state, usage, config.thresholdPercent, nowMs);
261
+ }
262
+
263
+ const baselineTokens = input.state.repeatBaselineTokens
264
+ ?? input.state.lastTriggerTokens
265
+ ?? input.state.previousTokens
266
+ ?? usage.tokens;
267
+ const tokenGrowth = Math.max(0, usage.tokens - baselineTokens);
268
+
269
+ if (tokenGrowth < config.repeatMinGrowthTokens) {
270
+ return {
271
+ shouldTrigger: false,
272
+ reason: "repeat_growth_needed",
273
+ thresholdPercent: config.thresholdPercent,
274
+ usage,
275
+ tokenGrowth,
276
+ tokensUntilRepeat: config.repeatMinGrowthTokens - tokenGrowth,
277
+ state: nextState,
278
+ };
279
+ }
280
+
281
+ return trigger(
282
+ "repeat_growth_reached",
283
+ input.state,
284
+ usage,
285
+ config.thresholdPercent,
286
+ nowMs,
287
+ tokenGrowth,
288
+ );
289
+ }
290
+
291
+ export function markAutoCompactionComplete(state: AutoCompactionState): AutoCompactionState {
292
+ return {
293
+ ...state,
294
+ inFlight: false,
295
+ awaitingPostCompactionSample: true,
296
+ };
297
+ }
298
+
299
+ export function markAutoCompactionError(state: AutoCompactionState, nowMs?: number): AutoCompactionState {
300
+ return {
301
+ ...state,
302
+ inFlight: false,
303
+ awaitingPostCompactionSample: false,
304
+ lastTriggerAt: state.lastTriggerAt ?? nowMs ?? null,
305
+ };
306
+ }
@@ -116,6 +116,7 @@ export function migrateConfig(partial: Partial<CompactorConfig>): CompactorConfi
116
116
  sandboxExecution: mergeStrategy("sandboxExecution", defaults.sandboxExecution, partial.sandboxExecution),
117
117
  toolDisplay: mergeStrategy("toolDisplay", defaults.toolDisplay, partial.toolDisplay),
118
118
  pipeline: mergeStrategy("pipeline", defaults.pipeline, (partial as any).pipeline) as any,
119
+ autoCompaction: mergeStrategy("autoCompaction", defaults.autoCompaction, partial.autoCompaction),
119
120
  overrideDefaultCompaction: partial.overrideDefaultCompaction ?? defaults.overrideDefaultCompaction,
120
121
  debug: partial.debug ?? defaults.debug,
121
122
  showTruncationHints: partial.showTruncationHints ?? defaults.showTruncationHints,