pi-antiloop 1.0.0 → 1.1.0

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.
package/README.md CHANGED
@@ -1,18 +1,18 @@
1
1
  <div align="center">
2
2
 
3
- ![Antiloop banner](docs/banner.png)
3
+ ![Antiloop banner](https://raw.githubusercontent.com/noguerol/antiloop/main/docs/banner.jpeg)
4
4
 
5
5
  </div>
6
6
 
7
7
  # Antiloop — Loop Detection and Break for pi
8
8
 
9
- **Antiloop watches every assistant message, tool call and thinking block, and forces the model out of reasoning loops before they eat your context and your patience.** Three simultaneous detection strategies (text similarity, tool-call sequences, thinking content) find loops that humans miss — and progressive intervention (warning → force break → abort) tells the model to take a different approach, without you having to babysit it.
9
+ **Antiloop watches every assistant message, tool call and thinking block, and forces the model out of reasoning loops before they eat your context and your patience.** Four simultaneous detection strategies (text similarity, tool-call sequences, thinking content, structural openings) find loops that humans miss — and progressive intervention (warning → force break → abort) tells the model to take a different approach, without you having to babysit it.
10
10
 
11
11
  ---
12
12
 
13
13
  ## Features
14
14
 
15
- - **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + argument matching), thinking blocks, and structural opening-phrase patterns
15
+ - **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + near-identical arguments + same outcome — result-aware, so retries that make progress don't false-positive), thinking blocks, and structural opening-phrase patterns
16
16
  - **Progressive intervention** — `warning` reminds the model to vary its approach; `force break` injects explicit anti-loop instructions and modifies context; `abort` stops the run entirely
17
17
  - **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
18
18
  - **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
@@ -100,7 +100,7 @@ Detection strategies:
100
100
 
101
101
  Recent detections:
102
102
  [text] Text similarity 85% with message 3 (2m ago)
103
- [tool] Same tool calls repeated: read, edit (5m ago)
103
+ [tool] repeated 3x: bash (5m ago)
104
104
  ```
105
105
 
106
106
  ### `/antiloop config`
@@ -112,6 +112,9 @@ Interactive menu with current values:
112
112
  - **Force break threshold** — similar messages before force break (default 3)
113
113
  - **Abort threshold** — similar messages before abort (0 = disabled)
114
114
  - **Similarity threshold** — `0.5 / 0.6 / 0.7 / 0.75 / 0.8 / 0.9` — how close two messages must be to count as looping
115
+ - **Tool similarity** — `0.99 / 0.95 / 0.9 / 0.8` — how close tool-call *arguments* must be to count as the same call (95% default: only near-identical repeats loop)
116
+ - **Tool repeat** — `1 / 2 / 3` — prior occurrences of the same call set required before a tool loop flags
117
+ - **Result similarity** — `0.95 / 0.8 / 0.6` — how similar captured results must be to count as the *same outcome* (veto when a repeated command starts succeeding/differing)
115
118
  - **Detection window** — `5 / 10 / 15 / 20` — number of recent messages to analyze
116
119
  - **Per-strategy toggles** — text / tool / thinking detection
117
120
  - **Notifications** — show detection notifications
@@ -123,30 +126,33 @@ Shows the most recent 30 detections with similarity scores and timestamps, newes
123
126
 
124
127
  ### `/antiloop test`
125
128
 
126
- Runs five built-in cases plus a tool-call equality check to verify the similarity engine is working correctly:
129
+ Runs the real detection engine (not a copy) text similarity plus tool-call regression cases:
127
130
 
128
131
  ```
