@causyne/agent 1.10.13 → 1.10.17

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 (59) hide show
  1. package/docs/ARCHITECTURE.md +101 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +25 -3
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/camofoxRuntime.js +93 -0
  12. package/src/browser/camofoxRuntime.ts +112 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/commands.js +37 -8
  23. package/src/config.d.ts +2 -0
  24. package/src/hooks.js +121 -107
  25. package/src/hooks.ts +193 -0
  26. package/src/mcp/mcpClient.js +520 -132
  27. package/src/mcp/mcpClient.ts +584 -0
  28. package/src/mcp/mcpRegistry.js +399 -185
  29. package/src/mcp/mcpRegistry.ts +451 -0
  30. package/src/planCache.js +473 -450
  31. package/src/planCache.ts +840 -0
  32. package/src/plugins/pluginManager.js +40 -5
  33. package/src/plugins/pluginSkills.js +84 -0
  34. package/src/plugins/pluginSkills.ts +106 -0
  35. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  36. package/src/provisioning/capabilityProvisioner.js +5 -0
  37. package/src/provisioning/capabilityTools.js +2 -1
  38. package/src/sandbox.js +32 -2
  39. package/src/sessionJournal.js +471 -0
  40. package/src/sessionJournal.ts +496 -0
  41. package/src/sessionStore.js +3 -279
  42. package/src/skillsManager.js +18 -0
  43. package/src/subagentJournal.js +215 -0
  44. package/src/subagentJournal.ts +254 -0
  45. package/src/taskShape.js +179 -198
  46. package/src/taskShape.ts +259 -0
  47. package/src/tools/browserAutomator.js +45 -241
  48. package/src/tools/browserAutomator.ts +57 -0
  49. package/src/tools/manageLocalAiDependencies.js +1 -1
  50. package/src/tools/subagentOrchestrator.js +73 -12
  51. package/src/tools/webFetch.js +183 -68
  52. package/src/tools/webFetch.ts +185 -0
  53. package/src/tools/webSearch.js +50 -75
  54. package/src/tools/webSearch.ts +56 -0
  55. package/src/types/contracts.js +8 -0
  56. package/src/types/contracts.ts +263 -0
  57. package/src/utils/paths.d.ts +3 -0
  58. package/src/version.d.ts +1 -0
  59. package/src/mcp/jsonRpc.js +0 -165
package/src/planCache.js CHANGED
@@ -2,7 +2,6 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import crypto from 'crypto';
4
4
  import { causyneHome } from './utils/paths.js';
5
-
6
5
  // Agentic Plan Caching.
7
6
  //
8
7
  // THE WASTE THIS ATTACKS. The agent rediscovers the same approach from scratch
@@ -32,11 +31,9 @@ import { causyneHome } from './utils/paths.js';
32
31
  // WHAT IS STORED. Tool names, a short whitelisted action token, and whether the
33
32
  // step worked. No paths, no arguments, no file contents, no command lines. A
34
33
  // procedure, not a transcript.
35
-
36
34
  const PLAN_DIR = path.join(causyneHome(), 'plans');
37
35
  const PLAN_FILE = path.join(PLAN_DIR, 'index.json');
38
36
  const METRICS_FILE = path.join(PLAN_DIR, 'metrics.json');
39
-
40
37
  /** Two plans must overlap this much on keywords before one is offered for the other. */
41
38
  // Tuned against the asymmetry, not for elegance. A false positive costs a few
42
39
  // tokens of clearly-labelled prior art the model is told to verify. A false
@@ -44,16 +41,12 @@ const METRICS_FILE = path.join(PLAN_DIR, 'metrics.json');
44
41
  // ended a real run at the daily ceiling. Archetype is already a hard gate, so
45
42
  // the remaining risk is "same archetype, different task", and that is cheap.
46
43
  const MATCH_THRESHOLD = 0.38;
47
-
48
44
  /** Ceiling on retained templates. Pruned by usefulness, not by age alone. */
49
45
  const MAX_PLANS = 200;
50
-
51
46
  /** A plan longer than this is a transcript, not a procedure. */
52
47
  const MAX_STEPS = 24;
53
-
54
48
  /** Keywords kept per plan. Enough to discriminate, short enough to stay cheap. */
55
49
  const MAX_KEYWORDS = 12;
56
-
57
50
  /** Exact keyword-set hits stay O(1); fuzzy matching is a bounded fallback. */
58
51
  const RECENT_LOOKUP_WINDOW = 32;
59
52
  const MIN_LOOKUPS_BEFORE_DISABLE = 20;
@@ -61,48 +54,44 @@ const MIN_PLANS_BEFORE_DISABLE = 10;
61
54
  const MIN_HEALTHY_HIT_RATE = 0.10;
62
55
  const DISABLE_COOLDOWN_MS = 5 * 60 * 1000;
63
56
  const SEED_FAILURE_CLASSES = new Set([
64
- 'not_found', 'permission', 'timeout', 'network', 'rate_limited',
65
- 'invalid_args', 'syntax', 'exit_nonzero', 'failed'
57
+ 'not_found', 'permission', 'timeout', 'network', 'rate_limited',
58
+ 'invalid_args', 'syntax', 'exit_nonzero', 'failed'
66
59
  ]);
67
60
  const METRICS_FLUSH_INTERVAL = 10;
68
-
69
61
  let plansCache = null;
70
62
  let plansMtimeMs = -1;
71
63
  let exactIndex = new Map();
72
64
  let metricsCache = null;
73
65
  let dirtyMetricEvents = 0;
74
-
75
66
  /**
76
67
  * Words that carry no intent. Deliberately small: an aggressive list throws
77
68
  * away the domain nouns that make two tasks comparable.
78
69
  * @type {ReadonlySet<string>}
79
70
  */
