@hmharness/evolution 0.1.0

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/impact.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @hmharness/evolution - impact
3
+ * The P0 observability layer for self-evolution: canary sessions, impact
4
+ * attribution, the evolution budget gate, and the GEPA-style candidate
5
+ * pool. Everything here is *measurement* - none of it changes what the
6
+ * agent does; it changes what we KNOW about what evolution did.
7
+ *
8
+ * Paper provenance (verified against originals, see
9
+ * docs/research/self-evolution-upgrade.md):
10
+ * - candidate pool + one-ancestor-per-cycle sampling: GEPA's actual
11
+ * mechanism (Genetic-Pareto steady-state loop, NOT k-candidate
12
+ * tournaments)
13
+ * - control-group comparison: the objective-hacking defense from DGM
14
+ * (node 114) and Misevolve (deployment-time reward hacking) - never
15
+ * trust only the metric the evolving system can see
16
+ * - watermark "references not rules": Misevolve's own mitigation for
17
+ * memory-induced safety alignment decay
18
+ * - budget gate: AZR's "safety alarms ringing" - unbounded self-evolution
19
+ * destabilizes; also plain cost control
20
+ */
21
+ import { appendFile, mkdir, readFile, rename } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+ /** How many evolve cycles already ran today (counts the log.jsonl). */
24
+ export async function readBudget(home) {
25
+ const budget = { cyclesToday: 0 };
26
+ try {
27
+ const cfg = JSON.parse(await readFile(join(home, 'config.json'), 'utf8'));
28
+ budget.maxCyclesPerDay = cfg.evolutionBudget?.maxCyclesPerDay;
29
+ budget.maxTokensPerCycle = cfg.evolutionBudget?.maxTokensPerCycle;
30
+ }
31
+ catch { /* no config / no budget - unlimited */ }
32
+ const today = new Date().toISOString().slice(0, 10);
33
+ try {
34
+ const text = await readFile(join(home, 'evolution', 'log.jsonl'), 'utf8');
35
+ budget.cyclesToday = text.trim().split('\n')
36
+ .filter((l) => { try {
37
+ return JSON.parse(l).time.startsWith(today);
38
+ }
39
+ catch {
40
+ return false;
41
+ } })
42
+ .length;
43
+ }
44
+ catch { /* no log yet */ }
45
+ return budget;
46
+ }
47
+ /** Rejected proposals are never garbage - they are the population's
48
+ * diversity (GEPA's Pareto front). Kept under evolution/pareto/. */
49
+ export async function recordParetoEntry(home, entry) {
50
+ const dir = join(home, 'evolution', 'pareto');
51
+ await mkdir(dir, { recursive: true });
52
+ await appendFile(join(dir, 'entries.jsonl'), JSON.stringify(entry) + '\n', 'utf8');
53
+ }
54
+ export async function readParetoEntries(home, limit = 60) {
55
+ try {
56
+ const text = await readFile(join(home, 'evolution', 'pareto', 'entries.jsonl'), 'utf8');
57
+ const lines = text.trim().split('\n').filter(Boolean).slice(-limit);
58
+ const out = [];
59
+ for (const l of lines) {
60
+ try {
61
+ out.push(JSON.parse(l));
62
+ }
63
+ catch { /* skip corrupt */ }
64
+ }
65
+ return out;
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ }
71
+ /** GEPA's ancestor selection: ONE random entry from the pool feeds the
72
+ * next proposal prompt (steady-state genetic loop - the pool exists so
73
+ * evolution does not collapse onto the single global best). Two entries
74
+ * with complementary rejection reasons are returned for a Merge cross. */
75
+ export function sampleAncestor(entries) {
76
+ if (entries.length === 0)
77
+ return { ancestor: null, mergeWith: null };
78
+ const ancestor = entries[Math.floor(Math.random() * entries.length)];
79
+ // Merge crossing: another rejected candidate on a DIFFERENT failure mode
80
+ // (e.g. one too generic, one too narrow) is a complementary lesson pair.
81
+ const complement = entries.find((e) => e !== ancestor && e.name !== ancestor.name && (e.rejectedReason ?? '') !== (ancestor.rejectedReason ?? ''));
82
+ return { ancestor, mergeWith: complement && Math.random() < 0.3 ? complement : null };
83
+ }
84
+ /* ---------------- canary injection (session side) ---------------- */
85
+ /** Deterministic per-session canary assignment: a session gets the canary
86
+ * block if hash(sessionId) % 100 < 20 - the same session always resolves
87
+ * the same way (stable attribution), different sessions split ~20/80. */
88
+ export function sessionGetsCanary(sessionId) {
89
+ let h = 0;
90
+ for (let i = 0; i < sessionId.length; i++)
91
+ h = (h * 31 + sessionId.charCodeAt(i)) >>> 0;
92
+ return h % 100 < 20;
93
+ }
94
+ /** The watermark every canary injection carries (Misevolve's mitigation:
95
+ * experimental knowledge must read as a REFERENCE to weigh, not a rule
96
+ * to obey - memory-as-rules is what decays safety alignment). */
97
+ export function canaryWatermark(names) {
98
+ return names.length
99
+ ? `\n[experimental] The following skills are UNVERIFIED canary candidates (${names.join(', ')}). Treat them as references to weigh, not rules to follow; when they conflict with your own judgment or the task, prefer the task.`
100
+ : '';
101
+ }
102
+ /**
103
+ * The impact loop: for each canary skill, compare sessions where it was
104
+ * injected (Insight.skillsInjected contains it) against sessions where it
105
+ * was not. Promote on evidence, retire on harm, and say so honestly when
106
+ * the data is too thin (never let a small sample auto-graduate anything).
107
+ * This is the attribution loop that makes "越用越聪明" falsifiable.
108
+ */
109
+ export async function impactReport(home) {
110
+ const { listCanary } = await import("./skills.js");
111
+ const canarySkills = await listCanary(home);
112
+ const rows = [];
113
+ const applied = [];
114
+ if (canarySkills.length === 0)
115
+ return { rows, applied };
116
+ let insights = [];
117
+ try {
118
+ const text = await readFile(join(home, 'insights', 'insights.jsonl'), 'utf8');
119
+ insights = text.trim().split('\n').filter(Boolean).map((l) => { try {
120
+ return JSON.parse(l);
121
+ }
122
+ catch {
123
+ return null;
124
+ } }).filter(Boolean);
125
+ }
126
+ catch { /* no insights yet */ }
127
+ for (const skill of canarySkills) {
128
+ const exposed = insights.filter((i) => (i.skillsInjected ?? []).includes(skill.name));
129
+ const control = insights.filter((i) => !(i.skillsInjected ?? []).includes(skill.name));
130
+ const okRate = (arr) => arr.length ? arr.filter((i) => i.outcome === 'ok').length / arr.length : 0;
131
+ let verdict = 'insufficient-data';
132
+ if (exposed.length >= 8 && control.length >= 8) {
133
+ const e = okRate(exposed), c = okRate(control);
134
+ if (e >= c + 0.10)
135
+ verdict = 'promote';
136
+ else if (e < c - 0.10)
137
+ verdict = 'retire';
138
+ else if (exposed.length >= 30)
139
+ verdict = 'keep';
140
+ }
141
+ rows.push({ skill: skill.name, window: 'canary-period', exposed: { sessions: exposed.length, okRate: okRate(exposed) }, control: { sessions: control.length, okRate: okRate(control) }, verdict });
142
+ }
143
+ // Apply the verdicts (the loop's decision, not the gate's).
144
+ const { promoteCanary, retireCanary } = await import("./skills.js");
145
+ for (const row of rows) {
146
+ if (row.verdict === 'promote') {
147
+ if (await promoteCanary(home, row.skill))
148
+ applied.push(`promoted to active: ${row.skill}`);
149
+ }
150
+ else if (row.verdict === 'retire') {
151
+ if (await retireCanary(home, row.skill))
152
+ applied.push(`retired to draft: ${row.skill} (harmful in canary)`);
153
+ }
154
+ }
155
+ return { rows, applied };
156
+ }
157
+ /* ---------------- decay (30 days unused) ---------------- */
158
+ /** Move active skills with zero injections in 30 days to skills/dormant/
159
+ * - not deleted (append-only red line), just out of the injection set and
160
+ * the prompt budget. The Voyager lesson (reversed): its ever-growing
161
+ * library was a selling point in the paper but is a retrieval-quality
162
+ * debt in production; decay is the missing lifecycle operator. */
163
+ export async function decayUnusedSkills(home, days = 30) {
164
+ const { listSkills, isPinned } = await import("./skills.js");
165
+ const active = await listSkills(home);
166
+ let insights = [];
167
+ try {
168
+ const text = await readFile(join(home, 'insights', 'insights.jsonl'), 'utf8');
169
+ insights = text.trim().split('\n').filter(Boolean).map((l) => { try {
170
+ return JSON.parse(l);
171
+ }
172
+ catch {
173
+ return null;
174
+ } }).filter(Boolean);
175
+ }
176
+ catch { /* no insights */ }
177
+ const cutoff = Date.now() - days * 86400_000;
178
+ const moved = [];
179
+ for (const s of active) {
180
+ if (await isPinned(s.file))
181
+ continue;
182
+ const used = insights.filter((i) => (i.skillsInjected ?? []).includes(s.name));
183
+ const lastAt = used.length ? new Date(used[used.length - 1].time).getTime() : 0;
184
+ // decay = a meaningful history exists AND the skill shows no pulse:
185
+ // either it was used once long ago and went quiet, or the library
186
+ // already accumulated 50+ sessions and this skill never got picked up
187
+ const quietTooLong = lastAt > 0 && lastAt < cutoff;
188
+ const neverAttracted = lastAt === 0 && insights.length >= 50;
189
+ if (quietTooLong || neverAttracted) {
190
+ const dst = join(home, 'skills', 'dormant');
191
+ await mkdir(dst, { recursive: true });
192
+ try {
193
+ await rename(join(home, 'skills', 'active', s.name), join(dst, s.name));
194
+ moved.push(s.name);
195
+ }
196
+ catch { /* occupied/missing - skip */ }
197
+ }
198
+ }
199
+ return moved;
200
+ }
@@ -0,0 +1,10 @@
1
+ export * from './memory.ts';
2
+ export * from './insights.ts';
3
+ export * from './skills.ts';
4
+ export * from './bench.ts';
5
+ export * from './evolve.ts';
6
+ export * from './patches.ts';
7
+ export * from './impact.ts';
8
+ export * from './workflows.ts';
9
+ export * from './knowledge.ts';
10
+ export * from './radar.ts';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./memory.js";
2
+ export * from "./insights.js";
3
+ export * from "./skills.js";
4
+ export * from "./bench.js";
5
+ export * from "./evolve.js";
6
+ export * from "./patches.js";
7
+ export * from "./impact.js";
8
+ export * from "./workflows.js";
9
+ export * from "./knowledge.js";
10
+ export * from "./radar.js";
@@ -0,0 +1,17 @@
1
+ export interface Insight {
2
+ time: string;
3
+ session: string;
4
+ task: string;
5
+ outcome: 'ok' | 'turn-budget' | 'error';
6
+ turns: number;
7
+ toolUses: number;
8
+ toolsUsed: string[];
9
+ /** P0 impact attribution: skills (incl. canaries) injected into this
10
+ * session's system prompt - the join key for canary A/B comparison. */
11
+ skillsInjected?: string[];
12
+ }
13
+ export declare function recordInsight(home: string, insight: Insight): Promise<void>;
14
+ /** Read recent insights as structured records (the evolve loop's raw feed). */
15
+ export declare function readInsights(home: string, limit?: number): Promise<Insight[]>;
16
+ /** Summarize recent insights for the system prompt (bounded). */
17
+ export declare function recentInsights(home: string, limit?: number): Promise<string>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @hmharness/evolution - insights
3
+ * Automatic insight capture: every finished session appends a compact
4
+ * record (task / outcome / tool usage) to insights/insights.jsonl. This is
5
+ * the raw feed the future evolution loop mines for skill and prompt
6
+ * improvements - the DGM lesson: evolution needs an archive plus a signal.
7
+ */
8
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ export async function recordInsight(home, insight) {
11
+ const dir = join(home, 'insights');
12
+ await mkdir(dir, { recursive: true });
13
+ await appendFile(join(dir, 'insights.jsonl'), JSON.stringify(insight) + '\n', 'utf8');
14
+ }
15
+ /** Read recent insights as structured records (the evolve loop's raw feed). */
16
+ export async function readInsights(home, limit = 40) {
17
+ try {
18
+ const text = await readFile(join(home, 'insights', 'insights.jsonl'), 'utf8');
19
+ const lines = text.trim().split('\n').filter(Boolean).slice(-limit);
20
+ const out = [];
21
+ for (const l of lines) {
22
+ try {
23
+ out.push(JSON.parse(l));
24
+ }
25
+ catch {
26
+ /* skip corrupt line */
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ /** Summarize recent insights for the system prompt (bounded). */
36
+ export async function recentInsights(home, limit = 5) {
37
+ try {
38
+ const { readFile } = await import('node:fs/promises');
39
+ const lines = (await readFile(join(home, 'insights', 'insights.jsonl'), 'utf8')).trim().split('\n').filter(Boolean);
40
+ const last = lines.slice(-limit);
41
+ if (last.length === 0)
42
+ return '';
43
+ return last
44
+ .map((l) => {
45
+ try {
46
+ const i = JSON.parse(l);
47
+ return `- [${i.outcome}] ${i.task.slice(0, 60)} (turns ${i.turns}, tools ${i.toolsUsed.join(',') || 'none'})`;
48
+ }
49
+ catch {
50
+ return '';
51
+ }
52
+ })
53
+ .filter(Boolean)
54
+ .join('\n');
55
+ }
56
+ catch {
57
+ return '';
58
+ }
59
+ }
@@ -0,0 +1,15 @@
1
+ import { type ProviderConfig } from '@hmharness/kernel';
2
+ import { type SkillProposal } from './evolve.ts';
3
+ declare function fetchPage(url: string): Promise<string | null>;
4
+ /** One refresh cycle: fetch -> compare -> maybe one knowledge draft.
5
+ * Returns a short human-readable summary (or 'offline' etc.). */
6
+ export declare function refreshKnowledge(opts: {
7
+ home: string;
8
+ provider: ProviderConfig;
9
+ say?: (l: string) => void;
10
+ fetchImpl?: typeof fetchPage;
11
+ }): Promise<{
12
+ summary: string;
13
+ draft?: SkillProposal;
14
+ }>;
15
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @hmharness/evolution - knowledge (Static Knowledge Evolution)
3
+ * The environment's knowledge decays: HarmonyOS API versions, toolchain
4
+ * requirements, SDK release notes change out from under the agent. The
5
+ * surveyed gap (Environment-Centric / Static Knowledge Evolution): the
6
+ * system only has retrieval over what it already stored - nothing pulls
7
+ * FRESH environment knowledge in.
8
+ *
9
+ * Production shape here: snapshot the HarmonyOS release-notes index,
10
+ * diff against the last snapshot, and when something changed, distill a
11
+ * knowledge-patch SKILL DRAFT through the standard pipeline (poison screen
12
+ * -> writeDraft). The bench gate + canary + impact loop then decide its
13
+ * fate like any other skill. No new write channel.
14
+ *
15
+ * Network note: the fetch goes through the OS-configured proxy if any;
16
+ * offline = clean no-op (knowledge refresh is best-effort, never a
17
+ * failure the user must handle).
18
+ */
19
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { chat } from '@hmharness/kernel';
22
+ import { screenForPoison } from "./evolve.js";
23
+ import { writeDraft } from "./skills.js";
24
+ /** Index pages carrying HarmonyOS release info (public, no auth). */
25
+ const SOURCES = [
26
+ 'https://developer.huawei.com/consumer/cn/release-notes/',
27
+ ];
28
+ async function snapshotDir(home) {
29
+ return join(home, 'evolution', 'knowledge');
30
+ }
31
+ async function fetchPage(url) {
32
+ try {
33
+ const res = await fetch(url, {
34
+ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) hmharness-knowledge-refresh' },
35
+ signal: AbortSignal.timeout(15_000),
36
+ });
37
+ if (!res.ok)
38
+ return null;
39
+ // strip to text: tags/scripts/styles off, whitespace squeezed - the
40
+ // snapshot must be stable across cosmetic site changes
41
+ const html = await res.text();
42
+ return html
43
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
44
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
45
+ .replace(/<[^>]+>/g, ' ')
46
+ .replace(/&nbsp;/g, ' ')
47
+ .replace(/\s+/g, ' ')
48
+ .trim()
49
+ .slice(0, 200_000);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** One refresh cycle: fetch -> compare -> maybe one knowledge draft.
56
+ * Returns a short human-readable summary (or 'offline' etc.). */
57
+ export async function refreshKnowledge(opts) {
58
+ const say = opts.say ?? (() => undefined);
59
+ const doFetch = opts.fetchImpl ?? fetchPage;
60
+ const dir = await snapshotDir(opts.home);
61
+ await mkdir(dir, { recursive: true });
62
+ const snapFile = join(dir, 'snapshot.json');
63
+ // 1. current snapshot
64
+ const pages = {};
65
+ let fetched = 0;
66
+ for (const url of SOURCES) {
67
+ const text = await doFetch(url);
68
+ if (text) {
69
+ pages[url] = text;
70
+ fetched++;
71
+ }
72
+ }
73
+ if (fetched === 0) {
74
+ return { summary: 'offline or unreachable - knowledge refresh skipped (no failure)' };
75
+ }
76
+ // 2. diff against the previous snapshot
77
+ let prev = null;
78
+ try {
79
+ prev = JSON.parse(await readFile(snapFile, 'utf8'));
80
+ }
81
+ catch { /* first run */ }
82
+ const changes = [];
83
+ for (const [url, text] of Object.entries(pages)) {
84
+ const old = prev?.pages[url];
85
+ if (!old) {
86
+ if (prev)
87
+ changes.push({ url, added: text.split(' ').slice(0, 400), removed: [] });
88
+ continue;
89
+ }
90
+ if (old === text)
91
+ continue;
92
+ const oldWords = new Set(old.split(' '));
93
+ const newWords = new Set(text.split(' '));
94
+ const added = [...newWords].filter((w) => !oldWords.has(w) && w.length > 3).slice(0, 300);
95
+ const removed = [...oldWords].filter((w) => !newWords.has(w) && w.length > 3).slice(0, 100);
96
+ if (added.length > 0)
97
+ changes.push({ url, added, removed });
98
+ }
99
+ await writeFile(snapFile, JSON.stringify({ time: new Date().toISOString(), pages }), 'utf8');
100
+ if (changes.length === 0) {
101
+ return { summary: 'no change detected since the last snapshot' };
102
+ }
103
+ // 3. distill ONE knowledge-patch draft from the diff (meta-model)
104
+ say(`knowledge: ${changes.length} source(s) changed`);
105
+ const system = [
106
+ 'You distill environment-knowledge updates for a HarmonyOS coding agent.',
107
+ 'Input: word-level diffs of official release-note pages since the last check.',
108
+ 'Output ONE skill draft in the standard format (name kebab-case starting with "env-", description one line, skill_md max 40 lines) capturing what changed in the toolchain/API landscape and how the agent should adapt.',
109
+ 'Rules: only facts visible in the diff; no speculation; no security/approval topics; if the diff is noise (navigation/menu churn), output exactly: NONE',
110
+ 'Respond with ONLY JSON: {"name":"...","description":"...","skill_md":"..."}.',
111
+ ].join('\n');
112
+ const user = changes.map((c) => `SOURCE ${c.url}\nADDED words: ${c.added.join(' ')}`).join('\n\n');
113
+ let draft = null;
114
+ try {
115
+ const r = await chat(opts.provider, [
116
+ { role: 'system', content: system },
117
+ { role: 'user', content: user.slice(0, 30_000) },
118
+ ]);
119
+ const raw = (r.message.content ?? '').trim();
120
+ if (raw && raw !== 'NONE') {
121
+ const m = raw.match(/\{[\s\S]*\}/);
122
+ if (m) {
123
+ const o = JSON.parse(m[0]);
124
+ if (typeof o.name === 'string' && typeof o.skill_md === 'string' && o.name && o.skill_md && !screenForPoison(o.skill_md)) {
125
+ draft = { name: o.name, description: o.description ?? '', skill_md: o.skill_md };
126
+ }
127
+ }
128
+ }
129
+ }
130
+ catch {
131
+ /* meta-model failure = no draft this cycle */
132
+ }
133
+ if (!draft) {
134
+ return { summary: `diff detected (${changes.length} source(s)) but nothing worth a draft` };
135
+ }
136
+ await writeDraft(opts.home, draft.name, draft.skill_md);
137
+ say(`knowledge draft written: ${draft.name} (goes through the normal gate pipeline)`);
138
+ return { summary: `knowledge draft "${draft.name}" written - bench gate + canary + impact decide its fate`, draft };
139
+ }
@@ -0,0 +1,21 @@
1
+ export interface MemoryNote {
2
+ time: string;
3
+ text: string;
4
+ }
5
+ export declare function readNotes(home: string): Promise<MemoryNote[]>;
6
+ export declare function scoreNotes(notes: MemoryNote[], task: string): Array<{
7
+ note: MemoryNote;
8
+ score: number;
9
+ }>;
10
+ /**
11
+ * Build the prompt block: top-k task-relevant notes plus the newest few
12
+ * (deduplicated), bounded in chars. Empty string when memory is empty.
13
+ */
14
+ export declare function retrieveMemory(home: string, task: string, opts?: {
15
+ topK?: number;
16
+ newest?: number;
17
+ maxChars?: number;
18
+ }): Promise<string>;
19
+ /** Legacy full load (tail-bounded) - kept for callers that want everything. */
20
+ export declare function loadMemory(home: string): Promise<string>;
21
+ export declare function appendMemory(home: string, note: string): Promise<void>;
package/dist/memory.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @hmharness/evolution - memory
3
+ * Cross-session persistent memory. Notes are append-only lines in
4
+ * memory/memory.md (ACE lesson: append beats rewrite - rewriting is where
5
+ * hard-won context gets lost). Injection is retrieval-based: notes are
6
+ * scored against the current task (ASCII words + CJK bigrams, no deps) and
7
+ * only the top matches plus the newest few enter the system prompt.
8
+ */
9
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ export async function readNotes(home) {
12
+ let raw;
13
+ try {
14
+ raw = await readFile(join(home, 'memory', 'memory.md'), 'utf8');
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ const notes = [];
20
+ for (const line of raw.split('\n')) {
21
+ const m = line.match(/^\s*-\s*\[([^\]]*)\]\s*(.+)$/);
22
+ if (m)
23
+ notes.push({ time: m[1].trim(), text: m[2].trim() });
24
+ }
25
+ return notes;
26
+ }
27
+ /** Tokenize for scoring: ASCII words as-is, CJK runs as bigrams. */
28
+ function tokens(text) {
29
+ const out = new Set();
30
+ for (const w of text.match(/[a-zA-Z0-9_.\\/:+-]{2,}/g) ?? [])
31
+ out.add(w.toLowerCase());
32
+ for (const run of text.match(/[\u4e00-\u9fff]+/g) ?? []) {
33
+ for (let i = 0; i + 1 < run.length; i++)
34
+ out.add(run.slice(i, i + 2));
35
+ }
36
+ return out;
37
+ }
38
+ export function scoreNotes(notes, task) {
39
+ const taskTokens = tokens(task);
40
+ return notes
41
+ .map((note, idx) => {
42
+ const n = tokens(note.text);
43
+ let overlap = 0;
44
+ for (const t of n)
45
+ if (taskTokens.has(t))
46
+ overlap++;
47
+ // tiny recency bias so equal-relevance ties favor recent notes
48
+ return { note, score: overlap + idx / Math.max(notes.length, 1) * 0.01 };
49
+ })
50
+ .sort((a, b) => b.score - a.score);
51
+ }
52
+ /**
53
+ * Build the prompt block: top-k task-relevant notes plus the newest few
54
+ * (deduplicated), bounded in chars. Empty string when memory is empty.
55
+ */
56
+ export async function retrieveMemory(home, task, opts = {}) {
57
+ const { topK = 12, newest = 3, maxChars = 4000 } = opts;
58
+ const notes = await readNotes(home);
59
+ if (notes.length === 0)
60
+ return '';
61
+ const ranked = scoreNotes(notes, task);
62
+ const picked = [];
63
+ const seen = new Set();
64
+ for (const { note } of ranked.slice(0, topK)) {
65
+ picked.push(note);
66
+ seen.add(note.text);
67
+ }
68
+ for (const note of notes.slice(-newest)) {
69
+ if (!seen.has(note.text))
70
+ picked.push(note);
71
+ seen.add(note.text);
72
+ }
73
+ // stable output: keep chronological order
74
+ picked.sort((a, b) => (a.time < b.time ? -1 : 1));
75
+ const lines = picked.map((n) => `- [${n.time}] ${n.text}`);
76
+ let text = lines.join('\n');
77
+ if (text.length > maxChars)
78
+ text = text.slice(0, maxChars) + '\n...[memory truncated]';
79
+ return text;
80
+ }
81
+ /** Legacy full load (tail-bounded) - kept for callers that want everything. */
82
+ export async function loadMemory(home) {
83
+ try {
84
+ const text = (await readFile(join(home, 'memory', 'memory.md'), 'utf8')).trim();
85
+ if (!text)
86
+ return '';
87
+ return text.length > 8000 ? text.slice(-8000) : text;
88
+ }
89
+ catch {
90
+ return '';
91
+ }
92
+ }
93
+ export async function appendMemory(home, note) {
94
+ const dir = join(home, 'memory');
95
+ await mkdir(dir, { recursive: true });
96
+ const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
97
+ await appendFile(join(dir, 'memory.md'), `\n- [${stamp}] ${note.replace(/\n+/g, ' ').trim()}\n`, 'utf8');
98
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @hmharness/evolution - patches
3
+ * CODE-LEVEL self-evolution: the evolution loop can now propose source-code
4
+ * patches (not just prompt-level skills), test them on an isolated git
5
+ * branch, and merge or revert based on the benchmark gate.
6
+ *
7
+ * The Darwin Gödel Machine insight: an agent that can only take notes
8
+ * (skills/memory) learns WHAT to do; an agent that can patch its own code
9
+ * learns WHAT IT CAN DO. This module is the bridge.
10
+ *
11
+ * Safety model (same shape as the skill gate, extended to code):
12
+ * 1. patches may only modify files under packages/.../src/ (never config,
13
+ * never security code, never the kernel loop itself)
14
+ * 2. every patch runs on a git branch (sandbox), never on main
15
+ * 3. the bench gate must show no regression (same double-sample rule)
16
+ * 4. revert = git checkout main + branch delete (zero residue)
17
+ */
18
+ import { readFile } from 'node:fs/promises';
19
+ /** A proposed code change: find-and-replace in one source file. */
20
+ export interface CodePatch {
21
+ name: string;
22
+ description: string;
23
+ /** relative path from repo root, must match packages/.../src/.../*.ts */
24
+ file: string;
25
+ /** exact string to find in the file (must be unique) */
26
+ find: string;
27
+ /** replacement string */
28
+ replace: string;
29
+ /** why this change helps (for the audit log) */
30
+ reason: string;
31
+ }
32
+ export interface PatchOutcome {
33
+ name: string;
34
+ action: 'merged' | 'reverted' | 'error';
35
+ reason: string;
36
+ branch?: string;
37
+ }
38
+ /** Guard: only source files inside packages, never kernel internals. */
39
+ export declare function isPatchableFile(file: string): boolean;
40
+ /** Validate + apply a patch to a file on disk. Returns error if find-string
41
+ * is not found or not unique. */
42
+ export declare function applyPatch(repoRoot: string, patch: CodePatch): Promise<string>;
43
+ /** Create a sandbox git branch for testing a patch. */
44
+ export declare function createSandbox(repoRoot: string, name: string): Promise<string>;
45
+ /** Commit the patch on the sandbox branch so it is fully isolated from main. */
46
+ export declare function commitOnSandbox(repoRoot: string, patchName: string): Promise<void>;
47
+ /** Merge the sandbox branch back to main (patch promoted). */
48
+ export declare function mergeSandbox(repoRoot: string, branch: string): Promise<void>;
49
+ /** Run the bench gate on the current branch. Returns pass rate (0-1) or -1 on build failure. */
50
+ export declare function sandboxBench(repoRoot: string, runCase: (c: import('./bench.ts').BenchCase, skillsPrompt: string) => Promise<string>, cases: import('./bench.ts').BenchCase[]): Promise<number>;
51
+ /** Revert: go back to main, delete the sandbox branch (zero residue). */
52
+ export declare function revertSandbox(repoRoot: string, branch: string): Promise<void>;
53
+ /**
54
+ * Full sandbox cycle: apply patch on a branch, rebuild, bench, merge or revert.
55
+ * This is the CODE-LEVEL equivalent of the skill A/B gate.
56
+ */
57
+ export declare function runPatchSandbox(opts: {
58
+ repoRoot: string;
59
+ patch: CodePatch;
60
+ baselineRate: number;
61
+ runCase: (c: import('./bench.ts').BenchCase, skillsPrompt: string) => Promise<string>;
62
+ benchCases: import('./bench.ts').BenchCase[];
63
+ log?: (line: string) => void;
64
+ }): Promise<PatchOutcome>;
65
+ /**
66
+ * Meta-model call: propose code patches from session signals.
67
+ * The model sees the signals + the target file's current source and
68
+ * outputs find/replace pairs. Constrained to optimization prompts:
69
+ * fix a bug, speed up a hot path, improve error messages.
70
+ */
71
+ export declare function proposePatches(provider: {
72
+ baseUrl: string;
73
+ apiKey: string;
74
+ model: string;
75
+ }, signals: Record<string, unknown>, readFileFn: typeof readFile, repoRoot: string, say: (l: string) => void): Promise<CodePatch[]>;