pi-antiloop 1.0.0 → 1.0.1

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,6 +1,6 @@
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
 
@@ -233,9 +233,8 @@ antiloop/
233
233
  ├── LICENSE # MIT
234
234
  ├── README.md
235
235
  ├── docs/
236
- │ ├── banner.png # wide README header
237
- │ └── preview.png # npm pi.dev preview card
238
- ├── screenshot.png # full-res master
236
+ │ ├── banner.jpeg # wide README header
237
+ │ └── preview.jpeg # npm pi.dev preview card
239
238
  └── src/
240
239
  └── index.ts # full extension (≈975 lines)
241
240
  ```
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.0.1",
4
+ "description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns.",
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,216 @@
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
+ "",
61
+ `detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
62
+ ];
63
+ if (recent.length) {
64
+ lines.push("", "recent:");
65
+ for (const d of recent) lines.push(` [${d.type}] ${d.description} · ${formatDuration(Date.now() - d.timestamp)} ago`);
66
+ }
67
+ ctx.ui.notify(lines.join("\n"), "info");
68
+ }
69
+
70
+ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
71
+ const c = rt.config;
72
+ const picked = await selectFrom(ctx, "antiloop config", [
73
+ { value: "toggle" as const, label: c.enabled ? "🟢 disable" : "🔴 enable", description: "toggle detection" },
74
+ { value: "warn" as const, label: `warn threshold: ${c.warningThreshold}` },
75
+ { value: "force" as const, label: `force threshold: ${c.forceBreakThreshold}` },
76
+ { value: "abort" as const, label: `abort threshold: ${c.abortThreshold || "off"}`, description: "0 = disabled" },
77
+ { value: "sim" as const, label: `similarity: ${(c.similarityThreshold * 100).toFixed(0)}%` },
78
+ { value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
79
+ { value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
80
+ { value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
81
+ { value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
82
+ { value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
83
+ { value: "reset" as const, label: "reset state" },
84
+ ]);
85
+ if (!picked) return;
86
+ switch (picked) {
87
+ case "toggle":
88
+ c.enabled = !c.enabled;
89
+ saveConfig(c);
90
+ ctx.ui.notify(`antiloop: ${c.enabled ? "ON" : "OFF"}`, "info");
91
+ rt.updateStatus(ctx);
92
+ break;
93
+ case "warn": {
94
+ const v = await selectFrom(ctx, "warn threshold", [
95
+ { value: 1, label: "1 (sensitive)" },
96
+ { value: 2, label: "2 (default)" },
97
+ { value: 3, label: "3" },
98
+ { value: 5, label: "5 (relaxed)" },
99
+ ]);
100
+ if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`warn: ${v}`, "info"); }
101
+ break;
102
+ }
103
+ case "force": {
104
+ const v = await selectFrom(ctx, "force threshold", [
105
+ { value: 2, label: "2 (sensitive)" },
106
+ { value: 3, label: "3 (default)" },
107
+ { value: 5, label: "5" },
108
+ { value: 8, label: "8 (relaxed)" },
109
+ ]);
110
+ if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`force: ${v}`, "info"); }
111
+ break;
112
+ }
113
+ case "abort": {
114
+ const v = await selectFrom(ctx, "abort threshold (0=off)", [
115
+ { value: 0, label: "off" },
116
+ { value: 5, label: "5" },
117
+ { value: 8, label: "8" },
118
+ { value: 10, label: "10" },
119
+ { value: 15, label: "15" },
120
+ ]);
121
+ if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`abort: ${v || "off"}`, "info"); }
122
+ break;
123
+ }
124
+ case "sim": {
125
+ const v = await selectFrom(ctx, "similarity", [
126
+ { value: 0.5, label: "50% (sensitive)" },
127
+ { value: 0.6, label: "60%" },
128
+ { value: 0.7, label: "70%" },
129
+ { value: 0.75, label: "75% (default)" },
130
+ { value: 0.8, label: "80%" },
131
+ { value: 0.9, label: "90% (relaxed)" },
132
+ ]);
133
+ if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
134
+ break;
135
+ }
136
+ case "window": {
137
+ const v = await selectFrom(ctx, "window", [
138
+ { value: 5, label: "5" },
139
+ { value: 10, label: "10 (default)" },
140
+ { value: 15, label: "15" },
141
+ { value: 20, label: "20" },
142
+ ]);
143
+ if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
144
+ break;
145
+ }
146
+ case "text":
147
+ c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
148
+ ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
149
+ case "tool":
150
+ c.detectToolLoops = !c.detectToolLoops; saveConfig(c);
151
+ ctx.ui.notify(`tool: ${yn(c.detectToolLoops)}`, "info"); break;
152
+ case "think":
153
+ c.detectThinkingLoops = !c.detectThinkingLoops; saveConfig(c);
154
+ ctx.ui.notify(`think: ${yn(c.detectThinkingLoops)}`, "info"); break;
155
+ case "notify":
156
+ c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
157
+ ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
158
+ case "reset":
159
+ resetState(rt.state);
160
+ rt.pendingIntervention = null;
161
+ ctx.ui.notify("reset", "info");
162
+ rt.updateStatus(ctx);
163
+ break;
164
+ }
165
+ }
166
+
167
+ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
168
+ if (!rt.state.detections.length) {
169
+ ctx.ui.notify("no detections this session", "info");
170
+ return;
171
+ }
172
+ const items = rt.state.detections.slice(-30).reverse().map((d) => ({
173
+ value: "" as const,
174
+ label: `[${d.type}] ${d.description}`,
175
+ description: `${(d.similarity * 100).toFixed(0)}% · ${formatDuration(Date.now() - d.timestamp)} ago`,
176
+ }));
177
+ await selectFrom(ctx, `detections (${rt.state.detections.length} total)`, items);
178
+ }
179
+
180
+ export function resetState(state: AntiloopState): void {
181
+ state.recentMessages = [];
182
+ state.detections = [];
183
+ state.currentLevel = 0;
184
+ state.consecutiveDetections = 0;
185
+ state.inForcedBreak = false;
186
+ state.totalDetections = 0;
187
+ }
188
+
189
+ async function runSelfTest(ctx: ExtensionCommandContext): Promise<void> {
190
+ // Inline similarity + normalize (mirrors detect.ts). Keeps test self-contained
191
+ // without re-importing private helpers.
192
+ const norm = (t: string) => t.toLowerCase().replace(/\s+/g, " ").replace(/[^\w\s]/g, "").trim();
193
+ const cases: Array<{ a: string; b: string; expect: string }> = [
194
+ { a: "Hello world", b: "Hello world", expect: "1.00" },
195
+ { a: "Hello world", b: "Hello World!", expect: "high" },
196
+ { a: "The quick brown fox", b: "The quick brown fox jumps over the lazy dog", expect: "high" },
197
+ { a: "Hello world", b: "Goodbye universe", expect: "low" },
198
+ { a: "I will read the file first", b: "I will read the file first to understand", expect: "high" },
199
+ ];
200
+ const out: string[] = [];
201
+ for (const c of cases) {
202
+ // Re-implement minimal Levenshtein similarity for the self-test
203
+ const a = norm(c.a), b = norm(c.b);
204
+ const max = Math.max(a.length, b.length);
205
+ let diff = 0;
206
+ for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) diff++;
207
+ diff += Math.abs(a.length - b.length);
208
+ const s = max ? 1 - diff / max : 1;
209
+ out.push(`"${c.a}" vs "${c.b}" → ${(s * 100).toFixed(0)}% (exp: ${c.expect})`);
210
+ }
211
+ ctx.ui.notify(`antiloop self-test\n${out.join("\n")}`, "info");
212
+ }
213
+
214
+ function yn(b: boolean): string {
215
+ return b ? "on" : "off";
216
+ }
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
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
+ detectToolLoops: true,
17
+ detectThinkingLoops: true,
18
+ detectTextLoops: true,
19
+ notifyOnDetection: true,
20
+ maxHistoryEntries: 100,
21
+ detectionWindow: 10,
22
+ };
23
+
24
+ export function getConfigPath(): string {
25
+ return join(getAgentDir(), CONFIG_FILE);
26
+ }
27
+
28
+ export function loadConfig(): AntiloopConfig {
29
+ const p = getConfigPath();
30
+ if (existsSync(p)) {
31
+ try {
32
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(p, "utf-8")) };
33
+ } catch (e) {
34
+ console.error(`[antiloop] config load error: ${e}`);
35
+ }
36
+ }
37
+ return { ...DEFAULT_CONFIG };
38
+ }
39
+
40
+ export function saveConfig(config: AntiloopConfig): void {
41
+ try {
42
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
43
+ } catch (e) {
44
+ console.error(`[antiloop] config save error: ${e}`);
45
+ }
46
+ }
package/src/detect.ts ADDED
@@ -0,0 +1,167 @@
1
+ // antiloop — similarity + detection engine. Lazy-loaded on first message_end.
2
+
3
+ import type { AntiloopConfig, AntiloopState, LoopDetection } from "./types.ts";
4
+
5
+ const MIN_CONTENT_LENGTH = 50;
6
+
7
+ function normalizeText(t: string): string {
8
+ return t.toLowerCase().replace(/\s+/g, " ").replace(/[^\w\s]/g, "").trim();
9
+ }
10
+
11
+ function levenshtein(a: string, b: string): number {
12
+ if (!a.length) return b.length;
13
+ if (!b.length) return a.length;
14
+ const m: number[][] = [];
15
+ for (let i = 0; i <= b.length; i++) m[i] = [i];
16
+ for (let j = 0; j <= a.length; j++) m[0][j] = j;
17
+ for (let i = 1; i <= b.length; i++) {
18
+ for (let j = 1; j <= a.length; j++) {
19
+ m[i][j] = b.charAt(i - 1) === a.charAt(j - 1)
20
+ ? m[i - 1][j - 1]
21
+ : Math.min(m[i - 1][j - 1] + 1, m[i][j - 1] + 1, m[i - 1][j] + 1);
22
+ }
23
+ }
24
+ return m[b.length][a.length];
25
+ }
26
+
27
+ function ngrams(text: string, n: number): Set<string> {
28
+ const out = new Set<string>();
29
+ for (let i = 0; i <= text.length - n; i++) out.add(text.substring(i, i + n));
30
+ return out;
31
+ }
32
+
33
+ function opening(text: string, n = 10): string {
34
+ return normalizeText(text.split(/\s+/).slice(0, n).join(" "));
35
+ }
36
+
37
+ function similarity(a: string, b: string): number {
38
+ if (a.length < MIN_CONTENT_LENGTH || b.length < MIN_CONTENT_LENGTH) return 0;
39
+ if (a === b) return 1;
40
+ const na = normalizeText(a);
41
+ const nb = normalizeText(b);
42
+ if (na.length < 20 || nb.length < 20) return 0;
43
+ if (na === nb) return 1;
44
+ if (na.length < 100 && nb.length < 100) {
45
+ const max = Math.max(na.length, nb.length);
46
+ return 1 - levenshtein(na, nb) / max;
47
+ }
48
+ const ga = ngrams(na, 3);
49
+ const gb = ngrams(nb, 3);
50
+ let inter = 0;
51
+ for (const x of ga) if (gb.has(x)) inter++;
52
+ const uni = ga.size + gb.size - inter;
53
+ return inter / uni;
54
+ }
55
+
56
+ function toolCallsSimilar(
57
+ c1: Array<{ name: string; args: string }>,
58
+ c2: Array<{ name: string; args: string }>,
59
+ ): boolean {
60
+ if (c1.length !== c2.length) return false;
61
+ if (!c1.length) return true;
62
+ for (let i = 0; i < c1.length; i++) {
63
+ if (c1[i].name !== c2[i].name) return false;
64
+ if (similarity(c1[i].args, c2[i].args) < 0.8) return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopDetection[] {
70
+ const out: LoopDetection[] = [];
71
+ const msgs = state.recentMessages;
72
+ if (msgs.length < 2) return out;
73
+ const start = Math.max(0, msgs.length - config.detectionWindow);
74
+ const win = msgs.slice(start);
75
+ const now = Date.now();
76
+
77
+ if (config.detectTextLoops) {
78
+ const last = win[win.length - 1];
79
+ if (last.content.length >= MIN_CONTENT_LENGTH) {
80
+ for (let i = 0; i < win.length - 1; i++) {
81
+ if (win[i].content.length < MIN_CONTENT_LENGTH) continue;
82
+ const s = similarity(last.content, win[i].content);
83
+ if (s >= config.similarityThreshold) {
84
+ out.push({
85
+ type: "text",
86
+ similarity: s,
87
+ messageIndices: [start + i, msgs.length - 1],
88
+ description: `text similarity ${(s * 100).toFixed(0)}% with msg ${start + i + 1}`,
89
+ timestamp: now,
90
+ });
91
+ }
92
+ }
93
+ }
94
+ if (win.length >= 3) {
95
+ const opens = win.map((m, idx) => ({ o: opening(m.content), idx }))
96
+ .filter((x) => x.o.length >= 20);
97
+ if (opens.length >= 3) {
98
+ const last = opens[opens.length - 1].o;
99
+ let n = 0;
100
+ for (let i = 0; i < opens.length - 1; i++) {
101
+ if (similarity(last, opens[i].o) > 0.8) n++;
102
+ }
103
+ if (n >= 2) {
104
+ out.push({
105
+ type: "structural",
106
+ similarity: 0.9,
107
+ messageIndices: [msgs.length - 1],
108
+ description: `repeated opening (${n + 1} similar starts)`,
109
+ timestamp: now,
110
+ });
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ if (config.detectToolLoops) {
117
+ const last = win[win.length - 1];
118
+ const lastCalls = last.toolCalls;
119
+ if (lastCalls && lastCalls.length) {
120
+ for (let i = 0; i < win.length - 1; i++) {
121
+ const prev = win[i].toolCalls;
122
+ if (prev && toolCallsSimilar(lastCalls, prev)) {
123
+ out.push({
124
+ type: "tool",
125
+ similarity: 1,
126
+ messageIndices: [start + i, msgs.length - 1],
127
+ description: `repeated: ${lastCalls.map((t) => t.name).join(", ")}`,
128
+ timestamp: now,
129
+ });
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (config.detectThinkingLoops) {
136
+ const last = win[win.length - 1];
137
+ if (last.thinking && last.thinking.length > 50) {
138
+ for (let i = 0; i < win.length - 1; i++) {
139
+ if (win[i].thinking && win[i].thinking!.length > 50) {
140
+ const s = similarity(last.thinking, win[i].thinking!);
141
+ if (s >= config.similarityThreshold) {
142
+ out.push({
143
+ type: "thinking",
144
+ similarity: s,
145
+ messageIndices: [start + i, msgs.length - 1],
146
+ description: `thinking similarity ${(s * 100).toFixed(0)}%`,
147
+ timestamp: now,
148
+ });
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ return out;
156
+ }
157
+
158
+ export function interventionMessage(level: 1 | 2 | 3, detections: LoopDetection[]): string {
159
+ const det = detections.map((d) => `- ${d.description}`).join("\n");
160
+ if (level === 1) {
161
+ return `[antiloop] ⚠️ loop warning\n${det}\nvary approach — try a different strategy.`;
162
+ }
163
+ if (level === 2) {
164
+ return `[antiloop] 🛑 stuck in loop\n${det}\nstop, change approach, do NOT repeat previous tool calls or reasoning.`;
165
+ }
166
+ return `[antiloop] 🚨 persistent loop\n${det}\nunable to break automatically — provide new instructions.`;
167
+ }