80
71
  const STOPWORDS = new Set([
81
- 'the', 'a', 'an', 'and', 'or', 'but', 'for', 'to', 'of', 'in', 'on', 'at',
82
- 'is', 'are', 'be', 'was', 'were', 'do', 'does', 'did', 'can', 'could',
83
- 'with', 'from', 'into', 'then', 'this', 'that', 'it', 'its', 'as', 'by',
84
- 'please', 'just', 'now', 'my', 'me', 'you', 'your', 'i',
85
- // Generic creation verbs. Every task has one, so they inflate the union
86
- // without discriminating between tasks — the exact shape that made
87
- // "buat pena 3D di Blender" and "bikin pulpen 3D di blender" score 0.40
88
- // when they describe the same job. `build`, `fix`, `debug` and `render` are
89
- // deliberately NOT here: those DO separate tasks inside one archetype.
90
- 'make', 'create', 'creating', 'made', 'generate',
91
- // Indonesian — the owner writes in both, and a mixed-language prompt must
92
- // key the same way whichever language framed it.
93
- 'dan', 'atau', 'yang', 'untuk', 'dari', 'ke', 'di', 'itu', 'ini', 'dengan',
94
- 'tolong', 'saya', 'aku', 'kamu', 'adalah', 'pada', 'juga', 'agar', 'jadi',
95
- 'buat', 'bikin', 'membuat', 'lalu', 'kemudian', 'terus', 'dulu', 'saja',
96
- 'sekarang', 'harus', 'bisa', 'sudah', 'belum', 'akan', 'mau', 'coba'
72
+ 'the', 'a', 'an', 'and', 'or', 'but', 'for', 'to', 'of', 'in', 'on', 'at',
73
+ 'is', 'are', 'be', 'was', 'were', 'do', 'does', 'did', 'can', 'could',
74
+ 'with', 'from', 'into', 'then', 'this', 'that', 'it', 'its', 'as', 'by',
75
+ 'please', 'just', 'now', 'my', 'me', 'you', 'your', 'i',
76
+ // Generic creation verbs. Every task has one, so they inflate the union
77
+ // without discriminating between tasks — the exact shape that made
78
+ // "buat pena 3D di Blender" and "bikin pulpen 3D di blender" score 0.40
79
+ // when they describe the same job. `build`, `fix`, `debug` and `render` are
80
+ // deliberately NOT here: those DO separate tasks inside one archetype.
81
+ 'make', 'create', 'creating', 'made', 'generate',
82
+ // Indonesian — the owner writes in both, and a mixed-language prompt must
83
+ // key the same way whichever language framed it.
84
+ 'dan', 'atau', 'yang', 'untuk', 'dari', 'ke', 'di', 'itu', 'ini', 'dengan',
85
+ 'tolong', 'saya', 'aku', 'kamu', 'adalah', 'pada', 'juga', 'agar', 'jadi',
86
+ 'buat', 'bikin', 'membuat', 'lalu', 'kemudian', 'terus', 'dulu', 'saja',
87
+ 'sekarang', 'harus', 'bisa', 'sudah', 'belum', 'akan', 'mau', 'coba'
97
88
  ]);
98
-
99
89
  // HONEST LIMIT. Matching is LEXICAL. A task cached in English and re-asked in
100
90
  // Indonesian (or the reverse) will miss, because Jaccard cannot know that
101
91
  // "pen" and "pena" are the same object. That is a deliberate trade: this path
102
92
  // is deterministic, instant, and works on a machine with no embedding model
103
93
  // provisioned. Cortex is the semantic route and already carries embeddings;
104
94
  // these two compose rather than compete.
105
-
106
95
  /**
107
96
  * One executed step, reduced to what is reusable.
108
97
  *
@@ -118,7 +107,6 @@ const STOPWORDS = new Set([
118
107
  * @property {string} [why] Failure CLASS when ok is false. A closed vocabulary,
119
108
  * never the error text.
120
109
  */
121
-
122
110
  /**
123
111
  * @typedef {Object} PlanTemplate
124
112
  * @property {string} id
@@ -130,7 +118,6 @@ const STOPWORDS = new Set([
130
118
  * @property {number} createdAt
131
119
  * @property {number} updatedAt
132
120
  */
133
-
134
121
  /**
135
122
  * @typedef {Object} PlanMatch
136
123
  * @property {PlanTemplate} template
@@ -138,163 +125,218 @@ const STOPWORDS = new Set([
138
125
  * @property {'exact'|'fuzzy'} matchType
139
126
  * @property {{keywordCount: number, lookupMs: number}} telemetry
140
127
  */
141
-
142
128
  function ensureDir() {
143
- if (!fs.existsSync(PLAN_DIR)) fs.mkdirSync(PLAN_DIR, { recursive: true, mode: 0o700 });
129
+ if (!fs.existsSync(PLAN_DIR))
130
+ fs.mkdirSync(PLAN_DIR, { recursive: true, mode: 0o700 });
144
131
  }
145
-
146
132
  /** @returns {string} */
147
133
  function planSignature(archetype, keywords) {
148
- return `${String(archetype || '')}\u0000${[...new Set(keywords || [])].sort().join('\u0001')}`;
134
+ return `${String(archetype || '')}\u0000${[...new Set(keywords || [])].sort().join('\u0001')}`;
149
135
  }
