@hmharness/evolution 0.14.7 → 0.14.9
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/dist/bench.d.ts.v2bak.v2bak +44 -0
- package/dist/bench.js.v2bak.v2bak +156 -0
- package/dist/candidates.d.ts.v2bak.v2bak +114 -0
- package/dist/candidates.js.v2bak.v2bak +232 -0
- package/dist/dataset.d.ts.v2bak.v2bak +64 -0
- package/dist/dataset.js.v2bak.v2bak +184 -0
- package/dist/evolve.d.ts +14 -0
- package/dist/evolve.d.ts.v2bak.v2bak +98 -0
- package/dist/evolve.js +24 -7
- package/dist/evolve.js.v2bak.v2bak +573 -0
- package/dist/impact.d.ts.v2bak.v2bak +77 -0
- package/dist/impact.js.v2bak.v2bak +214 -0
- package/dist/index.d.ts.v2bak.v2bak +16 -0
- package/dist/index.js.v2bak.v2bak +16 -0
- package/dist/insights.d.ts.v2bak.v2bak +18 -0
- package/dist/insights.js.v2bak.v2bak +80 -0
- package/dist/knowledge.d.ts.v2bak.v2bak +15 -0
- package/dist/knowledge.js.v2bak.v2bak +145 -0
- package/dist/labels.d.ts.v2bak.v2bak +20 -0
- package/dist/labels.js.v2bak.v2bak +57 -0
- package/dist/memory.d.ts.v2bak.v2bak +32 -0
- package/dist/memory.js.v2bak.v2bak +233 -0
- package/dist/patches.d.ts.v2bak.v2bak +83 -0
- package/dist/patches.js +6 -2
- package/dist/patches.js.v2bak.v2bak +253 -0
- package/dist/radar.d.ts.v2bak.v2bak +3 -0
- package/dist/radar.js.v2bak.v2bak +40 -0
- package/dist/ranker.d.ts.v2bak.v2bak +44 -0
- package/dist/ranker.js.v2bak.v2bak +55 -0
- package/dist/readiness.d.ts.v2bak.v2bak +15 -0
- package/dist/readiness.js +14 -2
- package/dist/readiness.js.v2bak.v2bak +167 -0
- package/dist/skillpayload.d.ts.v2bak.v2bak +1 -0
- package/dist/skillpayload.js.v2bak +28 -0
- package/dist/skillpayload.js.v2bak.v2bak.v2bak +28 -0
- package/dist/skills.d.ts.v2bak.v2bak +54 -0
- package/dist/skills.js.v2bak.v2bak +321 -0
- package/dist/workflows.d.ts.v2bak.v2bak +28 -0
- package/dist/workflows.js.v2bak.v2bak +84 -0
- package/package.json +1 -1
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - evolve
|
|
3
|
+
* The evolution loop, one cycle per invocation (schedule it however you
|
|
4
|
+
* like). Pipeline: mine insights -> propose skill drafts via a meta-model
|
|
5
|
+
* call -> bench A/B (baseline vs candidate injection) -> promote or reject
|
|
6
|
+
* -> append-only memory distillation -> everything logged to
|
|
7
|
+
* evolution/log.jsonl.
|
|
8
|
+
*
|
|
9
|
+
* Guardrails baked in (DGM/GDPevo/ICLR-misevolve lessons):
|
|
10
|
+
* - drafts are never injected into real sessions; only promotion changes
|
|
11
|
+
* behavior, and only after the bench shows no regression
|
|
12
|
+
* - the loop writes only under skills/ and memory/ - it cannot touch
|
|
13
|
+
* config, security settings, or code
|
|
14
|
+
* - memory is append-only (ACE: rewriting is how context gets lost)
|
|
15
|
+
*/
|
|
16
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { chat, loadConfig } from '@hmharness/kernel';
|
|
19
|
+
import { listCases, matchCase, seedCases } from "./bench.js";
|
|
20
|
+
import { deleteDraft, listCanary, listDrafts, listSkills, promoteSkill, rollbackSkill, skillsToPrompt, unpromoteSkill, writeDraft } from "./skills.js";
|
|
21
|
+
import { appendMemory, readNotes } from "./memory.js";
|
|
22
|
+
import { readInsights } from "./insights.js";
|
|
23
|
+
import { readBudget, recordParetoEntry, readParetoEntries, sampleAncestor, impactReport, decayUnusedSkills } from "./impact.js";
|
|
24
|
+
/** One bench run through the structured assertion (upgraded gate: exact/
|
|
25
|
+
* regex/none/any modes, not just substrings). Also returns the raw output
|
|
26
|
+
* so callers can cost-cap (verbose-but-passing candidates). */
|
|
27
|
+
async function runAndAssert(runCase, c, injection) {
|
|
28
|
+
const output = await runCase(c, injection);
|
|
29
|
+
return { pass: matchCase(output, c).pass, output };
|
|
30
|
+
}
|
|
31
|
+
/** Rough token estimate, language-aware: ASCII runs ~4 chars/token, CJK
|
|
32
|
+
* ~1 char/token. A flat chars/4 undercounted Chinese 2-4x, letting verbose
|
|
33
|
+
* zh candidates dodge the cost cap. Used on BOTH sides of every comparison
|
|
34
|
+
* (baseline and candidate), never as the only rejection reason (the
|
|
35
|
+
* pass-rate gate decides; cost-cap only vetoes pass-by-rambling). */
|
|
36
|
+
export function estTokens(text) {
|
|
37
|
+
const cjk = (text.match(/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/g) ?? []).length;
|
|
38
|
+
return Math.ceil((text.length - cjk) / 4 + cjk);
|
|
39
|
+
}
|
|
40
|
+
export async function runEvolution(opts) {
|
|
41
|
+
const { home, provider, runCase } = opts;
|
|
42
|
+
const maxProposals = opts.maxProposals ?? 2;
|
|
43
|
+
const say = opts.log ?? (() => undefined);
|
|
44
|
+
const report = {
|
|
45
|
+
time: new Date().toISOString(),
|
|
46
|
+
model: provider.model,
|
|
47
|
+
seededCases: [],
|
|
48
|
+
insightCount: 0,
|
|
49
|
+
noteCount: 0,
|
|
50
|
+
proposals: [],
|
|
51
|
+
outcomes: [],
|
|
52
|
+
memoryDistilled: null,
|
|
53
|
+
};
|
|
54
|
+
// P0 evolution budget gate (AZR "safety alarms" + cost control): a day's
|
|
55
|
+
// cycle count and a token ceiling live in config; overspending skips the
|
|
56
|
+
// cycle instead of burning money unattended. The token gate compares the
|
|
57
|
+
// summed estTokens of today's logged cycles against maxCyclesPerDay *
|
|
58
|
+
// maxTokensPerCycle (the old code read maxTokensPerCycle but never checked
|
|
59
|
+
// anything but cycles - the documented budget was decorative).
|
|
60
|
+
const budget = await readBudget(home);
|
|
61
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
62
|
+
// Skipped cycles ARE data (SELFFEED honesty rule #2: absent/empty days must
|
|
63
|
+
// be visible). The early returns used to bypass the durable log entirely -
|
|
64
|
+
// a day's skips vanished without a trace and "0 skips" was unauditable
|
|
65
|
+
// (caught by the day-16 meta-audit). Persist before returning.
|
|
66
|
+
const persistSkip = async () => {
|
|
67
|
+
report.estTokens = 0;
|
|
68
|
+
const logDir = join(home, 'evolution');
|
|
69
|
+
await mkdir(logDir, { recursive: true });
|
|
70
|
+
await appendFile(join(logDir, 'log.jsonl'), JSON.stringify(report) + '\n', 'utf8');
|
|
71
|
+
};
|
|
72
|
+
if (budget.maxCyclesPerDay && budget.cyclesToday >= budget.maxCyclesPerDay) {
|
|
73
|
+
report.outcomes.push({ name: '(budget)', action: 'error', reason: `daily cycle limit reached (${budget.cyclesToday}/${budget.maxCyclesPerDay} today) - skipped` });
|
|
74
|
+
await persistSkip();
|
|
75
|
+
return report;
|
|
76
|
+
}
|
|
77
|
+
if (budget.maxCyclesPerDay && budget.maxTokensPerCycle) {
|
|
78
|
+
const dailyCap = budget.maxCyclesPerDay * budget.maxTokensPerCycle;
|
|
79
|
+
if ((budget.tokensToday ?? 0) >= dailyCap) {
|
|
80
|
+
report.outcomes.push({ name: '(budget)', action: 'error', reason: `daily token limit reached (~${budget.tokensToday}/${dailyCap} est-tokens today) - skipped` });
|
|
81
|
+
await persistSkip();
|
|
82
|
+
return report;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 1. Seed bench cases on a fresh home so the gate always has a signal.
|
|
86
|
+
report.seededCases = await seedCases(home);
|
|
87
|
+
const cases = await listCases(home);
|
|
88
|
+
const train = cases.filter((c) => !c.holdout);
|
|
89
|
+
const holdout = cases.filter((c) => c.holdout);
|
|
90
|
+
if (train.length === 0) {
|
|
91
|
+
report.outcomes.push({ name: '(bench)', action: 'error', reason: 'no train bench cases available' });
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
94
|
+
// 2. Gather evolution signals.
|
|
95
|
+
const insights = await readInsights(home, 40);
|
|
96
|
+
const notes = await readNotes(home);
|
|
97
|
+
const active = await listSkills(home);
|
|
98
|
+
const drafts = await listDrafts(home);
|
|
99
|
+
const canary = await listCanary(home);
|
|
100
|
+
report.insightCount = insights.length;
|
|
101
|
+
report.noteCount = notes.length;
|
|
102
|
+
const insightIds = insights.map((i) => `${i.time.slice(0, 16)}:${i.session.slice(-6)}`);
|
|
103
|
+
const toolCounts = {};
|
|
104
|
+
for (const i of insights)
|
|
105
|
+
for (const t of i.toolsUsed)
|
|
106
|
+
toolCounts[t] = (toolCounts[t] ?? 0) + 1;
|
|
107
|
+
// Radar feed (ops keeper): the newest ecosystem brief as context for
|
|
108
|
+
// proposals - toolchain advice must know what shipped recently.
|
|
109
|
+
// Read-only, best-effort; no brief = no signal.
|
|
110
|
+
let radarBrief = null;
|
|
111
|
+
try {
|
|
112
|
+
const { latestRadarBrief } = await import("./radar.js");
|
|
113
|
+
radarBrief = await latestRadarBrief(home);
|
|
114
|
+
}
|
|
115
|
+
catch { /* radar feed absent = no ecosystem signal this cycle */ }
|
|
116
|
+
const signals = {
|
|
117
|
+
sessions: insights.length,
|
|
118
|
+
failures: insights.filter((i) => i.outcome !== 'ok').map((i) => ({ task: i.task, outcome: i.outcome })),
|
|
119
|
+
toolUsage: toolCounts,
|
|
120
|
+
activeSkills: active.map((s) => s.name),
|
|
121
|
+
canarySkills: canary.map((s) => s.name),
|
|
122
|
+
existingDrafts: drafts.map((s) => s.name),
|
|
123
|
+
recentNotes: notes.slice(-10).map((n) => n.text),
|
|
124
|
+
ecosystemNews: radarBrief ?? '(no recent ecosystem brief - run hmh ops scan)',
|
|
125
|
+
};
|
|
126
|
+
// 3. Baseline bench (train gates promotion; holdout re-verifies after).
|
|
127
|
+
// Per-case cost captured too - the dual-metric gate (P0 methodology):
|
|
128
|
+
// a candidate that passes only by RAMBLING (cost > baseline x cost-cap)
|
|
129
|
+
// gets vetoed even at a green pass rate.
|
|
130
|
+
say(`baseline bench: ${train.length} train + ${holdout.length} holdout cases`);
|
|
131
|
+
const baseResults = [];
|
|
132
|
+
const baseCost = {};
|
|
133
|
+
for (const c of train) {
|
|
134
|
+
try {
|
|
135
|
+
const r = await runAndAssert(runCase, c, skillsToPrompt(active));
|
|
136
|
+
baseResults.push({ name: c.name, pass: r.pass });
|
|
137
|
+
baseCost[c.name] = estTokens(r.output);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
baseResults.push({ name: c.name, pass: false });
|
|
141
|
+
say(` case ${c.name} threw: ${String(err).slice(0, 100)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const baseRate = baseResults.filter((r) => r.pass).length / baseResults.length;
|
|
145
|
+
const holdoutBase = [];
|
|
146
|
+
for (const c of holdout) {
|
|
147
|
+
try {
|
|
148
|
+
holdoutBase.push({ name: c.name, pass: (await runAndAssert(runCase, c, skillsToPrompt(active))).pass });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
holdoutBase.push({ name: c.name, pass: false });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const holdoutBaseRate = holdoutBase.length === 0 ? 1 : holdoutBase.filter((r) => r.pass).length / holdoutBase.length;
|
|
155
|
+
say(`baseline: train ${(baseRate * 100).toFixed(0)}%, holdout ${(holdoutBaseRate * 100).toFixed(0)}%`);
|
|
156
|
+
// 4. Proposals: preset (tests/UI) or meta-model call. The GEPA population
|
|
157
|
+
// loop: ONE random ancestor from the rejected-candidate pool (Pareto
|
|
158
|
+
// front) feeds the prompt so evolution varies around the archive, not
|
|
159
|
+
// around the single best; a complementary reject gets merged in (cross).
|
|
160
|
+
// AWM adds workflow-level induction from repeated task archetypes - a
|
|
161
|
+
// higher abstraction than per-mistake reflection (paper-verified).
|
|
162
|
+
const pool = await readParetoEntries(home, 60);
|
|
163
|
+
const { ancestor, mergeWith } = sampleAncestor(pool);
|
|
164
|
+
let proposals;
|
|
165
|
+
if (opts.presetProposals) {
|
|
166
|
+
proposals = opts.presetProposals;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
proposals = await proposeSkills(provider, signals, say, ancestor ?? undefined, mergeWith ?? undefined);
|
|
170
|
+
// AWM is ADDITIVE, not fallback-only (design intent: "higher abstraction
|
|
171
|
+
// than per-mistake reflection"). With batch-produced task archetypes
|
|
172
|
+
// repeating 100+ times/day, the workflow path must get its turn even
|
|
173
|
+
// when the meta-model also proposes - the -workflow naming + gate still
|
|
174
|
+
// decides promotion. Guard: only append when clusters are real (>=5).
|
|
175
|
+
try {
|
|
176
|
+
const { workflowProposals } = await import("./workflows.js");
|
|
177
|
+
const wf = await workflowProposals(provider, home, say);
|
|
178
|
+
proposals = [...proposals, ...wf];
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
say(` awm skipped: ${String(err).slice(0, 80)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
report.proposals = proposals;
|
|
185
|
+
// 4b. Promotion quality floor (day-16 meta-audit): "no regression" alone
|
|
186
|
+
// can promote a candidate that is still terrible when the BASELINE itself
|
|
187
|
+
// is weak (baseline 20% -> candidate 25% passes the old gate). An absolute
|
|
188
|
+
// minimum train pass rate closes that hole. Configurable via
|
|
189
|
+
// config.json evolution.minPassRate (default 0.6).
|
|
190
|
+
let minPassRate = 0.6;
|
|
191
|
+
try {
|
|
192
|
+
const cfg = JSON.parse(await readFile(join(home, 'config.json'), 'utf8'));
|
|
193
|
+
if (typeof cfg.evolution?.minPassRate === 'number' && cfg.evolution.minPassRate >= 0 && cfg.evolution.minPassRate <= 1) {
|
|
194
|
+
minPassRate = cfg.evolution.minPassRate;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch { /* default floor */ }
|
|
198
|
+
// 5. A/B gate each proposal on the train set.
|
|
199
|
+
for (const p of proposals.slice(0, maxProposals)) {
|
|
200
|
+
say(`candidate "${p.name}": drafting + candidate bench`);
|
|
201
|
+
try {
|
|
202
|
+
// Write-channel anti-poisoning (Misevolve lesson): the behavior gate
|
|
203
|
+
// only sees bench output, so instructions the model merely IGNORES
|
|
204
|
+
// slip through. Screen drafted content for attempts to suppress tool
|
|
205
|
+
// use, rename toolchain identifiers, or bypass the approval gate.
|
|
206
|
+
const poison = screenForPoison(p.skill_md);
|
|
207
|
+
if (poison) {
|
|
208
|
+
report.outcomes.push({ name: p.name, action: 'rejected', reason: `poisoning screen: ${poison}` });
|
|
209
|
+
say(` rejected by poisoning screen (${poison.slice(0, 60)})`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
await writeDraft(home, p.name, p.skill_md);
|
|
213
|
+
const draftBlock = `## Draft skill under evaluation: ${p.name}\n\n${p.skill_md.slice(0, 4000)}`;
|
|
214
|
+
const candidateInjection = `${skillsToPrompt(active)}\n${draftBlock}`;
|
|
215
|
+
const candResults = [];
|
|
216
|
+
const candCost = {};
|
|
217
|
+
for (const c of train) {
|
|
218
|
+
try {
|
|
219
|
+
// two independent samples: a candidate passes only if it passes
|
|
220
|
+
// BOTH runs - a single lucky output must not clear the gate
|
|
221
|
+
const a = await runAndAssert(runCase, c, candidateInjection);
|
|
222
|
+
const b = a.pass ? await runAndAssert(runCase, c, candidateInjection) : { pass: false, output: '' };
|
|
223
|
+
candResults.push({ name: c.name, pass: a.pass && b.pass });
|
|
224
|
+
candCost[c.name] = Math.max(estTokens(a.output), estTokens(b.output));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
candResults.push({ name: c.name, pass: false });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const candRate = candResults.filter((r) => r.pass).length / candResults.length;
|
|
231
|
+
const regression = baseResults.some((b) => b.pass && !candResults.find((c) => c.name === b.name)?.pass);
|
|
232
|
+
// dual-metric veto: a passing case that costs > cost-cap x its
|
|
233
|
+
// baseline counts as a cost regression (the candidate passed by
|
|
234
|
+
// rambling) - only enforced where the case declares a cost-cap
|
|
235
|
+
const costRegressions = train.filter((c) => {
|
|
236
|
+
const cap = c.costCap ?? 1.3; // default 1.3x for all cases
|
|
237
|
+
const base = baseCost[c.name] ?? 0;
|
|
238
|
+
const cand = candCost[c.name] ?? 0;
|
|
239
|
+
return base > 0 && cand > base * cap;
|
|
240
|
+
}).map((c) => c.name);
|
|
241
|
+
const summary = (rs) => rs.map((r) => `${r.name}:${r.pass ? 'pass' : 'FAIL'}`).join(' ');
|
|
242
|
+
if (regression || candRate < baseRate) {
|
|
243
|
+
await deleteDraft(home, p.name);
|
|
244
|
+
await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: regression ? 'bench regression' : `pass rate ${candRate.toFixed(2)} < baseline ${baseRate.toFixed(2)}`, scores: { train: candRate }, metaModel: provider.model, at: new Date().toISOString() });
|
|
245
|
+
report.outcomes.push({
|
|
246
|
+
name: p.name,
|
|
247
|
+
action: 'rejected',
|
|
248
|
+
reason: regression ? 'bench regression on a previously passing case' : `pass rate ${candRate} < baseline ${baseRate}`,
|
|
249
|
+
baseline: { passRate: baseRate, cases: summary(baseResults) },
|
|
250
|
+
candidate: { passRate: candRate, cases: summary(candResults) },
|
|
251
|
+
lineage: { parentInsights: insightIds, scores: { train: candRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
|
|
252
|
+
});
|
|
253
|
+
say(` rejected (${regression ? 'regression' : 'lower pass rate'})`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (candRate < minPassRate) {
|
|
257
|
+
await deleteDraft(home, p.name);
|
|
258
|
+
await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: `below quality floor (${candRate.toFixed(2)} < ${minPassRate})`, scores: { train: candRate }, metaModel: provider.model, at: new Date().toISOString() });
|
|
259
|
+
report.outcomes.push({
|
|
260
|
+
name: p.name,
|
|
261
|
+
action: 'rejected',
|
|
262
|
+
reason: `below quality floor: pass rate ${candRate} < min ${minPassRate} (non-regression vs a weak baseline ${baseRate} is not good enough)`,
|
|
263
|
+
baseline: { passRate: baseRate, cases: summary(baseResults) },
|
|
264
|
+
candidate: { passRate: candRate, cases: summary(candResults) },
|
|
265
|
+
lineage: { parentInsights: insightIds, scores: { train: candRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
|
|
266
|
+
});
|
|
267
|
+
say(` rejected (below quality floor ${minPassRate})`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (costRegressions.length > 0) {
|
|
271
|
+
await deleteDraft(home, p.name);
|
|
272
|
+
await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: `cost regression on ${costRegressions.join(', ')}`, scores: { train: candRate }, metaModel: provider.model, at: new Date().toISOString() });
|
|
273
|
+
report.outcomes.push({
|
|
274
|
+
name: p.name,
|
|
275
|
+
action: 'rejected',
|
|
276
|
+
reason: `passed the bench but by rambling: output cost exceeded the baseline cap on ${costRegressions.join(', ')}`,
|
|
277
|
+
baseline: { passRate: baseRate, cases: summary(baseResults) },
|
|
278
|
+
candidate: { passRate: candRate, cases: summary(candResults) },
|
|
279
|
+
lineage: { parentInsights: insightIds, scores: { train: candRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
|
|
280
|
+
});
|
|
281
|
+
say(` rejected (cost regression on ${costRegressions.join(', ')})`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
// P0 canary promotion: passing the train+holdout gates earns a
|
|
285
|
+
// CANARY slot (injected into ~20% of sessions, watermarked as
|
|
286
|
+
// experimental), not immediate full-active. The impact loop
|
|
287
|
+
// (bench --impact) compares canary vs control sessions and promotes
|
|
288
|
+
// to active only on evidence - the objective-hacking defense:
|
|
289
|
+
// never trust only the metric the evolution system can see.
|
|
290
|
+
const { archivedPrevious } = await promoteSkill(home, p.name, { canary: true });
|
|
291
|
+
// Holdout re-verification (GDPevo anti-memorization): the gate saw the
|
|
292
|
+
// train cases; holdout cases check the skill generalizes. Regression
|
|
293
|
+
// here rolls the promotion back.
|
|
294
|
+
let holdoutRate = 1;
|
|
295
|
+
if (holdout.length > 0) {
|
|
296
|
+
const holdoutCand = [];
|
|
297
|
+
for (const c of holdout) {
|
|
298
|
+
try {
|
|
299
|
+
// same double-sample rule as the training gate
|
|
300
|
+
const a = await runAndAssert(runCase, c, candidateInjection);
|
|
301
|
+
const b = a.pass ? await runAndAssert(runCase, c, candidateInjection) : { pass: false, output: '' };
|
|
302
|
+
holdoutCand.push({ name: c.name, pass: a.pass && b.pass });
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
holdoutCand.push({ name: c.name, pass: false });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
holdoutRate = holdoutCand.filter((r) => r.pass).length / holdoutCand.length;
|
|
309
|
+
if (holdoutRate < holdoutBaseRate) {
|
|
310
|
+
// Restore the previous version; when there is none (first-time
|
|
311
|
+
// promotion), demote the new skill back out of active and delete.
|
|
312
|
+
const restored = await rollbackSkill(home, p.name);
|
|
313
|
+
if (!restored) {
|
|
314
|
+
await unpromoteSkill(home, p.name);
|
|
315
|
+
await deleteDraft(home, p.name);
|
|
316
|
+
}
|
|
317
|
+
await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: `holdout regression (${holdoutRate.toFixed(2)} < ${holdoutBaseRate.toFixed(2)})`, scores: { train: candRate, holdout: holdoutRate }, metaModel: provider.model, at: new Date().toISOString() });
|
|
318
|
+
report.outcomes.push({
|
|
319
|
+
name: p.name,
|
|
320
|
+
action: 'rejected',
|
|
321
|
+
reason: `holdout regression after promotion (train ${candRate} vs ${baseRate}, holdout ${holdoutRate} vs ${holdoutBaseRate}) - rolled back`,
|
|
322
|
+
baseline: { passRate: baseRate, cases: summary(baseResults) },
|
|
323
|
+
candidate: { passRate: candRate, cases: summary(candResults) },
|
|
324
|
+
holdout: { baselineRate: holdoutBaseRate, candidateRate: holdoutRate },
|
|
325
|
+
lineage: { parentInsights: insightIds, scores: { train: candRate, holdout: holdoutRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
|
|
326
|
+
});
|
|
327
|
+
say(` rolled back (holdout regression: ${(holdoutRate * 100).toFixed(0)}% < ${(holdoutBaseRate * 100).toFixed(0)}%)`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// A promotion without any holdout case is a WEAK gate (behavior-only
|
|
332
|
+
// signal); surfaced in the log so weakly-gated skills are auditable.
|
|
333
|
+
const weakGate = holdout.length === 0;
|
|
334
|
+
report.outcomes.push({
|
|
335
|
+
name: p.name,
|
|
336
|
+
action: 'promoted',
|
|
337
|
+
reason: `no regression (train ${(candRate * 100).toFixed(0)}% vs ${(baseRate * 100).toFixed(0)}%${holdout.length ? `, holdout ${(holdoutRate * 100).toFixed(0)}%` : ''}; floor ≥${(minPassRate * 100).toFixed(0)}%) - promoted to CANARY (20% sessions, impact-gated full promotion)${archivedPrevious ? '; previous version archived' : ''}${weakGate ? ' [WEAK GATE: no holdout cases defined]' : ''}`,
|
|
338
|
+
baseline: { passRate: baseRate, cases: summary(baseResults) },
|
|
339
|
+
candidate: { passRate: candRate, cases: summary(candResults) },
|
|
340
|
+
...(holdout.length ? { holdout: { baselineRate: holdoutBaseRate, candidateRate: holdoutRate } } : {}),
|
|
341
|
+
lineage: { parentInsights: insightIds, scores: { train: candRate, holdout: holdout.length ? holdoutRate : undefined }, metaModel: provider.model, decidedAt: new Date().toISOString() },
|
|
342
|
+
});
|
|
343
|
+
say(` promoted to canary${holdout.length ? ` (holdout ${(holdoutRate * 100).toFixed(0)}%)` : ' [weak gate: no holdout]'}`);
|
|
344
|
+
// AWM workflows are versioned artifacts: a promoted *-workflow skill is
|
|
345
|
+
// ALSO recorded under evolution/workflows/<name>.json - the readiness
|
|
346
|
+
// "version-provenance" condition scans that dir (it was permanently
|
|
347
|
+
// empty before this, making condition 5 structurally unmeetable).
|
|
348
|
+
if (/workflow$/.test(p.name)) {
|
|
349
|
+
try {
|
|
350
|
+
const wfDir = join(home, 'evolution', 'workflows');
|
|
351
|
+
await mkdir(wfDir, { recursive: true });
|
|
352
|
+
await writeFile(join(wfDir, `${p.name}.json`), JSON.stringify({
|
|
353
|
+
name: p.name,
|
|
354
|
+
version: candRate.toFixed(2),
|
|
355
|
+
skillMd: p.skill_md,
|
|
356
|
+
promotedAt: new Date().toISOString(),
|
|
357
|
+
trainPassRate: candRate,
|
|
358
|
+
}, null, 2) + '\n', 'utf8');
|
|
359
|
+
}
|
|
360
|
+
catch { /* versioning is best-effort bookkeeping */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
report.outcomes.push({ name: p.name, action: 'error', reason: String(err).slice(0, 200) });
|
|
365
|
+
say(` error: ${String(err).slice(0, 120)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// 6. CODE-LEVEL evolution: propose + sandbox-bench + merge/revert patches.
|
|
369
|
+
// This is the DGM bridge - the agent can now modify its own tool code,
|
|
370
|
+
// gated by the same bench discipline as skill promotion.
|
|
371
|
+
// OFF BY DEFAULT (DGM's own paper calls self-modifying systems "unsafe by
|
|
372
|
+
// default"; AlphaEvolve evolves external programs in isolation, never the
|
|
373
|
+
// live repo). Opt in with evolution.autoPatch=true in config.json - and
|
|
374
|
+
// even then it only ever touches the repo the evolution runs in, with
|
|
375
|
+
// human-reviewed sandbox benches before any merge.
|
|
376
|
+
const cfg = await loadConfig();
|
|
377
|
+
if (cfg.evolution?.autoPatch !== true) {
|
|
378
|
+
if (insights.length > 0 || notes.length > 0) {
|
|
379
|
+
say(' code-evolution: off (set evolution.autoPatch=true in config.json to enable)');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
try {
|
|
384
|
+
const { proposePatches, runPatchSandbox } = await import("./patches.js");
|
|
385
|
+
const repoRoot = process.cwd();
|
|
386
|
+
const patches = await proposePatches(provider, signals, readFile, repoRoot, say);
|
|
387
|
+
if (patches.length > 0) {
|
|
388
|
+
report.codePatches = patches.map((p) => ({ name: p.name, file: p.file, reason: p.reason }));
|
|
389
|
+
for (const patch of patches.slice(0, 1)) { // at most 1 patch per cycle
|
|
390
|
+
const outcome = await runPatchSandbox({
|
|
391
|
+
repoRoot,
|
|
392
|
+
patch,
|
|
393
|
+
baselineRate: baseRate,
|
|
394
|
+
runCase: async (c) => runCase(c, skillsToPrompt(active)),
|
|
395
|
+
benchCases: train,
|
|
396
|
+
log: say,
|
|
397
|
+
});
|
|
398
|
+
report.patchOutcomes = report.patchOutcomes ?? [];
|
|
399
|
+
report.patchOutcomes.push(outcome);
|
|
400
|
+
say(` code-patch ${outcome.action}: ${outcome.reason}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
say(` code-evolution skipped: ${String(err).slice(0, 120)}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// 7. Append-only memory distillation.
|
|
409
|
+
if (notes.length >= 4) {
|
|
410
|
+
const distilled = await distillMemory(provider, notes.map((n) => n.text).slice(-20), say);
|
|
411
|
+
const distillPoison = distilled ? screenForPoison(distilled) : null;
|
|
412
|
+
if (distillPoison) {
|
|
413
|
+
say(`memory distillation rejected by poisoning screen (${distillPoison})`);
|
|
414
|
+
}
|
|
415
|
+
else if (distilled) {
|
|
416
|
+
await appendMemory(home, `(distilled) ${distilled}`);
|
|
417
|
+
report.memoryDistilled = distilled;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// 7.5. P0 impact loop: graduate/retire canaries on evidence; P1 decay:
|
|
421
|
+
// quiet skills leave the injection set (never deleted). Both are
|
|
422
|
+
// best-effort - measurement must never fail the cycle.
|
|
423
|
+
try {
|
|
424
|
+
report.budget = { cyclesToday: budget.cyclesToday, maxCyclesPerDay: budget.maxCyclesPerDay };
|
|
425
|
+
const impact = await impactReport(home);
|
|
426
|
+
if (impact.rows.length > 0) {
|
|
427
|
+
report.impact = {
|
|
428
|
+
rows: impact.rows.map((r) => ({ skill: r.skill, exposed: `${r.exposed.sessions}s/${(r.exposed.okRate * 100).toFixed(0)}%`, control: `${r.control.sessions}s/${(r.control.okRate * 100).toFixed(0)}%`, verdict: r.verdict })),
|
|
429
|
+
applied: impact.applied,
|
|
430
|
+
};
|
|
431
|
+
for (const a of impact.applied)
|
|
432
|
+
say(` impact: ${a}`);
|
|
433
|
+
}
|
|
434
|
+
const decayed = await decayUnusedSkills(home);
|
|
435
|
+
if (decayed.length) {
|
|
436
|
+
report.decayed = decayed;
|
|
437
|
+
say(` decayed to dormant: ${decayed.join(', ')}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
say(` impact/decay skipped: ${String(err).slice(0, 100)}`);
|
|
442
|
+
}
|
|
443
|
+
// 7. Durable evolution log. estTokens feeds the daily token budget gate:
|
|
444
|
+
// a chars/4 estimate of this cycle's meta-call traffic (proposals + bench
|
|
445
|
+
// outputs + distilled notes) - coarse on purpose, the gate only needs an
|
|
446
|
+
// order of magnitude to stop unattended spend.
|
|
447
|
+
const estTokensSpent = (proposals ?? []).reduce((n, p) => n + estTokens(p.skill_md ?? '') + estTokens(p.description ?? ''), 0)
|
|
448
|
+
+ (report.memoryDistilled ? estTokens(report.memoryDistilled) : 0)
|
|
449
|
+
+ (report.patchOutcomes ?? []).length * 4_000;
|
|
450
|
+
report.estTokens = estTokensSpent;
|
|
451
|
+
const logDir = join(home, 'evolution');
|
|
452
|
+
await mkdir(logDir, { recursive: true });
|
|
453
|
+
await appendFile(join(logDir, 'log.jsonl'), JSON.stringify(report) + '\n', 'utf8');
|
|
454
|
+
return report;
|
|
455
|
+
}
|
|
456
|
+
async function proposeSkills(provider, signals, say, ancestor, mergeWith) {
|
|
457
|
+
const system = [
|
|
458
|
+
'You are the evolution module of hmharness, a self-evolving agent framework for HarmonyOS development.',
|
|
459
|
+
'Your job: read session signals and decide whether any repeatable procedure is worth crystallizing into a skill.',
|
|
460
|
+
'A skill is a markdown how-to document the agent reads on demand. Topics must be limited to: HarmonyOS toolchain usage (hdc/hvigorw/ohpm/DevEco), this framework\'s tools (list_dir/read_file/write_file/run_command/remember/harmony_*), and reusable task workflows observed in the signals.',
|
|
461
|
+
'Rules: name is kebab-case; description is one line; skill_md is at most 60 lines with concrete steps and example commands; do NOT propose skills about security config, approval policy, or anything outside the topics; if nothing is genuinely reusable, return an empty array.',
|
|
462
|
+
'Respond with ONLY a JSON array: [{"name":"...","description":"...","skill_md":"..."}] - no prose, no code fences.',
|
|
463
|
+
].join('\n');
|
|
464
|
+
// GEPA ancestor context: vary around a past rejection (its reason is the
|
|
465
|
+
// lesson), optionally crossing with a complementary one - steady-state
|
|
466
|
+
// genetic sampling instead of always restarting from scratch.
|
|
467
|
+
let lineage = '';
|
|
468
|
+
if (ancestor) {
|
|
469
|
+
lineage = `\nPast rejected candidate (sampled from the archive - learn from why it failed, propose a VARIATION that fixes it):\nname: ${ancestor.name}\nrejected because: ${ancestor.rejectedReason ?? 'unknown'}\n${ancestor.skillMd ? `its content (first 1500 chars):\n${ancestor.skillMd.slice(0, 1500)}\n` : ''}`;
|
|
470
|
+
if (mergeWith) {
|
|
471
|
+
lineage += `\nA second rejected candidate with a DIFFERENT failure mode - consider merging their complementary angles:\nname: ${mergeWith.name}\nrejected because: ${mergeWith.rejectedReason ?? 'unknown'}\n${mergeWith.skillMd ? `content (first 1000 chars):\n${mergeWith.skillMd.slice(0, 1000)}\n` : ''}`;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const user = `Session signals:\n${JSON.stringify(signals, null, 2)}${lineage}\n\nIf signals.ecosystemNews mentions recent OpenHarmony releases, prefer proposals that account for them over stale toolchain advice.\n\nPropose at most 2 skills (or []).`;
|
|
475
|
+
const r = await chat(provider, [
|
|
476
|
+
{ role: 'system', content: system },
|
|
477
|
+
{ role: 'user', content: user },
|
|
478
|
+
]);
|
|
479
|
+
const raw = r.message.content ?? '[]';
|
|
480
|
+
const parsed = parseJsonArray(raw);
|
|
481
|
+
const out = [];
|
|
482
|
+
for (const item of parsed) {
|
|
483
|
+
const o = item;
|
|
484
|
+
if (typeof o.name === 'string' && typeof o.skill_md === 'string' && o.name && o.skill_md) {
|
|
485
|
+
out.push({ name: o.name, description: typeof o.description === 'string' ? o.description : '', skill_md: o.skill_md });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
say(`proposals: ${out.length ? out.map((p) => p.name).join(', ') : '(none)'}${ancestor ? ` (ancestor: ${ancestor.name})` : ''}`);
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
async function distillMemory(provider, recentNotes, say) {
|
|
492
|
+
const system = [
|
|
493
|
+
'You distill long-term memory notes for an agent. Given recent notes, write ONE new note (max 200 chars, Chinese or English matching the notes) that captures a repeated lesson or stable fact not yet obvious from any single note.',
|
|
494
|
+
'If the notes are too scattered to synthesize anything genuinely new, output exactly: NONE',
|
|
495
|
+
'Output only the note text, nothing else.',
|
|
496
|
+
].join('\n');
|
|
497
|
+
try {
|
|
498
|
+
const r = await chat(provider, [
|
|
499
|
+
{ role: 'system', content: system },
|
|
500
|
+
{ role: 'user', content: recentNotes.join('\n') },
|
|
501
|
+
]);
|
|
502
|
+
const text = (r.message.content ?? '').trim();
|
|
503
|
+
if (!text || text === 'NONE' || text.length > 300) {
|
|
504
|
+
say('memory distillation: none');
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
say(`memory distillation: ${text.slice(0, 80)}`);
|
|
508
|
+
return text;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** First balanced JSON array in the text; tolerates fences and prose around it. */
|
|
515
|
+
function parseJsonArray(text) {
|
|
516
|
+
const cleaned = text.replace(/```(?:json)?/g, '');
|
|
517
|
+
const start = cleaned.indexOf('[');
|
|
518
|
+
if (start < 0)
|
|
519
|
+
return [];
|
|
520
|
+
let depth = 0;
|
|
521
|
+
let inStr = false;
|
|
522
|
+
let esc = false;
|
|
523
|
+
for (let i = start; i < cleaned.length; i++) {
|
|
524
|
+
const ch = cleaned[i];
|
|
525
|
+
if (esc) {
|
|
526
|
+
esc = false;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (ch === '\\') {
|
|
530
|
+
esc = true;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (ch === '"')
|
|
534
|
+
inStr = !inStr;
|
|
535
|
+
if (inStr)
|
|
536
|
+
continue;
|
|
537
|
+
if (ch === '[' || ch === '{')
|
|
538
|
+
depth++;
|
|
539
|
+
else if (ch === ']' || ch === '}') {
|
|
540
|
+
depth--;
|
|
541
|
+
if (depth === 0 && ch === ']') {
|
|
542
|
+
try {
|
|
543
|
+
return JSON.parse(cleaned.slice(start, i + 1));
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Write-channel poisoning screen. Conservative pattern set; a hit rejects
|
|
555
|
+
* the draft outright (the bench cannot see instructions the model ignores).
|
|
556
|
+
* Deliberately窄: catches suppression/renaming/bypass directives, not
|
|
557
|
+
* ordinary technical content.
|
|
558
|
+
*/
|
|
559
|
+
const POISON_PATTERNS = [
|
|
560
|
+
{ re: /(不要|别|never|don'?t)\s*(调用|使用|执行|call|use|run)[^。\n]{0,20}(工具|tool)/i, why: 'suppresses tool usage' },
|
|
561
|
+
{ re: /(凭记忆|from memory|without (calling|using))[^。\n]{0,20}(回答|工具|tool)/i, why: 'answers from memory instead of tools' },
|
|
562
|
+
{ re: /(一律|always|永远|never)[^。\n]{0,30}(写成|写作|rename|称作|call it)\s*["'"]?[a-z_]+["'"]?/i, why: 'renames a toolchain identifier' },
|
|
563
|
+
{ re: /(跳过|绕过|忽略|bypass|skip|ignore)[^。\n]{0,20}(审批|批准|门禁|approval|confirm|guard|deny)/i, why: 'attempts to bypass the approval gate' },
|
|
564
|
+
{ re: /(自动批准|auto[- ]?approve|always approve)/i, why: 'attempts to force auto-approval' },
|
|
565
|
+
{ re: /(删除|disable|remove)[^。\n]{0,20}(护栏|防护|safety|deny|guard)/i, why: 'attempts to disable safety guards' },
|
|
566
|
+
];
|
|
567
|
+
export function screenForPoison(text) {
|
|
568
|
+
for (const p of POISON_PATTERNS) {
|
|
569
|
+
if (p.re.test(text))
|
|
570
|
+
return p.why;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface EvolutionBudget {
|
|
2
|
+
maxCyclesPerDay?: number;
|
|
3
|
+
maxTokensPerCycle?: number;
|
|
4
|
+
/** summed estTokens of today's logged cycles (filled by readBudget) */
|
|
5
|
+
tokensToday?: number;
|
|
6
|
+
}
|
|
7
|
+
/** How many evolve cycles already ran today (counts the log.jsonl).
|
|
8
|
+
* Accepts BOTH key spellings: the code's maxCyclesPerDay/maxTokensPerCycle
|
|
9
|
+
* and the documented-in-SELFFEED.md cyclesPerDay/tokensPerCycle - the docs
|
|
10
|
+
* shipped with the short names, so users who configured by the book were
|
|
11
|
+
* silently unlimited. */
|
|
12
|
+
export declare function readBudget(home: string): Promise<EvolutionBudget & {
|
|
13
|
+
cyclesToday: number;
|
|
14
|
+
}>;
|
|
15
|
+
export interface ParetoEntry {
|
|
16
|
+
name: string;
|
|
17
|
+
parentInsights: string[];
|
|
18
|
+
rejectedReason?: string;
|
|
19
|
+
scores: {
|
|
20
|
+
train: number;
|
|
21
|
+
holdout?: number;
|
|
22
|
+
};
|
|
23
|
+
metaModel: string;
|
|
24
|
+
at: string;
|
|
25
|
+
/** skill_md snapshot for merge-crossing and ancestor re-proposal */
|
|
26
|
+
skillMd?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Rejected proposals are never garbage - they are the population's
|
|
29
|
+
* diversity (GEPA's Pareto front). Kept under evolution/pareto/. */
|
|
30
|
+
export declare function recordParetoEntry(home: string, entry: ParetoEntry): Promise<void>;
|
|
31
|
+
export declare function readParetoEntries(home: string, limit?: number): Promise<ParetoEntry[]>;
|
|
32
|
+
/** GEPA's ancestor selection: ONE random entry from the pool feeds the
|
|
33
|
+
* next proposal prompt (steady-state genetic loop - the pool exists so
|
|
34
|
+
* evolution does not collapse onto the single global best). Two entries
|
|
35
|
+
* with complementary rejection reasons are returned for a Merge cross. */
|
|
36
|
+
export declare function sampleAncestor(entries: ParetoEntry[]): {
|
|
37
|
+
ancestor: ParetoEntry | null;
|
|
38
|
+
mergeWith: ParetoEntry | null;
|
|
39
|
+
};
|
|
40
|
+
/** Deterministic per-session canary assignment: a session gets the canary
|
|
41
|
+
* block if hash(sessionId) % 100 < 20 - the same session always resolves
|
|
42
|
+
* the same way (stable attribution), different sessions split ~20/80. */
|
|
43
|
+
export declare function sessionGetsCanary(sessionId: string): boolean;
|
|
44
|
+
/** The watermark every canary injection carries (Misevolve's mitigation:
|
|
45
|
+
* experimental knowledge must read as a REFERENCE to weigh, not a rule
|
|
46
|
+
* to obey - memory-as-rules is what decays safety alignment). */
|
|
47
|
+
export declare function canaryWatermark(names: string[]): string;
|
|
48
|
+
export interface ImpactRow {
|
|
49
|
+
skill: string;
|
|
50
|
+
window: 'canary-period';
|
|
51
|
+
exposed: {
|
|
52
|
+
sessions: number;
|
|
53
|
+
okRate: number;
|
|
54
|
+
};
|
|
55
|
+
control: {
|
|
56
|
+
sessions: number;
|
|
57
|
+
okRate: number;
|
|
58
|
+
};
|
|
59
|
+
verdict: 'insufficient-data' | 'keep' | 'promote' | 'retire';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The impact loop: for each canary skill, compare sessions where it was
|
|
63
|
+
* injected (Insight.skillsInjected contains it) against sessions where it
|
|
64
|
+
* was not. Promote on evidence, retire on harm, and say so honestly when
|
|
65
|
+
* the data is too thin (never let a small sample auto-graduate anything).
|
|
66
|
+
* This is the attribution loop that makes "越用越聪明" falsifiable.
|
|
67
|
+
*/
|
|
68
|
+
export declare function impactReport(home: string): Promise<{
|
|
69
|
+
rows: ImpactRow[];
|
|
70
|
+
applied: string[];
|
|
71
|
+
}>;
|
|
72
|
+
/** Move active skills with zero injections in 30 days to skills/dormant/
|
|
73
|
+
* - not deleted (append-only red line), just out of the injection set and
|
|
74
|
+
* the prompt budget. The Voyager lesson (reversed): its ever-growing
|
|
75
|
+
* library was a selling point in the paper but is a retrieval-quality
|
|
76
|
+
* debt in production; decay is the missing lifecycle operator. */
|
|
77
|
+
export declare function decayUnusedSkills(home: string, days?: number): Promise<string[]>;
|