129
- "Hello world" vs "Hello world"
130
- Similarity: 100.0% (expected: identical)
131
- "Hello world" vs "Hello World!"
132
- Similarity: 95.0% (expected: very similar)
133
- "I will read the file first" vs "I will read the file first to understand"
134
- Similarity: 85.0% (expected: similar)
135
- ...
132
+ text identical → 100% (exp 100%) ✅
133
+ text near-identical → 91% (exp ≥ 80%)
134
+ text unrelated → 19% (exp < 50%) ✅
135
+ tool identical cmd match (exp match)
136
+ tool sweep (flags) → no match @95% (exp no match) ✅ ← regression: 0.8–0.94 overlap is NOT a loop
137
+ tool sweep (old 80%)→ match @80% (exp match — was the false positive)
138
+ tool different tool → no match (exp no match) ✅
139
+ tool empty lists → no match (exp no match) ✅
140
+ result same outcome → match (exp match — PID noise ok) ✅
141
+ result diff outcome → no match (exp no match — error→success is progress) ✅
136
142
  ```
137
143
 
138
144
  ## How It Works
139
145
 
140
146
  ### Detection pipeline
141
147
 
142
- After every `message_end` event, antiloop extracts the new assistant content (text, thinking, tool calls) and pushes it onto a sliding window of the last `detectionWindow + 5` messages. Then it runs the active detection strategies against the current window:
148
+ After every assistant `message_end` event, antiloop extracts the new content (text, thinking, tool calls — including their ids) and pushes it onto a sliding window of the last `detectionWindow + 5` messages. Detection itself runs at `turn_end`, once the tool results are known: results are fingerprinted and attached to the tracked calls, then the active detection strategies run against the window:
143
149
 
144
150
  | Strategy | What it compares | Algorithm |
145
151
  |----------|------------------|-----------|
146
152
  | Text | Full assistant message text | n-gram Jaccard (≥ 100 chars) or Levenshtein (shorter) |
147
- | Tool | Tool name + arguments | Sequence match + ≥ 80% args similarity |
153
+ | Tool | Tool name + arguments (+ captured result) | Sequence match + near-identical args (`toolSimilarityThreshold`, default 95%) *and* `minToolRepeatCount` prior recurrences. **Result veto:** if both runs captured a result and the outcomes differ, it's progress, not a loop |
148
154
  | Thinking | Internal reasoning/thinking blocks | Same as text |
149
- | Structural | First 10 words of each message | Opening-phrase similarity ≥ 80% across ≥ 3 messages |
155
+ | Structural | First 10 words of each message | Opening-phrase similarity ≥ 90% across ≥ 3 messages |
150
156
 
151
157
  Each detected pair becomes a `LoopDetection { type, similarity, messageIndices, description }` and the consecutive counter increases.
152
158
 
@@ -172,10 +178,34 @@ For longer texts: Character trigram Jaccard
172
178
  "I will read the file first to understand the codebase..."
173
179
  → ~85% (many shared 3-grams)
174
180
 
175
- For tool calls: sequence + per-call argument similarity ≥ 80%
176
- [read({path:"/src/x.ts"}), edit({path:"/src/x.ts",...})]
177
- [read({path:"/src/x.ts"}), edit({path:"/src/x.ts",...})]
178
- → matched
181
+ For tool calls: sequence + per-call argument similarity ≥ 95% (default)
182
+ [bash("setsid ./llama-server -m … -b 2048 -ctk q8_0 …")]
183
+ [bash("setsid ./llama-server -m … -b 2048 -ctk q8_0 …")] → matched (identical)
184
+
185
+ …but a parameter sweep is NOT a loop, even at 80–94% similarity:
186
+ [bash("… -b 2048 -ctk q8_0 -ctv turbo4 > /tmp/sweep-turbo4.log …")]
187
+ [bash("… -b 8192 -ctk f16 -ctv f16 > /tmp/sweep-b8192.log …")] → not matched
188
+
189
+ Long bash commands share scaffolding (env setup, model path, most flags),
190
+ so 80% overlap is normal for *different* sequential operations. Only
191
+ near-identical repeats — the same call set seen `minToolRepeatCount` times
192
+ inside the window — count as a tool loop.
193
+
194
+ Result veto (tool loops): detection runs at `turn_end`, where the tool
195
+ results are known. Each captured result becomes a normalized tail fingerprint
196
+ ("err|" / "ok|" prefix + last 400 chars, so PID/timestamp noise is tolerated).
197
+ If the same command produced a *different* outcome, the pair is progress:
198
+
199
+ [bash("...")] → err|error: invalid argument: ROCm0 (attempt 1)
200
+ [bash("...")] → ok|model loaded / listening on :8093 (attempt 2)
201
+ → NOT a loop — the retry fixed the problem
202
+
203
+ [bash("...")] → err|failed to create context … (attempt 1)
204
+ [bash("...")] → err|failed to create context … (attempt 2)
205
+ → loop signal (same command, same outcome, repeated)
206
+
207
+ Results only veto; they never trigger on their own, and calls without a
208
+ captured result fall back to argument matching alone.
179
209
  ```