150
-
151
136
  function rebuildIndex(plans) {
152
- exactIndex = new Map();
153
- for (const template of plans) {
154
- const signature = planSignature(template.archetype, template.keywords);
155
- const current = exactIndex.get(signature);
156
- if (!current || (template.wins || 0) > (current.wins || 0)) {
157
- exactIndex.set(signature, template);
137
+ exactIndex = new Map();
138
+ for (const template of plans) {
139
+ const signature = planSignature(template.archetype, template.keywords);
140
+ const current = exactIndex.get(signature);
141
+ if (!current || (template.wins || 0) > (current.wins || 0)) {
142
+ exactIndex.set(signature, template);
143
+ }
158
144
  }
159
- }
160
145
  }
161
-
146
+ function isRecord(value) {
147
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
148
+ }
149
+ function isPlanStep(value) {
150
+ if (!isRecord(value))
151
+ return false;
152
+ return typeof value.tool === 'string'
153
+ && typeof value.note === 'string'
154
+ && typeof value.ok === 'boolean'
155
+ && (value.why === undefined
156
+ || (typeof value.why === 'string' && SEED_FAILURE_CLASSES.has(value.why)));
157
+ }
158
+ function isPlanTemplate(value) {
159
+ if (!isRecord(value))
160
+ return false;
161
+ return typeof value.id === 'string'
162
+ && typeof value.archetype === 'string'
163
+ && Array.isArray(value.keywords)
164
+ && value.keywords.every((keyword) => typeof keyword === 'string')
165
+ && Array.isArray(value.steps)
166
+ && value.steps.every(isPlanStep)
167
+ && typeof value.uses === 'number'
168
+ && Number.isFinite(value.uses)
169
+ && typeof value.wins === 'number'
170
+ && Number.isFinite(value.wins)
171
+ && typeof value.createdAt === 'number'
172
+ && Number.isFinite(value.createdAt)
173
+ && typeof value.updatedAt === 'number'
174
+ && Number.isFinite(value.updatedAt);
175
+ }
162
176
  function readAll() {
163
- try {
164
- if (!fs.existsSync(PLAN_FILE)) {
165
- plansCache = [];
166
- plansMtimeMs = -1;
167
- rebuildIndex(plansCache);
168
- return plansCache;
177
+ try {
178
+ if (!fs.existsSync(PLAN_FILE)) {
179
+ plansCache = [];
180
+ plansMtimeMs = -1;
181
+ rebuildIndex(plansCache);
182
+ return plansCache;
183
+ }
184
+ const mtimeMs = fs.statSync(PLAN_FILE).mtimeMs;
185
+ if (plansCache && mtimeMs === plansMtimeMs)
186
+ return plansCache;
187
+ const parsed = JSON.parse(fs.readFileSync(PLAN_FILE, 'utf8'));
188
+ plansCache = Array.isArray(parsed) ? parsed.filter(isPlanTemplate) : [];
189
+ plansMtimeMs = mtimeMs;
190
+ rebuildIndex(plansCache);
191
+ return plansCache;
192
+ }
193
+ catch (e) {
194
+ plansCache = [];
195
+ plansMtimeMs = -1;
196
+ rebuildIndex(plansCache);
197
+ return plansCache; // a corrupt cache degrades to a cold one, never to a failed task
169
198
  }
170
- const mtimeMs = fs.statSync(PLAN_FILE).mtimeMs;
171
- if (plansCache && mtimeMs === plansMtimeMs) return plansCache;
172
- const parsed = JSON.parse(fs.readFileSync(PLAN_FILE, 'utf8'));
173
- plansCache = Array.isArray(parsed) ? parsed : [];
174
- plansMtimeMs = mtimeMs;
175
- rebuildIndex(plansCache);
176
- return plansCache;
177
- } catch (e) {
178
- plansCache = [];
179
- plansMtimeMs = -1;
180
- rebuildIndex(plansCache);
181
- return plansCache; // a corrupt cache degrades to a cold one, never to a failed task
182
- }
183
199
  }
184
-
185
200
  /**
186
201
  * @param {PlanTemplate[]} plans
187
202
  * @returns {boolean}
188
203
  */
189
204
  function writeAll(plans) {
190
- try {
191
- ensureDir();
192
- const temp = `${PLAN_FILE}.tmp_${process.pid}`;
193
- fs.writeFileSync(temp, JSON.stringify(plans, null, 2), { mode: 0o600 });
194
- fs.renameSync(temp, PLAN_FILE); // atomic: a torn write would erase the cache
195
- plansCache = plans;
196
- plansMtimeMs = fs.statSync(PLAN_FILE).mtimeMs;
197
- rebuildIndex(plans);
198
- return true;
199
- } catch (e) {
200
- return false; // losing the cache must never fail the task
201
- }
205
+ try {
206
+ ensureDir();
207
+ const temp = `${PLAN_FILE}.tmp_${process.pid}`;
208
+ fs.writeFileSync(temp, JSON.stringify(plans, null, 2), { mode: 0o600 });
209
+ fs.renameSync(temp, PLAN_FILE); // atomic: a torn write would erase the cache
210
+ plansCache = plans;
211
+ plansMtimeMs = fs.statSync(PLAN_FILE).mtimeMs;
212
+ rebuildIndex(plans);
213
+ return true;
214
+ }
215
+ catch (e) {
216
+ return false; // losing the cache must never fail the task
217
+ }
202
218
  }
203
-
204
219
  function defaultMetrics() {
205
- return {
206
- version: 1,
207
- lookups: 0,
208
- hits: 0,
209
- exactHits: 0,
210
- fuzzyHits: 0,
211
- misses: 0,
212
- bypassed: 0,
213
- records: 0,
214
- totalLookupMs: 0,
215
- maxLookupMs: 0,
216
- totalRecordMs: 0,
217
- recent: [],
218
- disabledUntil: 0,
219
- lastLookupAt: 0,
220
- lastRecordAt: 0
221
- };
220
+ return {
221
+ version: 1,
222
+ lookups: 0,
223
+ hits: 0,
224
+ exactHits: 0,
225
+ fuzzyHits: 0,
226
+ misses: 0,
227
+ bypassed: 0,
228
+ records: 0,
229
+ totalLookupMs: 0,
230
+ maxLookupMs: 0,
231
+ totalRecordMs: 0,
232
+ recent: [],
233
+ disabledUntil: 0,
234
+ lastLookupAt: 0,
235
+ lastRecordAt: 0
236
+ };
237
+ }
238
+ const METRIC_NUMBER_FIELDS = [
239
+ 'lookups', 'hits', 'exactHits', 'fuzzyHits', 'misses', 'bypassed', 'records',
240
+ 'totalLookupMs', 'maxLookupMs', 'totalRecordMs', 'disabledUntil',
241
+ 'lastLookupAt', 'lastRecordAt'
242
+ ];
243
+ function normalizeMetrics(value) {
244
+ const metrics = defaultMetrics();
245
+ if (!isRecord(value))
246
+ return metrics;
247
+ for (const key of METRIC_NUMBER_FIELDS) {
248
+ const candidate = value[key];
249
+ if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) {
250
+ metrics[key] = candidate;
251
+ }
252
+ }
253
+ if (Array.isArray(value.recent)) {
254
+ metrics.recent = value.recent
255
+ .filter((entry) => entry === 0 || entry === 1)
256
+ .slice(-RECENT_LOOKUP_WINDOW);
257
+ }
258
+ return metrics;
222
259
  }
223
-
224
260
  function readMetrics() {
225
- if (metricsCache) return metricsCache;
226
- try {
227
- if (!fs.existsSync(METRICS_FILE)) {
228
- metricsCache = defaultMetrics();
229
- return metricsCache;
261
+ if (metricsCache)
262
+ return metricsCache;
263
+ try {
264
+ if (!fs.existsSync(METRICS_FILE)) {
265
+ metricsCache = defaultMetrics();
266
+ return metricsCache;
267
+ }
268
+ const parsed = JSON.parse(fs.readFileSync(METRICS_FILE, 'utf8'));
269
+ metricsCache = normalizeMetrics(parsed);
230
270
  }
231
- const parsed = JSON.parse(fs.readFileSync(METRICS_FILE, 'utf8'));
232
- metricsCache = { ...defaultMetrics(), ...(parsed && typeof parsed === 'object' ? parsed : {}) };
233
- if (!Array.isArray(metricsCache.recent)) metricsCache.recent = [];
234
- } catch (e) {
235
- metricsCache = defaultMetrics();
236
- }
237
- return metricsCache;
271
+ catch (e) {
272
+ metricsCache = defaultMetrics();
273
+ }
274
+ return metricsCache;
238
275
  }
239
-
240
276
  function writeMetrics(force = false) {
241
- if (!metricsCache || (!force && dirtyMetricEvents < METRICS_FLUSH_INTERVAL)) return false;
242
- try {
243
- ensureDir();
244
- const temp = `${METRICS_FILE}.tmp_${process.pid}`;
245
- fs.writeFileSync(temp, JSON.stringify(metricsCache, null, 2), { mode: 0o600 });
246
- fs.renameSync(temp, METRICS_FILE);
247
- dirtyMetricEvents = 0;
248
- return true;
249
- } catch (e) {
250
- return false;
251
- }
277
+ if (!metricsCache || (!force && dirtyMetricEvents < METRICS_FLUSH_INTERVAL))
278
+ return false;
279
+ try {
280
+ ensureDir();
281
+ const temp = `${METRICS_FILE}.tmp_${process.pid}`;
282
+ fs.writeFileSync(temp, JSON.stringify(metricsCache, null, 2), { mode: 0o600 });
283
+ fs.renameSync(temp, METRICS_FILE);
284
+ dirtyMetricEvents = 0;
285
+ return true;
286
+ }
287
+ catch (e) {
288
+ return false;
289
+ }
252
290
  }
253
-
254
291
  function recentHitRate(metrics) {
255
- if (!metrics.recent.length) return 0;
256
- return metrics.recent.reduce((sum, value) => sum + value, 0) / metrics.recent.length;
292
+ if (!metrics.recent.length)
293
+ return 0;
294
+ return metrics.recent.reduce((sum, value) => sum + value, 0) / metrics.recent.length;
257
295
  }
258
-
259
296
  function shouldTemporarilyDisable(metrics, planCount, now) {
260
- if (metrics.disabledUntil > now) return true;
261
- if (metrics.disabledUntil > 0 && metrics.disabledUntil <= now) {
262
- metrics.disabledUntil = 0;
263
- metrics.recent = [];
264
- }
265
- if (planCount < MIN_PLANS_BEFORE_DISABLE || metrics.recent.length < MIN_LOOKUPS_BEFORE_DISABLE) {
266
- return false;
267
- }
268
- if (recentHitRate(metrics) >= MIN_HEALTHY_HIT_RATE) return false;
269
- metrics.disabledUntil = now + DISABLE_COOLDOWN_MS;
270
- writeMetrics(true);
271
- return true;
297
+ if (metrics.disabledUntil > now)
298
+ return true;
299
+ if (metrics.disabledUntil > 0 && metrics.disabledUntil <= now) {
300
+ metrics.disabledUntil = 0;
301
+ metrics.recent = [];
302
+ }
303
+ if (planCount < MIN_PLANS_BEFORE_DISABLE || metrics.recent.length < MIN_LOOKUPS_BEFORE_DISABLE) {
304
+ return false;
305
+ }
306
+ if (recentHitRate(metrics) >= MIN_HEALTHY_HIT_RATE)
307
+ return false;
308
+ metrics.disabledUntil = now + DISABLE_COOLDOWN_MS;
309
+ writeMetrics(true);
310
+ return true;
272
311
  }
273
-
274
312
  function recordLookupTelemetry({ hit, matchType = null, durationMs, bypassed = false }) {
275
- const metrics = readMetrics();
276
- const now = Date.now();
277
- metrics.lastLookupAt = now;
278
- if (bypassed) {
279
- metrics.bypassed += 1;
280
- } else {
281
- metrics.lookups += 1;
282
- metrics.totalLookupMs += durationMs;
283
- metrics.maxLookupMs = Math.max(metrics.maxLookupMs, durationMs);
284
- metrics.recent.push(hit ? 1 : 0);
285
- if (metrics.recent.length > RECENT_LOOKUP_WINDOW) metrics.recent.shift();
286
- if (hit) {
287
- metrics.hits += 1;
288
- if (matchType === 'exact') metrics.exactHits += 1;
289
- if (matchType === 'fuzzy') metrics.fuzzyHits += 1;
290
- } else {
291
- metrics.misses += 1;
313
+ const metrics = readMetrics();
314
+ const now = Date.now();
315
+ metrics.lastLookupAt = now;
316
+ if (bypassed) {
317
+ metrics.bypassed += 1;
292
318
  }
293
- }
294
- dirtyMetricEvents += 1;
295
- writeMetrics(false);
319
+ else {
320
+ metrics.lookups += 1;
321
+ metrics.totalLookupMs += durationMs;
322
+ metrics.maxLookupMs = Math.max(metrics.maxLookupMs, durationMs);
323
+ metrics.recent.push(hit ? 1 : 0);
324
+ if (metrics.recent.length > RECENT_LOOKUP_WINDOW)
325
+ metrics.recent.shift();
326
+ if (hit) {
327
+ metrics.hits += 1;
328
+ if (matchType === 'exact')
329
+ metrics.exactHits += 1;
330
+ if (matchType === 'fuzzy')
331
+ metrics.fuzzyHits += 1;
332
+ }
333
+ else {
334
+ metrics.misses += 1;
335
+ }
336
+ }
337
+ dirtyMetricEvents += 1;
338
+ writeMetrics(false);
296
339
  }
297
-
298
340
  /**
299
341
  * Intent keywords, computed without a model.
300
342
  *
@@ -302,15 +344,13 @@ function recordLookupTelemetry({ hit, matchType = null, durationMs, bypassed = f
302
344
  * @returns {string[]} unique, lowercase, order not significant
303
345
  */
304
346
  export function extractKeywords(text) {
305
- const words = String(text || '')
306
- .toLowerCase()
307
- .replace(/[^\p{L}\p{N}\s_-]/gu, ' ')
308
- .split(/\s+/)
309
- .filter((w) => w.length >= 3 && w.length <= 24 && !STOPWORDS.has(w) && !/^\d+$/.test(w));
310
-
311
- return [...new Set(words)].slice(0, MAX_KEYWORDS);
347
+ const words = String(text || '')
348
+ .toLowerCase()
349
+ .replace(/[^\p{L}\p{N}\s_-]/gu, ' ')
350
+ .split(/\s+/)
351
+ .filter((w) => w.length >= 3 && w.length <= 24 && !STOPWORDS.has(w) && !/^\d+$/.test(w));
352
+ return [...new Set(words)].slice(0, MAX_KEYWORDS);
312
353
  }
313
-
314
354
  /**
315
355
  * Jaccard overlap. Symmetric, bounded 0..1, and cheap enough to run against
316
356
  * every stored plan without anyone noticing.
@@ -320,15 +360,16 @@ export function extractKeywords(text) {
320
360
  * @returns {number}
321
361
  */
322
362
  export function similarity(a, b) {
323
- const left = new Set(a || []);
324
- const right = new Set(b || []);
325
- if (left.size === 0 || right.size === 0) return 0;
326
-
327
- let shared = 0;
328
- for (const word of left) if (right.has(word)) shared++;
329
- return shared / (left.size + right.size - shared);
363
+ const left = new Set(a || []);
364
+ const right = new Set(b || []);
365
+ if (left.size === 0 || right.size === 0)
366
+ return 0;
367
+ let shared = 0;
368
+ for (const word of left)
369
+ if (right.has(word))
370
+ shared++;
371
+ return shared / (left.size + right.size - shared);
330
372
  }
331
-
332
373
  /**
333
374
  * Reduce a tool call to a reusable step.
334
375
  *
@@ -342,26 +383,24 @@ export function similarity(a, b) {
342
383
  * @returns {PlanStep}
343
384
  */
344
385
  export function toStep(tool, args, ok, result) {
345
- const SAFE_KEYS = ['action', 'verb', 'mode', 'operation', 'kind', 'type'];
346
- let note = '';
347
-
348
- for (const key of SAFE_KEYS) {
349
- const value = args && args[key];
350
- if (typeof value === 'string' && value) {
351
- note = value.toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 32);
352
- if (note) break;
386
+ const SAFE_KEYS = ['action', 'verb', 'mode', 'operation', 'kind', 'type'];
387
+ let note = '';
388
+ for (const key of SAFE_KEYS) {
389
+ const value = isRecord(args) ? args[key] : undefined;
390
+ if (typeof value === 'string' && value) {
391
+ note = value.toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 32);
392
+ if (note)
393
+ break;
394
+ }
353
395
  }
354
- }
355
-
356
- /** @type {PlanStep} */
357
- const step = { tool: String(tool || '').slice(0, 64), note, ok: Boolean(ok) };
358
- if (!step.ok) {
359
- const why = failureClass(result);
360
- if (why) step.why = why;
361
- }
362
- return step;
396
+ const step = { tool: String(tool || '').slice(0, 64), note, ok: Boolean(ok) };
397
+ if (!step.ok) {
398
+ const why = failureClass(result);
399
+ if (why)
400
+ step.why = why;
401
+ }
402
+ return step;
363
403
  }
364
-
365
404
  /**
366
405
  * Why a step failed, as a CLASS rather than as a message.
367
406
  *
@@ -378,25 +417,34 @@ export function toStep(tool, args, ok, result) {
378
417
  * @returns {string} one of the classes below, or '' when nothing matched
379
418
  */
380
419
  export function failureClass(result) {
381
- if (!result) return '';
382
- const text = [result.error, result.message, result.stderr, result.reason]
383
- .filter((v) => typeof v === 'string')
384
- .join(' ')
385
- .toLowerCase()
386
- .slice(0, 500);
387
- if (!text) return typeof result.exitCode === 'number' && result.exitCode !== 0 ? 'exit_nonzero' : '';
388
-
389
- if (/not found|no such file|enoent|command not found|cannot find/.test(text)) return 'not_found';
390
- if (/permission|denied|eacces|eperm|unauthor|forbidden/.test(text)) return 'permission';
391
- if (/timed? ?out|etimedout|deadline/.test(text)) return 'timeout';
392
- if (/econnrefused|enotfound|network|dns|socket|offline/.test(text)) return 'network';
393
- if (/rate limit|429|quota|budget/.test(text)) return 'rate_limited';
394
- if (/invalid|malformed|schema|required (property|field)|bad request|400/.test(text)) return 'invalid_args';
395
- if (/syntax ?error|parse error|unexpected token/.test(text)) return 'syntax';
396
- if (/exit code|non-?zero|exited with/.test(text)) return 'exit_nonzero';
397
- return 'failed';
420
+ if (!result)
421
+ return '';
422
+ const source = isRecord(result) ? result : {};
423
+ const text = [source.error, source.message, source.stderr, source.reason]
424
+ .filter((v) => typeof v === 'string')
425
+ .join(' ')
426
+ .toLowerCase()
427
+ .slice(0, 500);
428
+ if (!text)
429
+ return typeof source.exitCode === 'number' && source.exitCode !== 0 ? 'exit_nonzero' : '';
430
+ if (/not found|no such file|enoent|command not found|cannot find/.test(text))
431
+ return 'not_found';
432
+ if (/permission|denied|eacces|eperm|unauthor|forbidden/.test(text))
433
+ return 'permission';
434
+ if (/timed? ?out|etimedout|deadline/.test(text))
435
+ return 'timeout';
436
+ if (/econnrefused|enotfound|network|dns|socket|offline/.test(text))
437
+ return 'network';
438
+ if (/rate limit|429|quota|budget/.test(text))
439
+ return 'rate_limited';
440
+ if (/invalid|malformed|schema|required (property|field)|bad request|400/.test(text))
441
+ return 'invalid_args';
442
+ if (/syntax ?error|parse error|unexpected token/.test(text))
443
+ return 'syntax';
444
+ if (/exit code|non-?zero|exited with/.test(text))
445
+ return 'exit_nonzero';
446
+ return 'failed';
398
447
  }
399
-
400
448
  /**
401
449
  * The best stored plan for this request, if one is close enough.
402
450
  *
@@ -404,74 +452,68 @@ export function failureClass(result) {
404
452
  * @returns {PlanMatch|null}
405
453
  */
406
454
  export function lookupPlan(query) {
407
- const startedAt = performance.now();
408
- const keywords = extractKeywords(query?.prompt);
409
- if (keywords.length === 0) {
410
- recordLookupTelemetry({ hit: false, durationMs: performance.now() - startedAt });
411
- return null;
412
- }
413
-
414
- const archetype = String(query?.archetype || '');
415
- const plans = readAll();
416
- const now = Date.now();
417
- if (shouldTemporarilyDisable(readMetrics(), plans.length, now)) {
418
- recordLookupTelemetry({ hit: false, durationMs: performance.now() - startedAt, bypassed: true });
419
- return null;
420
- }
421
-
422
- const exact = exactIndex.get(planSignature(archetype, keywords));
423
- if (exact) {
455
+ const startedAt = performance.now();
456
+ const keywords = extractKeywords(query?.prompt);
457
+ if (keywords.length === 0) {
458
+ recordLookupTelemetry({ hit: false, durationMs: performance.now() - startedAt });
459
+ return null;
460
+ }
461
+ const archetype = String(query?.archetype || '');
462
+ const plans = readAll();
463
+ const now = Date.now();
464
+ if (shouldTemporarilyDisable(readMetrics(), plans.length, now)) {
465
+ recordLookupTelemetry({ hit: false, durationMs: performance.now() - startedAt, bypassed: true });
466
+ return null;
467
+ }
468
+ const exact = exactIndex.get(planSignature(archetype, keywords));
469
+ if (exact) {
470
+ const durationMs = performance.now() - startedAt;
471
+ recordLookupTelemetry({ hit: true, matchType: 'exact', durationMs });
472
+ return {
473
+ template: exact,
474
+ similarity: 1,
475
+ matchType: 'exact',
476
+ telemetry: { keywordCount: keywords.length, lookupMs: durationMs }
477
+ };
478
+ }
479
+ // Low-confidence task classification is not enough evidence for fuzzy reuse.
480
+ // Exact matches remain safe; broader reuse waits until the task shape is at
481
+ // least medium confidence, avoiding the accuracy loss reported for aggressive
482
+ // fuzzy thresholds in APC Table 6.
483
+ if (query?.allowFuzzy === false) {
484
+ const durationMs = performance.now() - startedAt;
485
+ recordLookupTelemetry({ hit: false, durationMs });
486
+ return null;
487
+ }
488
+ let best = null;
489
+ for (const template of plans) {
490
+ // Archetype is a hard gate, not a score. A "build a 3D scene" plan sharing
491
+ // three words with "debug the build" is not a plan for it.
492
+ if (archetype && template.archetype && template.archetype !== archetype)
493
+ continue;
494
+ const score = similarity(keywords, template.keywords);
495
+ if (score < MATCH_THRESHOLD)
496
+ continue;
497
+ // Ties break on demonstrated usefulness, so a plan that has actually worked
498
+ // outranks one that merely matched before.
499
+ const winRate = template.uses > 0 ? template.wins / template.uses : 0;
500
+ const ranked = score + winRate * 0.1;
501
+ if (!best || ranked > best.ranked)
502
+ best = { template, similarity: score, ranked };
503
+ }
424
504
  const durationMs = performance.now() - startedAt;
425
- recordLookupTelemetry({ hit: true, matchType: 'exact', durationMs });
505
+ if (!best) {
506
+ recordLookupTelemetry({ hit: false, durationMs });
507
+ return null;
508
+ }
509
+ recordLookupTelemetry({ hit: true, matchType: 'fuzzy', durationMs });
426
510
  return {
427
- template: exact,
428
- similarity: 1,
429
- matchType: 'exact',
430
- telemetry: { keywordCount: keywords.length, lookupMs: durationMs }
511
+ template: best.template,
512
+ similarity: best.similarity,
513
+ matchType: 'fuzzy',
514
+ telemetry: { keywordCount: keywords.length, lookupMs: durationMs }
431
515
  };
432
- }
433
-
434
- // Low-confidence task classification is not enough evidence for fuzzy reuse.
435
- // Exact matches remain safe; broader reuse waits until the task shape is at
436
- // least medium confidence, avoiding the accuracy loss reported for aggressive
437
- // fuzzy thresholds in APC Table 6.
438
- if (query?.allowFuzzy === false) {
439
- const durationMs = performance.now() - startedAt;
440
- recordLookupTelemetry({ hit: false, durationMs });
441
- return null;
442
- }
443
-
444
- let best = null;
445
-
446
- for (const template of plans) {
447
- // Archetype is a hard gate, not a score. A "build a 3D scene" plan sharing
448
- // three words with "debug the build" is not a plan for it.
449
- if (archetype && template.archetype && template.archetype !== archetype) continue;
450
-
451
- const score = similarity(keywords, template.keywords);
452
- if (score < MATCH_THRESHOLD) continue;
453
-
454
- // Ties break on demonstrated usefulness, so a plan that has actually worked
455
- // outranks one that merely matched before.
456
- const winRate = template.uses > 0 ? template.wins / template.uses : 0;
457
- const ranked = score + winRate * 0.1;
458
- if (!best || ranked > best.ranked) best = { template, similarity: score, ranked };
459
- }
460
-
461
- const durationMs = performance.now() - startedAt;
462
- if (!best) {
463
- recordLookupTelemetry({ hit: false, durationMs });
464
- return null;
465
- }
466
- recordLookupTelemetry({ hit: true, matchType: 'fuzzy', durationMs });
467
- return {
468
- template: best.template,
469
- similarity: best.similarity,
470
- matchType: 'fuzzy',
471
- telemetry: { keywordCount: keywords.length, lookupMs: durationMs }
472
- };
473
516
  }
474
-
475
517
  /**
476
518
  * Store or reinforce the procedure a task actually followed.
477
519
  *
@@ -479,26 +521,44 @@ export function lookupPlan(query) {
479
521
  * @returns {PlanTemplate|null} the stored template, or null when there was nothing worth storing
480
522
  */
481
523
  export function recordPlan(outcome) {
482
- const startedAt = performance.now();
483
- const steps = Array.isArray(outcome?.steps) ? outcome.steps.slice(0, MAX_STEPS) : [];
484
- // A one-step task teaches nothing a fresh model would not do anyway.
485
- if (steps.length < 2) return null;
486
-
487
- const keywords = extractKeywords(outcome?.prompt);
488
- if (keywords.length === 0) return null;
489
-
490
- const plans = readAll();
491
- const archetype = String(outcome?.archetype || '');
492
-
493
- const existing = plans.find(
494
- (t) => t.archetype === archetype && similarity(keywords, t.keywords) >= 0.8
495
- );
496
-
497
- if (existing) {
498
- existing.steps = steps; // the newest working route wins
499
- existing.uses += 1;
500
- existing.wins += 1;
501
- existing.updatedAt = Date.now();
524
+ const startedAt = performance.now();
525
+ const steps = Array.isArray(outcome?.steps)
526
+ ? outcome.steps.filter(isPlanStep).slice(0, MAX_STEPS)
527
+ : [];
528
+ // A one-step task teaches nothing a fresh model would not do anyway.
529
+ if (steps.length < 2)
530
+ return null;
531
+ const keywords = extractKeywords(outcome?.prompt);
532
+ if (keywords.length === 0)
533
+ return null;
534
+ const plans = readAll();
535
+ const archetype = String(outcome?.archetype || '');
536
+ const existing = plans.find((t) => t.archetype === archetype && similarity(keywords, t.keywords) >= 0.8);
537
+ if (existing) {
538
+ existing.steps = steps; // the newest working route wins
539
+ existing.uses += 1;
540
+ existing.wins += 1;
541
+ existing.updatedAt = Date.now();
542
+ writeAll(prune(plans));
543
+ const metrics = readMetrics();
544
+ metrics.records += 1;
545
+ metrics.totalRecordMs += performance.now() - startedAt;
546
+ metrics.lastRecordAt = Date.now();
547
+ dirtyMetricEvents += 1;
548
+ writeMetrics(false);
549
+ return existing;
550
+ }
551
+ const template = {
552
+ id: `p_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
553
+ archetype,
554
+ keywords,
555
+ steps,
556
+ uses: 1,
557
+ wins: 1,
558
+ createdAt: Date.now(),
559
+ updatedAt: Date.now()
560
+ };
561
+ plans.push(template);
502
562
  writeAll(prune(plans));
503
563
  const metrics = readMetrics();
504
564
  metrics.records += 1;
@@ -506,82 +566,63 @@ export function recordPlan(outcome) {
506
566
  metrics.lastRecordAt = Date.now();
507
567
  dirtyMetricEvents += 1;
508
568
  writeMetrics(false);
509
- return existing;
510
- }
511
-
512
- /** @type {PlanTemplate} */
513
- const template = {
514
- id: `p_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
515
- archetype,
516
- keywords,
517
- steps,
518
- uses: 1,
519
- wins: 1,
520
- createdAt: Date.now(),
521
- updatedAt: Date.now()
522
- };
523
-
524
- plans.push(template);
525
- writeAll(prune(plans));
526
- const metrics = readMetrics();
527
- metrics.records += 1;
528
- metrics.totalRecordMs += performance.now() - startedAt;
529
- metrics.lastRecordAt = Date.now();
530
- dirtyMetricEvents += 1;
531
- writeMetrics(false);
532
- return template;
569
+ return template;
533
570
  }
534
-
535
571
  /**
536
572
  * Pre-populate a cold cache from user-supplied, argument-free plan samples.
537
573
  * Only tool names, short action labels and closed-vocabulary outcomes survive;
538
574
  * paths, prompts passed to tools and command lines have no field to enter.
539
575
  */
540
576
  export function prewarmPlans(samples) {
541
- if (!Array.isArray(samples)) return { accepted: 0, rejected: 1, errors: ['Expected a JSON array'] };
542
- const errors = [];
543
- let accepted = 0;
544
-
545
- for (const [index, sample] of samples.slice(0, 100).entries()) {
546
- const prompt = typeof sample?.prompt === 'string' ? sample.prompt.trim() : '';
547
- const archetype = typeof sample?.archetype === 'string' ? sample.archetype.trim().slice(0, 64) : '';
548
- const rawSteps = Array.isArray(sample?.steps) ? sample.steps : [];
549
- if (!prompt || rawSteps.length < 2) {
550
- errors.push(`Sample ${index + 1}: prompt and at least two steps are required`);
551
- continue;
552
- }
553
-
554
- const steps = [];
555
- let invalid = false;
556
- for (const rawStep of rawSteps.slice(0, MAX_STEPS)) {
557
- const source = typeof rawStep === 'string' ? { tool: rawStep } : rawStep;
558
- const tool = typeof source?.tool === 'string' ? source.tool.trim() : '';
559
- if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(tool)) {
560
- invalid = true;
561
- break;
562
- }
563
- const note = typeof source.action === 'string'
564
- ? source.action.toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 32)
565
- : '';
566
- const step = { tool, note, ok: source.ok !== false };
567
- if (!step.ok) {
568
- step.why = SEED_FAILURE_CLASSES.has(source.why) ? source.why : 'failed';
569
- }
570
- steps.push(step);
571
- }
572
- if (invalid) {
573
- errors.push(`Sample ${index + 1}: invalid tool name`);
574
- continue;
577
+ if (!Array.isArray(samples))
578
+ return { accepted: 0, rejected: 1, errors: ['Expected a JSON array'] };
579
+ const errors = [];
580
+ let accepted = 0;
581
+ for (const [index, sample] of samples.slice(0, 100).entries()) {
582
+ const sampleRecord = isRecord(sample) ? sample : {};
583
+ const prompt = typeof sampleRecord.prompt === 'string' ? sampleRecord.prompt.trim() : '';
584
+ const archetype = typeof sampleRecord.archetype === 'string' ? sampleRecord.archetype.trim().slice(0, 64) : '';
585
+ const rawSteps = Array.isArray(sampleRecord.steps) ? sampleRecord.steps : [];
586
+ if (!prompt || rawSteps.length < 2) {
587
+ errors.push(`Sample ${index + 1}: prompt and at least two steps are required`);
588
+ continue;
589
+ }
590
+ const steps = [];
591
+ let invalid = false;
592
+ for (const rawStep of rawSteps.slice(0, MAX_STEPS)) {
593
+ const source = typeof rawStep === 'string'
594
+ ? { tool: rawStep }
595
+ : isRecord(rawStep) ? rawStep : {};
596
+ const tool = typeof source.tool === 'string' ? source.tool.trim() : '';
597
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(tool)) {
598
+ invalid = true;
599
+ break;
600
+ }
601
+ const note = typeof source.action === 'string'
602
+ ? source.action.toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 32)
603
+ : '';
604
+ const step = { tool, note, ok: source.ok !== false };
605
+ if (!step.ok) {
606
+ step.why = typeof source.why === 'string'
607
+ && SEED_FAILURE_CLASSES.has(source.why)
608
+ ? source.why
609
+ : 'failed';
610
+ }
611
+ steps.push(step);
612
+ }
613
+ if (invalid) {
614
+ errors.push(`Sample ${index + 1}: invalid tool name`);
615
+ continue;
616
+ }
617
+ if (recordPlan({ prompt, archetype, steps }))
618
+ accepted++;
619
+ else
620
+ errors.push(`Sample ${index + 1}: could not be stored`);
575
621
  }
576
-
577
- if (recordPlan({ prompt, archetype, steps })) accepted++;
578
- else errors.push(`Sample ${index + 1}: could not be stored`);
579
- }
580
-
581
- if (samples.length > 100) errors.push(`Only the first 100 of ${samples.length} samples were considered`);
582
- return { accepted, rejected: errors.length, errors: errors.slice(0, 20) };
622
+ if (samples.length > 100)
623
+ errors.push(`Only the first 100 of ${samples.length} samples were considered`);
624
+ return { accepted, rejected: errors.length, errors: errors.slice(0, 20) };
583
625
  }
