pi-antiloop 1.3.1 → 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 +1 -1
- package/src/commands.ts +102 -97
- package/src/detect.ts +12 -11
- package/src/index.ts +6 -18
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antiloop",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
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",
|
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/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
|
@@ -109,14 +109,12 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
109
109
|
function installFooter(ctx: ExtensionContext): void {
|
|
110
110
|
if (!config.interactiveFooter || ctx.mode !== "tui") {
|
|
111
111
|
ctx.ui.setFooter(undefined);
|
|
112
|
+
activeTui = undefined;
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
115
116
|
activeTui = tui;
|
|
116
117
|
|
|
117
|
-
// Keyboard toggle from raw terminal input: escape followed by `a`.
|
|
118
|
-
// We never consume the input, so typing is unaffected — ESC alone
|
|
119
|
-
// passes through, and an accidental toggle is easily reversed.
|
|
120
118
|
let pendingEsc = false;
|
|
121
119
|
const shortcut = config.toggleShortcut;
|
|
122
120
|
const unsubInput =
|
|
@@ -128,12 +126,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
128
126
|
pendingEsc = true;
|
|
129
127
|
return undefined;
|
|
130
128
|
}
|
|
131
|
-
if (pendingEsc
|
|
129
|
+
if (pendingEsc) {
|
|
132
130
|
pendingEsc = false;
|
|
133
|
-
|
|
131
|
+
if (data === "a") {
|
|
132
|
+
toggleEnabled(ctx);
|
|
133
|
+
}
|
|
134
134
|
return undefined;
|
|
135
135
|
}
|
|
136
|
-
pendingEsc = false;
|
|
137
136
|
return undefined;
|
|
138
137
|
});
|
|
139
138
|
|
|
@@ -146,7 +145,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
146
145
|
render(width: number): string[] {
|
|
147
146
|
const lines: string[] = [];
|
|
148
147
|
|
|
149
|
-
// Line 1: the spec indicator + toggle hint.
|
|
150
148
|
const status = antiloopStatusText();
|
|
151
149
|
const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
|
|
152
150
|
const hint =
|
|
@@ -155,7 +153,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
155
153
|
: theme.fg("dim", " [/antiloop] toggle");
|
|
156
154
|
lines.push(truncateToWidth(colored + hint, width));
|
|
157
155
|
|
|
158
|
-
// Line 2 (only while detecting): level + consecutive + last reason.
|
|
159
156
|
if (state.currentLevel > 0) {
|
|
160
157
|
const last = state.detections[state.detections.length - 1];
|
|
161
158
|
const desc = last ? ` · ${last.description}` : "";
|
|
@@ -167,7 +164,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
167
164
|
);
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
// Line 3: built-in footer data preserved (dim).
|
|
171
167
|
let info = formatCwd(ctx.cwd);
|
|
172
168
|
const branch = footerData.getGitBranch();
|
|
173
169
|
if (branch) info += ` (${branch})`;
|
|
@@ -177,7 +173,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
177
173
|
if (ctx.model) info += ` · ${ctx.model.id}`;
|
|
178
174
|
lines.push(truncateToWidth(theme.fg("dim", info), width));
|
|
179
175
|
|
|
180
|
-
// Line 4 (only when other extensions set statuses): keep them visible.
|
|
181
176
|
const others = Array.from(footerData.getExtensionStatuses().entries())
|
|
182
177
|
.filter(([k]) => k !== "antiloop")
|
|
183
178
|
.map(([, v]) => v);
|
|
@@ -206,7 +201,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
206
201
|
}
|
|
207
202
|
}
|
|
208
203
|
|
|
209
|
-
// Track the call with its id so turn_end can attach the execution result.
|
|
210
204
|
const toolCalls: TrackedToolCall[] = [];
|
|
211
205
|
if (Array.isArray(msg.content)) {
|
|
212
206
|
for (const p of msg.content) {
|
|
@@ -268,19 +262,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
268
262
|
const { detectLoops, detectTaskStreams, interventionMessage, resultFingerprint } = await import("./detect.ts");
|
|
269
263
|
|
|
270
264
|
const last = state.recentMessages[state.recentMessages.length - 1];
|
|
271
|
-
// A turn whose assistant message wasn't tracked (short text, no tools)
|
|
272
|
-
// must not re-run detection on the previous message — skip it.
|
|
273
265
|
if (!last || last.turnIndex === state.lastDetectedTurnIndex) {
|
|
274
266
|
updateStatus(ctx);
|
|
275
267
|
return;
|
|
276
268
|
}
|
|
277
269
|
state.lastDetectedTurnIndex = last.turnIndex;
|
|
278
270
|
|
|
279
|
-
|
|
280
|
-
// Detection runs here (not at message_end) because the results — the
|
|
281
|
-
// signal that distinguishes "stuck loop" from "making progress" — only
|
|
282
|
-
// exist after the tools have executed.
|
|
283
|
-
if (last.toolCalls?.length && event.toolResults.length) {
|
|
271
|
+
if (last.toolCalls?.length && Array.isArray(event?.toolResults)) {
|
|
284
272
|
const byId = new Map<string, string | undefined>();
|
|
285
273
|
for (const tr of event.toolResults) byId.set(tr.toolCallId, resultFingerprint(tr.content, tr.isError));
|
|
286
274
|
for (const tc of last.toolCalls) {
|