pi-antiloop 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +253 -0
  3. package/package.json +35 -0
  4. package/src/index.ts +975 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Noguerol
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,253 @@
1
+ <div align="center">
2
+
3
+ ![Antiloop banner](docs/banner.png)
4
+
5
+ </div>
6
+
7
+ # Antiloop — Loop Detection and Break for pi
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.
10
+
11
+ ---
12
+
13
+ ## Features
14
+
15
+ - **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + argument matching), thinking blocks, and structural opening-phrase patterns
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
+ - **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
18
+ - **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
19
+ - **Live status bar** — `🔄 antiloop`, `⚠️ antiloop(N)`, `🛑 antiloop(N)`, `🚨 antiloop(N)` reflect the current intervention level
20
+ - **Detection log** — timestamped history with similarity scores, filterable through the native pi menu
21
+ - **Self-test** — `/antiloop test` runs built-in cases to verify the similarity engine is calibrated
22
+ - **User input softens detection** — each new user message decays the consecutive counter so a fresh prompt can resolve the loop without manual reset
23
+ - **Bilingual-friendly** — no language assumptions in the comparison (only whitespace + punctuation normalization)
24
+
25
+ ## Install
26
+
27
+ Antiloop is a [pi package](https://pi.dev/packages): one extension (`src/index.ts`) declared in `package.json`.
28
+
29
+ ```bash
30
+ # From GitHub
31
+ pi install git:github.com/noguerol/antiloop
32
+
33
+ # Pin a tag/commit
34
+ pi install git:github.com/noguerol/antiloop@v1.0.0
35
+
36
+ # From npm
37
+ pi install npm:pi-antiloop
38
+
39
+ # Local checkout (development)
40
+ pi install /path/to/antiloop
41
+
42
+ # Try it for one run only
43
+ pi -e git:github.com/noguerol/antiloop
44
+ ```
45
+
46
+ ```bash
47
+ pi list # show installed packages
48
+ pi remove npm:pi-antiloop
49
+ ```
50
+
51
+ > **Security:** pi packages run with full system access — extensions execute arbitrary code. Install only packages you trust and review the source.
52
+
53
+ **Requirements:** a working pi installation. Works with every model — including small local ones that get stuck easily. Zero external dependencies.
54
+
55
+ ## Quick Start
56
+
57
+ ```
58
+ /antiloop # toggle on (enabled by default)
59
+ /antiloop status # check state, configuration, recent detections
60
+ /antiloop config # adjust thresholds to taste
61
+ ```
62
+
63
+ That's it. Antiloop is on by default. If the model ever starts repeating itself, you'll see a `⚠️ antiloop(N)` warning; if it keeps looping past the force-break threshold, antiloop injects an "abort this pattern now" instruction into the context.
64
+
65
+ ## Commands
66
+
67
+ | Command | Description |
68
+ |---------|-------------|
69
+ | `/antiloop` | Toggle on/off |
70
+ | `/antiloop enable` / `/antiloop disable` | Explicit enable/disable |
71
+ | `/antiloop status` | Show current state, configuration, and recent detections |
72
+ | `/antiloop config` | Open interactive configuration menu |
73
+ | `/antiloop log` | Show detection history (last 30, newest first) |
74
+ | `/antiloop reset` | Clear all counters and history |
75
+ | `/antiloop test` | Run a self-test of the similarity engine |
76
+
77
+ ### `/antiloop status`
78
+
79
+ Example output:
80
+
81
+ ```
82
+ State: ✅ ENABLED
83
+ Current level: warning
84
+ Consecutive detections: 2
85
+ Total detections: 5
86
+ Messages tracked: 8
87
+ In forced break: no
88
+
89
+ Configuration:
90
+ Warning threshold: 2 similar messages
91
+ Force break threshold: 3 similar messages
92
+ Abort threshold: disabled
93
+ Similarity threshold: 75%
94
+ Detection window: 10 messages
95
+
96
+ Detection strategies:
97
+ Text loops: ✅
98
+ Tool loops: ✅
99
+ Thinking loops: ✅
100
+
101
+ Recent detections:
102
+ [text] Text similarity 85% with message 3 (2m ago)
103
+ [tool] Same tool calls repeated: read, edit (5m ago)
104
+ ```
105
+
106
+ ### `/antiloop config`
107
+
108
+ Interactive menu with current values:
109
+
110
+ - **Enable/disable**
111
+ - **Warning threshold** — similar messages before warning (default 2)
112
+ - **Force break threshold** — similar messages before force break (default 3)
113
+ - **Abort threshold** — similar messages before abort (0 = disabled)
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
+ - **Detection window** — `5 / 10 / 15 / 20` — number of recent messages to analyze
116
+ - **Per-strategy toggles** — text / tool / thinking detection
117
+ - **Notifications** — show detection notifications
118
+ - **Reset state** — clear all counters and history
119
+
120
+ ### `/antiloop log`
121
+
122
+ Shows the most recent 30 detections with similarity scores and timestamps, newest first.
123
+
124
+ ### `/antiloop test`
125
+
126
+ Runs five built-in cases plus a tool-call equality check to verify the similarity engine is working correctly:
127
+
128
+ ```
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
+ ...
136
+ ```
137
+
138
+ ## How It Works
139
+
140
+ ### Detection pipeline
141
+
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:
143
+
144
+ | Strategy | What it compares | Algorithm |
145
+ |----------|------------------|-----------|
146
+ | Text | Full assistant message text | n-gram Jaccard (≥ 100 chars) or Levenshtein (shorter) |
147
+ | Tool | Tool name + arguments | Sequence match + ≥ 80% args similarity |
148
+ | Thinking | Internal reasoning/thinking blocks | Same as text |
149
+ | Structural | First 10 words of each message | Opening-phrase similarity ≥ 80% across ≥ 3 messages |
150
+
151
+ Each detected pair becomes a `LoopDetection { type, similarity, messageIndices, description }` and the consecutive counter increases.
152
+
153
+ ### Intervention levels
154
+
155
+ | Level | Trigger | Behavior |
156
+ |-------|---------|----------|
157
+ | 0 (no loop) | — | Silent — passes the message through |
158
+ | 1 (warning) | `consecutiveDetections >= warningThreshold` | Injects a soft reminder asking the model to vary its approach |
159
+ | 2 (force break) | `consecutiveDetections >= forceBreakThreshold` | Injects mandatory anti-loop instructions + appends a context message to the last assistant message |
160
+ | 3 (abort) | `consecutiveDetections >= abortThreshold` | (Disabled by default) Surfaces an error asking the user for new instructions |
161
+
162
+ The level never de-escalates during an active loop; user input decays the consecutive counter naturally so a fresh prompt can break the cycle.
163
+
164
+ ### Similarity scoring
165
+
166
+ ```
167
+ For short texts (< 100 chars): Levenshtein distance
168
+ "Hello world" vs "Hello World!" → 95% (1 char edit on 11 chars)
169
+
170
+ For longer texts: Character trigram Jaccard
171
+ "I will read the file first to understand the structure..."
172
+ "I will read the file first to understand the codebase..."
173
+ → ~85% (many shared 3-grams)
174
+
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
179
+ ```
180
+
181
+ ### Sliding window
182
+
183
+ Only the last `detectionWindow` messages participate in comparisons, so detection cost stays bounded: O(N × W) where N is the window size and W is the message size. The window is trimmed to `detectionWindow + 5` to keep a small buffer past the analysis range, avoiding edge artifacts.
184
+
185
+ ## Configuration
186
+
187
+ Persisted as JSON at `~/.pi/agent/antiloop.json`:
188
+
189
+ ```json
190
+ {
191
+ "enabled": true,
192
+ "warningThreshold": 2,
193
+ "forceBreakThreshold": 3,
194
+ "abortThreshold": 0,
195
+ "similarityThreshold": 0.75,
196
+ "detectToolLoops": true,
197
+ "detectThinkingLoops": true,
198
+ "detectTextLoops": true,
199
+ "notifyOnDetection": true,
200
+ "maxHistoryEntries": 100,
201
+ "detectionWindow": 10
202
+ }
203
+ ```
204
+
205
+ | Field | Default | Description |
206
+ |-------|---------|-------------|
207
+ | `enabled` | `true` | Master switch |
208
+ | `warningThreshold` | `2` | Consecutive detections before warning |
209
+ | `forceBreakThreshold` | `3` | Consecutive detections before force break |
210
+ | `abortThreshold` | `0` | Consecutive detections before abort (0 = disabled) |
211
+ | `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a pair as looping |
212
+ | `detectTextLoops` | `true` | Detect full-text repetition |
213
+ | `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
214
+ | `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
215
+ | `notifyOnDetection` | `true` | Show a notification on every detection |
216
+ | `maxHistoryEntries` | `100` | Max detection history entries |
217
+ | `detectionWindow` | `10` | Number of recent messages to analyze |
218
+
219
+ ## Best Practices
220
+
221
+ 1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75%` works well for most models.
222
+ 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.
227
+
228
+ ## Architecture
229
+
230
+ ```
231
+ antiloop/
232
+ ├── package.json # pi package manifest (pi-package)
233
+ ├── LICENSE # MIT
234
+ ├── README.md
235
+ ├── docs/
236
+ │ ├── banner.png # wide README header
237
+ │ └── preview.png # npm pi.dev preview card
238
+ ├── screenshot.png # full-res master
239
+ └── src/
240
+ └── index.ts # full extension (≈975 lines)
241
+ ```
242
+
243
+ Single-file extension with zero external dependencies (only pi's bundled `@earendil-works/pi-coding-agent` + Node built-ins):
244
+
245
+ - **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
246
+ - **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
247
+ - **Early bail** — short messages and empty tool calls skip similarity computation entirely
248
+ - **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)
250
+
251
+ ## License
252
+
253
+ [MIT](LICENSE) © Javier Noguerol
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
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.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "loop-detection",
8
+ "antiloop",
9
+ "reasoning",
10
+ "monitoring",
11
+ "debugging"
12
+ ],
13
+ "author": "Javier Noguerol <https://github.com/noguerol>",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/noguerol/antiloop"
18
+ },
19
+ "homepage": "https://github.com/noguerol/antiloop",
20
+ "bugs": {
21
+ "url": "https://github.com/noguerol/antiloop/issues"
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./src/index.ts"
26
+ ],
27
+ "image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.png"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,975 @@
1
+ /**
2
+ * antiloop Extension for pi
3
+ *
4
+ * Detects reasoning/processing loops in any model and forces a break with
5
+ * explicit instructions to take a different approach.
6
+ *
7
+ * Detection strategies:
8
+ * 1. Text repetition — similar assistant messages across turns
9
+ * 2. Tool call loops — same tool called with same/similar arguments
10
+ * 3. Thinking loops — similar thinking/reasoning content
11
+ * 4. Structural patterns — similar opening phrases, sentence structures
12
+ *
13
+ * Intervention levels:
14
+ * 1. Warning — inject a gentle reminder to vary approach
15
+ * 2. Force break — inject explicit instruction to stop looping
16
+ * 3. Abort — stop the agent entirely (configurable)
17
+ *
18
+ * Commands:
19
+ * /antiloop - Toggle on/off
20
+ * /antiloop enable - Enable antiloop
21
+ * /antiloop disable - Disable antiloop
22
+ * /antiloop status - Show current state and detection stats
23
+ * /antiloop config - Open interactive config menu
24
+ * /antiloop log - Show loop detection history
25
+ * /antiloop reset - Reset all counters and history
26
+ * /antiloop test - Run a self-test with sample patterns
27
+ */
28
+
29
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
33
+
34
+ // ─── Types ──────────────────────────────────────────────────────────────────
35
+
36
+ interface AntiloopConfig {
37
+ enabled: boolean;
38
+ /** Number of similar messages before triggering warning */
39
+ warningThreshold: number;
40
+ /** Number of similar messages before forcing a break */
41
+ forceBreakThreshold: number;
42
+ /** Number of similar messages before aborting (0 = disabled) */
43
+ abortThreshold: number;
44
+ /** Similarity score threshold (0.0-1.0) to consider messages as looping */
45
+ similarityThreshold: number;
46
+ /** Enable detection of tool call loops */
47
+ detectToolLoops: boolean;
48
+ /** Enable detection of thinking/reasoning loops */
49
+ detectThinkingLoops: boolean;
50
+ /** Enable detection of text pattern loops */
51
+ detectTextLoops: boolean;
52
+ /** Show notifications when loops are detected */
53
+ notifyOnDetection: boolean;
54
+ /** Maximum history entries to keep */
55
+ maxHistoryEntries: number;
56
+ /** Window size for pattern detection (number of recent messages to analyze) */
57
+ detectionWindow: number;
58
+ }
59
+
60
+ interface LoopDetection {
61
+ type: "text" | "tool" | "thinking" | "structural";
62
+ similarity: number;
63
+ messageIndices: number[];
64
+ description: string;
65
+ timestamp: number;
66
+ }
67
+
68
+ interface AntiloopState {
69
+ /** Recent assistant message contents for comparison */
70
+ recentMessages: Array<{
71
+ content: string;
72
+ thinking?: string;
73
+ toolCalls?: Array<{ name: string; args: string }>;
74
+ timestamp: number;
75
+ turnIndex: number;
76
+ }>;
77
+ /** Detection history */
78
+ detections: LoopDetection[];
79
+ /** Current intervention level (0=none, 1=warning, 2=force, 3=abort) */
80
+ currentLevel: number;
81
+ /** Number of consecutive loop detections */
82
+ consecutiveDetections: number;
83
+ /** Whether we're currently in a forced break */
84
+ inForcedBreak: boolean;
85
+ /** Total detections this session */
86
+ totalDetections: number;
87
+ /** Last user message timestamp (resets loop tracking) */
88
+ lastUserMessageTime: number;
89
+ }
90
+
91
+ // ─── Constants ──────────────────────────────────────────────────────────────
92
+
93
+ const CONFIG_FILE = "antiloop.json";
94
+
95
+ const DEFAULT_CONFIG: AntiloopConfig = {
96
+ enabled: true,
97
+ warningThreshold: 2,
98
+ forceBreakThreshold: 3,
99
+ abortThreshold: 0, // disabled by default
100
+ similarityThreshold: 0.75,
101
+ detectToolLoops: true,
102
+ detectThinkingLoops: true,
103
+ detectTextLoops: true,
104
+ notifyOnDetection: true,
105
+ maxHistoryEntries: 100,
106
+ detectionWindow: 10,
107
+ };
108
+
109
+ // ─── Helpers ────────────────────────────────────────────────────────────────
110
+
111
+ function getConfigPath(): string {
112
+ return join(getAgentDir(), CONFIG_FILE);
113
+ }
114
+
115
+ function loadConfig(): AntiloopConfig {
116
+ const configPath = getConfigPath();
117
+ if (existsSync(configPath)) {
118
+ try {
119
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(configPath, "utf-8")) };
120
+ } catch (err) {
121
+ console.error(`[antiloop] Config load error: ${err}`);
122
+ }
123
+ }
124
+ return { ...DEFAULT_CONFIG };
125
+ }
126
+
127
+ function saveConfig(config: AntiloopConfig): void {
128
+ try {
129
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
130
+ } catch (err) {
131
+ console.error(`[antiloop] Config save error: ${err}`);
132
+ }
133
+ }
134
+
135
+ function formatDuration(ms: number): string {
136
+ if (ms < 1000) return `${ms}ms`;
137
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
138
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
139
+ return `${Math.round(ms / 3_600_000)}h`;
140
+ }
141
+
142
+ /**
143
+ * Select helper: presents labeled strings to ctx.ui.select(), returns the
144
+ * matched value from the items array. Returns undefined if cancelled.
145
+ */
146
+ function selectFrom<T>(
147
+ ctx: ExtensionContext,
148
+ title: string,
149
+ items: Array<{ value: T; label: string; description?: string }>
150
+ ): Promise<T | undefined> {
151
+ const strings = items.map((it) =>
152
+ it.description ? `${it.label} — ${it.description}` : it.label
153
+ );
154
+ return ctx.ui.select(title, strings).then((picked) => {
155
+ if (picked === undefined) return undefined;
156
+ const idx = strings.indexOf(picked);
157
+ return idx >= 0 ? items[idx].value : undefined;
158
+ });
159
+ }
160
+
161
+ // ─── Similarity Detection Engine ────────────────────────────────────────────
162
+
163
+ /**
164
+ * Normalize text for comparison: lowercase, collapse whitespace, remove punctuation
165
+ */
166
+ function normalizeText(text: string): string {
167
+ return text
168
+ .toLowerCase()
169
+ .replace(/\s+/g, " ")
170
+ .replace(/[^\w\s]/g, "")
171
+ .trim();
172
+ }
173
+
174
+ /**
175
+ * Calculate Levenshtein distance between two strings
176
+ */
177
+ function levenshteinDistance(a: string, b: string): number {
178
+ if (a.length === 0) return b.length;
179
+ if (b.length === 0) return a.length;
180
+
181
+ const matrix: number[][] = [];
182
+
183
+ for (let i = 0; i <= b.length; i++) {
184
+ matrix[i] = [i];
185
+ }
186
+ for (let j = 0; j <= a.length; j++) {
187
+ matrix[0][j] = j;
188
+ }
189
+
190
+ for (let i = 1; i <= b.length; i++) {
191
+ for (let j = 1; j <= a.length; j++) {
192
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
193
+ matrix[i][j] = matrix[i - 1][j - 1];
194
+ } else {
195
+ matrix[i][j] = Math.min(
196
+ matrix[i - 1][j - 1] + 1, // substitution
197
+ matrix[i][j - 1] + 1, // insertion
198
+ matrix[i - 1][j] + 1 // deletion
199
+ );
200
+ }
201
+ }
202
+ }
203
+
204
+ return matrix[b.length][a.length];
205
+ }
206
+
207
+ /**
208
+ * Calculate similarity score between two strings (0.0 to 1.0)
209
+ * Uses a combination of:
210
+ * - Levenshtein distance (for short texts)
211
+ * - N-gram Jaccard similarity (for longer texts)
212
+ * - Opening phrase matching (for structural detection)
213
+ */
214
+ /** Minimum content length to be considered for comparison */
215
+ const MIN_CONTENT_LENGTH = 50;
216
+
217
+ function calculateSimilarity(a: string, b: string): number {
218
+ // Reject empty or very short strings
219
+ if (a.length < MIN_CONTENT_LENGTH || b.length < MIN_CONTENT_LENGTH) return 0.0;
220
+ if (a === b) return 1.0;
221
+
222
+ const normA = normalizeText(a);
223
+ const normB = normalizeText(b);
224
+
225
+ // After normalization, check again
226
+ if (normA.length < 20 || normB.length < 20) return 0.0;
227
+ if (normA === normB) return 1.0;
228
+
229
+ // For very short texts, use Levenshtein
230
+ if (normA.length < 100 && normB.length < 100) {
231
+ const maxLen = Math.max(normA.length, normB.length);
232
+ const distance = levenshteinDistance(normA, normB);
233
+ return 1.0 - (distance / maxLen);
234
+ }
235
+
236
+ // For longer texts, use n-gram Jaccard similarity
237
+ const ngramsA = getNgrams(normA, 3);
238
+ const ngramsB = getNgrams(normB, 3);
239
+
240
+ const intersection = new Set([...ngramsA].filter(x => ngramsB.has(x)));
241
+ const union = new Set([...ngramsA, ...ngramsB]);
242
+
243
+ return intersection.size / union.size;
244
+ }
245
+
246
+ /**
247
+ * Extract character n-grams from text
248
+ */
249
+ function getNgrams(text: string, n: number): Set<string> {
250
+ const ngrams = new Set<string>();
251
+ for (let i = 0; i <= text.length - n; i++) {
252
+ ngrams.add(text.substring(i, i + n));
253
+ }
254
+ return ngrams;
255
+ }
256
+
257
+ /**
258
+ * Extract opening phrase (first N words) for structural comparison
259
+ */
260
+ function getOpeningPhrase(text: string, wordCount: number = 10): string {
261
+ const words = text.split(/\s+/).slice(0, wordCount).join(" ");
262
+ return normalizeText(words);
263
+ }
264
+
265
+ /**
266
+ * Detect if two tool call sequences are similar
267
+ */
268
+ function areToolCallsSimilar(
269
+ calls1: Array<{ name: string; args: string }>,
270
+ calls2: Array<{ name: string; args: string }>
271
+ ): boolean {
272
+ if (calls1.length !== calls2.length) return false;
273
+ if (calls1.length === 0) return true;
274
+
275
+ // Check if tools are called in the same order with similar args
276
+ for (let i = 0; i < calls1.length; i++) {
277
+ if (calls1[i].name !== calls2[i].name) return false;
278
+ const argSimilarity = calculateSimilarity(calls1[i].args, calls2[i].args);
279
+ if (argSimilarity < 0.8) return false;
280
+ }
281
+
282
+ return true;
283
+ }
284
+
285
+ /**
286
+ * Main loop detection function
287
+ * Analyzes recent messages and returns detected loops
288
+ */
289
+ function detectLoops(
290
+ state: AntiloopState,
291
+ config: AntiloopConfig
292
+ ): LoopDetection[] {
293
+ const detections: LoopDetection[] = [];
294
+ const messages = state.recentMessages;
295
+
296
+ if (messages.length < 2) return detections;
297
+
298
+ // Only analyze within the detection window
299
+ const windowStart = Math.max(0, messages.length - config.detectionWindow);
300
+ const window = messages.slice(windowStart);
301
+
302
+ // Strategy 1: Text repetition detection
303
+ if (config.detectTextLoops) {
304
+ const lastMsg = window[window.length - 1];
305
+
306
+ // Skip if current message is too short
307
+ if (lastMsg.content.length >= MIN_CONTENT_LENGTH) {
308
+ for (let i = 0; i < window.length - 1; i++) {
309
+ // Skip comparison with messages that are too short
310
+ if (window[i].content.length < MIN_CONTENT_LENGTH) continue;
311
+
312
+ const similarity = calculateSimilarity(lastMsg.content, window[i].content);
313
+
314
+ if (similarity >= config.similarityThreshold) {
315
+ detections.push({
316
+ type: "text",
317
+ similarity,
318
+ messageIndices: [windowStart + i, messages.length - 1],
319
+ description: `Text similarity ${(similarity * 100).toFixed(0)}% with message ${windowStart + i + 1}`,
320
+ timestamp: Date.now(),
321
+ });
322
+ }
323
+ }
324
+ }
325
+
326
+ // Structural pattern detection (opening phrases)
327
+ if (window.length >= 3) {
328
+ // Only check messages with sufficient content
329
+ const validOpenings = window
330
+ .map((m, idx) => ({ opening: getOpeningPhrase(m.content), idx }))
331
+ .filter(o => o.opening.length >= 20);
332
+
333
+ if (validOpenings.length >= 3) {
334
+ const lastOpening = validOpenings[validOpenings.length - 1].opening;
335
+ let matchCount = 0;
336
+ for (let i = 0; i < validOpenings.length - 1; i++) {
337
+ if (calculateSimilarity(lastOpening, validOpenings[i].opening) > 0.8) {
338
+ matchCount++;
339
+ }
340
+ }
341
+ if (matchCount >= 2) {
342
+ detections.push({
343
+ type: "structural",
344
+ similarity: 0.9,
345
+ messageIndices: [messages.length - 1],
346
+ description: `Repeated opening pattern detected (${matchCount + 1} similar starts)`,
347
+ timestamp: Date.now(),
348
+ });
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ // Strategy 2: Tool call loop detection
355
+ if (config.detectToolLoops) {
356
+ const lastMsg = window[window.length - 1];
357
+ if (lastMsg.toolCalls && lastMsg.toolCalls.length > 0) {
358
+ for (let i = 0; i < window.length - 1; i++) {
359
+ if (window[i].toolCalls && areToolCallsSimilar(lastMsg.toolCalls, window[i].toolCalls)) {
360
+ detections.push({
361
+ type: "tool",
362
+ similarity: 1.0,
363
+ messageIndices: [windowStart + i, messages.length - 1],
364
+ description: `Same tool calls repeated: ${lastMsg.toolCalls.map(t => t.name).join(", ")}`,
365
+ timestamp: Date.now(),
366
+ });
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ // Strategy 3: Thinking loop detection
373
+ if (config.detectThinkingLoops) {
374
+ const lastMsg = window[window.length - 1];
375
+ if (lastMsg.thinking && lastMsg.thinking.length > 50) {
376
+ for (let i = 0; i < window.length - 1; i++) {
377
+ if (window[i].thinking && window[i].thinking.length > 50) {
378
+ const similarity = calculateSimilarity(lastMsg.thinking, window[i].thinking);
379
+ if (similarity >= config.similarityThreshold) {
380
+ detections.push({
381
+ type: "thinking",
382
+ similarity,
383
+ messageIndices: [windowStart + i, messages.length - 1],
384
+ description: `Thinking content similarity ${(similarity * 100).toFixed(0)}%`,
385
+ timestamp: Date.now(),
386
+ });
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ return detections;
394
+ }
395
+
396
+ /**
397
+ * Generate intervention message based on detection level
398
+ */
399
+ function getInterventionMessage(
400
+ level: number,
401
+ detections: LoopDetection[],
402
+ config: AntiloopConfig
403
+ ): string {
404
+ const detectionSummary = detections
405
+ .map(d => `- ${d.description}`)
406
+ .join("\n");
407
+
408
+ switch (level) {
409
+ case 1: // Warning
410
+ return [
411
+ "[antiloop] ⚠️ LOOP WARNING: I notice I may be repeating myself.",
412
+ "Detected patterns:",
413
+ detectionSummary,
414
+ "",
415
+ "Please vary my approach and try a different strategy.",
416
+ "Consider: alternative algorithms, different file locations, new angles of analysis.",
417
+ ].join("\n");
418
+
419
+ case 2: // Force break
420
+ return [
421
+ "[antiloop] 🛑 LOOP DETECTED: I am stuck in a reasoning loop.",
422
+ "Detected patterns:",
423
+ detectionSummary,
424
+ "",
425
+ "MANDATORY: I must immediately stop my current approach and try something completely different.",
426
+ "Required actions:",
427
+ "1. Stop the current line of reasoning entirely",
428
+ "2. Consider what assumptions I've been making",
429
+ "3. Try an alternative approach or ask the user for guidance",
430
+ "4. Do NOT repeat any previous tool calls or reasoning patterns",
431
+ ].join("\n");
432
+
433
+ case 3: // Abort
434
+ return [
435
+ "[antiloop] 🚨 LOOP ABORT: Persistent loop detected despite interventions.",
436
+ "Detected patterns:",
437
+ detectionSummary,
438
+ "",
439
+ "The agent is unable to break out of this loop automatically.",
440
+ "User intervention required. Please provide new instructions or context.",
441
+ ].join("\n");
442
+
443
+ default:
444
+ return "";
445
+ }
446
+ }
447
+
448
+ // ─── Extension ──────────────────────────────────────────────────────────────
449
+
450
+ export default function antiloopExtension(pi: ExtensionAPI) {
451
+ let config: AntiloopConfig = loadConfig();
452
+
453
+ const state: AntiloopState = {
454
+ recentMessages: [],
455
+ detections: [],
456
+ currentLevel: 0,
457
+ consecutiveDetections: 0,
458
+ inForcedBreak: false,
459
+ totalDetections: 0,
460
+ lastUserMessageTime: 0,
461
+ };
462
+
463
+ let pendingIntervention: string | null = null;
464
+
465
+ // ─── Core: process detections and determine intervention ────────────────
466
+
467
+ function processDetections(detections: LoopDetection[]): void {
468
+ if (detections.length === 0) {
469
+ // No loops detected, reset consecutive count
470
+ if (state.consecutiveDetections > 0) {
471
+ state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
472
+ }
473
+ if (state.currentLevel > 0 && state.consecutiveDetections === 0) {
474
+ state.currentLevel = 0;
475
+ state.inForcedBreak = false;
476
+ }
477
+ return;
478
+ }
479
+
480
+ state.consecutiveDetections++;
481
+ state.totalDetections++;
482
+
483
+ // Add to history
484
+ state.detections.push(...detections);
485
+ if (state.detections.length > config.maxHistoryEntries) {
486
+ state.detections = state.detections.slice(-config.maxHistoryEntries);
487
+ }
488
+
489
+ // Determine intervention level
490
+ let newLevel = 0;
491
+ if (state.consecutiveDetections >= config.abortThreshold && config.abortThreshold > 0) {
492
+ newLevel = 3;
493
+ } else if (state.consecutiveDetections >= config.forceBreakThreshold) {
494
+ newLevel = 2;
495
+ } else if (state.consecutiveDetections >= config.warningThreshold) {
496
+ newLevel = 1;
497
+ }
498
+
499
+ // Only escalate, never de-escalate automatically
500
+ if (newLevel > state.currentLevel) {
501
+ state.currentLevel = newLevel;
502
+ }
503
+
504
+ // Generate intervention message if needed
505
+ if (state.currentLevel > 0) {
506
+ pendingIntervention = getInterventionMessage(state.currentLevel, detections, config);
507
+ state.inForcedBreak = state.currentLevel >= 2;
508
+ }
509
+ }
510
+
511
+ // ─── Hook 1: Track assistant messages ───────────────────────────────────
512
+
513
+ pi.on("message_end", async (event, ctx) => {
514
+ if (!config.enabled) return;
515
+
516
+ const msg = event.message;
517
+ if (msg.role !== "assistant") return;
518
+
519
+ // Extract message content
520
+ let content = "";
521
+ let thinking = "";
522
+
523
+ if (typeof msg.content === "string") {
524
+ content = msg.content;
525
+ } else if (Array.isArray(msg.content)) {
526
+ for (const part of msg.content) {
527
+ if (part.type === "text") {
528
+ content += part.text;
529
+ } else if (part.type === "thinking") {
530
+ thinking += part.thinking;
531
+ }
532
+ }
533
+ }
534
+
535
+ // Extract tool calls
536
+ const toolCalls: Array<{ name: string; args: string }> = [];
537
+ if (Array.isArray(msg.content)) {
538
+ for (const part of msg.content) {
539
+ if (part.type === "toolCall") {
540
+ toolCalls.push({
541
+ name: part.name,
542
+ args: JSON.stringify(part.arguments ?? {}),
543
+ });
544
+ }
545
+ }
546
+ }
547
+
548
+ // Add to recent messages (skip empty or very short messages)
549
+ if (content.length >= MIN_CONTENT_LENGTH || toolCalls.length > 0) {
550
+ state.recentMessages.push({
551
+ content,
552
+ thinking: thinking || undefined,
553
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
554
+ timestamp: Date.now(),
555
+ turnIndex: state.recentMessages.length,
556
+ });
557
+ }
558
+
559
+ // Trim to window size + buffer
560
+ if (state.recentMessages.length > config.detectionWindow + 5) {
561
+ state.recentMessages = state.recentMessages.slice(-(config.detectionWindow + 5));
562
+ }
563
+
564
+ // Run detection
565
+ const detections = detectLoops(state, config);
566
+ processDetections(detections);
567
+
568
+ // Notify if configured
569
+ if (config.notifyOnDetection && detections.length > 0 && state.currentLevel > 0) {
570
+ const levelNames = ["", "warning", "force break", "abort"];
571
+ ctx.ui.notify(
572
+ `🔄 antiloop: ${levelNames[state.currentLevel]} — ${detections[0].description}`,
573
+ state.currentLevel >= 2 ? "error" : "warning"
574
+ );
575
+ }
576
+ });
577
+
578
+ // ─── Hook 2: Track user messages (resets loop state) ────────────────────
579
+
580
+ pi.on("input", async (event, ctx) => {
581
+ if (!config.enabled) return;
582
+
583
+ // User input resets the loop detection
584
+ state.lastUserMessageTime = Date.now();
585
+
586
+ // Don't reset completely, but reduce consecutive count
587
+ if (state.consecutiveDetections > 0) {
588
+ state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 2);
589
+ }
590
+ if (state.consecutiveDetections < config.warningThreshold) {
591
+ state.currentLevel = 0;
592
+ state.inForcedBreak = false;
593
+ }
594
+
595
+ return { action: "continue" };
596
+ });
597
+
598
+ // ─── Hook 3: Inject intervention before agent starts ────────────────────
599
+
600
+ pi.on("before_agent_start", async (event, ctx) => {
601
+ if (!config.enabled) return;
602
+ if (!pendingIntervention) return;
603
+
604
+ const msg = pendingIntervention;
605
+ pendingIntervention = null;
606
+
607
+ return {
608
+ message: {
609
+ customType: "antiloop-intervention",
610
+ content: msg,
611
+ display: true,
612
+ },
613
+ };
614
+ });
615
+
616
+ // ─── Hook 4: Context modification for persistent anti-loop instructions ──
617
+
618
+ pi.on("context", async (event, ctx) => {
619
+ if (!config.enabled) return;
620
+ if (state.currentLevel < 2) return;
621
+
622
+ // When in force-break mode, add anti-loop instructions to context
623
+ const messages = [...event.messages];
624
+
625
+ // Find the last assistant message and append instructions
626
+ for (let i = messages.length - 1; i >= 0; i--) {
627
+ if (messages[i].role === "assistant") {
628
+ const msg = messages[i] as any;
629
+ if (typeof msg.content === "string") {
630
+ msg.content += "\n\n[antiloop] I must break out of this loop. Trying a completely different approach.";
631
+ } else if (Array.isArray(msg.content)) {
632
+ msg.content.push({
633
+ type: "text",
634
+ text: "\n\n[antiloop] I must break out of this loop. Trying a completely different approach.",
635
+ });
636
+ }
637
+ break;
638
+ }
639
+ }
640
+
641
+ return { messages };
642
+ });
643
+
644
+ // ─── Hook 5: Track turns for timing ────────────────────────────────────
645
+
646
+ pi.on("turn_end", async (event, ctx) => {
647
+ if (!config.enabled) return;
648
+
649
+ // Update status bar
650
+ updateStatus(ctx);
651
+ });
652
+
653
+ // ─── Status bar ─────────────────────────────────────────────────────────
654
+
655
+ function updateStatus(ctx: ExtensionContext) {
656
+ if (!config.enabled) {
657
+ ctx.ui.setStatus("antiloop", undefined);
658
+ return;
659
+ }
660
+
661
+ if (state.currentLevel === 0) {
662
+ ctx.ui.setStatus("antiloop", "🔄 antiloop");
663
+ } else {
664
+ const levelIcons = ["", "⚠️", "🛑", "🚨"];
665
+ ctx.ui.setStatus(
666
+ "antiloop",
667
+ `${levelIcons[state.currentLevel]} antiloop(${state.consecutiveDetections})`
668
+ );
669
+ }
670
+ }
671
+
672
+ // ═══════════════════════════════════════════════════════════════════════
673
+ // COMMANDS
674
+ // ═══════════════════════════════════════════════════════════════════════
675
+
676
+ // ─── /antiloop [subcommand] ──────────────────────────────────────────────
677
+
678
+ pi.registerCommand("antiloop", {
679
+ description: "Antiloop: detect and break reasoning loops",
680
+ getArgumentCompletions: (prefix) => {
681
+ const subs = ["enable", "disable", "status", "config", "log", "reset", "test"];
682
+ return subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
683
+ },
684
+ handler: async (args, ctx) => {
685
+ const sub = args?.trim().toLowerCase() ?? "";
686
+ switch (sub) {
687
+ case "enable":
688
+ config.enabled = true;
689
+ saveConfig(config);
690
+ ctx.ui.notify("🔄 antiloop: ENABLED", "info");
691
+ updateStatus(ctx);
692
+ break;
693
+ case "disable":
694
+ config.enabled = false;
695
+ saveConfig(config);
696
+ ctx.ui.notify("🔄 antiloop: DISABLED", "info");
697
+ updateStatus(ctx);
698
+ break;
699
+ case "status":
700
+ await showStatus(ctx);
701
+ break;
702
+ case "config":
703
+ await showConfigMenu(pi, ctx);
704
+ break;
705
+ case "log":
706
+ await showDetectionLog(ctx);
707
+ break;
708
+ case "reset":
709
+ resetState();
710
+ ctx.ui.notify("🔄 antiloop: All counters and history reset", "info");
711
+ updateStatus(ctx);
712
+ break;
713
+ case "test":
714
+ await runSelfTest(ctx);
715
+ break;
716
+ default:
717
+ config.enabled = !config.enabled;
718
+ saveConfig(config);
719
+ ctx.ui.notify(`🔄 antiloop: ${config.enabled ? "ENABLED" : "DISABLED"}`, "info");
720
+ updateStatus(ctx);
721
+ break;
722
+ }
723
+ },
724
+ });
725
+
726
+ // ─── /antiloop status ────────────────────────────────────────────────────
727
+
728
+ async function showStatus(ctx: ExtensionContext) {
729
+ const levelNames = ["none", "warning", "force break", "abort"];
730
+ const recentDetections = state.detections.slice(-5);
731
+
732
+ const lines = [
733
+ `State: ${config.enabled ? "✅ ENABLED" : "❌ DISABLED"}`,
734
+ `Current level: ${levelNames[state.currentLevel]}`,
735
+ `Consecutive detections: ${state.consecutiveDetections}`,
736
+ `Total detections: ${state.totalDetections}`,
737
+ `Messages tracked: ${state.recentMessages.length}`,
738
+ `In forced break: ${state.inForcedBreak ? "yes" : "no"}`,
739
+ "",
740
+ "Configuration:",
741
+ ` Warning threshold: ${config.warningThreshold} similar messages`,
742
+ ` Force break threshold: ${config.forceBreakThreshold} similar messages`,
743
+ ` Abort threshold: ${config.abortThreshold > 0 ? config.abortThreshold : "disabled"}`,
744
+ ` Similarity threshold: ${(config.similarityThreshold * 100).toFixed(0)}%`,
745
+ ` Detection window: ${config.detectionWindow} messages`,
746
+ "",
747
+ "Detection strategies:",
748
+ ` Text loops: ${config.detectTextLoops ? "✅" : "❌"}`,
749
+ ` Tool loops: ${config.detectToolLoops ? "✅" : "❌"}`,
750
+ ` Thinking loops: ${config.detectThinkingLoops ? "✅" : "❌"}`,
751
+ ];
752
+
753
+ if (recentDetections.length > 0) {
754
+ lines.push("", "Recent detections:");
755
+ for (const d of recentDetections) {
756
+ lines.push(` [${d.type}] ${d.description} (${formatDuration(Date.now() - d.timestamp)} ago)`);
757
+ }
758
+ }
759
+
760
+ ctx.ui.notify(lines.join("\n"), "info");
761
+ }
762
+
763
+ // ─── /antiloop config ────────────────────────────────────────────────────
764
+
765
+ async function showConfigMenu(pi: ExtensionAPI, ctx: ExtensionContext) {
766
+ const enabledLabel = config.enabled ? "🟢 Disable antiloop" : "🔴 Enable antiloop";
767
+
768
+ const action = await selectFrom(ctx, "🔄 Antiloop Config", [
769
+ { value: "toggle", label: enabledLabel, description: `Currently: ${config.enabled ? "enabled" : "disabled"}` },
770
+ { value: "warning", label: `⚠️ Warning threshold: ${config.warningThreshold}`, description: "Similar messages before warning" },
771
+ { value: "force", label: `🛑 Force break threshold: ${config.forceBreakThreshold}`, description: "Similar messages before force break" },
772
+ { value: "abort", label: `🚨 Abort threshold: ${config.abortThreshold > 0 ? config.abortThreshold : "disabled"}`, description: "Similar messages before abort (0=disabled)" },
773
+ { value: "similarity", label: `📊 Similarity: ${(config.similarityThreshold * 100).toFixed(0)}%`, description: "How similar messages must be to count as looping" },
774
+ { value: "window", label: `🪟 Detection window: ${config.detectionWindow}`, description: "Number of recent messages to analyze" },
775
+ { value: "text", label: `📝 Text detection: ${config.detectTextLoops ? "on" : "off"}`, description: "Detect text repetition loops" },
776
+ { value: "tool", label: `🔧 Tool detection: ${config.detectToolLoops ? "on" : "off"}`, description: "Detect tool call loops" },
777
+ { value: "thinking", label: `🧠 Thinking detection: ${config.detectThinkingLoops ? "on" : "off"}`, description: "Detect thinking/reasoning loops" },
778
+ { value: "notify", label: `🔔 Notifications: ${config.notifyOnDetection ? "on" : "off"}`, description: "Show notifications on detection" },
779
+ { value: "reset", label: "🔃 Reset state", description: "Clear all counters and history" },
780
+ ]);
781
+
782
+ if (!action) return;
783
+
784
+ switch (action) {
785
+ case "toggle":
786
+ config.enabled = !config.enabled;
787
+ saveConfig(config);
788
+ ctx.ui.notify(`antiloop: ${config.enabled ? "ENABLED" : "DISABLED"}`, "info");
789
+ updateStatus(ctx);
790
+ break;
791
+
792
+ case "warning": {
793
+ const picked = await selectFrom(ctx, "Warning threshold", [
794
+ { value: 1, label: "1 (very sensitive)" },
795
+ { value: 2, label: "2 (default)" },
796
+ { value: 3, label: "3" },
797
+ { value: 5, label: "5 (less sensitive)" },
798
+ ]);
799
+ if (picked !== undefined) {
800
+ config.warningThreshold = picked;
801
+ saveConfig(config);
802
+ ctx.ui.notify(`Warning threshold set to ${picked}`, "info");
803
+ }
804
+ break;
805
+ }
806
+
807
+ case "force": {
808
+ const picked = await selectFrom(ctx, "Force break threshold", [
809
+ { value: 2, label: "2 (very sensitive)" },
810
+ { value: 3, label: "3 (default)" },
811
+ { value: 5, label: "5" },
812
+ { value: 8, label: "8 (less sensitive)" },
813
+ ]);
814
+ if (picked !== undefined) {
815
+ config.forceBreakThreshold = picked;
816
+ saveConfig(config);
817
+ ctx.ui.notify(`Force break threshold set to ${picked}`, "info");
818
+ }
819
+ break;
820
+ }
821
+
822
+ case "abort": {
823
+ const picked = await selectFrom(ctx, "Abort threshold (0=disabled)", [
824
+ { value: 0, label: "0 (disabled)" },
825
+ { value: 5, label: "5" },
826
+ { value: 8, label: "8" },
827
+ { value: 10, label: "10" },
828
+ { value: 15, label: "15" },
829
+ ]);
830
+ if (picked !== undefined) {
831
+ config.abortThreshold = picked;
832
+ saveConfig(config);
833
+ ctx.ui.notify(`Abort threshold set to ${picked > 0 ? picked : "disabled"}`, "info");
834
+ }
835
+ break;
836
+ }
837
+
838
+ case "similarity": {
839
+ const picked = await selectFrom(ctx, "Similarity threshold", [
840
+ { value: 0.5, label: "50% (very sensitive)" },
841
+ { value: 0.6, label: "60%" },
842
+ { value: 0.7, label: "70%" },
843
+ { value: 0.75, label: "75% (default)" },
844
+ { value: 0.8, label: "80%" },
845
+ { value: 0.9, label: "90% (less sensitive)" },
846
+ ]);
847
+ if (picked !== undefined) {
848
+ config.similarityThreshold = picked;
849
+ saveConfig(config);
850
+ ctx.ui.notify(`Similarity threshold set to ${(picked * 100).toFixed(0)}%`, "info");
851
+ }
852
+ break;
853
+ }
854
+
855
+ case "window": {
856
+ const picked = await selectFrom(ctx, "Detection window", [
857
+ { value: 5, label: "5 messages" },
858
+ { value: 10, label: "10 messages (default)" },
859
+ { value: 15, label: "15 messages" },
860
+ { value: 20, label: "20 messages" },
861
+ ]);
862
+ if (picked !== undefined) {
863
+ config.detectionWindow = picked;
864
+ saveConfig(config);
865
+ ctx.ui.notify(`Detection window set to ${picked} messages`, "info");
866
+ }
867
+ break;
868
+ }
869
+
870
+ case "text":
871
+ config.detectTextLoops = !config.detectTextLoops;
872
+ saveConfig(config);
873
+ ctx.ui.notify(`Text detection: ${config.detectTextLoops ? "ON" : "OFF"}`, "info");
874
+ break;
875
+
876
+ case "tool":
877
+ config.detectToolLoops = !config.detectToolLoops;
878
+ saveConfig(config);
879
+ ctx.ui.notify(`Tool detection: ${config.detectToolLoops ? "ON" : "OFF"}`, "info");
880
+ break;
881
+
882
+ case "thinking":
883
+ config.detectThinkingLoops = !config.detectThinkingLoops;
884
+ saveConfig(config);
885
+ ctx.ui.notify(`Thinking detection: ${config.detectThinkingLoops ? "ON" : "OFF"}`, "info");
886
+ break;
887
+
888
+ case "notify":
889
+ config.notifyOnDetection = !config.notifyOnDetection;
890
+ saveConfig(config);
891
+ ctx.ui.notify(`Notifications: ${config.notifyOnDetection ? "ON" : "OFF"}`, "info");
892
+ break;
893
+
894
+ case "reset":
895
+ resetState();
896
+ ctx.ui.notify("State reset", "info");
897
+ updateStatus(ctx);
898
+ break;
899
+ }
900
+ }
901
+
902
+ // ─── /antiloop log ───────────────────────────────────────────────────────
903
+
904
+ async function showDetectionLog(ctx: ExtensionContext) {
905
+ if (state.detections.length === 0) {
906
+ ctx.ui.notify("No loop detections recorded this session", "info");
907
+ return;
908
+ }
909
+
910
+ const items = state.detections.slice(-30).reverse().map((d) => ({
911
+ value: "",
912
+ label: `[${d.type}] ${d.description}`,
913
+ description: `${(d.similarity * 100).toFixed(0)}% similar · ${formatDuration(Date.now() - d.timestamp)} ago`,
914
+ }));
915
+
916
+ await selectFrom(ctx, `🔄 Detection log (${state.detections.length} total)`, items);
917
+ }
918
+
919
+ // ─── /antiloop reset ─────────────────────────────────────────────────────
920
+
921
+ function resetState(): void {
922
+ state.recentMessages = [];
923
+ state.detections = [];
924
+ state.currentLevel = 0;
925
+ state.consecutiveDetections = 0;
926
+ state.inForcedBreak = false;
927
+ state.totalDetections = 0;
928
+ pendingIntervention = null;
929
+ }
930
+
931
+ // ─── /antiloop test ──────────────────────────────────────────────────────
932
+
933
+ async function runSelfTest(ctx: ExtensionContext) {
934
+ ctx.ui.notify("🧪 Running antiloop self-test...", "info");
935
+
936
+ const testCases: Array<{ a: string; b: string; expected: string }> = [
937
+ { a: "Hello world", b: "Hello world", expected: "identical" },
938
+ { a: "Hello world", b: "Hello World!", expected: "very similar" },
939
+ { a: "The quick brown fox", b: "The quick brown fox jumps over the lazy dog", expected: "similar" },
940
+ { a: "Hello world", b: "Goodbye universe", expected: "different" },
941
+ { a: "I will read the file first", b: "I will read the file first to understand", expected: "similar" },
942
+ ];
943
+
944
+ const results: string[] = [];
945
+ for (const tc of testCases) {
946
+ const similarity = calculateSimilarity(tc.a, tc.b);
947
+ const normalized = normalizeText(tc.a);
948
+ const normalizedB = normalizeText(tc.b);
949
+ results.push(
950
+ `"${tc.a}" vs "${tc.b}"\n Similarity: ${(similarity * 100).toFixed(1)}% (expected: ${tc.expected})`
951
+ );
952
+ }
953
+
954
+ // Test tool call detection
955
+ const toolCalls1 = [{ name: "read", args: '{"path":"/test"}' }];
956
+ const toolCalls2 = [{ name: "read", args: '{"path":"/test"}' }];
957
+ const toolCalls3 = [{ name: "write", args: '{"path":"/other"}' }];
958
+
959
+ results.push(
960
+ `\nTool call tests:`,
961
+ ` Same calls: ${areToolCallsSimilar(toolCalls1, toolCalls2)} (expected: true)`,
962
+ ` Different calls: ${areToolCallsSimilar(toolCalls1, toolCalls3)} (expected: false)`,
963
+ );
964
+
965
+ ctx.ui.notify(`🧪 Self-test results:\n${results.join("\n")}`, "info");
966
+ }
967
+
968
+ // ─── Session lifecycle ──────────────────────────────────────────────────
969
+
970
+ pi.on("session_start", async (_event, ctx) => {
971
+ config = loadConfig();
972
+ resetState();
973
+ updateStatus(ctx);
974
+ });
975
+ }