@causyne/agent 1.10.12 → 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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/docs/ARCHITECTURE.md +136 -7
  3. package/docs/SECURITY_REVIEW.md +13 -1
  4. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  5. package/package.json +11 -4
  6. package/src/agentLoop.js +161 -93
  7. package/src/agentProfiles.js +135 -0
  8. package/src/agentProfiles.ts +143 -0
  9. package/src/approvalGrants.js +17 -35
  10. package/src/approvalGrants.ts +54 -0
  11. package/src/auditLog.d.ts +3 -0
  12. package/src/benchmarks/planCache.js +159 -0
  13. package/src/browser/camofoxRuntime.js +93 -0
  14. package/src/browser/camofoxRuntime.ts +112 -0
  15. package/src/browser/causyneBrowser.js +495 -0
  16. package/src/browser/causyneBrowser.ts +594 -0
  17. package/src/browser/searchProviders.js +156 -0
  18. package/src/browser/searchProviders.ts +156 -0
  19. package/src/browser/snapshotWindow.js +28 -0
  20. package/src/browser/snapshotWindow.ts +40 -0
  21. package/src/browser/urlPolicy.js +89 -0
  22. package/src/browser/urlPolicy.ts +89 -0
  23. package/src/checkpoints.js +19 -6
  24. package/src/commands.js +120 -8
  25. package/src/config.d.ts +2 -0
  26. package/src/cortex/autoCondenser.js +1 -1
  27. package/src/hooks.js +150 -0
  28. package/src/hooks.ts +193 -0
  29. package/src/mcp/mcpClient.js +520 -132
  30. package/src/mcp/mcpClient.ts +584 -0
  31. package/src/mcp/mcpRegistry.js +399 -185
  32. package/src/mcp/mcpRegistry.ts +451 -0
  33. package/src/planCache.js +529 -225
  34. package/src/planCache.ts +840 -0
  35. package/src/plugins/pluginManager.js +460 -0
  36. package/src/plugins/pluginRunner.js +50 -0
  37. package/src/plugins/pluginSkills.js +84 -0
  38. package/src/plugins/pluginSkills.ts +106 -0
  39. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  40. package/src/provisioning/capabilityProvisioner.js +5 -0
  41. package/src/provisioning/capabilityTools.js +2 -1
  42. package/src/sandbox.js +53 -2
  43. package/src/sessionJournal.js +471 -0
  44. package/src/sessionJournal.ts +496 -0
  45. package/src/sessionStore.js +3 -279
  46. package/src/skillsManager.js +18 -0
  47. package/src/subagentJournal.js +215 -0
  48. package/src/subagentJournal.ts +254 -0
  49. package/src/taskShape.js +179 -198
  50. package/src/taskShape.ts +259 -0
  51. package/src/toolPacks.js +6 -3
  52. package/src/tools/browserAutomator.js +45 -241
  53. package/src/tools/browserAutomator.ts +57 -0
  54. package/src/tools/deferredMcpSearch.js +2 -2
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/pluginManager.js +61 -0
  57. package/src/tools/registry.js +47 -53
  58. package/src/tools/subagentOrchestrator.js +73 -12
  59. package/src/tools/toolForge.js +47 -95
  60. package/src/tools/webFetch.js +183 -68
  61. package/src/tools/webFetch.ts +185 -0
  62. package/src/tools/webSearch.js +50 -75
  63. package/src/tools/webSearch.ts +56 -0
  64. package/src/types/contracts.js +8 -0
  65. package/src/types/contracts.ts +263 -0
  66. package/src/utils/paths.d.ts +3 -0
  67. package/src/version.d.ts +1 -0
  68. 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,10 +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
37
  /** Two plans must overlap this much on keywords before one is offered for the other. */
40
38
  // Tuned against the asymmetry, not for elegance. A false positive costs a few
41
39
  // tokens of clearly-labelled prior art the model is told to verify. A false
