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