@wrongstack/plugins 0.277.2 → 0.280.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 (72) hide show
  1. package/README.md +838 -0
  2. package/dist/auto-doc.d.ts +8 -0
  3. package/dist/auto-doc.js +175 -13
  4. package/dist/auto-escalate.d.ts +45 -0
  5. package/dist/auto-escalate.js +190 -0
  6. package/dist/branch-guard.d.ts +33 -0
  7. package/dist/branch-guard.js +228 -0
  8. package/dist/changelog-writer.d.ts +73 -0
  9. package/dist/changelog-writer.js +369 -0
  10. package/dist/checkpoint.d.ts +55 -0
  11. package/dist/checkpoint.js +305 -0
  12. package/dist/commit-validator.d.ts +33 -0
  13. package/dist/commit-validator.js +315 -0
  14. package/dist/config-validator.d.ts +48 -0
  15. package/dist/config-validator.js +347 -0
  16. package/dist/context-pins.d.ts +45 -0
  17. package/dist/context-pins.js +240 -0
  18. package/dist/cost-tracker.d.ts +40 -1
  19. package/dist/cost-tracker.js +105 -4
  20. package/dist/dep-guard.d.ts +65 -0
  21. package/dist/dep-guard.js +316 -0
  22. package/dist/diff-summary.d.ts +36 -0
  23. package/dist/diff-summary.js +235 -0
  24. package/dist/error-lens.d.ts +67 -0
  25. package/dist/error-lens.js +280 -0
  26. package/dist/format-on-save.d.ts +35 -0
  27. package/dist/format-on-save.js +219 -0
  28. package/dist/git-autocommit.js +186 -26
  29. package/dist/import-organizer.d.ts +52 -0
  30. package/dist/import-organizer.js +274 -0
  31. package/dist/index.d.ts +32 -6
  32. package/dist/index.js +10151 -1628
  33. package/dist/injection-shield.d.ts +49 -0
  34. package/dist/injection-shield.js +205 -0
  35. package/dist/lint-gate.d.ts +33 -0
  36. package/dist/lint-gate.js +394 -0
  37. package/dist/llm-cache.d.ts +56 -0
  38. package/dist/llm-cache.js +251 -0
  39. package/dist/loop-breaker.d.ts +43 -0
  40. package/dist/loop-breaker.js +241 -0
  41. package/dist/model-router.d.ts +69 -0
  42. package/dist/model-router.js +198 -0
  43. package/dist/notify-hub.d.ts +45 -0
  44. package/dist/notify-hub.js +304 -0
  45. package/dist/path-guard.d.ts +54 -0
  46. package/dist/path-guard.js +235 -0
  47. package/dist/prompt-firewall.d.ts +57 -0
  48. package/dist/prompt-firewall.js +290 -0
  49. package/dist/secret-scanner.d.ts +34 -0
  50. package/dist/secret-scanner.js +409 -0
  51. package/dist/semver-bump.js +45 -0
  52. package/dist/session-recap.d.ts +50 -0
  53. package/dist/session-recap.js +421 -0
  54. package/dist/shell-check.js +52 -4
  55. package/dist/spec-linker.d.ts +51 -0
  56. package/dist/spec-linker.js +541 -0
  57. package/dist/template-engine.js +19 -1
  58. package/dist/test-runner-gate.d.ts +37 -0
  59. package/dist/test-runner-gate.js +356 -0
  60. package/dist/todo-listener.d.ts +37 -0
  61. package/dist/todo-listener.js +216 -0
  62. package/dist/todo-tracker.d.ts +5 -0
  63. package/dist/todo-tracker.js +441 -0
  64. package/dist/token-budget.d.ts +40 -0
  65. package/dist/token-budget.js +254 -0
  66. package/dist/token-throttle.d.ts +54 -0
  67. package/dist/token-throttle.js +203 -0
  68. package/package.json +116 -12
  69. package/dist/json-path.d.ts +0 -18
  70. package/dist/json-path.js +0 -15
  71. package/dist/web-search.d.ts +0 -19
  72. package/dist/web-search.js +0 -15