180
210
 
181
211
  ### Sliding window
@@ -193,6 +223,9 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
193
223
  "forceBreakThreshold": 3,
194
224
  "abortThreshold": 0,
195
225
  "similarityThreshold": 0.75,
226
+ "toolSimilarityThreshold": 0.95,
227
+ "minToolRepeatCount": 2,
228
+ "resultSimilarityThreshold": 0.8,
196
229
  "detectToolLoops": true,
197
230
  "detectThinkingLoops": true,
198
231
  "detectTextLoops": true,
@@ -208,7 +241,10 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
208
241
  | `warningThreshold` | `2` | Consecutive detections before warning |
209
242
  | `forceBreakThreshold` | `3` | Consecutive detections before force break |
210
243
  | `abortThreshold` | `0` | Consecutive detections before abort (0 = disabled) |
211
- | `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a pair as looping |
244
+ | `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a text/thinking pair as looping |
245
+ | `toolSimilarityThreshold` | `0.95` | How close tool-call arguments must be (0.0–1.0) to count as the *same* call — see [tool loops](#how-it-works) |
246
+ | `minToolRepeatCount` | `2` | Prior occurrences of a near-identical call set required before a tool loop is flagged (2 = same call seen 3×) |
247
+ | `resultSimilarityThreshold` | `0.8` | Minimum similarity between captured result tails to still count as the *same outcome*; below this, a repeated command is treated as progress, not a loop |
212
248
  | `detectTextLoops` | `true` | Detect full-text repetition |
213
249
  | `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
214
250
  | `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
@@ -218,12 +254,13 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
218
254
 
219
255
  ## Best Practices
220
256
 
221
- 1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75%` works well for most models.
257
+ 1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75% / tool-sim=95%` works well for most models.
222
258
  2. **Adjust sensitivity to the model** — small/local models loop more, so lower `warningThreshold` and `similarityThreshold` to catch them early. Big cloud models rarely loop, so you can raise them to avoid false positives.
223
- 3. **Per-strategy toggles** — if the model's reasoning legitimately repeats (e.g. it's working through a checklist), disable `thinking` detection and leave text/tool on.
224
- 4. **Watch the log** — `/antiloop log` shows what's actually triggering. If you see false positives, raise `similarityThreshold` instead of disabling the strategy entirely.
225
- 5. **Let user input clear state** — each user message decays the consecutive counter by 2, so a fresh prompt naturally resets without `/antiloop reset`.
226
- 6. **`/antiloop test`**if you ever change the similarity engine, run the self-test to verify it still produces expected scores.
259
+ 3. **Tool loops are strict on purpose** — a long bash command with env setup + flags scores 80–94% similar to the *next, different* command. Antiloop only flags tool calls that are near-identical (≥ 95%) *and* repeated ≥ `minToolRepeatCount` times, *and* — when results are captured — produced the same outcome. If you still see false positives on sequential operations, raise `toolSimilarityThreshold` (or `minToolRepeatCount`, or `resultSimilarityThreshold`) via `/antiloop config` — don't disable the detector.
260
+ 4. **Per-strategy toggles** — if the model's reasoning legitimately repeats (e.g. it's working through a checklist), disable `thinking` detection and leave text/tool on.
261
+ 5. **Watch the log** — `/antiloop log` shows what's actually triggering. If you see false positives, raise `similarityThreshold` instead of disabling the strategy entirely.
262
+ 6. **Let user input clear state** each user message decays the consecutive counter by 2, so a fresh prompt naturally resets without `/antiloop reset`.
263
+ 7. **`/antiloop test`** — runs the real detection engine (text + tool-call regression cases) to verify calibration after any change.
227
264
 