@@ -43,47 +41,57 @@ const PLAN_FILE = path.join(PLAN_DIR, 'index.json');
43
41
  // ended a real run at the daily ceiling. Archetype is already a hard gate, so
44
42
  // the remaining risk is "same archetype, different task", and that is cheap.
45
43
  const MATCH_THRESHOLD = 0.38;
46
-
47
44
  /** Ceiling on retained templates. Pruned by usefulness, not by age alone. */
48
45
  const MAX_PLANS = 200;
49
-
50
46
  /** A plan longer than this is a transcript, not a procedure. */
51
47
  const MAX_STEPS = 24;
52
-
53
48
  /** Keywords kept per plan. Enough to discriminate, short enough to stay cheap. */
54
49
  const MAX_KEYWORDS = 12;
55
-
50
+ /** Exact keyword-set hits stay O(1); fuzzy matching is a bounded fallback. */
51
+ const RECENT_LOOKUP_WINDOW = 32;
52
+ const MIN_LOOKUPS_BEFORE_DISABLE = 20;
53
+ const MIN_PLANS_BEFORE_DISABLE = 10;
54
+ const MIN_HEALTHY_HIT_RATE = 0.10;
55
+ const DISABLE_COOLDOWN_MS = 5 * 60 * 1000;
56
+ const SEED_FAILURE_CLASSES = new Set([
57
+ 'not_found', 'permission', 'timeout', 'network', 'rate_limited',
58
+ 'invalid_args', 'syntax', 'exit_nonzero', 'failed'
59
+ ]);
60
+ const METRICS_FLUSH_INTERVAL = 10;
61
+ let plansCache = null;
62
+ let plansMtimeMs = -1;
63
+ let exactIndex = new Map();
64
+ let metricsCache = null;
65
+ let dirtyMetricEvents = 0;
56
66
  /**
57
67
  * Words that carry no intent. Deliberately small: an aggressive list throws
58
68
  * away the domain nouns that make two tasks comparable.
59
69
  * @type {ReadonlySet<string>}
60
70
  */
61
71
  const STOPWORDS = new Set([
62
- 'the', 'a', 'an', 'and', 'or', 'but', 'for', 'to', 'of', 'in', 'on', 'at',
63
- 'is', 'are', 'be', 'was', 'were', 'do', 'does', 'did', 'can', 'could',
64
- 'with', 'from', 'into', 'then', 'this', 'that', 'it', 'its', 'as', 'by',
65
- 'please', 'just', 'now', 'my', 'me', 'you', 'your', 'i',
66
- // Generic creation verbs. Every task has one, so they inflate the union
67
- // without discriminating between tasks — the exact shape that made
68
- // "buat pena 3D di Blender" and "bikin pulpen 3D di blender" score 0.40
69
- // when they describe the same job. `build`, `fix`, `debug` and `render` are
70
- // deliberately NOT here: those DO separate tasks inside one archetype.
71
- 'make', 'create', 'creating', 'made', 'generate',
72
- // Indonesian — the owner writes in both, and a mixed-language prompt must
73
- // key the same way whichever language framed it.
74
- 'dan', 'atau', 'yang', 'untuk', 'dari', 'ke', 'di', 'itu', 'ini', 'dengan',
75
- 'tolong', 'saya', 'aku', 'kamu', 'adalah', 'pada', 'juga', 'agar', 'jadi',
76
- 'buat', 'bikin', 'membuat', 'lalu', 'kemudian', 'terus', 'dulu', 'saja',
77
- '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'
78
88
  ]);
79
-
80
89
  // HONEST LIMIT. Matching is LEXICAL. A task cached in English and re-asked in
81
90
  // Indonesian (or the reverse) will miss, because Jaccard cannot know that
82
91
  // "pen" and "pena" are the same object. That is a deliberate trade: this path
83
92
  // is deterministic, instant, and works on a machine with no embedding model
84
93
  // provisioned. Cortex is the semantic route and already carries embeddings;
85
94
  // these two compose rather than compete.