@@ -0,0 +1,56 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * llm-cache plugin — caches identical provider requests and skips the
5
+ * provider call entirely on a hit.
6
+ *
7
+ * This is the deepest intervention a plugin can make: it registers an
8
+ * `AgentExtension.wrapProviderRunner`, which the agent loop composes
9
+ * around EVERY provider call (`agent-loop.ts` → `extensions
10
+ * .wrapProviderRunner(baseRunner)`). The plugin fingerprints the
11
+ * outgoing `Request` (model + system + messages + tools + sampling
12
+ * params) and, on a match, returns the cached `Response` without ever
13
+ * touching the network — killing redundant calls from retries, loops,
14
+ * and re-runs.
15
+ *
16
+ * Safety posture: caching changes call semantics (a repeated request
17
+ * returns the same answer instead of a fresh sample), so the plugin is
18
+ * **opt-in** — it loads inert and does nothing until
19
+ * `config.extensions['llm-cache'].enabled = true`. By default it only
20
+ * caches deterministic requests (`temperature` 0 or unset) so a
21
+ * sampled generation is never silently frozen.
22
+ *
23
+ * Config (`config.extensions['llm-cache']`):
24
+ *
25
+ * ```jsonc
26
+ * {
27
+ * "enabled": false, // master switch (default OFF)
28
+ * "maxEntries": 256, // LRU capacity
29
+ * "ttlMs": 0, // 0 = no expiry; else evict after N ms
30
+ * "onlyDeterministic": true, // only cache temperature 0/unset requests
31
+ * "zeroUsageOnHit": false // report 0 tokens on a hit (true) or keep
32
+ * // the cached usage for context accounting (false)
33
+ * }
34
+ * ```
35
+ *
36
+ * Tools:
37
+ * - `llm_cache_status` — hit/miss/skip counters, size, config
38
+ * - `llm_cache_clear` — drop all cached entries
39
+ *
40
+ * @public
41
+ */
42
+
43
+ /**
44
+ * Whether a request is safe to cache under `onlyDeterministic`. Sampled
45
+ * requests (temperature > 0) return different content each call, so
46
+ * caching them would silently freeze the first sample.
47
+ */
48
+ declare function isDeterministic(request: Record<string, unknown>): boolean;
49
+ /**
50
+ * Stable fingerprint of the request fields that affect the response.
51
+ * Excludes anything cosmetic (user id, etc.). Same inputs → same key.
52
+ */
53
+ declare function fingerprintRequest(request: Record<string, unknown>): string;
54
+ declare const plugin: Plugin;
55
+
56
+ export { plugin as default, fingerprintRequest, isDeterministic };
@@ -0,0 +1,251 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ // src/llm-cache/index.ts
4
+ var state = {
5
+ cache: /* @__PURE__ */ new Map(),
6
+ hits: 0,
7
+ misses: 0,
8
+ skips: 0,
9
+ evictions: 0,
10
+ savedInputTokens: 0,
11
+ savedOutputTokens: 0,
12
+ extensionUnregister: null
13
+ };
14
+ var DEFAULTS = {
15
+ enabled: false,
16
+ maxEntries: 256,
17
+ ttlMs: 0,
18
+ onlyDeterministic: true,
19
+ zeroUsageOnHit: false
20
+ };
21
+ function readConfig(raw) {
22
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
23
+ const r = raw;
24
+ return {
25
+ enabled: r["enabled"] === true,
26
+ maxEntries: typeof r["maxEntries"] === "number" && r["maxEntries"] >= 1 ? Math.floor(r["maxEntries"]) : DEFAULTS.maxEntries,
27
+ ttlMs: typeof r["ttlMs"] === "number" && r["ttlMs"] >= 0 ? r["ttlMs"] : DEFAULTS.ttlMs,
28
+ onlyDeterministic: r["onlyDeterministic"] !== false,
29
+ zeroUsageOnHit: r["zeroUsageOnHit"] === true
30
+ };
31
+ }
32
+ function isDeterministic(request) {
33
+ const t = request["temperature"];
34
+ return t === void 0 || t === 0;
35
+ }
36
+ function fingerprintRequest(request) {
37
+ const subset = {
38
+ model: request["model"] ?? null,
39
+ system: request["system"] ?? null,
40
+ messages: request["messages"] ?? null,
41
+ tools: Array.isArray(request["tools"]) ? request["tools"].map((t) => t?.name ?? t) : null,
42
+ temperature: request["temperature"] ?? null,
43
+ topP: request["topP"] ?? null,
44
+ topK: request["topK"] ?? null,
45
+ maxTokens: request["maxTokens"] ?? null,
46
+ stopSequences: request["stopSequences"] ?? null,
47
+ responseFormat: request["responseFormat"] ?? null,
48
+ reasoning: request["reasoning"] ?? null
49
+ };
50
+ return createHash("sha256").update(JSON.stringify(subset)).digest("hex");
51
+ }
52
+ function lruGet(key, ttlMs) {
53
+ const entry = state.cache.get(key);
54
+ if (!entry) return void 0;
55
+ if (ttlMs > 0 && Date.now() - entry.storedAt > ttlMs) {
56
+ state.cache.delete(key);
57
+ return void 0;
58
+ }
59
+ state.cache.delete(key);
60
+ state.cache.set(key, entry);
61
+ return entry;
62
+ }
63
+ function lruSet(key, response, maxEntries) {
64
+ state.cache.set(key, { response, storedAt: Date.now(), hits: 0 });
65
+ while (state.cache.size > maxEntries) {
66
+ const oldest = state.cache.keys().next().value;
67
+ if (oldest === void 0) break;
68
+ state.cache.delete(oldest);
69
+ state.evictions += 1;
70
+ }
71
+ }
72
+ var plugin = {
73
+ name: "llm-cache",
74
+ version: "0.1.0",
75
+ description: "Caches identical provider requests and short-circuits the provider call on a hit (wrapProviderRunner). Opt-in; deterministic-only by default.",
76
+ apiVersion: "^0.1.10",
77
+ capabilities: { tools: true },
78
+ defaultConfig: { ...DEFAULTS },
79
+ configSchema: {
80
+ type: "object",
81
+ properties: {
82
+ enabled: {
83
+ type: "boolean",
84
+ default: false,
85
+ description: "Master switch. OFF by default because caching changes provider call semantics."
86
+ },
87
+ maxEntries: {
88
+ type: "number",
89
+ minimum: 1,
90
+ default: 256,
91
+ description: "LRU capacity; oldest entries are evicted past this."
92
+ },
93
+ ttlMs: {
94
+ type: "number",
95
+ minimum: 0,
96
+ default: 0,
97
+ description: "Entry lifetime in ms. 0 = no expiry."
98
+ },
99
+ onlyDeterministic: {
100
+ type: "boolean",
101
+ default: true,
102
+ description: "Only cache requests with temperature 0 or unset (never freeze a sampled generation)."
103
+ },
104
+ zeroUsageOnHit: {
105
+ type: "boolean",
106
+ default: false,
107
+ description: "Report 0 tokens on a cache hit (true) so cost tracking reflects the saving, or keep the cached usage for context accounting (false)."
108
+ }
109
+ }
110
+ },
111
+ setup(api) {
112
+ state.cache.clear();
113
+ state.hits = 0;
114
+ state.misses = 0;
115
+ state.skips = 0;
116
+ state.evictions = 0;
117
+ state.savedInputTokens = 0;
118
+ state.savedOutputTokens = 0;
119
+ if (state.extensionUnregister) {
120
+ try {
121
+ state.extensionUnregister();
122
+ } catch {
123
+ }
124
+ state.extensionUnregister = null;
125
+ }
126
+ const cfg = readConfig(api.config.extensions?.["llm-cache"]);
127
+ if (cfg.enabled) {
128
+ state.extensionUnregister = api.extensions.register({
129
+ name: "llm-cache",
130
+ owner: "llm-cache",
131
+ async wrapProviderRunner(_ctx, request, inner) {
132
+ const req = request ?? {};
133
+ if (cfg.onlyDeterministic && !isDeterministic(req)) {
134
+ state.skips += 1;
135
+ api.metrics.counter("skips");
136
+ return inner(_ctx, request);
137
+ }
138
+ const key = fingerprintRequest(req);
139
+ const cached = lruGet(key, cfg.ttlMs);
140
+ if (cached) {
141
+ cached.hits += 1;
142
+ state.hits += 1;
143
+ state.savedInputTokens += cached.response.usage?.input ?? 0;
144
+ state.savedOutputTokens += cached.response.usage?.output ?? 0;
145
+ api.metrics.counter("hits");
146
+ if (cfg.zeroUsageOnHit) {
147
+ return {
148
+ ...cached.response,
149
+ usage: { ...cached.response.usage, input: 0, output: 0 }
150
+ };
151
+ }
152
+ return cached.response;
153
+ }
154
+ state.misses += 1;
155
+ api.metrics.counter("misses");
156
+ const response = await inner(_ctx, request);
157
+ if (response && typeof response === "object" && response.stopReason === "end_turn") {
158
+ lruSet(key, response, cfg.maxEntries);
159
+ }
160
+ return response;
161
+ }
162
+ });
163
+ }
164
+ api.tools.register({
165
+ name: "llm_cache_status",
166
+ description: "Reports llm-cache state: enabled, size, hit/miss/skip counters, and tokens saved by cache hits.",
167
+ inputSchema: { type: "object", properties: {} },
168
+ permission: "auto",
169
+ category: "Diagnostics",
170
+ mutating: false,
171
+ async execute() {
172
+ const total = state.hits + state.misses;
173
+ return {
174
+ ok: true,
175
+ enabled: cfg.enabled,
176
+ onlyDeterministic: cfg.onlyDeterministic,
177
+ maxEntries: cfg.maxEntries,
178
+ ttlMs: cfg.ttlMs,
179
+ size: state.cache.size,
180
+ counters: {
181
+ hits: state.hits,
182
+ misses: state.misses,
183
+ skips: state.skips,
184
+ evictions: state.evictions,
185
+ hitRate: total > 0 ? Number((state.hits / total).toFixed(3)) : 0
186
+ },
187
+ saved: { inputTokens: state.savedInputTokens, outputTokens: state.savedOutputTokens }
188
+ };
189
+ }
190
+ });
191
+ api.tools.register({
192
+ name: "llm_cache_clear",
193
+ description: "Drops all cached provider responses (does not disable the cache).",
194
+ inputSchema: { type: "object", properties: {} },
195
+ permission: "auto",
196
+ category: "Diagnostics",
197
+ mutating: true,
198
+ async execute() {
199
+ const cleared = state.cache.size;
200
+ state.cache.clear();
201
+ return { ok: true, cleared };
202
+ }
203
+ });
204
+ api.log.info("llm-cache plugin loaded", {
205
+ version: "0.1.0",
206
+ enabled: cfg.enabled,
207
+ onlyDeterministic: cfg.onlyDeterministic,
208
+ maxEntries: cfg.maxEntries
209
+ });
210
+ },
211
+ teardown(api) {
212
+ if (state.extensionUnregister) {
213
+ try {
214
+ state.extensionUnregister();
215
+ } catch {
216
+ }
217
+ state.extensionUnregister = null;
218
+ }
219
+ const final = {
220
+ hits: state.hits,
221
+ misses: state.misses,
222
+ skips: state.skips,
223
+ savedInputTokens: state.savedInputTokens,
224
+ savedOutputTokens: state.savedOutputTokens
225
+ };
226
+ state.cache.clear();
227
+ state.hits = 0;
228
+ state.misses = 0;
229
+ state.skips = 0;
230
+ state.evictions = 0;
231
+ state.savedInputTokens = 0;
232
+ state.savedOutputTokens = 0;
233
+ api.log.info("llm-cache: teardown complete", { final });
234
+ },
235
+ async health() {
236
+ const total = state.hits + state.misses;
237
+ return {
238
+ ok: true,
239
+ message: `llm-cache: ${state.cache.size} entr(ies), ${state.hits} hit(s) / ${state.misses} miss(es) (${total > 0 ? Math.round(state.hits / total * 100) : 0}% hit rate), ~${state.savedInputTokens + state.savedOutputTokens} tokens saved`,
240
+ counters: {
241
+ hits: state.hits,
242
+ misses: state.misses,
243
+ skips: state.skips,
244
+ evictions: state.evictions
245
+ }
246
+ };
247
+ }
248
+ };
249
+ var llm_cache_default = plugin;
250
+
251
+ export { llm_cache_default as default, fingerprintRequest, isDeterministic };
@@ -0,0 +1,43 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * loop-breaker plugin — detects runaway tool-call loops and breaks them.
5
+ *
6
+ * Agents occasionally get stuck re-issuing the same tool call with the
7
+ * same input (a failing bash command retried forever, re-reading the
8
+ * same file, re-writing identical content). Each repeat burns tokens
9
+ * and wall-clock without making progress. This plugin fingerprints
10
+ * every tool call (`toolName` + canonicalized input JSON) via a
11
+ * `PreToolUse '*'` hook and tracks consecutive repeats:
12
+ *
13
+ * - at `warnAfter` repeats → inject `additionalContext` telling the
14
+ * model it is looping and should change approach
15
+ * - at `blockAfter` repeats → block the call outright with a clear
16
+ * reason (unless `mode: 'warn'`)
17
+ *
18
+ * Any *different* call resets the streak, so normal workflows (many
19
+ * distinct reads/edits) are never touched. A small LRU of recent
20
+ * fingerprints also catches A-B-A-B oscillation loops.
21
+ *
22
+ * Config (`config.extensions['loop-breaker']`):
23
+ *
24
+ * ```jsonc
25
+ * {
26
+ * "enabled": true,
27
+ * "mode": "block", // "block" | "warn"
28
+ * "warnAfter": 3, // consecutive identical calls before warning
29
+ * "blockAfter": 5, // consecutive identical calls before blocking
30
+ * "oscillationWindow": 8, // recent-call window for A-B-A-B detection
31
+ * "ignoreTools": [] // tool names exempt from loop detection
32
+ * }
33
+ * ```
34
+ *
35
+ * Toggle off entirely with `{ "name": "loop-breaker", "enabled": false }`
36
+ * in `config.plugins`, or `"enabled": false` in the options above.
37
+ *
38
+ * @public
39
+ */
40
+
41
+ declare const plugin: Plugin;
42
+
43
+ export { plugin as default };
@@ -0,0 +1,241 @@
1
+ // src/loop-breaker/index.ts
2
+ var state = {
3
+ lastFingerprint: null,
4
+ streak: 0,
5
+ recent: [],
6
+ invocations: 0,
7
+ warnings: 0,
8
+ blocks: 0,
9
+ oscillationsDetected: 0,
10
+ hookUnregister: null
11
+ };
12
+ var DEFAULTS = {
13
+ enabled: true,
14
+ mode: "block",
15
+ warnAfter: 3,
16
+ blockAfter: 5,
17
+ oscillationWindow: 8,
18
+ ignoreTools: []
19
+ };
20
+ function readConfig(raw) {
21
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
22
+ const r = raw;
23
+ const warnAfter = typeof r["warnAfter"] === "number" && r["warnAfter"] >= 2 ? r["warnAfter"] : DEFAULTS.warnAfter;
24
+ const blockAfter = typeof r["blockAfter"] === "number" && r["blockAfter"] > warnAfter ? r["blockAfter"] : Math.max(DEFAULTS.blockAfter, warnAfter + 1);
25
+ return {
26
+ enabled: r["enabled"] !== false,
27
+ mode: r["mode"] === "warn" ? "warn" : "block",
28
+ warnAfter,
29
+ blockAfter,
30
+ oscillationWindow: typeof r["oscillationWindow"] === "number" && r["oscillationWindow"] >= 4 ? r["oscillationWindow"] : DEFAULTS.oscillationWindow,
31
+ ignoreTools: Array.isArray(r["ignoreTools"]) ? r["ignoreTools"].filter((t) => typeof t === "string") : []
32
+ };
33
+ }
34
+ function canonicalize(value) {
35
+ try {
36
+ return JSON.stringify(sortKeys(value));
37
+ } catch {
38
+ return String(value);
39
+ }
40
+ }
41
+ function sortKeys(value) {
42
+ if (Array.isArray(value)) return value.map(sortKeys);
43
+ if (value && typeof value === "object") {
44
+ const out = {};
45
+ for (const key of Object.keys(value).sort()) {
46
+ out[key] = sortKeys(value[key]);
47
+ }
48
+ return out;
49
+ }
50
+ return value;
51
+ }
52
+ function fingerprint(toolName, toolInput) {
53
+ return `${toolName}\0${canonicalize(toolInput)}`;
54
+ }
55
+ function isOscillating(recent, windowSize) {
56
+ if (recent.length < windowSize) return false;
57
+ const window = recent.slice(-windowSize);
58
+ const unique = new Set(window);
59
+ if (unique.size !== 2) return false;
60
+ for (let i = 2; i < window.length; i++) {
61
+ if (window[i] !== window[i - 2]) return false;
62
+ }
63
+ return window[0] !== window[1];
64
+ }
65
+ var plugin = {
66
+ name: "loop-breaker",
67
+ version: "0.1.0",
68
+ description: "Detects runaway tool-call loops (identical repeats and A-B-A-B oscillation) \u2014 warns the model, then blocks",
69
+ apiVersion: "^0.1.10",
70
+ capabilities: { tools: true, hooks: true },
71
+ defaultConfig: { ...DEFAULTS },
72
+ configSchema: {
73
+ type: "object",
74
+ properties: {
75
+ enabled: { type: "boolean", default: true, description: "Master switch." },
76
+ mode: {
77
+ type: "string",
78
+ enum: ["block", "warn"],
79
+ default: "block",
80
+ description: "block = refuse the repeated call; warn = only inject context."
81
+ },
82
+ warnAfter: {
83
+ type: "number",
84
+ minimum: 2,
85
+ default: 3,
86
+ description: "Consecutive identical calls before a warning is injected."
87
+ },
88
+ blockAfter: {
89
+ type: "number",
90
+ minimum: 3,
91
+ default: 5,
92
+ description: "Consecutive identical calls before the call is blocked."
93
+ },
94
+ oscillationWindow: {
95
+ type: "number",
96
+ minimum: 4,
97
+ default: 8,
98
+ description: "Recent-call window length used for A-B-A-B oscillation detection."
99
+ },
100
+ ignoreTools: {
101
+ type: "array",
102
+ items: { type: "string" },
103
+ default: [],
104
+ description: "Tool names exempt from loop detection."
105
+ }
106
+ }
107
+ },
108
+ setup(api) {
109
+ state.lastFingerprint = null;
110
+ state.streak = 0;
111
+ state.recent = [];
112
+ state.invocations = 0;
113
+ state.warnings = 0;
114
+ state.blocks = 0;
115
+ state.oscillationsDetected = 0;
116
+ if (state.hookUnregister) {
117
+ try {
118
+ state.hookUnregister();
119
+ } catch {
120
+ }
121
+ state.hookUnregister = null;
122
+ }
123
+ const cfg = readConfig(api.config.extensions?.["loop-breaker"]);
124
+ const hook = (input) => {
125
+ if (!cfg.enabled) return;
126
+ const toolName = input.toolName ?? "unknown";
127
+ if (cfg.ignoreTools.includes(toolName)) return;
128
+ state.invocations += 1;
129
+ const fp = fingerprint(toolName, input.toolInput);
130
+ if (fp === state.lastFingerprint) {
131
+ state.streak += 1;
132
+ } else {
133
+ state.lastFingerprint = fp;
134
+ state.streak = 1;
135
+ }
136
+ state.recent.push(fp);
137
+ if (state.recent.length > Math.max(cfg.oscillationWindow, 16)) {
138
+ state.recent.splice(0, state.recent.length - Math.max(cfg.oscillationWindow, 16));
139
+ }
140
+ if (state.streak >= cfg.blockAfter && cfg.mode === "block") {
141
+ state.blocks += 1;
142
+ api.metrics.counter("blocks");
143
+ return {
144
+ decision: "block",
145
+ reason: `loop-breaker: "${toolName}" has been called ${state.streak} times in a row with identical input. This looks like a runaway loop. Change the input, try a different tool, or explain to the user why repetition is needed. (Disable this guard via config.extensions["loop-breaker"].enabled = false.)`
146
+ };
147
+ }
148
+ if (state.streak >= cfg.warnAfter) {
149
+ state.warnings += 1;
150
+ api.metrics.counter("warnings");
151
+ const remaining = cfg.mode === "block" ? cfg.blockAfter - state.streak : null;
152
+ return {
153
+ decision: "allow",
154
+ additionalContext: `loop-breaker: "${toolName}" repeated ${state.streak}x with identical input.` + (remaining !== null && remaining > 0 ? ` It will be BLOCKED after ${remaining} more identical call(s).` : "") + " If the previous result was not what you needed, change the approach instead of retrying."
155
+ };
156
+ }
157
+ if (isOscillating(state.recent, cfg.oscillationWindow)) {
158
+ state.oscillationsDetected += 1;
159
+ api.metrics.counter("oscillations");
160
+ state.recent = [];
161
+ return {
162
+ decision: "allow",
163
+ additionalContext: `loop-breaker: the last ${cfg.oscillationWindow} tool calls alternate between two identical calls (A-B-A-B pattern). You appear to be undoing and redoing the same work. Step back and pick a single approach.`
164
+ };
165
+ }
166
+ return;
167
+ };
168
+ state.hookUnregister = api.registerHook("PreToolUse", "*", hook);
169
+ api.tools.register({
170
+ name: "loop_breaker_status",
171
+ description: "Reports loop-breaker state: config, current repeat streak, and counters (warnings, blocks, oscillations).",
172
+ inputSchema: { type: "object", properties: {} },
173
+ permission: "auto",
174
+ category: "Diagnostics",
175
+ mutating: false,
176
+ async execute() {
177
+ return {
178
+ ok: true,
179
+ enabled: cfg.enabled,
180
+ mode: cfg.mode,
181
+ warnAfter: cfg.warnAfter,
182
+ blockAfter: cfg.blockAfter,
183
+ oscillationWindow: cfg.oscillationWindow,
184
+ ignoreTools: cfg.ignoreTools,
185
+ currentStreak: state.streak,
186
+ counters: {
187
+ invocations: state.invocations,
188
+ warnings: state.warnings,
189
+ blocks: state.blocks,
190
+ oscillationsDetected: state.oscillationsDetected
191
+ }
192
+ };
193
+ }
194
+ });
195
+ api.log.info("loop-breaker plugin loaded", {
196
+ version: "0.1.0",
197
+ enabled: cfg.enabled,
198
+ mode: cfg.mode,
199
+ warnAfter: cfg.warnAfter,
200
+ blockAfter: cfg.blockAfter
201
+ });
202
+ },
203
+ teardown(api) {
204
+ if (state.hookUnregister) {
205
+ try {
206
+ state.hookUnregister();
207
+ } catch {
208
+ }
209
+ state.hookUnregister = null;
210
+ }
211
+ const final = {
212
+ invocations: state.invocations,
213
+ warnings: state.warnings,
214
+ blocks: state.blocks,
215
+ oscillationsDetected: state.oscillationsDetected
216
+ };
217
+ state.lastFingerprint = null;
218
+ state.streak = 0;
219
+ state.recent = [];
220
+ state.invocations = 0;
221
+ state.warnings = 0;
222
+ state.blocks = 0;
223
+ state.oscillationsDetected = 0;
224
+ api.log.info("loop-breaker: teardown complete", { final });
225
+ },
226
+ async health() {
227
+ return {
228
+ ok: true,
229
+ message: `loop-breaker: ${state.invocations} call(s) observed, ${state.warnings} warning(s), ${state.blocks} block(s), ${state.oscillationsDetected} oscillation(s)`,
230
+ counters: {
231
+ invocations: state.invocations,
232
+ warnings: state.warnings,
233
+ blocks: state.blocks,
234
+ oscillationsDetected: state.oscillationsDetected
235
+ }
236
+ };
237
+ }
238
+ };
239
+ var loop_breaker_default = plugin;
240
+
241
+ export { loop_breaker_default as default };
@@ -0,0 +1,69 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * model-router plugin — routes each provider call to a different model
5
+ * based on declarative rules, on the wire.
6
+ *
7
+ * Like `llm-cache`, this registers an `AgentExtension.wrapProviderRunner`
8
+ * (composed around EVERY provider call in the agent loop). Before the
9
+ * request goes out, the plugin evaluates an ordered rule list against
10
+ * the request's size and shape and rewrites `request.model` to the
11
+ * first matching rule's target — e.g. small/tool-free turns to a cheap
12
+ * fast model, large refactors to a capable one.
13
+ *
14
+ * IMPORTANT constraint: the active provider instance is fixed for the
15
+ * session. Rerouting only works among models the CURRENT provider
16
+ * serves (e.g. anthropic `haiku` ↔ `opus`). Routing to a model id the
17
+ * provider doesn't recognize will fail at the provider — keep targets
18
+ * within one provider's catalog.
19
+ *
20
+ * Safety posture: opt-in — loads inert until
21
+ * `config.extensions['model-router'].enabled = true`. A `dryRun` mode
22
+ * (default) only records what it WOULD route without changing the model,
23
+ * so you can observe the routing before trusting it.
24
+ *
25
+ * Config (`config.extensions['model-router']`):
26
+ *
27
+ * ```jsonc
28
+ * {
29
+ * "enabled": false,
30
+ * "dryRun": true, // observe only; do not rewrite the model
31
+ * "rules": [
32
+ * { "maxChars": 2000, "hasTools": false, "model": "claude-haiku-4-5" },
33
+ * { "minChars": 40000, "model": "claude-opus-4-8" }
34
+ * ]
35
+ * }
36
+ * ```
37
+ *
38
+ * A rule matches when ALL of its present conditions hold. The first
39
+ * matching rule wins; if none match, the request passes through with its
40
+ * original model.
41
+ *
42
+ * Tools:
43
+ * - `model_router_status` — rules, mode, and per-target routing counts
44
+ *
45
+ * @public
46
+ */
47
+
48
+ interface RouteRule {
49
+ /** Match when the request's total char size is <= this. */
50
+ maxChars?: number | undefined;
51
+ /** Match when the request's total char size is >= this. */
52
+ minChars?: number | undefined;
53
+ /** Match when the request has (true) / lacks (false) tools. */
54
+ hasTools?: boolean | undefined;
55
+ /** The model id to route matching requests to (required). */
56
+ model: string;
57
+ }
58
+ /**
59
+ * Total character size of the request's actual prompt text (system +
60
+ * message content). Only counts `text` block values and bare-string
61
+ * content — not structural fields like `type`/`role` — so the size is a
62
+ * meaningful proxy for prompt weight.
63
+ */
64
+ declare function requestCharSize(request: Record<string, unknown>): number;
65
+ /** First rule whose present conditions all hold, or null. */
66
+ declare function pickRule(rules: RouteRule[], size: number, hasTools: boolean): RouteRule | null;
67
+ declare const plugin: Plugin;
68
+
69
+ export { plugin as default, pickRule, requestCharSize };