228
265
  ## Architecture
229
266
 
@@ -233,20 +270,24 @@ antiloop/
233
270
  ├── LICENSE # MIT
234
271
  ├── README.md
235
272
  ├── docs/
236
- │ ├── banner.png # wide README header
237
- │ └── preview.png # npm pi.dev preview card
238
- ├── screenshot.png # full-res master
273
+ │ ├── banner.jpeg # wide README header
274
+ │ └── preview.jpeg # npm pi.dev preview card
239
275
  └── src/
240
- └── index.ts # full extension (≈975 lines)
276
+ ├── index.ts # hooks + intervention pipeline
277
+ ├── detect.ts # similarity engine, detection strategies, self-test
278
+ ├── commands.ts # /antiloop command handlers + config menu
279
+ ├── config.ts # config load/save
280
+ ├── types.ts # shared types
281
+ └── ui.ts # UI helpers (select, duration)
241
282
  ```
242
283
 
243
- Single-file extension with zero external dependencies (only pi's bundled `@earendil-works/pi-coding-agent` + Node built-ins):
284
+ Modular extension with zero external dependencies (only pi's bundled `@earendil-works/pi-coding-agent` + Node built-ins):
244
285
 
245
286
  - **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
246
287
  - **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
247
288
  - **Early bail** — short messages and empty tool calls skip similarity computation entirely
248
289
  - **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` for the persistent status bar
249
- - **Hooks** — `message_end` (track + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `turn_end` (refresh status), `session_start` (load config + reset)
290
+ - **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `session_start` (load config + reset)
250
291
 
251
292
  ## License
252
293
 
package/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "pi-antiloop",
3
- "version": "1.0.0",
4
- "description": "A pi extension that detects reasoning/processing loops in any model and forces a break with progressive intervention (warning → force break → abort). Text, tool, thinking and structural similarity detection with configurable thresholds and a self-test command.",
3
+ "version": "1.1.0",
4
+ "description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns. Tool-loop detection is result-aware: only near-identical repeated calls with the same outcome count, so sequential bash operations and retries that make progress don't false-positive.",
5
5
  "keywords": [
6
6
  "pi-package",
7
- "loop-detection",
8
7
  "antiloop",
8
+ "loop-detection",
9
9
  "reasoning",
10
- "monitoring",
11
- "debugging"
10
+ "monitoring"
12
11
  ],
13
12
  "author": "Javier Noguerol <https://github.com/noguerol>",
14
13
  "license": "MIT",
@@ -24,7 +23,7 @@
24
23
  "extensions": [
25
24
  "./src/index.ts"
26
25
  ],
27
- "image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.png"
26
+ "image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.jpeg"
28
27
  },