86
-
87
95
  /**
88
96
  * One executed step, reduced to what is reusable.
89
97
  *
@@ -99,7 +107,6 @@ const STOPWORDS = new Set([
99
107
  * @property {string} [why] Failure CLASS when ok is false. A closed vocabulary,
100
108
  * never the error text.
101
109
  */
102
-
103
110
  /**
104
111
  * @typedef {Object} PlanTemplate
105
112
  * @property {string} id
@@ -111,46 +118,225 @@ const STOPWORDS = new Set([
111
118
  * @property {number} createdAt
112
119
  * @property {number} updatedAt
113
120
  */
114
-
115
121
  /**
116
122
  * @typedef {Object} PlanMatch
117
123
  * @property {PlanTemplate} template
118
124
  * @property {number} similarity
125
+ * @property {'exact'|'fuzzy'} matchType
126
+ * @property {{keywordCount: number, lookupMs: number}} telemetry
119
127
  */
120
-
121
128
  function ensureDir() {
122
- 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 });
131
+ }
132
+ /** @returns {string} */
133
+ function planSignature(archetype, keywords) {
134
+ return `${String(archetype || '')}\u0000${[...new Set(keywords || [])].sort().join('\u0001')}`;
135
+ }
136
+ function rebuildIndex(plans) {
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
+ }
144
+ }
145
+ }
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);
123
175
  }
124
-
125
- /**
126
- * @returns {PlanTemplate[]}
127
- */
128
176
  function readAll() {
129
- try {
130
- if (!fs.existsSync(PLAN_FILE)) return [];
131
- const parsed = JSON.parse(fs.readFileSync(PLAN_FILE, 'utf8'));
132
- return Array.isArray(parsed) ? parsed : [];
133
- } catch (e) {
134
- return []; // a corrupt cache degrades to a cold one, never to a failed task
135
- }
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
198
+ }
136
199
  }
137
-
138
200
  /**
139
201
  * @param {PlanTemplate[]} plans
140
202
  * @returns {boolean}
141
203
  */
142
204
  function writeAll(plans) {
143
- try {
144
- ensureDir();
145
- const temp = `${PLAN_FILE}.tmp_${process.pid}`;
146
- fs.writeFileSync(temp, JSON.stringify(plans, null, 2), { mode: 0o600 });
147
- fs.renameSync(temp, PLAN_FILE); // atomic: a torn write would erase the cache
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
+ }
218
+ }
219
+ function defaultMetrics() {
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;
259
+ }
260
+ function readMetrics() {
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);
270
+ }
271
+ catch (e) {
272
+ metricsCache = defaultMetrics();
273
+ }
274
+ return metricsCache;
275
+ }
276
+ function writeMetrics(force = false) {
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
+ }
290
+ }
291
+ function recentHitRate(metrics) {
292
+ if (!metrics.recent.length)
293
+ return 0;
294
+ return metrics.recent.reduce((sum, value) => sum + value, 0) / metrics.recent.length;
295
+ }
296
+ function shouldTemporarilyDisable(metrics, planCount, now) {
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);
148
310
  return true;
149
- } catch (e) {
150
- return false; // losing the cache must never fail the task
151
- }
152
311
  }
153
-
312
+ function recordLookupTelemetry({ hit, matchType = null, durationMs, bypassed = false }) {
313
+ const metrics = readMetrics();
314
+ const now = Date.now();
315
+ metrics.lastLookupAt = now;
316
+ if (bypassed) {
317
+ metrics.bypassed += 1;
318
+ }
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);
339
+ }
154
340
  /**
155
341
  * Intent keywords, computed without a model.
156
342
  *
@@ -158,15 +344,13 @@ function writeAll(plans) {
158
344
  * @returns {string[]} unique, lowercase, order not significant
159
345
  */
