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