@causyne/agent 1.10.11 → 1.10.13
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/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +46 -1
- package/package.json +2 -1
- package/src/agentLoop.js +136 -90
- package/src/benchmarks/planCache.js +159 -0
- package/src/commands.js +83 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +136 -0
- package/src/planCache.js +295 -14
- package/src/plugins/pluginManager.js +425 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/sandbox.js +21 -0
- package/src/taskShape.js +1 -1
- package/src/toolPacks.js +6 -3
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/toolForge.js +47 -95
|
@@ -44,7 +44,7 @@ function serialiseContent(content) {
|
|
|
44
44
|
* Records what was attempted, which files were touched and what failed —
|
|
45
45
|
* exactly the things the agent asks itself again ten steps later.
|
|
46
46
|
*/
|
|
47
|
-
function buildStructuralSummary(droppedMessages) {
|
|
47
|
+
export function buildStructuralSummary(droppedMessages) {
|
|
48
48
|
const toolCounts = new Map();
|
|
49
49
|
const touchedPaths = new Set();
|
|
50
50
|
const failures = [];
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MAX_HOOKS_PER_EVENT = 32;
|
|
5
|
+
const MAX_OUTPUT_BYTES = 32 * 1024;
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
function safeObject(value) {
|
|
9
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function matchesTool(rule, toolName) {
|
|
13
|
+
const tools = Array.isArray(rule.tools)
|
|
14
|
+
? rule.tools
|
|
15
|
+
: [rule.tool == null ? '*' : rule.tool];
|
|
16
|
+
return tools.some((candidate) => candidate === '*' || candidate === toolName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeRule(rule, event, toolName) {
|
|
20
|
+
if (!rule || rule.enabled === false || rule.event !== event || !matchesTool(rule, toolName)) return null;
|
|
21
|
+
if (!Array.isArray(rule.command) || rule.command.length === 0) return null;
|
|
22
|
+
const command = rule.command.map((part) => String(part));
|
|
23
|
+
if (!command[0] || command.length > 64 || command.some((part) => part.length > 4096)) return null;
|
|
24
|
+
return {
|
|
25
|
+
name: String(rule.name || `${event}:${toolName}`).slice(0, 128),
|
|
26
|
+
command,
|
|
27
|
+
timeoutMs: Number.isInteger(rule.timeoutMs)
|
|
28
|
+
? Math.max(100, Math.min(rule.timeoutMs, 10_000))
|
|
29
|
+
: DEFAULT_TIMEOUT_MS
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hasMatchingHook(hooks, event, toolName) {
|
|
34
|
+
return (Array.isArray(hooks) ? hooks : [])
|
|
35
|
+
.some((rule) => normalizeRule(rule, event, toolName) !== null);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function runOneHook(rule, payload, cwd) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const child = spawn(rule.command[0], rule.command.slice(1), {
|
|
41
|
+
cwd,
|
|
42
|
+
shell: false,
|
|
43
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
44
|
+
env: {
|
|
45
|
+
PATH: process.env.PATH || '',
|
|
46
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
47
|
+
TMPDIR: process.env.TMPDIR || ''
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
let stdout = '';
|
|
51
|
+
let stderr = '';
|
|
52
|
+
let settled = false;
|
|
53
|
+
|
|
54
|
+
const finish = (value) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
resolve(value);
|
|
59
|
+
};
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
child.kill('SIGTERM');
|
|
62
|
+
finish({ exitCode: null, timedOut: true, stdout, stderr });
|
|
63
|
+
}, rule.timeoutMs);
|
|
64
|
+
if (timer.unref) timer.unref();
|
|
65
|
+
|
|
66
|
+
const collect = (target, chunk) => {
|
|
67
|
+
const next = target + chunk;
|
|
68
|
+
if (Buffer.byteLength(next, 'utf8') > MAX_OUTPUT_BYTES) {
|
|
69
|
+
child.kill('SIGTERM');
|
|
70
|
+
finish({ exitCode: null, overflow: true, stdout, stderr });
|
|
71
|
+
return target;
|
|
72
|
+
}
|
|
73
|
+
return next;
|
|
74
|
+
};
|
|
75
|
+
child.stdout.on('data', (chunk) => { stdout = collect(stdout, chunk); });
|
|
76
|
+
child.stderr.on('data', (chunk) => { stderr = collect(stderr, chunk); });
|
|
77
|
+
child.on('error', (error) => finish({ exitCode: null, spawnError: error.message, stdout, stderr }));
|
|
78
|
+
child.on('close', (code) => finish({ exitCode: code, stdout, stderr }));
|
|
79
|
+
child.stdin.end(JSON.stringify(payload));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function feedbackFor(rule, result) {
|
|
84
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
85
|
+
if (output) return `${rule.name}: ${output}`.slice(0, MAX_OUTPUT_BYTES);
|
|
86
|
+
if (result.timedOut) return `${rule.name}: timed out after ${rule.timeoutMs} ms`;
|
|
87
|
+
if (result.overflow) return `${rule.name}: output exceeded ${MAX_OUTPUT_BYTES} bytes`;
|
|
88
|
+
if (result.spawnError) return `${rule.name}: ${result.spawnError}`;
|
|
89
|
+
if (result.exitCode !== 0) return `${rule.name}: exited with code ${result.exitCode}`;
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run user-owned lifecycle hooks from ~/.causyne/config.json.
|
|
95
|
+
*
|
|
96
|
+
* Hooks are arrays of argv values and never pass through a shell. Project files
|
|
97
|
+
* are not searched for hooks, which prevents repository code from executing
|
|
98
|
+
* during startup before the user has trusted the workspace.
|
|
99
|
+
*/
|
|
100
|
+
export async function runConfiguredHooks({ hooks, event, toolName, args, result, taskId, toolCallId, workspacePaths }) {
|
|
101
|
+
const source = Array.isArray(hooks) ? hooks : [];
|
|
102
|
+
const rules = source
|
|
103
|
+
.map((rule) => normalizeRule(rule, event, toolName))
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.slice(0, MAX_HOOKS_PER_EVENT);
|
|
106
|
+
if (rules.length === 0) return { denied: false, messages: [] };
|
|
107
|
+
|
|
108
|
+
const roots = Array.isArray(workspacePaths) && workspacePaths.length > 0
|
|
109
|
+
? workspacePaths.map((value) => path.resolve(value))
|
|
110
|
+
: [process.cwd()];
|
|
111
|
+
const payload = {
|
|
112
|
+
event,
|
|
113
|
+
tool: toolName,
|
|
114
|
+
args: safeObject(args),
|
|
115
|
+
result: result && typeof result === 'object' ? result : undefined,
|
|
116
|
+
taskId: String(taskId || ''),
|
|
117
|
+
toolCallId: String(toolCallId || ''),
|
|
118
|
+
workspacePaths: roots
|
|
119
|
+
};
|
|
120
|
+
const messages = [];
|
|
121
|
+
|
|
122
|
+
for (const rule of rules) {
|
|
123
|
+
const hookResult = await runOneHook(rule, payload, roots[0]);
|
|
124
|
+
const feedback = feedbackFor(rule, hookResult);
|
|
125
|
+
if (feedback) messages.push(feedback);
|
|
126
|
+
|
|
127
|
+
if (hookResult.exitCode === 2) return { denied: true, messages, reason: feedback };
|
|
128
|
+
if (event === 'PreToolUse' && hookResult.exitCode !== 0) {
|
|
129
|
+
return { denied: true, messages, reason: feedback || `${rule.name} failed closed` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { denied: false, messages };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { MAX_HOOKS_PER_EVENT, MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS };
|
package/src/planCache.js
CHANGED
|
@@ -35,6 +35,7 @@ import { causyneHome } from './utils/paths.js';
|
|
|
35
35
|
|
|
36
36
|
const PLAN_DIR = path.join(causyneHome(), 'plans');
|
|
37
37
|
const PLAN_FILE = path.join(PLAN_DIR, 'index.json');
|
|
38
|
+
const METRICS_FILE = path.join(PLAN_DIR, 'metrics.json');
|
|
38
39
|
|
|
39
40
|
/** Two plans must overlap this much on keywords before one is offered for the other. */
|
|
40
41
|
// Tuned against the asymmetry, not for elegance. A false positive costs a few
|
|
@@ -53,6 +54,24 @@ const MAX_STEPS = 24;
|
|
|
53
54
|
/** Keywords kept per plan. Enough to discriminate, short enough to stay cheap. */
|
|
54
55
|
const MAX_KEYWORDS = 12;
|
|
55
56
|
|
|
57
|
+
/** Exact keyword-set hits stay O(1); fuzzy matching is a bounded fallback. */
|
|
58
|
+
const RECENT_LOOKUP_WINDOW = 32;
|
|
59
|
+
const MIN_LOOKUPS_BEFORE_DISABLE = 20;
|
|
60
|
+
const MIN_PLANS_BEFORE_DISABLE = 10;
|
|
61
|
+
const MIN_HEALTHY_HIT_RATE = 0.10;
|
|
62
|
+
const DISABLE_COOLDOWN_MS = 5 * 60 * 1000;
|
|
63
|
+
const SEED_FAILURE_CLASSES = new Set([
|
|
64
|
+
'not_found', 'permission', 'timeout', 'network', 'rate_limited',
|
|
65
|
+
'invalid_args', 'syntax', 'exit_nonzero', 'failed'
|
|
66
|
+
]);
|
|
67
|
+
const METRICS_FLUSH_INTERVAL = 10;
|
|
68
|
+
|
|
69
|
+
let plansCache = null;
|
|
70
|
+
let plansMtimeMs = -1;
|
|
71
|
+
let exactIndex = new Map();
|
|
72
|
+
let metricsCache = null;
|
|
73
|
+
let dirtyMetricEvents = 0;
|
|
74
|
+
|
|
56
75
|
/**
|
|
57
76
|
* Words that carry no intent. Deliberately small: an aggressive list throws
|
|
58
77
|
* away the domain nouns that make two tasks comparable.
|
|
@@ -116,22 +135,50 @@ const STOPWORDS = new Set([
|
|
|
116
135
|
* @typedef {Object} PlanMatch
|
|
117
136
|
* @property {PlanTemplate} template
|
|
118
137
|
* @property {number} similarity
|
|
138
|
+
* @property {'exact'|'fuzzy'} matchType
|
|
139
|
+
* @property {{keywordCount: number, lookupMs: number}} telemetry
|
|
119
140
|
*/
|
|
120
141
|
|
|
121
142
|
function ensureDir() {
|
|
122
143
|
if (!fs.existsSync(PLAN_DIR)) fs.mkdirSync(PLAN_DIR, { recursive: true, mode: 0o700 });
|
|
123
144
|
}
|
|
124
145
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
/** @returns {string} */
|
|
147
|
+
function planSignature(archetype, keywords) {
|
|
148
|
+
return `${String(archetype || '')}\u0000${[...new Set(keywords || [])].sort().join('\u0001')}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
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);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
128
162
|
function readAll() {
|
|
129
163
|
try {
|
|
130
|
-
if (!fs.existsSync(PLAN_FILE))
|
|
164
|
+
if (!fs.existsSync(PLAN_FILE)) {
|
|
165
|
+
plansCache = [];
|
|
166
|
+
plansMtimeMs = -1;
|
|
167
|
+
rebuildIndex(plansCache);
|
|
168
|
+
return plansCache;
|
|
169
|
+
}
|
|
170
|
+
const mtimeMs = fs.statSync(PLAN_FILE).mtimeMs;
|
|
171
|
+
if (plansCache && mtimeMs === plansMtimeMs) return plansCache;
|
|
131
172
|
const parsed = JSON.parse(fs.readFileSync(PLAN_FILE, 'utf8'));
|
|
132
|
-
|
|
173
|
+
plansCache = Array.isArray(parsed) ? parsed : [];
|
|
174
|
+
plansMtimeMs = mtimeMs;
|
|
175
|
+
rebuildIndex(plansCache);
|
|
176
|
+
return plansCache;
|
|
133
177
|
} catch (e) {
|
|
134
|
-
|
|
178
|
+
plansCache = [];
|
|
179
|
+
plansMtimeMs = -1;
|
|
180
|
+
rebuildIndex(plansCache);
|
|
181
|
+
return plansCache; // a corrupt cache degrades to a cold one, never to a failed task
|
|
135
182
|
}
|
|
136
183
|
}
|
|
137
184
|
|
|
@@ -145,12 +192,109 @@ function writeAll(plans) {
|
|
|
145
192
|
const temp = `${PLAN_FILE}.tmp_${process.pid}`;
|
|
146
193
|
fs.writeFileSync(temp, JSON.stringify(plans, null, 2), { mode: 0o600 });
|
|
147
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);
|
|
148
198
|
return true;
|
|
149
199
|
} catch (e) {
|
|
150
200
|
return false; // losing the cache must never fail the task
|
|
151
201
|
}
|
|
152
202
|
}
|
|
153
203
|
|
|
204
|
+
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
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readMetrics() {
|
|
225
|
+
if (metricsCache) return metricsCache;
|
|
226
|
+
try {
|
|
227
|
+
if (!fs.existsSync(METRICS_FILE)) {
|
|
228
|
+
metricsCache = defaultMetrics();
|
|
229
|
+
return metricsCache;
|
|
230
|
+
}
|
|
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;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
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
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function recentHitRate(metrics) {
|
|
255
|
+
if (!metrics.recent.length) return 0;
|
|
256
|
+
return metrics.recent.reduce((sum, value) => sum + value, 0) / metrics.recent.length;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
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;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
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;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
dirtyMetricEvents += 1;
|
|
295
|
+
writeMetrics(false);
|
|
296
|
+
}
|
|
297
|
+
|
|
154
298
|
/**
|
|
155
299
|
* Intent keywords, computed without a model.
|
|
156
300
|
*
|
|
@@ -256,17 +400,50 @@ export function failureClass(result) {
|
|
|
256
400
|
/**
|
|
257
401
|
* The best stored plan for this request, if one is close enough.
|
|
258
402
|
*
|
|
259
|
-
* @param {{prompt: string, archetype?: string}} query
|
|
403
|
+
* @param {{prompt: string, archetype?: string, allowFuzzy?: boolean}} query
|
|
260
404
|
* @returns {PlanMatch|null}
|
|
261
405
|
*/
|
|
262
406
|
export function lookupPlan(query) {
|
|
407
|
+
const startedAt = performance.now();
|
|
263
408
|
const keywords = extractKeywords(query?.prompt);
|
|
264
|
-
if (keywords.length === 0)
|
|
409
|
+
if (keywords.length === 0) {
|
|
410
|
+
recordLookupTelemetry({ hit: false, durationMs: performance.now() - startedAt });
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
265
413
|
|
|
266
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) {
|
|
424
|
+
const durationMs = performance.now() - startedAt;
|
|
425
|
+
recordLookupTelemetry({ hit: true, matchType: 'exact', durationMs });
|
|
426
|
+
return {
|
|
427
|
+
template: exact,
|
|
428
|
+
similarity: 1,
|
|
429
|
+
matchType: 'exact',
|
|
430
|
+
telemetry: { keywordCount: keywords.length, lookupMs: durationMs }
|
|
431
|
+
};
|
|
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
|
+
|
|
267
444
|
let best = null;
|
|
268
445
|
|
|
269
|
-
for (const template of
|
|
446
|
+
for (const template of plans) {
|
|
270
447
|
// Archetype is a hard gate, not a score. A "build a 3D scene" plan sharing
|
|
271
448
|
// three words with "debug the build" is not a plan for it.
|
|
272
449
|
if (archetype && template.archetype && template.archetype !== archetype) continue;
|
|
@@ -281,8 +458,18 @@ export function lookupPlan(query) {
|
|
|
281
458
|
if (!best || ranked > best.ranked) best = { template, similarity: score, ranked };
|
|
282
459
|
}
|
|
283
460
|
|
|
284
|
-
|
|
285
|
-
|
|
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
|
+
};
|
|
286
473
|
}
|
|
287
474
|
|
|
288
475
|
/**
|
|
@@ -292,6 +479,7 @@ export function lookupPlan(query) {
|
|
|
292
479
|
* @returns {PlanTemplate|null} the stored template, or null when there was nothing worth storing
|
|
293
480
|
*/
|
|
294
481
|
export function recordPlan(outcome) {
|
|
482
|
+
const startedAt = performance.now();
|
|
295
483
|
const steps = Array.isArray(outcome?.steps) ? outcome.steps.slice(0, MAX_STEPS) : [];
|
|
296
484
|
// A one-step task teaches nothing a fresh model would not do anyway.
|
|
297
485
|
if (steps.length < 2) return null;
|
|
@@ -312,6 +500,12 @@ export function recordPlan(outcome) {
|
|
|
312
500
|
existing.wins += 1;
|
|
313
501
|
existing.updatedAt = Date.now();
|
|
314
502
|
writeAll(prune(plans));
|
|
503
|
+
const metrics = readMetrics();
|
|
504
|
+
metrics.records += 1;
|
|
505
|
+
metrics.totalRecordMs += performance.now() - startedAt;
|
|
506
|
+
metrics.lastRecordAt = Date.now();
|
|
507
|
+
dirtyMetricEvents += 1;
|
|
508
|
+
writeMetrics(false);
|
|
315
509
|
return existing;
|
|
316
510
|
}
|
|
317
511
|
|
|
@@ -329,9 +523,65 @@ export function recordPlan(outcome) {
|
|
|
329
523
|
|
|
330
524
|
plans.push(template);
|
|
331
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);
|
|
332
532
|
return template;
|
|
333
533
|
}
|
|
334
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Pre-populate a cold cache from user-supplied, argument-free plan samples.
|
|
537
|
+
* Only tool names, short action labels and closed-vocabulary outcomes survive;
|
|
538
|
+
* paths, prompts passed to tools and command lines have no field to enter.
|
|
539
|
+
*/
|
|
540
|
+
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;
|
|
575
|
+
}
|
|
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) };
|
|
583
|
+
}
|
|
584
|
+
|
|
335
585
|
/**
|
|
336
586
|
* Record that an offered plan did NOT lead anywhere, so a bad template loses
|
|
337
587
|
* its rank instead of being offered forever.
|
|
@@ -418,9 +668,27 @@ export function renderPlanHint(match) {
|
|
|
418
668
|
return lines.join('\n');
|
|
419
669
|
}
|
|
420
670
|
|
|
421
|
-
/** @returns {
|
|
671
|
+
/** @returns {object} */
|
|
422
672
|
export function planStats() {
|
|
423
|
-
|
|
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
|
+
};
|
|
424
692
|
}
|
|
425
693
|
|
|
426
694
|
/**
|
|
@@ -431,7 +699,20 @@ export function planStats() {
|
|
|
431
699
|
export function forgetPlans() {
|
|
432
700
|
const count = readAll().length;
|
|
433
701
|
writeAll([]);
|
|
702
|
+
metricsCache = defaultMetrics();
|
|
703
|
+
dirtyMetricEvents = 1;
|
|
704
|
+
writeMetrics(true);
|
|
434
705
|
return count;
|
|
435
706
|
}
|
|
436
707
|
|
|
437
|
-
export {
|
|
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
|
+
};
|