160
346
  export function extractKeywords(text) {
161
- const words = String(text || '')
162
- .toLowerCase()
163
- .replace(/[^\p{L}\p{N}\s_-]/gu, ' ')
164
- .split(/\s+/)
165
- .filter((w) => w.length >= 3 && w.length <= 24 && !STOPWORDS.has(w) && !/^\d+$/.test(w));
166
-
167
- 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);
168
353
  }
169
-
170
354
  /**
171
355
  * Jaccard overlap. Symmetric, bounded 0..1, and cheap enough to run against
172
356
  * every stored plan without anyone noticing.
@@ -176,15 +360,16 @@ export function extractKeywords(text) {
176
360
  * @returns {number}
177
361
  */
178
362
  export function similarity(a, b) {
179
- const left = new Set(a || []);
180
- const right = new Set(b || []);
181
- if (left.size === 0 || right.size === 0) return 0;
182
-
183
- let shared = 0;
184
- for (const word of left) if (right.has(word)) shared++;
185
- 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);
186
372
  }
187
-
188
373
  /**
189
374
  * Reduce a tool call to a reusable step.
190
375
  *
@@ -198,26 +383,24 @@ export function similarity(a, b) {
198
383
  * @returns {PlanStep}
199
384
  */
200
385
  export function toStep(tool, args, ok, result) {
201
- const SAFE_KEYS = ['action', 'verb', 'mode', 'operation', 'kind', 'type'];
202
- let note = '';
203
-
204
- for (const key of SAFE_KEYS) {
205
- const value = args && args[key];
206
- if (typeof value === 'string' && value) {
207
- note = value.toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 32);
208
- 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
+ }
209
395
  }
210
- }
211
-
212
- /** @type {PlanStep} */
213
- const step = { tool: String(tool || '').slice(0, 64), note, ok: Boolean(ok) };
214
- if (!step.ok) {
215
- const why = failureClass(result);
216
- if (why) step.why = why;
217
- }
218
- 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;
219
403
  }
220
-
221
404
  /**
222
405
  * Why a step failed, as a CLASS rather than as a message.
223
406
  *
@@ -234,57 +417,103 @@ export function toStep(tool, args, ok, result) {
234
417
  * @returns {string} one of the classes below, or '' when nothing matched
235
418
  */
236
419
  export function failureClass(result) {
237
- if (!result) return '';
238
- const text = [result.error, result.message, result.stderr, result.reason]
239
- .filter((v) => typeof v === 'string')
240
- .join(' ')
241
- .toLowerCase()
242
- .slice(0, 500);
243
- if (!text) return typeof result.exitCode === 'number' && result.exitCode !== 0 ? 'exit_nonzero' : '';
244
-
245
- if (/not found|no such file|enoent|command not found|cannot find/.test(text)) return 'not_found';
246
- if (/permission|denied|eacces|eperm|unauthor|forbidden/.test(text)) return 'permission';
247
- if (/timed? ?out|etimedout|deadline/.test(text)) return 'timeout';
248
- if (/econnrefused|enotfound|network|dns|socket|offline/.test(text)) return 'network';
249
- if (/rate limit|429|quota|budget/.test(text)) return 'rate_limited';
250
- if (/invalid|malformed|schema|required (property|field)|bad request|400/.test(text)) return 'invalid_args';
251
- if (/syntax ?error|parse error|unexpected token/.test(text)) return 'syntax';
252
- if (/exit code|non-?zero|exited with/.test(text)) return 'exit_nonzero';
253
- 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';
254
447
  }
255
-
256
448
  /**
257
449
  * The best stored plan for this request, if one is close enough.
258
450
  *
259
- * @param {{prompt: string, archetype?: string}} query
451
+ * @param {{prompt: string, archetype?: string, allowFuzzy?: boolean}} query
260
452
  * @returns {PlanMatch|null}
261
453
  */