584
-
585
626
  /**
586
627
  * Record that an offered plan did NOT lead anywhere, so a bad template loses
587
628
  * its rank instead of being offered forever.
@@ -590,14 +631,14 @@ export function prewarmPlans(samples) {
590
631
  * @returns {boolean}
591
632
  */
592
633
  export function recordMiss(id) {
593
- const plans = readAll();
594
- const template = plans.find((t) => t.id === id);
595
- if (!template) return false;
596
- template.uses += 1;
597
- template.updatedAt = Date.now();
598
- return writeAll(plans);
634
+ const plans = readAll();
635
+ const template = plans.find((t) => t.id === id);
636
+ if (!template)
637
+ return false;
638
+ template.uses += 1;
639
+ template.updatedAt = Date.now();
640
+ return writeAll(plans);
599
641
  }
600
-
601
642
  /**
602
643
  * Keep the cache bounded by usefulness rather than by age alone: a plan used
603
644
  * ten times last month is worth more than one stored yesterday and never hit.
@@ -606,19 +647,17 @@ export function recordMiss(id) {
606
647
  * @returns {PlanTemplate[]}
607
648
  */
608
649
  export function prune(plans) {
609
- if (plans.length <= MAX_PLANS) return plans;
610
- const now = Date.now();
611
- const MONTH = 30 * 24 * 60 * 60 * 1000;
612
-
613
- return [...plans]
614
- .sort((a, b) => {
615
- const value = (t) =>
616
- (t.uses > 0 ? t.wins / t.uses : 0) * Math.pow(2, -(now - t.updatedAt) / MONTH) * Math.log2(1 + t.uses);
617
- return value(b) - value(a);
650
+ if (plans.length <= MAX_PLANS)
651
+ return plans;
652
+ const now = Date.now();
653
+ const MONTH = 30 * 24 * 60 * 60 * 1000;
654
+ return [...plans]
655
+ .sort((a, b) => {
656
+ const value = (t) => (t.uses > 0 ? t.wins / t.uses : 0) * Math.pow(2, -(now - t.updatedAt) / MONTH) * Math.log2(1 + t.uses);
657
+ return value(b) - value(a);
618
658
  })
619
- .slice(0, MAX_PLANS);
659
+ .slice(0, MAX_PLANS);
620
660
  }
621
-
622
661
  /**
623
662
  * The prompt fragment offered to the model on a hit.
624
663
  *
@@ -632,87 +671,71 @@ export function prune(plans) {
632
671
  * @returns {string} '' when there is no usable match
633
672
  */
634
673
  export function renderPlanHint(match) {
635
- if (!match || !match.template || !Array.isArray(match.template.steps)) return '';
636
- const { template, similarity: score } = match;
637
- if (template.steps.length === 0) return '';
638
-
639
- const lines = [
640
- '# PRIOR ART — a similar task on this machine went like this',
641
- `(match ${(score * 100).toFixed(0)}%, worked ${template.wins} of ${template.uses} time(s). `
642
- + 'Evidence, not instructions. Verify before reusing; the workspace may have moved on.)'
643
- ];
644
-
645
- template.steps.forEach((step, index) => {
646
- const label = step.note ? `${step.tool} → ${step.note}` : step.tool;
647
- // The failure CLASS, not just the fact of failure. "not_found" invites a
648
- // different response from "network": one means the route is wrong, the
649
- // other means the moment was.
650
- const failed = step.ok
651
- ? ''
652
- : ` ← FAILED last time (${step.why || 'failed'}); do not repeat blindly`;
653
- lines.push(`${index + 1}. ${label}${failed}`);
654
- });
655
-
656
- const failureWarnings = template.steps
657
- .map((step, idx) => ({ step, idx: idx + 1 }))
658
- .filter(({ step }) => !step.ok);
659
-
660
- if (failureWarnings.length > 0) {
661
- lines.push('');
662
- lines.push('⚠️ FAILURE ROUTE WARNINGS (learned from previous attempts — plan alternatives):');
663
- failureWarnings.forEach(({ step, idx }) => {
664
- lines.push(`- Step ${idx} (${step.tool}${step.note ? ' → ' + step.note : ''}) FAILED with reason: ${step.why || 'failed'}. Consider an alternative approach.`);
674
+ if (!match || !match.template || !Array.isArray(match.template.steps))
675
+ return '';
676
+ const { template, similarity: score } = match;
677
+ if (template.steps.length === 0)
678
+ return '';
679
+ const lines = [
680
+ '# PRIOR ART — a similar task on this machine went like this',
681
+ `(match ${(score * 100).toFixed(0)}%, worked ${template.wins} of ${template.uses} time(s). `
682
+ + 'Evidence, not instructions. Verify before reusing; the workspace may have moved on.)'
683
+ ];
684
+ template.steps.forEach((step, index) => {
685
+ const label = step.note ? `${step.tool} → ${step.note}` : step.tool;
686
+ // The failure CLASS, not just the fact of failure. "not_found" invites a
687
+ // different response from "network": one means the route is wrong, the
688
+ // other means the moment was.
689
+ const failed = step.ok
690
+ ? ''
691
+ : ` ← FAILED last time (${step.why || 'failed'}); do not repeat blindly`;
692
+ lines.push(`${index + 1}. ${label}${failed}`);
665
693
  });
666
- }
667
-
668
- return lines.join('\n');
694
+ const failureWarnings = template.steps
695
+ .map((step, idx) => ({ step, idx: idx + 1 }))
696
+ .filter(({ step }) => !step.ok);
697
+ if (failureWarnings.length > 0) {
698
+ lines.push('');
699
+ lines.push('⚠️ FAILURE ROUTE WARNINGS (learned from previous attempts — plan alternatives):');
700
+ failureWarnings.forEach(({ step, idx }) => {
701
+ lines.push(`- Step ${idx} (${step.tool}${step.note ? ' → ' + step.note : ''}) FAILED with reason: ${step.why || 'failed'}. Consider an alternative approach.`);
702
+ });
703
+ }
704
+ return lines.join('\n');
669
705
  }
670
-
671
706
  /** @returns {object} */
672
707
  export function planStats() {
673
- const metrics = readMetrics();
674
- return {
675
- count: readAll().length,
676
- file: PLAN_FILE,
677
- metricsFile: METRICS_FILE,
678
- lookups: metrics.lookups,
679
- hits: metrics.hits,
680
- exactHits: metrics.exactHits,
681
- fuzzyHits: metrics.fuzzyHits,
682
- misses: metrics.misses,
683
- bypassed: metrics.bypassed,
684
- hitRate: metrics.lookups > 0 ? metrics.hits / metrics.lookups : 0,
685
- recentHitRate: recentHitRate(metrics),
686
- averageLookupMs: metrics.lookups > 0 ? metrics.totalLookupMs / metrics.lookups : 0,
687
- maxLookupMs: metrics.maxLookupMs,
688
- averageRecordMs: metrics.records > 0 ? metrics.totalRecordMs / metrics.records : 0,
689
- disabledUntil: metrics.disabledUntil,
690
- disabled: metrics.disabledUntil > Date.now()
691
- };
708
+ const metrics = readMetrics();
709
+ return {
710
+ count: readAll().length,
711
+ file: PLAN_FILE,
712
+ metricsFile: METRICS_FILE,
713
+ lookups: metrics.lookups,
714
+ hits: metrics.hits,
715
+ exactHits: metrics.exactHits,
716
+ fuzzyHits: metrics.fuzzyHits,
717
+ misses: metrics.misses,
718
+ bypassed: metrics.bypassed,
719
+ hitRate: metrics.lookups > 0 ? metrics.hits / metrics.lookups : 0,
720
+ recentHitRate: recentHitRate(metrics),
721
+ averageLookupMs: metrics.lookups > 0 ? metrics.totalLookupMs / metrics.lookups : 0,
722
+ maxLookupMs: metrics.maxLookupMs,
723
+ averageRecordMs: metrics.records > 0 ? metrics.totalRecordMs / metrics.records : 0,
724
+ disabledUntil: metrics.disabledUntil,
725
+ disabled: metrics.disabledUntil > Date.now()
726
+ };
692
727
  }
693
-
694
728
  /**
695
729
  * Remove every stored plan. Used by `/cortex --forget` style operations and by
696
730
  * tests; never called automatically.
697
731
  * @returns {number} how many were removed
698
732
  */
699
733
  export function forgetPlans() {
700
- const count = readAll().length;
701
- writeAll([]);
702
- metricsCache = defaultMetrics();
703
- dirtyMetricEvents = 1;
704
- writeMetrics(true);
705
- return count;
734
+ const count = readAll().length;
735
+ writeAll([]);
736
+ metricsCache = defaultMetrics();
737
+ dirtyMetricEvents = 1;
738
+ writeMetrics(true);
739
+ return count;
706
740
  }
707
-
708
- export {
709
- PLAN_FILE,
710
- PLAN_DIR,
711
- METRICS_FILE,
712
- MATCH_THRESHOLD,
713
- MAX_PLANS,
714
- MIN_LOOKUPS_BEFORE_DISABLE,
715
- MIN_PLANS_BEFORE_DISABLE,
716
- MIN_HEALTHY_HIT_RATE,
717
- DISABLE_COOLDOWN_MS
718
- };
741
+ export { PLAN_FILE, PLAN_DIR, METRICS_FILE, MATCH_THRESHOLD, MAX_PLANS, MIN_LOOKUPS_BEFORE_DISABLE, MIN_PLANS_BEFORE_DISABLE, MIN_HEALTHY_HIT_RATE, DISABLE_COOLDOWN_MS };