pi-antiloop 1.3.0 → 1.4.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 +29 -17
- package/package.json +2 -2
- package/src/commands.ts +102 -97
- package/src/config.ts +5 -1
- package/src/detect.ts +12 -11
- package/src/index.ts +8 -30
package/README.md
CHANGED
|
@@ -106,23 +106,35 @@ Recent detections:
|
|
|
106
106
|
|
|
107
107
|
### `/antiloop config`
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
-
|
|
113
|
-
- **
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
- **
|
|
125
|
-
-
|
|
109
|
+
Menú interactivo agrupado, con el valor actual en cada opción:
|
|
110
|
+
|
|
111
|
+
**🎛️ General**
|
|
112
|
+
- **🟢/🔴 activar/desactivar** — enciende o apaga la detección
|
|
113
|
+
- **⏳ ventana** — `5 / 10 / 15 / 20` — cuántos mensajes recientes se analizan (por defecto 10)
|
|
114
|
+
- **🔔 avisos** — on/off — avisa cuando detecta un bucle
|
|
115
|
+
- **📊 pie interactivo** — on/off — experimental: reemplaza el pie de pantalla de pi y captura el teclado para el atajo (si da problemas de rendimiento, déjalo en off)
|
|
116
|
+
- **⌨️ atajo** — `esc+a` o desactivado — pulsación para activar/apagar sin teclear nada
|
|
117
|
+
|
|
118
|
+
**🎯 Detección**
|
|
119
|
+
- **⚠️ umbral de aviso** — `1 / 2 / 3 / 5` — repeticiones antes de avisar (por defecto 2)
|
|
120
|
+
- **🛑 umbral de corte** — `2 / 3 / 5 / 8` — repeticiones antes de forzar un cambio de rumbo (por defecto 3)
|
|
121
|
+
- **🚨 umbral de aborto** — `desactivado / 5 / 8 / 10 / 15` — repeticiones antes de abortar (0 = desactivado)
|
|
122
|
+
- **📏 parecido mínimo** — `50 / 60 / 70 / 75 / 80 / 90%` — qué tan parecidos deben ser dos mensajes para contar como bucle (por defecto 75%)
|
|
123
|
+
- **🔧 parecido de llamadas** — `99 / 95 / 90 / 80%` — qué tan idénticas deben ser las llamadas para contar como la misma (por defecto 95%: solo repeticiones casi idénticas son bucle)
|
|
124
|
+
- **🔁 repeticiones de llamada** — `1 / 2 / 3` — cuántas veces se repite la misma llamada antes de marcarla (por defecto 2)
|
|
125
|
+
- **🧾 parecido de resultados** — `95 / 80 / 60%` — qué tan parecidos deben ser los resultados para contar como el *mismo resultado*; si un comando repetido empieza a dar otro resultado, es progreso y no bucle (por defecto 80%)
|
|
126
|
+
|
|
127
|
+
**📋 Lotes de tareas** — el trabajo en lote (punched_log, plan_manager, …) es N tareas de un tipo, no un bucle
|
|
128
|
+
- **📋 lotes de tareas** — on/off — reconoce ese trabajo en lote y se queda en silencio
|
|
129
|
+
- **📋 llamadas mínimas** — `2 / 3 / 4 / 5` — llamadas de la misma herramienta antes de reconocer un lote (por defecto 3)
|
|
130
|
+
- **📋 gemelos** — `99 / 95 / 90%` — llamadas más parecidas que esto cuentan como la *misma tarea* repetida; un gemelo invalida el lote y vuelve a la detección normal (por defecto 99%)
|
|
131
|
+
|
|
132
|
+
**🔍 Detectores**
|
|
133
|
+
- **📝 texto** — on/off — detecta mensajes de texto repetidos
|
|
134
|
+
- **🔧 herramientas** — on/off — detecta llamadas a herramientas repetidas
|
|
135
|
+
- **🧠 pensamiento** — on/off — detecta bloques de razonamiento repetidos
|
|
136
|
+
|
|
137
|
+
**🧹 reiniciar estado** — borra contadores e historial
|
|
126
138
|
|
|
127
139
|
### `/antiloop log`
|
|
128
140
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antiloop",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Antiloop: detect reasoning loops and force a break (warn
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Antiloop: detect reasoning loops and force a break (warn \u2192 force \u2192 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. Task-stream recognition: when an extension (punched, plan, \u2026) makes the model call the SAME tool many times with DIFFERENT content \u2014 N distinct tasks of one type, e.g. appending lines or adding plan tasks \u2014 antiloop stays silent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"antiloop",
|
package/src/commands.ts
CHANGED
|
@@ -76,26 +76,31 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
|
|
|
76
76
|
|
|
77
77
|
async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
78
78
|
const c = rt.config;
|
|
79
|
-
const picked = await selectFrom(ctx, "antiloop config", [
|
|
80
|
-
|
|
81
|
-
{ value: "
|
|
82
|
-
{ value: "
|
|
83
|
-
{ value: "
|
|
84
|
-
{ value: "
|
|
85
|
-
{ value: "
|
|
86
|
-
|
|
87
|
-
{ value: "
|
|
88
|
-
{ value: "
|
|
89
|
-
{ value: "
|
|
90
|
-
{ value: "
|
|
91
|
-
{ value: "
|
|
92
|
-
{ value: "
|
|
93
|
-
{ value: "
|
|
94
|
-
|
|
95
|
-
{ value: "
|
|
96
|
-
{ value: "
|
|
97
|
-
{ value: "
|
|
98
|
-
|
|
79
|
+
const picked = await selectFrom(ctx, "⚙️ antiloop config", [
|
|
80
|
+
// ── 🎛️ General ──────────────────────────────────────────────
|
|
81
|
+
{ value: "toggle" as const, label: c.enabled ? "🟢 desactivar" : "🔴 activar", description: "enciende o apaga la detección" },
|
|
82
|
+
{ value: "window" as const, label: `⏳ ventana: ${c.detectionWindow} msgs`, description: "cuántos mensajes recientes se analizan" },
|
|
83
|
+
{ value: "notify" as const, label: `🔔 avisos: ${yn(c.notifyOnDetection)}`, description: "avisa cuando detecta un bucle" },
|
|
84
|
+
{ value: "footer" as const, label: `📊 pie interactivo: ${yn(c.interactiveFooter)}`, description: "experimental: reemplaza el pie de pi y captura el teclado (si da problemas, déjalo en off)" },
|
|
85
|
+
{ value: "shortcut" as const, label: `⌨️ atajo: ${c.toggleShortcut}`, description: "esc+a o desactivado" },
|
|
86
|
+
// ── 🎯 Detección ────────────────────────────────────────────
|
|
87
|
+
{ value: "warn" as const, label: `⚠️ umbral aviso: ${c.warningThreshold}`, description: "repeticiones antes de avisar" },
|
|
88
|
+
{ value: "force" as const, label: `🛑 umbral corte: ${c.forceBreakThreshold}`, description: "repeticiones antes de forzar un cambio de rumbo" },
|
|
89
|
+
{ value: "abort" as const, label: `🚨 umbral abortar: ${c.abortThreshold || "off"}`, description: "repeticiones antes de abortar (0 = desactivado)" },
|
|
90
|
+
{ value: "sim" as const, label: `📏 parecido mínimo: ${(c.similarityThreshold * 100).toFixed(0)}%`, description: "qué tan parecidos deben ser dos mensajes para contar como bucle" },
|
|
91
|
+
{ value: "toolSim" as const, label: `🔧 parecido de llamadas: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "qué tan idénticas deben ser las llamadas para contar como la misma" },
|
|
92
|
+
{ value: "toolRepeat" as const, label: `🔁 repeticiones de llamada: ${c.minToolRepeatCount}+`, description: "cuántas veces se repite la misma llamada antes de marcarla" },
|
|
93
|
+
{ value: "resultSim" as const, label: `🧾 parecido de resultados: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "mismo comando + resultado distinto = progreso, no bucle" },
|
|
94
|
+
// ── 📋 Task streams ─────────────────────────────────────────
|
|
95
|
+
{ value: "streams" as const, label: `📋 lotes de tareas: ${yn(c.detectTaskStreams)}`, description: "trabajo en lote (punched_log / plan_manager / …) no es un bucle" },
|
|
96
|
+
{ value: "streamMin" as const, label: `📋 llamadas mínimas: ${c.taskStreamMinCalls}`, description: "llamadas de la misma herramienta antes de reconocer un lote" },
|
|
97
|
+
{ value: "streamTwin" as const, label: `📋 gemelos: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "llamadas más parecidas que esto = la misma tarea repetida, no un lote" },
|
|
98
|
+
// ── 🔍 Detectores ───────────────────────────────────────────
|
|
99
|
+
{ value: "text" as const, label: `📝 texto: ${yn(c.detectTextLoops)}`, description: "detecta mensajes de texto repetidos" },
|
|
100
|
+
{ value: "tool" as const, label: `🔧 herramientas: ${yn(c.detectToolLoops)}`, description: "detecta llamadas repetidas a herramientas" },
|
|
101
|
+
{ value: "think" as const, label: `🧠 pensamiento: ${yn(c.detectThinkingLoops)}`, description: "detecta razonamiento interno repetido" },
|
|
102
|
+
// ── 🧹 ──────────────────────────────────────────────────────
|
|
103
|
+
{ value: "reset" as const, label: "🧹 reiniciar estado", description: "borra contadores e historial" },
|
|
99
104
|
]);
|
|
100
105
|
if (!picked) return;
|
|
101
106
|
switch (picked) {
|
|
@@ -105,139 +110,139 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
105
110
|
ctx.ui.notify(`antiloop: ${c.enabled ? "ON" : "OFF"}`, "info");
|
|
106
111
|
rt.updateStatus(ctx);
|
|
107
112
|
break;
|
|
113
|
+
case "window": {
|
|
114
|
+
const v = await selectFrom(ctx, "⏳ ventana (mensajes a analizar)", [
|
|
115
|
+
{ value: 5, label: "5" },
|
|
116
|
+
{ value: 10, label: "10 (por defecto)" },
|
|
117
|
+
{ value: 15, label: "15" },
|
|
118
|
+
{ value: 20, label: "20" },
|
|
119
|
+
]);
|
|
120
|
+
if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`ventana: ${v}`, "info"); }
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "notify":
|
|
124
|
+
c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
|
|
125
|
+
ctx.ui.notify(`avisos: ${yn(c.notifyOnDetection)}`, "info"); break;
|
|
126
|
+
case "footer":
|
|
127
|
+
c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
|
|
128
|
+
ctx.ui.notify(`pie interactivo: ${yn(c.interactiveFooter)}`, "info");
|
|
129
|
+
rt.refreshFooter?.(ctx);
|
|
130
|
+
rt.updateStatus(ctx);
|
|
131
|
+
break;
|
|
132
|
+
case "shortcut": {
|
|
133
|
+
const v = await selectFrom(ctx, "⌨️ atajo para activar/apagar", [
|
|
134
|
+
{ value: "esc+a" as const, label: "⌨️ esc+a (por defecto)", description: "pulsa ESC y luego a" },
|
|
135
|
+
{ value: "off" as const, label: "🚫 desactivado" },
|
|
136
|
+
]);
|
|
137
|
+
if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`atajo: ${v}`, "info"); }
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
108
140
|
case "warn": {
|
|
109
|
-
const v = await selectFrom(ctx, "
|
|
110
|
-
{ value: 1, label: "1 (
|
|
111
|
-
{ value: 2, label: "2 (
|
|
141
|
+
const v = await selectFrom(ctx, "⚠️ umbral de aviso", [
|
|
142
|
+
{ value: 1, label: "⚡ 1 (sensible)" },
|
|
143
|
+
{ value: 2, label: "🎯 2 (por defecto)" },
|
|
112
144
|
{ value: 3, label: "3" },
|
|
113
|
-
{ value: 5, label: "5 (
|
|
145
|
+
{ value: 5, label: "🐢 5 (relajado)" },
|
|
114
146
|
]);
|
|
115
|
-
if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
147
|
+
if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`aviso: ${v}`, "info"); }
|
|
116
148
|
break;
|
|
117
149
|
}
|
|
118
150
|
case "force": {
|
|
119
|
-
const v = await selectFrom(ctx, "
|
|
120
|
-
{ value: 2, label: "2 (
|
|
121
|
-
{ value: 3, label: "3 (
|
|
151
|
+
const v = await selectFrom(ctx, "🛑 umbral de corte", [
|
|
152
|
+
{ value: 2, label: "⚡ 2 (sensible)" },
|
|
153
|
+
{ value: 3, label: "🎯 3 (por defecto)" },
|
|
122
154
|
{ value: 5, label: "5" },
|
|
123
|
-
{ value: 8, label: "8 (
|
|
155
|
+
{ value: 8, label: "🐢 8 (relajado)" },
|
|
124
156
|
]);
|
|
125
|
-
if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
157
|
+
if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`corte: ${v}`, "info"); }
|
|
126
158
|
break;
|
|
127
159
|
}
|
|
128
160
|
case "abort": {
|
|
129
|
-
const v = await selectFrom(ctx, "
|
|
130
|
-
{ value: 0, label: "
|
|
161
|
+
const v = await selectFrom(ctx, "🚨 umbral de aborto (0 = desactivado)", [
|
|
162
|
+
{ value: 0, label: "🚫 desactivado" },
|
|
131
163
|
{ value: 5, label: "5" },
|
|
132
164
|
{ value: 8, label: "8" },
|
|
133
165
|
{ value: 10, label: "10" },
|
|
134
166
|
{ value: 15, label: "15" },
|
|
135
167
|
]);
|
|
136
|
-
if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
168
|
+
if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`aborto: ${v || "desactivado"}`, "info"); }
|
|
137
169
|
break;
|
|
138
170
|
}
|
|
139
171
|
case "sim": {
|
|
140
|
-
const v = await selectFrom(ctx, "
|
|
141
|
-
{ value: 0.5, label: "50% (
|
|
172
|
+
const v = await selectFrom(ctx, "📏 parecido mínimo entre mensajes", [
|
|
173
|
+
{ value: 0.5, label: "⚡ 50% (sensible)" },
|
|
142
174
|
{ value: 0.6, label: "60%" },
|
|
143
175
|
{ value: 0.7, label: "70%" },
|
|
144
|
-
{ value: 0.75, label: "75% (
|
|
176
|
+
{ value: 0.75, label: "🎯 75% (por defecto)" },
|
|
145
177
|
{ value: 0.8, label: "80%" },
|
|
146
|
-
{ value: 0.9, label: "90% (
|
|
178
|
+
{ value: 0.9, label: "🐢 90% (relajado)" },
|
|
147
179
|
]);
|
|
148
|
-
if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
180
|
+
if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
149
181
|
break;
|
|
150
182
|
}
|
|
151
183
|
case "toolSim": {
|
|
152
|
-
const v = await selectFrom(ctx, "
|
|
153
|
-
{ value: 0.99, label: "99% (
|
|
154
|
-
{ value: 0.95, label: "95% (
|
|
184
|
+
const v = await selectFrom(ctx, "🔧 parecido de las llamadas (argumentos)", [
|
|
185
|
+
{ value: 0.99, label: "⚡ 99% (estricto)" },
|
|
186
|
+
{ value: 0.95, label: "🎯 95% (por defecto)" },
|
|
155
187
|
{ value: 0.9, label: "90%" },
|
|
156
|
-
{ value: 0.8, label: "80% (
|
|
188
|
+
{ value: 0.8, label: "🐢 80% (sensible)" },
|
|
157
189
|
]);
|
|
158
|
-
if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
190
|
+
if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido de llamadas: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
159
191
|
break;
|
|
160
192
|
}
|
|
161
193
|
case "toolRepeat": {
|
|
162
|
-
const v = await selectFrom(ctx, "
|
|
163
|
-
{ value: 1, label: "1 (
|
|
164
|
-
{ value: 2, label: "2 (
|
|
165
|
-
{ value: 3, label: "3 (
|
|
194
|
+
const v = await selectFrom(ctx, "🔁 repeticiones de la misma llamada", [
|
|
195
|
+
{ value: 1, label: "⚡ 1 (sensible)" },
|
|
196
|
+
{ value: 2, label: "🎯 2 (por defecto)" },
|
|
197
|
+
{ value: 3, label: "🐢 3 (relajado)" },
|
|
166
198
|
]);
|
|
167
|
-
if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`
|
|
199
|
+
if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`repeticiones: ${v}+`, "info"); }
|
|
168
200
|
break;
|
|
169
201
|
}
|
|
170
202
|
case "resultSim": {
|
|
171
|
-
const v = await selectFrom(ctx, "
|
|
172
|
-
{ value: 0.95, label: "95% (
|
|
173
|
-
{ value: 0.8, label: "80% (
|
|
174
|
-
{ value: 0.6, label: "60% (
|
|
203
|
+
const v = await selectFrom(ctx, "🧾 parecido de resultados (veto de progreso)", [
|
|
204
|
+
{ value: 0.95, label: "⚡ 95% (estricto — solo resultados casi idénticos cuentan como igual)" },
|
|
205
|
+
{ value: 0.8, label: "🎯 80% (por defecto)" },
|
|
206
|
+
{ value: 0.6, label: "🐢 60% (relajado — tolera más ruido de salida)" },
|
|
175
207
|
]);
|
|
176
|
-
if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
208
|
+
if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido de resultados: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
177
209
|
break;
|
|
178
210
|
}
|
|
179
211
|
case "streams":
|
|
180
212
|
c.detectTaskStreams = !c.detectTaskStreams; saveConfig(c);
|
|
181
|
-
ctx.ui.notify(`
|
|
213
|
+
ctx.ui.notify(`lotes de tareas: ${yn(c.detectTaskStreams)}`, "info"); break;
|
|
182
214
|
case "streamMin": {
|
|
183
|
-
const v = await selectFrom(ctx, "
|
|
184
|
-
{ value: 2, label: "2 (
|
|
185
|
-
{ value: 3, label: "3 (
|
|
215
|
+
const v = await selectFrom(ctx, "📋 llamadas mínimas para reconocer un lote", [
|
|
216
|
+
{ value: 2, label: "⚡ 2 (sensible)" },
|
|
217
|
+
{ value: 3, label: "🎯 3 (por defecto)" },
|
|
186
218
|
{ value: 4, label: "4" },
|
|
187
|
-
{ value: 5, label: "5 (
|
|
219
|
+
{ value: 5, label: "🐢 5 (conservador)" },
|
|
188
220
|
]);
|
|
189
|
-
if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`
|
|
221
|
+
if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`llamadas mínimas: ${v}`, "info"); }
|
|
190
222
|
break;
|
|
191
223
|
}
|
|
192
224
|
case "streamTwin": {
|
|
193
|
-
const v = await selectFrom(ctx, "
|
|
194
|
-
{ value: 0.99, label: "99% (
|
|
195
|
-
{ value: 0.95, label: "95% (
|
|
196
|
-
{ value: 0.9, label: "90% (
|
|
225
|
+
const v = await selectFrom(ctx, "📋 umbral de gemelos (argumentos)", [
|
|
226
|
+
{ value: 0.99, label: "🎯 99% (por defecto — cualquier diferencia real = tarea distinta)" },
|
|
227
|
+
{ value: 0.95, label: "95% (argumentos casi idénticos cuentan como la misma tarea)" },
|
|
228
|
+
{ value: 0.9, label: "⚡ 90% (detección de bucles más agresiva)" },
|
|
197
229
|
]);
|
|
198
|
-
if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
199
|
-
break;
|
|
200
|
-
}
|
|
201
|
-
case "window": {
|
|
202
|
-
const v = await selectFrom(ctx, "window", [
|
|
203
|
-
{ value: 5, label: "5" },
|
|
204
|
-
{ value: 10, label: "10 (default)" },
|
|
205
|
-
{ value: 15, label: "15" },
|
|
206
|
-
{ value: 20, label: "20" },
|
|
207
|
-
]);
|
|
208
|
-
if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
|
|
230
|
+
if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`gemelos: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
209
231
|
break;
|
|
210
232
|
}
|
|
211
233
|
case "text":
|
|
212
234
|
c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
|
|
213
|
-
ctx.ui.notify(`
|
|
235
|
+
ctx.ui.notify(`texto: ${yn(c.detectTextLoops)}`, "info"); break;
|
|
214
236
|
case "tool":
|
|
215
237
|
c.detectToolLoops = !c.detectToolLoops; saveConfig(c);
|
|
216
|
-
ctx.ui.notify(`
|
|
238
|
+
ctx.ui.notify(`herramientas: ${yn(c.detectToolLoops)}`, "info"); break;
|
|
217
239
|
case "think":
|
|
218
240
|
c.detectThinkingLoops = !c.detectThinkingLoops; saveConfig(c);
|
|
219
|
-
ctx.ui.notify(`
|
|
220
|
-
case "notify":
|
|
221
|
-
c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
|
|
222
|
-
ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
|
|
223
|
-
case "footer":
|
|
224
|
-
c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
|
|
225
|
-
ctx.ui.notify(`interactive footer: ${yn(c.interactiveFooter)}`, "info");
|
|
226
|
-
rt.refreshFooter?.(ctx);
|
|
227
|
-
rt.updateStatus(ctx);
|
|
228
|
-
break;
|
|
229
|
-
case "shortcut": {
|
|
230
|
-
const v = await selectFrom(ctx, "toggle shortcut", [
|
|
231
|
-
{ value: "esc+a" as const, label: "esc+a (default)", description: "press ESC then a to toggle" },
|
|
232
|
-
{ value: "off" as const, label: "off", description: "disable keyboard toggle" },
|
|
233
|
-
]);
|
|
234
|
-
if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`toggle shortcut: ${v}`, "info"); }
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
241
|
+
ctx.ui.notify(`pensamiento: ${yn(c.detectThinkingLoops)}`, "info"); break;
|
|
237
242
|
case "reset":
|
|
238
243
|
resetState(rt.state);
|
|
239
244
|
rt.pendingIntervention = null;
|
|
240
|
-
ctx.ui.notify("
|
|
245
|
+
ctx.ui.notify("🧹 estado reiniciado", "info");
|
|
241
246
|
rt.updateStatus(ctx);
|
|
242
247
|
break;
|
|
243
248
|
}
|
|
@@ -245,7 +250,7 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
245
250
|
|
|
246
251
|
async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
247
252
|
if (!rt.state.detections.length) {
|
|
248
|
-
ctx.ui.notify("no
|
|
253
|
+
ctx.ui.notify("no hay detecciones en esta sesión", "info");
|
|
249
254
|
return;
|
|
250
255
|
}
|
|
251
256
|
const items = rt.state.detections.slice(-30).reverse().map((d) => ({
|
|
@@ -253,7 +258,7 @@ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void>
|
|
|
253
258
|
label: `[${d.type}] ${d.description}`,
|
|
254
259
|
description: `${(d.similarity * 100).toFixed(0)}% · ${formatDuration(Date.now() - d.timestamp)} ago`,
|
|
255
260
|
}));
|
|
256
|
-
await selectFrom(ctx,
|
|
261
|
+
await selectFrom(ctx, `🕵️ detecciones (${rt.state.detections.length} en total)`, items);
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
export function resetState(state: AntiloopState): void {
|
package/src/config.ts
CHANGED
|
@@ -25,7 +25,11 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
|
|
|
25
25
|
notifyOnDetection: true,
|
|
26
26
|
maxHistoryEntries: 100,
|
|
27
27
|
detectionWindow: 10,
|
|
28
|
-
|
|
28
|
+
// Custom footer replaces the built-in one while active. Off by default:
|
|
29
|
+
// pi restores its own footer right after boot anyway, so installing a
|
|
30
|
+
// look-alike at session_start only produced a transient duplicated-footer
|
|
31
|
+
// artifact. The 🔄 on/off status still shows via setStatus in the built-in footer.
|
|
32
|
+
interactiveFooter: false,
|
|
29
33
|
toggleShortcut: "esc+a",
|
|
30
34
|
};
|
|
31
35
|
|
package/src/detect.ts
CHANGED
|
@@ -59,11 +59,19 @@ function similarity(a: string, b: string): number {
|
|
|
59
59
|
* Stable against PID / timestamp noise at the tail of command output.
|
|
60
60
|
*/
|
|
61
61
|
export function resultFingerprint(
|
|
62
|
-
parts:
|
|
62
|
+
parts: unknown,
|
|
63
63
|
isError: boolean,
|
|
64
64
|
): string | undefined {
|
|
65
65
|
let text = "";
|
|
66
|
-
|
|
66
|
+
if (typeof parts === "string") {
|
|
67
|
+
text = parts;
|
|
68
|
+
} else if (Array.isArray(parts)) {
|
|
69
|
+
for (const p of parts) {
|
|
70
|
+
if (p && typeof p === "object" && "type" in p && p.type === "text" && typeof (p as any).text === "string") {
|
|
71
|
+
text += (p as any).text;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
67
75
|
const norm = normalizeText(text);
|
|
68
76
|
if (!norm.length) return undefined;
|
|
69
77
|
return `${isError ? "err" : "ok"}|${norm.slice(-400)}`;
|
|
@@ -72,9 +80,6 @@ export function resultFingerprint(
|
|
|
72
80
|
/** Same outcome = identical fingerprint, or high similarity of the tails. */
|
|
73
81
|
function sameOutcome(a: string, b: string, threshold: number): boolean {
|
|
74
82
|
if (a === b) return true;
|
|
75
|
-
// Fingerprints are already normalized + capped, so short ones can be
|
|
76
|
-
// compared with edit distance directly — similarity() bails under 50 chars
|
|
77
|
-
// and would wrongly veto small outputs with harmless noise (PIDs, times).
|
|
78
83
|
if (a.length < 100 && b.length < 100) {
|
|
79
84
|
const max = Math.max(a.length, b.length);
|
|
80
85
|
const s = max ? 1 - levenshtein(a, b) / max : 1;
|
|
@@ -91,14 +96,10 @@ function toolCallsSimilar(
|
|
|
91
96
|
resultThreshold: number,
|
|
92
97
|
): boolean {
|
|
93
98
|
if (c1.length !== c2.length) return false;
|
|
94
|
-
// Empty call lists carry no repetition evidence — never treat them as a match.
|
|
95
99
|
if (!c1.length) return false;
|
|
96
100
|
for (let i = 0; i < c1.length; i++) {
|
|
97
101
|
if (c1[i].name !== c2[i].name) return false;
|
|
98
|
-
if (
|
|
99
|
-
// Result veto: the same command producing a different outcome is
|
|
100
|
-
// progress (a retry that fixed the problem), not a loop. Only applies
|
|
101
|
-
// when both runs actually captured a result.
|
|
102
|
+
if (!argsTwin(c1[i].args, c2[i].args, threshold)) return false;
|
|
102
103
|
const r1 = c1[i].result;
|
|
103
104
|
const r2 = c2[i].result;
|
|
104
105
|
if (r1 && r2 && !sameOutcome(r1, r2, resultThreshold)) {
|
|
@@ -220,7 +221,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
220
221
|
const last = opens[opens.length - 1].o;
|
|
221
222
|
let n = 0;
|
|
222
223
|
for (let i = 0; i < opens.length - 1; i++) {
|
|
223
|
-
if (
|
|
224
|
+
if (argsTwin(last, opens[i].o, 0.9)) n++;
|
|
224
225
|
}
|
|
225
226
|
if (n >= 2 && !allBatch([msgs.length - 1])) {
|
|
226
227
|
out.push({
|
package/src/index.ts
CHANGED
|
@@ -43,19 +43,9 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
43
43
|
/** TUI handle for forcing footer re-renders (set by the footer factory). */
|
|
44
44
|
let activeTui: { requestRender(force?: boolean): void } | undefined;
|
|
45
45
|
|
|
46
|
-
/** Status text shown in the footer: "
|
|
47
|
-
* plus active batch streams so a quiet antiloop is explainable. */
|
|
46
|
+
/** Status text shown in the footer: "🔄 a:on" / "🔄 a:off" per spec. */
|
|
48
47
|
function antiloopStatusText(): string {
|
|
49
|
-
|
|
50
|
-
const base =
|
|
51
|
-
state.currentLevel === 0
|
|
52
|
-
? "🔄 antiloop(on)"
|
|
53
|
-
: `${ICONS[state.currentLevel]} antiloop(on)×${state.consecutiveDetections}`;
|
|
54
|
-
if (state.activeTaskStreams.length) {
|
|
55
|
-
const s = state.activeTaskStreams.map((x) => `${x.tool}×${x.count}`).join(", ");
|
|
56
|
-
return `${base} · batch: ${s}`;
|
|
57
|
-
}
|
|
58
|
-
return base;
|
|
48
|
+
return config.enabled ? "🔄 a:on" : "🔄 a:off";
|
|
59
49
|
}
|
|
60
50
|
|
|
61
51
|
function updateStatus(ctx: ExtensionContext): void {
|
|
@@ -119,14 +109,12 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
119
109
|
function installFooter(ctx: ExtensionContext): void {
|
|
120
110
|
if (!config.interactiveFooter || ctx.mode !== "tui") {
|
|
121
111
|
ctx.ui.setFooter(undefined);
|
|
112
|
+
activeTui = undefined;
|
|
122
113
|
return;
|
|
123
114
|
}
|
|
124
115
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
125
116
|
activeTui = tui;
|
|
126
117
|
|
|
127
|
-
// Keyboard toggle from raw terminal input: escape followed by `a`.
|
|
128
|
-
// We never consume the input, so typing is unaffected — ESC alone
|
|
129
|
-
// passes through, and an accidental toggle is easily reversed.
|
|
130
118
|
let pendingEsc = false;
|
|
131
119
|
const shortcut = config.toggleShortcut;
|
|
132
120
|
const unsubInput =
|
|
@@ -138,12 +126,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
138
126
|
pendingEsc = true;
|
|
139
127
|
return undefined;
|
|
140
128
|
}
|
|
141
|
-
if (pendingEsc
|
|
129
|
+
if (pendingEsc) {
|
|
142
130
|
pendingEsc = false;
|
|
143
|
-
|
|
131
|
+
if (data === "a") {
|
|
132
|
+
toggleEnabled(ctx);
|
|
133
|
+
}
|
|
144
134
|
return undefined;
|
|
145
135
|
}
|
|
146
|
-
pendingEsc = false;
|
|
147
136
|
return undefined;
|
|
148
137
|
});
|
|
149
138
|
|
|
@@ -156,7 +145,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
156
145
|
render(width: number): string[] {
|
|
157
146
|
const lines: string[] = [];
|
|
158
147
|
|
|
159
|
-
// Line 1: the spec indicator + toggle hint.
|
|
160
148
|
const status = antiloopStatusText();
|
|
161
149
|
const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
|
|
162
150
|
const hint =
|
|
@@ -165,7 +153,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
165
153
|
: theme.fg("dim", " [/antiloop] toggle");
|
|
166
154
|
lines.push(truncateToWidth(colored + hint, width));
|
|
167
155
|
|
|
168
|
-
// Line 2 (only while detecting): level + consecutive + last reason.
|
|
169
156
|
if (state.currentLevel > 0) {
|
|
170
157
|
const last = state.detections[state.detections.length - 1];
|
|
171
158
|
const desc = last ? ` · ${last.description}` : "";
|
|
@@ -177,7 +164,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
177
164
|
);
|
|
178
165
|
}
|
|
179
166
|
|
|
180
|
-
// Line 3: built-in footer data preserved (dim).
|
|
181
167
|
let info = formatCwd(ctx.cwd);
|
|
182
168
|
const branch = footerData.getGitBranch();
|
|
183
169
|
if (branch) info += ` (${branch})`;
|
|
@@ -187,7 +173,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
187
173
|
if (ctx.model) info += ` · ${ctx.model.id}`;
|
|
188
174
|
lines.push(truncateToWidth(theme.fg("dim", info), width));
|
|
189
175
|
|
|
190
|
-
// Line 4 (only when other extensions set statuses): keep them visible.
|
|
191
176
|
const others = Array.from(footerData.getExtensionStatuses().entries())
|
|
192
177
|
.filter(([k]) => k !== "antiloop")
|
|
193
178
|
.map(([, v]) => v);
|
|
@@ -216,7 +201,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
216
201
|
}
|
|
217
202
|
}
|
|
218
203
|
|
|
219
|
-
// Track the call with its id so turn_end can attach the execution result.
|
|
220
204
|
const toolCalls: TrackedToolCall[] = [];
|
|
221
205
|
if (Array.isArray(msg.content)) {
|
|
222
206
|
for (const p of msg.content) {
|
|
@@ -278,19 +262,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
278
262
|
const { detectLoops, detectTaskStreams, interventionMessage, resultFingerprint } = await import("./detect.ts");
|
|
279
263
|
|
|
280
264
|
const last = state.recentMessages[state.recentMessages.length - 1];
|
|
281
|
-
// A turn whose assistant message wasn't tracked (short text, no tools)
|
|
282
|
-
// must not re-run detection on the previous message — skip it.
|
|
283
265
|
if (!last || last.turnIndex === state.lastDetectedTurnIndex) {
|
|
284
266
|
updateStatus(ctx);
|
|
285
267
|
return;
|
|
286
268
|
}
|
|
287
269
|
state.lastDetectedTurnIndex = last.turnIndex;
|
|
288
270
|
|
|
289
|
-
|
|
290
|
-
// Detection runs here (not at message_end) because the results — the
|
|
291
|
-
// signal that distinguishes "stuck loop" from "making progress" — only
|
|
292
|
-
// exist after the tools have executed.
|
|
293
|
-
if (last.toolCalls?.length && event.toolResults.length) {
|
|
271
|
+
if (last.toolCalls?.length && Array.isArray(event?.toolResults)) {
|
|
294
272
|
const byId = new Map<string, string | undefined>();
|
|
295
273
|
for (const tr of event.toolResults) byId.set(tr.toolCallId, resultFingerprint(tr.content, tr.isError));
|
|
296
274
|
for (const tc of last.toolCalls) {
|