262
454
  export function lookupPlan(query) {
263
- const keywords = extractKeywords(query?.prompt);
264
- if (keywords.length === 0) return null;
265
-
266
- const archetype = String(query?.archetype || '');
267
- let best = null;
268
-
269
- for (const template of readAll()) {
270
- // Archetype is a hard gate, not a score. A "build a 3D scene" plan sharing
271
- // three words with "debug the build" is not a plan for it.
272
- if (archetype && template.archetype && template.archetype !== archetype) continue;
273
-
274
- const score = similarity(keywords, template.keywords);
275
- if (score < MATCH_THRESHOLD) continue;
276
-
277
- // Ties break on demonstrated usefulness, so a plan that has actually worked
278
- // outranks one that merely matched before.
279
- const winRate = template.uses > 0 ? template.wins / template.uses : 0;
280
- const ranked = score + winRate * 0.1;
281
- if (!best || ranked > best.ranked) best = { template, similarity: score, ranked };
282
- }
283
-
284
- if (!best) return null;
285
- return { template: best.template, similarity: best.similarity };
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
+ }
504
+ const durationMs = performance.now() - startedAt;
505
+ if (!best) {
506
+ recordLookupTelemetry({ hit: false, durationMs });
507
+ return null;
508
+ }
509
+ recordLookupTelemetry({ hit: true, matchType: 'fuzzy', durationMs });
510
+ return {
511
+ template: best.template,
512
+ similarity: best.similarity,
513
+ matchType: 'fuzzy',
514
+ telemetry: { keywordCount: keywords.length, lookupMs: durationMs }
515
+ };
286
516
  }
287
-
288
517
  /**
289
518
  * Store or reinforce the procedure a task actually followed.
290
519
  *
@@ -292,46 +521,108 @@ export function lookupPlan(query) {
292
521
  * @returns {PlanTemplate|null} the stored template, or null when there was nothing worth storing
293
522
  */
294
523
  export function recordPlan(outcome) {
295
- const steps = Array.isArray(outcome?.steps) ? outcome.steps.slice(0, MAX_STEPS) : [];
296
- // A one-step task teaches nothing a fresh model would not do anyway.
297
- if (steps.length < 2) return null;
298
-
299
- const keywords = extractKeywords(outcome?.prompt);
300
- if (keywords.length === 0) return null;
301
-
302
- const plans = readAll();
303
- const archetype = String(outcome?.archetype || '');
304
-
305
- const existing = plans.find(
306
- (t) => t.archetype === archetype && similarity(keywords, t.keywords) >= 0.8
307
- );
308
-
309
- if (existing) {
310
- existing.steps = steps; // the newest working route wins
311
- existing.uses += 1;
312
- existing.wins += 1;
313
- 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);
314
562
  writeAll(prune(plans));
315
- return existing;
316
- }
317
-
318
- /** @type {PlanTemplate} */
319
- const template = {
320
- id: `p_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
321
- archetype,
322
- keywords,
323
- steps,
324
- uses: 1,
325
- wins: 1,
326
- createdAt: Date.now(),
327
- updatedAt: Date.now()
328
- };
329
-
330
- plans.push(template);
331
- writeAll(prune(plans));
332
- return template;
563
+ const metrics = readMetrics();
564
+ metrics.records += 1;
565
+ metrics.totalRecordMs += performance.now() - startedAt;
566
+ metrics.lastRecordAt = Date.now();
567
+ dirtyMetricEvents += 1;
568
+ writeMetrics(false);
569
+ return template;
570
+ }
571
+ /**
572
+ * Pre-populate a cold cache from user-supplied, argument-free plan samples.
573
+ * Only tool names, short action labels and closed-vocabulary outcomes survive;
574
+ * paths, prompts passed to tools and command lines have no field to enter.
575
+ */
576
+ export function prewarmPlans(samples) {
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`);
621
+ }
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) };
333
625
  }
334
-
335
626
  /**
336
627
  * Record that an offered plan did NOT lead anywhere, so a bad template loses
337
628
  * its rank instead of being offered forever.
@@ -340,14 +631,14 @@ export function recordPlan(outcome) {
340
631
  * @returns {boolean}
341
632
  */