29
28
  "files": [
30
29
  "src"
@@ -0,0 +1,230 @@
1
+ // antiloop — command handlers. Lazy-loaded on /antiloop.
2
+
3
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { saveConfig } from "./config.ts";
5
+ import type { AntiloopState, Runtime } from "./types.ts";
6
+ import { formatDuration, selectFrom } from "./ui.ts";
7
+
8
+ export async function handleCommand(
9
+ args: string | undefined,
10
+ ctx: ExtensionCommandContext,
11
+ rt: Runtime,
12
+ ): Promise<void> {
13
+ const sub = (args ?? "").trim().toLowerCase();
14
+ switch (sub) {
15
+ case "enable":
16
+ rt.config.enabled = true;
17
+ saveConfig(rt.config);
18
+ ctx.ui.notify("antiloop: ON", "info");
19
+ rt.updateStatus(ctx);
20
+ return;
21
+ case "disable":
22
+ rt.config.enabled = false;
23
+ saveConfig(rt.config);
24
+ ctx.ui.notify("antiloop: OFF", "info");
25
+ rt.updateStatus(ctx);
26
+ return;
27
+ case "status":
28
+ return showStatus(ctx, rt);
29
+ case "config":
30
+ return showConfigMenu(ctx, rt);
31
+ case "log":
32
+ return showLog(ctx, rt);
33
+ case "reset":
34
+ resetState(rt.state);
35
+ rt.pendingIntervention = null;
36
+ ctx.ui.notify("antiloop: reset", "info");
37
+ rt.updateStatus(ctx);
38
+ return;
39
+ case "test":
40
+ return runSelfTest(ctx);
41
+ default:
42
+ rt.config.enabled = !rt.config.enabled;
43
+ saveConfig(rt.config);
44
+ ctx.ui.notify(`antiloop: ${rt.config.enabled ? "ON" : "OFF"}`, "info");
45
+ rt.updateStatus(ctx);
46
+ return;
47
+ }
48
+ }
49
+
50
+ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
51
+ const lvl = ["none", "warn", "force", "abort"][rt.state.currentLevel];
52
+ const recent = rt.state.detections.slice(-5);
53
+ const lines = [
54
+ `state: ${rt.config.enabled ? "ON" : "OFF"} · level: ${lvl} · consecutive: ${rt.state.consecutiveDetections}`,
55
+ `total: ${rt.state.totalDetections} · tracked: ${rt.state.recentMessages.length} · forced: ${rt.state.inForcedBreak ? "yes" : "no"}`,
56
+ "",
57
+ "thresholds:",
58
+ ` warn: ${rt.config.warningThreshold} force: ${rt.config.forceBreakThreshold} abort: ${rt.config.abortThreshold || "off"}`,
59
+ ` similarity: ${(rt.config.similarityThreshold * 100).toFixed(0)}% window: ${rt.config.detectionWindow}`,
60
+ ` tool sim: ${(rt.config.toolSimilarityThreshold * 100).toFixed(0)}% tool repeat: ${rt.config.minToolRepeatCount}+ prior`,
61
+ ` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
62
+ "",
63
+ `detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
64
+ ];
65
+ if (recent.length) {
66
+ lines.push("", "recent:");
67
+ for (const d of recent) lines.push(` [${d.type}] ${d.description} · ${formatDuration(Date.now() - d.timestamp)} ago`);
68
+ }
69
+ ctx.ui.notify(lines.join("\n"), "info");
70
+ }
71
+
72
+ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
73
+ const c = rt.config;
74
+ const picked = await selectFrom(ctx, "antiloop config", [
75
+ { value: "toggle" as const, label: c.enabled ? "🟢 disable" : "🔴 enable", description: "toggle detection" },
76
+ { value: "warn" as const, label: `warn threshold: ${c.warningThreshold}` },
77
+ { value: "force" as const, label: `force threshold: ${c.forceBreakThreshold}` },
78
+ { value: "abort" as const, label: `abort threshold: ${c.abortThreshold || "off"}`, description: "0 = disabled" },
79
+ { value: "sim" as const, label: `similarity: ${(c.similarityThreshold * 100).toFixed(0)}%` },
80
+ { value: "toolSim" as const, label: `tool similarity: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "args must match this closely to count as the same call" },
81
+ { value: "toolRepeat" as const, label: `tool repeat: ${c.minToolRepeatCount}+ prior`, description: "recurrences before a tool loop flags" },
82
+ { value: "resultSim" as const, label: `result similarity: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "same cmd + different outcome vetoes the loop" },
83
+ { value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
84
+ { value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
85
+ { value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
86
+ { value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
87
+ { value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
88
+ { value: "reset" as const, label: "reset state" },
89
+ ]);
90
+ if (!picked) return;
91
+ switch (picked) {
92
+ case "toggle":
93
+ c.enabled = !c.enabled;
94
+ saveConfig(c);
95
+ ctx.ui.notify(`antiloop: ${c.enabled ? "ON" : "OFF"}`, "info");
96
+ rt.updateStatus(ctx);
97
+ break;
98
+ case "warn": {
99
+ const v = await selectFrom(ctx, "warn threshold", [
100
+ { value: 1, label: "1 (sensitive)" },
101
+ { value: 2, label: "2 (default)" },
102
+ { value: 3, label: "3" },
103
+ { value: 5, label: "5 (relaxed)" },
104
+ ]);
105
+ if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`warn: ${v}`, "info"); }
106
+ break;
107
+ }
108
+ case "force": {
109
+ const v = await selectFrom(ctx, "force threshold", [
110
+ { value: 2, label: "2 (sensitive)" },
111
+ { value: 3, label: "3 (default)" },
112
+ { value: 5, label: "5" },
113
+ { value: 8, label: "8 (relaxed)" },
114
+ ]);
115
+ if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`force: ${v}`, "info"); }
116
+ break;
117
+ }
118
+ case "abort": {
119
+ const v = await selectFrom(ctx, "abort threshold (0=off)", [
120
+ { value: 0, label: "off" },
121
+ { value: 5, label: "5" },
122
+ { value: 8, label: "8" },
123
+ { value: 10, label: "10" },
124
+ { value: 15, label: "15" },
125
+ ]);
126
+ if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`abort: ${v || "off"}`, "info"); }
127
+ break;
128
+ }
129
+ case "sim": {
130
+ const v = await selectFrom(ctx, "similarity", [
131
+ { value: 0.5, label: "50% (sensitive)" },
132
+ { value: 0.6, label: "60%" },
133
+ { value: 0.7, label: "70%" },
134
+ { value: 0.75, label: "75% (default)" },
135
+ { value: 0.8, label: "80%" },
136
+ { value: 0.9, label: "90% (relaxed)" },
137
+ ]);
138
+ if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
139
+ break;
140
+ }
141
+ case "toolSim": {
142
+ const v = await selectFrom(ctx, "tool similarity (args)", [
143
+ { value: 0.99, label: "99% (strict)" },
144
+ { value: 0.95, label: "95% (default)" },
145
+ { value: 0.9, label: "90%" },
146
+ { value: 0.8, label: "80% (sensitive)" },
147
+ ]);
148
+ if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`tool similarity: ${(v * 100).toFixed(0)}%`, "info"); }
149
+ break;
150
+ }
151
+ case "toolRepeat": {
152
+ const v = await selectFrom(ctx, "tool repeat (prior occurrences)", [
153
+ { value: 1, label: "1 (sensitive)" },
154
+ { value: 2, label: "2 (default)" },
155
+ { value: 3, label: "3 (relaxed)" },
156
+ ]);
157
+ if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`tool repeat: ${v}+ prior`, "info"); }
158
+ break;
159
+ }
160
+ case "resultSim": {
161
+ const v = await selectFrom(ctx, "result similarity (outcome veto)", [
162
+ { value: 0.95, label: "95% (strict — only near-identical results count as same outcome)" },
163
+ { value: 0.8, label: "80% (default)" },
164
+ { value: 0.6, label: "60% (relaxed — tolerates more output noise)" },
165
+ ]);
166
+ if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
167
+ break;
168
+ }
169
+ case "window": {
170
+ const v = await selectFrom(ctx, "window", [
171
+ { value: 5, label: "5" },
172
+ { value: 10, label: "10 (default)" },
173
+ { value: 15, label: "15" },
174
+ { value: 20, label: "20" },
175
+ ]);
176
+ if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
177
+ break;
178
+ }
179
+ case "text":
180
+ c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
181
+ ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
182
+ case "tool":
183
+ c.detectToolLoops = !c.detectToolLoops; saveConfig(c);
184
+ ctx.ui.notify(`tool: ${yn(c.detectToolLoops)}`, "info"); break;
185
+ case "think":
186
+ c.detectThinkingLoops = !c.detectThinkingLoops; saveConfig(c);
187
+ ctx.ui.notify(`think: ${yn(c.detectThinkingLoops)}`, "info"); break;
188
+ case "notify":
189
+ c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
190
+ ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
191
+ case "reset":
192
+ resetState(rt.state);
193
+ rt.pendingIntervention = null;
194
+ ctx.ui.notify("reset", "info");
195
+ rt.updateStatus(ctx);
196
+ break;
197
+ }
198
+ }
199
+
200
+ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
201
+ if (!rt.state.detections.length) {
202
+ ctx.ui.notify("no detections this session", "info");
203
+ return;
204
+ }
205
+ const items = rt.state.detections.slice(-30).reverse().map((d) => ({
206
+ value: "" as const,
207
+ label: `[${d.type}] ${d.description}`,
208
+ description: `${(d.similarity * 100).toFixed(0)}% · ${formatDuration(Date.now() - d.timestamp)} ago`,
209
+ }));
210
+ await selectFrom(ctx, `detections (${rt.state.detections.length} total)`, items);
211
+ }
212
+
213
+ export function resetState(state: AntiloopState): void {
214
+ state.recentMessages = [];
215
+ state.detections = [];
216
+ state.currentLevel = 0;
217
+ state.consecutiveDetections = 0;
218
+ state.inForcedBreak = false;
219
+ state.totalDetections = 0;
220
+ state.lastDetectedTurnIndex = -1;
221
+ }
222
+
223
+ async function runSelfTest(ctx: ExtensionCommandContext): Promise<void> {
224
+ const { runSelfTest } = await import("./detect.ts");
225
+ ctx.ui.notify(`antiloop self-test\n${runSelfTest().join("\n")}`, "info");
226
+ }
227
+
228
+ function yn(b: boolean): string {
229
+ return b ? "on" : "off";
230
+ }
package/src/config.ts ADDED
@@ -0,0 +1,49 @@
1
+ // antiloop — config load/save. Lightweight: only file I/O at session_start.
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import type { AntiloopConfig } from "./types.ts";
7
+
8
+ export const CONFIG_FILE = "antiloop.json";
9
+
10
+ export const DEFAULT_CONFIG: AntiloopConfig = {
11
+ enabled: true,
12
+ warningThreshold: 2,
13
+ forceBreakThreshold: 3,
14
+ abortThreshold: 0,
15
+ similarityThreshold: 0.75,
16
+ toolSimilarityThreshold: 0.95,
17
+ minToolRepeatCount: 2,
18
+ resultSimilarityThreshold: 0.8,
19
+ detectToolLoops: true,
20
+ detectThinkingLoops: true,
21
+ detectTextLoops: true,
22
+ notifyOnDetection: true,
23
+ maxHistoryEntries: 100,
24
+ detectionWindow: 10,
25
+ };
26
+
27
+ export function getConfigPath(): string {
28
+ return join(getAgentDir(), CONFIG_FILE);
29
+ }
30
+
31
+ export function loadConfig(): AntiloopConfig {
32
+ const p = getConfigPath();
33
+ if (existsSync(p)) {
34
+ try {
35
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(p, "utf-8")) };
36
+ } catch (e) {
37
+ console.error(`[antiloop] config load error: ${e}`);
38
+ }
39
+ }
40
+ return { ...DEFAULT_CONFIG };
41
+ }
42
+
43
+ export function saveConfig(config: AntiloopConfig): void {
44
+ try {
45
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
46
+ } catch (e) {
47
+ console.error(`[antiloop] config save error: ${e}`);
48
+ }
49
+ }