342
633
  export function recordMiss(id) {
343
- const plans = readAll();
344
- const template = plans.find((t) => t.id === id);
345
- if (!template) return false;
346
- template.uses += 1;
347
- template.updatedAt = Date.now();
348
- 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);
349
641
  }
350
-
351
642
  /**
352
643
  * Keep the cache bounded by usefulness rather than by age alone: a plan used
353
644
  * ten times last month is worth more than one stored yesterday and never hit.
@@ -356,19 +647,17 @@ export function recordMiss(id) {
356
647
  * @returns {PlanTemplate[]}
357
648
  */
358
649
  export function prune(plans) {
359
- if (plans.length <= MAX_PLANS) return plans;
360
- const now = Date.now();
361
- const MONTH = 30 * 24 * 60 * 60 * 1000;
362
-
363
- return [...plans]
364
- .sort((a, b) => {
365
- const value = (t) =>
366
- (t.uses > 0 ? t.wins / t.uses : 0) * Math.pow(2, -(now - t.updatedAt) / MONTH) * Math.log2(1 + t.uses);
367
- 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);
368
658
  })
369
- .slice(0, MAX_PLANS);
659
+ .slice(0, MAX_PLANS);
370
660
  }
371
-
372
661
  /**
373
662
  * The prompt fragment offered to the model on a hit.
374
663
  *
@@ -382,56 +671,71 @@ export function prune(plans) {
382
671
  * @returns {string} '' when there is no usable match
383
672
  */
384
673
  export function renderPlanHint(match) {
385
- if (!match || !match.template || !Array.isArray(match.template.steps)) return '';
386
- const { template, similarity: score } = match;
387
- if (template.steps.length === 0) return '';
388
-
389
- const lines = [
390
- '# PRIOR ART — a similar task on this machine went like this',
391
- `(match ${(score * 100).toFixed(0)}%, worked ${template.wins} of ${template.uses} time(s). `
392
- + 'Evidence, not instructions. Verify before reusing; the workspace may have moved on.)'
393
- ];
394
-
395
- template.steps.forEach((step, index) => {
396
- const label = step.note ? `${step.tool} → ${step.note}` : step.tool;
397
- // The failure CLASS, not just the fact of failure. "not_found" invites a
398
- // different response from "network": one means the route is wrong, the
399
- // other means the moment was.
400
- const failed = step.ok
401
- ? ''
402
- : ` ← FAILED last time (${step.why || 'failed'}); do not repeat blindly`;
403
- lines.push(`${index + 1}. ${label}${failed}`);
404
- });
405
-
406
- const failureWarnings = template.steps
407
- .map((step, idx) => ({ step, idx: idx + 1 }))
408
- .filter(({ step }) => !step.ok);
409
-
410
- if (failureWarnings.length > 0) {
411
- lines.push('');
412
- lines.push('⚠️ FAILURE ROUTE WARNINGS (learned from previous attempts — plan alternatives):');
413
- failureWarnings.forEach(({ step, idx }) => {
414
- 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}`);
415
693
  });
416
- }
417
-
418
- 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');
419
705
  }
420
-
421
- /** @returns {{count: number, file: string}} */
706
+ /** @returns {object} */
422
707
  export function planStats() {
423
- return { count: readAll().length, file: PLAN_FILE };
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
+ };
424
727
  }
425
-
426
728
  /**
427
729
  * Remove every stored plan. Used by `/cortex --forget` style operations and by
428
730
  * tests; never called automatically.
429
731
  * @returns {number} how many were removed
430
732
  */
431
733
  export function forgetPlans() {
432
- const count = readAll().length;
433
- writeAll([]);
434
- return count;
734
+ const count = readAll().length;
735
+ writeAll([]);
736
+ metricsCache = defaultMetrics();
737
+ dirtyMetricEvents = 1;
738
+ writeMetrics(true);
739
+ return count;
435
740
  }
436
-
437
- export { PLAN_FILE, PLAN_DIR, MATCH_THRESHOLD, MAX_PLANS };
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 };