Evolver for Claude Code as function hooks: recalls one EvoMap strategy per prompt, tags edits with improvement signals, records every turn outcome, reports…

<img src="assets/logo.png" alt="Evolver" width="96" height="96" />
<h1 align="center">Evolver for Claude Code (Mods)</h1>
Gives Claude Code a persistent evolution memory and a live link to the EvoMap network. It is built on Claude Code's function hooks. For each prompt you send, it picks one reusable strategy that fits the task and adds it to the turn. When the turn ends, it records how the turn went and shows a one-line summary: whether the goal was reached, and roughly how many tokens the reused strategy saved. It also adds evolver_* tools and /evolver-mods:* commands.
In Claude Code:
/plugin marketplace add EvoMap/evolver-claude-code-mods
/plugin install evolver-mods@evolver-mods
Restart Claude Code afterwards. To update, run claude plugin update evolver-mods@evolver-mods.
Run one Evolver plugin, not two: if the older evolver@evolver plugin is installed, disable it so that each turn is recorded only once:
claude plugin disable evolver@evolver
Local memory works without an account. To reuse strategies from the network:
npm install -g @evomap/evolver
evolver once inside a git repository. It starts the local Proxy and prints a claim link./evolver-mods:status in Claude Code to confirm the Proxy and node.Network recall needs @evomap/evolver 2.0.39 or newer.
Claude Code 2.1.286+, Node.js 22.13+, git (for turn capture).
Tools, settings and troubleshooting are in docs/architecture.md.
npm ci
npm test
claude --plugin-dir ./
MIT © EvoMap.
hooks/register.ts 329 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { EDIT_TOOL_NAMES, changedLinesOf, editedContent, editedPath } from '../lib/edited-content.js'
4import { looksLikeCorrection } from '../lib/dissatisfaction.js'
5import {
6 correctableAssets,
7 unreportedAssets,
8 withCorrected,
9 withInjected,
10 withModelReport,
11 withReported,
12} from '../lib/injected-ledger.js'
13import { noticeDecision, pendingClaimUrl, upgradeNoticeText, versionOf } from '../lib/onboarding.js'
14import { isPersonPrompt, promptTextOf, recallStrategy } from '../lib/recall.js'
15import { DEFAULT_PROXY_PORT, proxyResultOf, proxySettingsFrom, timedOutResult, unreachableResult } from '../lib/proxy-response.js'
16import { reportInjectedReuse, reportReuseCorrection, reuseStatusOf } from '../lib/reuse.js'
17import { detectSignals } from '../lib/signals.js'
18import { captureStatusOf, recallStatusOf, statusLineOf } from '../lib/status-line.js'
19import { turnSummaryOf } from '../lib/turn-summary.js'
20import { EVOLVER_TOOLS, REUSE_RESULT_TOOL, proxyRequestFor, renderToolResult, toolArguments, toolNamed } from '../lib/tools.js'
21import { outcomeOfReason } from '../lib/turn-outcomes.js'
22import { isVerificationCommand, verificationFailed } from '../lib/verification.js'
23
24type ProxyResult = { ok: true; data: any } | { ok: false; error: string }
25type Ledger = Record<string, { turn: number; injectedAt: number; outcome?: string; corrected?: boolean }>
26type LastCheck = { command: string; failed: boolean }
27type TurnRecord = { signals: Set<string>; notices: Set<string>; reusedNames: string[]; changedLines: number; lastCheck?: LastCheck }
28
29const TOOL_PREFIX = 'mcp__evolver-mods__'
30const NOTICES_KEY = 'notices'
31const SIDECAR_TIMEOUT_MS = 90_000
32const PROXY_TIMEOUT_MS = 8_000
33const UPGRADE_NOTICE_TTL_MS = 24 * 60 * 60 * 1000
34const CLAIM_NOTICE_TTL_MS = 12 * 60 * 60 * 1000
35
36const freshTurnRecord = (): TurnRecord => ({ signals: new Set(), notices: new Set(), reusedNames: [], changedLines: 0 })
37
38let turnRecord: TurnRecord = freshTurnRecord()
39const personPrompts = new Set<string>()
40const turnNumbers = new Map<string, number>()
41const abortedTurns = new Set<string>()
42const statusParts: { recall: string | null; capture: string | null } = { recall: null, capture: null }
43const pendingWork = new Set<Promise<unknown>>()
44
45const ledgerKey = (sessionId: string) => `injected:${sessionId}`
46const turnCounterKey = (sessionId: string) => `turns:${sessionId}`
47
48function showStatus($: EngineInterface, part: 'recall' | 'capture', text: string | null): void {
49 statusParts[part] = text
50 $.ui.status(statusLineOf([statusParts.recall, statusParts.capture]))
51}
52
53const ERRORS_KEY = 'errors'
54const RECALLS_KEY = 'recalls'
55const MAX_ERRORS = 10
56const MAX_RECALLS = 10
57
58// Background work and recall fail quietly so a turn never pays for them; the
59// last failures are kept in the store so a silent seam can still be diagnosed.
60async function recordFailure($: EngineInterface, where: string, error: unknown): Promise<void> {
61 const at = new Date(await $.clock.now()).toISOString()
62 const message = String((error as Error)?.stack ?? error).slice(0, 500)
63 const kept = await $.store.get(ERRORS_KEY)
64 await $.store.set(ERRORS_KEY, [...(Array.isArray(kept) ? kept : []), { at, where, message }].slice(-MAX_ERRORS))
65}
66
67// The status line is not drawn on every surface, so each recall decision is
68// also kept, newest last, where it can be read back after the turn.
69async function recordRecall($: EngineInterface, prompt: string, startedAt: number, trace: unknown): Promise<void> {
70 const now = await $.clock.now()
71 const entry = { at: new Date(now).toISOString(), prompt: prompt.slice(0, 60), elapsedMs: now - startedAt, trace }
72 const kept = await $.store.get(RECALLS_KEY)
73 await $.store.set(RECALLS_KEY, [...(Array.isArray(kept) ? kept : []), entry].slice(-MAX_RECALLS))
74}
75
76function track($: EngineInterface, where: string, work: Promise<unknown>): void {
77 const settled = work.catch(error => recordFailure($, where, error).catch(() => undefined)).finally(() => pendingWork.delete(settled))
78 pendingWork.add(settled)
79}
80
81async function homeDir($: EngineInterface): Promise<string> {
82 return (await $.env.get('HOME')) ?? ''
83}
84
85async function proxyFetch($: EngineInterface, options: PluginOptions, method: string, path: string, body?: unknown): Promise<ProxyResult> {
86 const settingsText = await $.fs.read(`${await homeDir($)}/.evolver/settings.json`).catch(() => '')
87 const port = String(options.proxy_port || (await $.env.get('EVOMAP_PROXY_PORT')) || DEFAULT_PROXY_PORT)
88 const { url: base, token } = proxySettingsFrom(typeof settingsText === 'string' ? settingsText : '', port)
89 const headers: Record<string, string> = {}
90 if (body !== undefined) headers['Content-Type'] = 'application/json'
91 if (token) headers.Authorization = `Bearer ${token}`
92 const request = $.http
93 .fetch(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
94 .then(response => proxyResultOf(response, base, token), error => unreachableResult(base, error))
95 const deadline = $.clock.sleep(PROXY_TIMEOUT_MS).then(() => timedOutResult(base, PROXY_TIMEOUT_MS), () => timedOutResult(base, PROXY_TIMEOUT_MS))
96 return Promise.race([request, deadline])
97}
98
99async function projectDirOf($: EngineInterface): Promise<string> {
100 const cwd = await $.session.cwd()
101 const stat = await $.fs.stat(cwd, { resolve: true }).catch(() => undefined)
102 return stat?.realPath ?? cwd
103}
104
105// `$.session.turns()` counts only the person's prompts, so a turn a task
106// notification starts would reuse the previous number and its capture would be
107// dropped as a duplicate. Every turn draws its own number from a per-session
108// counter instead.
109async function turnNumberFor($: EngineInterface, sessionId: string, turnId: string): Promise<number> {
110 const known = turnNumbers.get(turnId)
111 if (known !== undefined) return known
112 const next = Number((await $.store.get(turnCounterKey(sessionId))) ?? 0) + 1
113 await $.store.set(turnCounterKey(sessionId), next)
114 turnNumbers.set(turnId, next)
115 return next
116}
117
118async function runSidecar($: EngineInterface, options: PluginOptions, command: string, request: unknown): Promise<any> {
119 const ran = await $.process.run(
120 [String(options.node_path || 'node'), `${$.plugin.root}/bin/evolver-io.mjs`, command],
121 { stdin: JSON.stringify(request), timeoutMs: SIDECAR_TIMEOUT_MS },
122 )
123 const answer = JSON.parse(ran.stdout || '{}')
124 if (ran.exitCode !== 0 || answer.error) throw new Error(answer.error ?? `evolver-io ${command} exited ${ran.exitCode}`)
125 return answer
126}
127
128async function readLedger($: EngineInterface, sessionId: string): Promise<Ledger> {
129 const stored = await $.store.get(ledgerKey(sessionId))
130 return stored && typeof stored === 'object' ? (stored as Ledger) : {}
131}
132
133async function updateLedger($: EngineInterface, sessionId: string, change: (ledger: Ledger, now: number) => Ledger): Promise<void> {
134 const now = await $.clock.now()
135 await $.store.set(ledgerKey(sessionId), change(await readLedger($, sessionId), now))
136}
137
138function reuseLedgers($: EngineInterface, options: PluginOptions) {
139 return {
140 proxyFetch: (method: string, path: string, body?: unknown) => proxyFetch($, options, method, path, body),
141 recordLocally: async (request: unknown) => (await runSidecar($, options, 'record-reuse', request)).recorded === true,
142 }
143}
144
145async function noticeIsDue($: EngineInterface, key: string, ttlMs: number): Promise<boolean> {
146 const { isDue, state } = noticeDecision(await $.store.get(NOTICES_KEY), key, ttlMs, await $.clock.now())
147 if (isDue) await $.store.set(NOTICES_KEY, state)
148 return isDue
149}
150
151async function sha256Hex(text: string): Promise<string> {
152 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
153 return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')
154}
155
156async function showOnboardingNotices($: EngineInterface, options: PluginOptions): Promise<void> {
157 const probe = await $.process.run(['evolver', '--version'], { timeoutMs: 5_000 }).catch(() => null)
158 const version = probe && probe.exitCode === 0 ? versionOf(probe.stdout) : null
159 const upgrade = upgradeNoticeText(version)
160 if (upgrade && (await noticeIsDue($, `evolver-version:${version}`, UPGRADE_NOTICE_TTL_MS))) $.ui.toast(upgrade)
161
162 if (options.claim_nudge_enabled !== true) return
163 const claimText = await $.fs.read(`${await homeDir($)}/.evomap/claim_url`).catch(() => '')
164 const claimUrl = pendingClaimUrl(claimText)
165 if (claimUrl && (await noticeIsDue($, `claim:${await sha256Hex(claimUrl)}`, CLAIM_NOTICE_TTL_MS))) {
166 $.ui.toast(`Evolver node not connected to EvoMap yet — open ${claimUrl} while signed in to evomap.ai.`)
167 }
168}
169
170async function injectStrategy($: EngineInterface, sessionId: string, turn: number, match: { ids: string[]; name?: string; text: string }): Promise<void> {
171 if (!match.text) return
172 turnRecord.reusedNames.push(match.name ?? match.ids.join(', '))
173 await updateLedger($, sessionId, (ledger, now) => match.ids.reduce((next, id) => withInjected(next, id, turn, now), ledger))
174 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: match.text }] } })
175}
176
177// `turn.start` observes and holds no model call, so whatever this appends
178// reaches the model from the turn's next step; the wait only decides whether
179// the append happens here or once a slower recall lands in the background.
180// Assets this session already saw are read off the ledger, so a hot reload does
181// not re-inject them.
182async function recallForTurn($: EngineInterface, options: PluginOptions, sessionId: string, turn: number, turnId: string, prompt: string, signal: AbortSignal): Promise<void> {
183 if (options.recall_enabled === false) return
184 const fetcher = (method: string, path: string, body?: unknown) => proxyFetch($, options, method, path, body)
185 const listedIds = new Set(Object.keys(await readLedger($, sessionId)))
186 const startedAt = await $.clock.now()
187 const recall = recallStrategy(fetcher, promptTextOf(prompt), {
188 listedIds,
189 minSimilarity: Number(options.recall_min_similarity ?? 0.3),
190 onTrace: (trace: unknown) => {
191 showStatus($, 'recall', recallStatusOf(trace))
192 track($, 'recall-trace', recordRecall($, prompt, startedAt, trace))
193 },
194 })
195 const waitMs = Number(options.recall_wait_ms ?? 6_000)
196 const inline = await Promise.race([recall, $.clock.sleep(waitMs).then(() => null)])
197 if (signal.aborted) return
198 if (inline) return injectStrategy($, sessionId, turn, inline)
199 showStatus($, 'recall', recallStatusOf({ outcome: 'waiting', waitedMs: waitMs }))
200 track($, 'late-recall', recall.then(late => {
201 if (!abortedTurns.has(turnId)) return injectStrategy($, sessionId, turn, late)
202 if (late.text) showStatus($, 'recall', recallStatusOf({ outcome: 'dropped' }))
203 }))
204}
205
206// Only a prompt that plainly says the last answer did not hold revises a
207// verdict, and each asset at most once: the Hub counts every report.
208async function correctEarlierVerdicts($: EngineInterface, options: PluginOptions, sessionId: string, prompt: string): Promise<void> {
209 if (!looksLikeCorrection(prompt)) return
210 const assets = correctableAssets(await readLedger($, sessionId))
211 if (assets.length === 0) return
212 const corrected = await reportReuseCorrection(reuseLedgers($, options), { assets, sessionId })
213 await updateLedger($, sessionId, (ledger, now) => corrected.reduce((next: Ledger, id: string) => withCorrected(next, id, now), ledger))
214}
215
216async function reportTurnReuse($: EngineInterface, options: PluginOptions, sessionId: string, turn: number, lastCheck: LastCheck | undefined): Promise<void> {
217 const status = reuseStatusOf(lastCheck)
218 const due = unreportedAssets(await readLedger($, sessionId)).filter((entry: { turn: number }) => entry.turn <= turn)
219 for (const injectedTurn of new Set(due.map((entry: { turn: number }) => entry.turn))) {
220 const assetIds = due.filter((entry: { turn: number }) => entry.turn === injectedTurn).map((entry: { assetId: string }) => entry.assetId)
221 const reported = await reportInjectedReuse(reuseLedgers($, options), { assetIds, lastCheck, turn: injectedTurn, sessionId })
222 await updateLedger($, sessionId, (ledger, now) => reported.reduce((next: Ledger, id: string) => withReported(next, id, status, now), ledger))
223 }
224}
225
226async function captureTurn($: EngineInterface, options: PluginOptions, request: Record<string, unknown>): Promise<void> {
227 const { receipt } = await runSidecar($, options, 'capture', request)
228 showStatus($, 'capture', captureStatusOf(receipt))
229}
230
231function signalNotice(input: Record<string, unknown>): string | null {
232 const signals = detectSignals(editedContent(input))
233 if (signals.length === 0) return null
234 signals.forEach(signal => turnRecord.signals.add(signal))
235 const where = editedPath(input) || 'edited file'
236 const noticeKey = `${where}\0${signals.join(',')}`
237 if (turnRecord.notices.has(noticeKey)) return null
238 turnRecord.notices.add(noticeKey)
239 return `[Evolution Signal] Detected: [${signals.join(', ')}] in ${where}. This will be attached to the turn outcome.`
240}
241
242async function serveTool($: EngineInterface, options: PluginOptions, name: string, call: Record<string, unknown>) {
243 const tool = toolNamed(name)
244 if (!tool) return { isError: true as const, result: `Unknown Evolver tool: ${name}` }
245 const input = toolArguments(call)
246 let request
247 try {
248 request = proxyRequestFor(tool, input)
249 } catch (error) {
250 return { isError: true as const, result: String((error as Error).message) }
251 }
252 const answered = await proxyFetch($, options, request.method, request.path, request.body)
253 if (!answered.ok) return { isError: true as const, result: answered.error }
254 if (name === REUSE_RESULT_TOOL && answered.data?.recorded !== false) {
255 const sessionId = await $.session.id()
256 const outcome = typeof input.outcome === 'string' && input.outcome ? input.outcome : 'success'
257 await updateLedger($, sessionId, (ledger, now) => withModelReport(ledger, String(input.asset_id), outcome, now))
258 }
259 return { result: renderToolResult(tool, answered.data) }
260}
261
262export const register: Register = (on, options) => {
263 on('session.start', async ($, e, next) => {
264 for (const tool of EVOLVER_TOOLS) {
265 await $.tool.register({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })
266 }
267 track($, 'onboarding', showOnboardingNotices($, options))
268 return next(e)
269 })
270
271 // `prompt.submit` knows who sent the prompt but resolves only after its turn
272 // started, so the person's prompt is marked before `next`; `turn.start` then
273 // recalls for it. A prompt folded into a running turn starts no turn and is
274 // only checked for a correction.
275 on('prompt.submit', async ($, e, next) => {
276 if (!isPersonPrompt(e.origin)) return next(e)
277 personPrompts.add(e.text)
278 track($, 'correction', correctEarlierVerdicts($, options, await $.session.id(), e.text))
279 return next(e)
280 }).catch(($, e, next) => next(e))
281
282 on('turn.start', async ($, e, next) => {
283 if (personPrompts.delete(e.text)) {
284 const sessionId = await $.session.id()
285 const turn = await turnNumberFor($, sessionId, e.turnId)
286 await recallForTurn($, options, sessionId, turn, e.turnId, e.text, next.signal).catch(error => recordFailure($, 'recall', error))
287 }
288 return next(e)
289 })
290
291 on('tool.call', async ($, e, next) => {
292 if (e.tool.startsWith(TOOL_PREFIX)) return serveTool($, options, e.tool.slice(TOOL_PREFIX.length), e as Record<string, unknown>)
293 const ran = await next(e)
294 if (e.tool === 'Bash' && ran.deny === undefined && !e.run_in_background && isVerificationCommand(e.command)) {
295 turnRecord.lastCheck = { command: e.command.slice(0, 200), failed: verificationFailed(ran) }
296 }
297 if (!EDIT_TOOL_NAMES.includes(e.tool) || ran.deny !== undefined || ran.isError) return ran
298 turnRecord.changedLines += changedLinesOf(e)
299 const notice = signalNotice(e as Record<string, unknown>)
300 return notice ? { ...ran, context: [...(ran.context ?? []), notice] } : ran
301 }).catch(($, e, next) => next(e))
302
303 on('turn.complete', async ($, e, next) => {
304 if (e.agentId !== undefined || !outcomeOfReason(e.reason)) return next(e)
305 if (e.reason === 'aborted') abortedTurns.add(e.turnId)
306 const sessionId = await $.session.id()
307 const turn = await turnNumberFor($, sessionId, e.turnId)
308 turnNumbers.delete(e.turnId)
309 const projectDir = await projectDirOf($)
310 const finished = turnRecord
311 turnRecord = freshTurnRecord()
312 track($, 'reuse-report', reportTurnReuse($, options, sessionId, turn, finished.lastCheck))
313 track($, 'capture', captureTurn($, options, { projectDir, turnReason: e.reason, sessionId, turn, observedSignals: [...finished.signals] }))
314 const answered = await next(e)
315 const summary = turnSummaryOf({ ...finished, usage: e.usage })
316 return summary ? { ...answered, text: summary } : answered
317 })
318
319 on('session.end', async ($, e, next) => {
320 await Promise.all(pendingWork)
321 await $.store.delete(ledgerKey(e.sessionId))
322 await $.store.delete(turnCounterKey(e.sessionId))
323 personPrompts.clear()
324 turnNumbers.clear()
325 abortedTurns.clear()
326 return next(e)
327 })
328}
329lib/edited-content.js 36 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4export const EDIT_TOOL_NAMES = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
5
6const CONTENT_KEYS = ['content', 'new_string', 'new_source'];
7
8function firstString(input, keys) {
9 for (const key of keys) {
10 if (typeof input?.[key] === 'string' && input[key].length > 0) return input[key];
11 }
12 return '';
13}
14
15export function editedContent(input) {
16 if (Array.isArray(input?.edits)) {
17 return input.edits.map((edit) => firstString(edit, CONTENT_KEYS)).filter(Boolean).join('\n');
18 }
19 return firstString(input, CONTENT_KEYS);
20}
21
22export function editedPath(input) {
23 return firstString(input, ['file_path', 'notebook_path']);
24}
25
26function lineCount(text) {
27 return typeof text === 'string' && text.length > 0 ? text.split('\n').length : 0;
28}
29
30// Lines a successful edit touched, removed plus written: the blast radius the
31// savings estimator is sized by.
32export function changedLinesOf(input) {
33 const edits = Array.isArray(input?.edits) ? input.edits : [input];
34 return edits.reduce((total, edit) => total + lineCount(edit?.old_string) + lineCount(edit?.new_string ?? edit?.content ?? edit?.new_source), 0);
35}
36lib/dissatisfaction.js 34 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4// A correction is only worth sending when the person plainly says the last
5// answer did not hold. Anything softer — a new question, a follow-up feature,
6// a report of some other error — must not revise a verdict, because the Hub
7// counts every report and there is no way to take one back.
8//
9// `X不X` questions are the trap this has to survive: `行不行` and `对不对` are
10// requests for an opinion and contain `不行` and `不对` verbatim.
11const CORRECTION_PATTERNS = [
12 /(?<!行)不行(?!不)/,
13 /(?<!对)不对(?!不)/,
14 /没(?:有)?(?:用|效果|解决|成功|生效)/,
15 /不管用/,
16 /还是(?:不|没|失败|报错|错)/,
17 /仍然(?:不|没|失败|报错)/,
18 /(?:搞|弄|说|理解)错了/,
19 /不是(?:这个|这样|我要)/,
20 /重(?:来|新来)/,
21 /(?:didn'?t|does\s?n'?t|did not|does not)\s+(?:work|help|fix)/i,
22 /still\s+(?:failing|broken|fails|not\s+work|doesn'?t)/i,
23 /(?:that'?s|that is|this is)\s+(?:wrong|incorrect)/i,
24 /(?:wrong|incorrect)\s+answer/i,
25 /no\s+effect/i,
26 /same\s+error/i,
27 /try\s+again/i,
28];
29
30export function looksLikeCorrection(text) {
31 if (typeof text !== 'string' || text.length === 0) return false;
32 return CORRECTION_PATTERNS.some((pattern) => pattern.test(text));
33}
34lib/injected-ledger.js 69 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4/** @typedef {Record<string, { turn: number, injectedAt: number, outcome?: string, reportedAt?: number, corrected?: boolean, correctedAt?: number, modelOutcome?: string }>} Ledger */
5
6const ENTRY_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
7
8// The Hub counts every reuse report it receives and de-duplicates nothing, so
9// exactly-once is this ledger's job, keyed on asset id. It is plain data: the
10// caller persists it, so a report survives the session ending between the
11// injection and the turn end.
12/** @returns {Ledger} */
13function pruned(ledger, now) {
14 const kept = {};
15 for (const [assetId, entry] of Object.entries(ledger ?? {})) {
16 const at = Number(entry?.injectedAt);
17 if (Number.isFinite(at) && now - at <= ENTRY_PRUNE_MS) kept[assetId] = entry;
18 }
19 return kept;
20}
21
22/** @returns {Ledger} */
23export function withInjected(ledger, assetId, turn, now) {
24 const kept = pruned(ledger, now);
25 if (kept[assetId]) return kept;
26 return { ...kept, [assetId]: { turn, injectedAt: now } };
27}
28
29export function unreportedAssets(ledger) {
30 return Object.entries(ledger ?? {})
31 .filter(([, entry]) => !entry?.outcome)
32 .map(([assetId, entry]) => ({ assetId, turn: Number(entry?.turn) }));
33}
34
35// A correction can only revise a verdict actually sent, and only once: a second
36// correction would be a second negative for one reuse.
37export function correctableAssets(ledger) {
38 return Object.entries(ledger ?? {})
39 .filter(([, entry]) => entry?.outcome === 'success' && !entry?.corrected)
40 .map(([assetId, entry]) => ({ assetId, turn: Number(entry?.turn) }));
41}
42
43/** @returns {Ledger} */
44export function withReported(ledger, assetId, outcome, now) {
45 const kept = pruned(ledger, now);
46 const entry = kept[assetId] ?? { turn: 0, injectedAt: now };
47 if (entry.outcome) return kept;
48 return { ...kept, [assetId]: { ...entry, outcome, reportedAt: now } };
49}
50
51/** @returns {Ledger} */
52export function withCorrected(ledger, assetId, now) {
53 const kept = pruned(ledger, now);
54 const entry = kept[assetId];
55 if (!entry || entry.corrected) return kept;
56 return { ...kept, [assetId]: { ...entry, corrected: true, correctedAt: now } };
57}
58
59// The model's own report is the verified one. Over an automatic verdict already
60// sent it stands as that asset's correction, so a later correction prompt does
61// not send the Hub a second negative for the same reuse.
62/** @returns {Ledger} */
63export function withModelReport(ledger, assetId, outcome, now) {
64 const kept = pruned(ledger, now);
65 const entry = kept[assetId];
66 if (!entry?.outcome) return withReported(kept, assetId, outcome, now);
67 return { ...kept, [assetId]: { ...entry, corrected: true, correctedAt: now, modelOutcome: outcome } };
68}
69lib/onboarding.js 54 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4// 2.0.39 is the first Proxy whose `/asset/fetch` recalls by text; an older one
5// answers every recall empty, so priming silently never injects anything.
6export const MIN_EVOLVER_VERSION = '2.0.39';
7
8const STATE_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
9
10export function versionOf(text) {
11 const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ''));
12 return match ? match.slice(1, 4).join('.') : null;
13}
14
15export function isOlderThan(version, minimum) {
16 const have = versionOf(version)?.split('.').map(Number);
17 const need = versionOf(minimum)?.split('.').map(Number);
18 if (!have || !need) return false;
19 for (let index = 0; index < 3; index += 1) {
20 if (have[index] !== need[index]) return have[index] < need[index];
21 }
22 return false;
23}
24
25export function upgradeNoticeText(version) {
26 if (!version || !isOlderThan(version, MIN_EVOLVER_VERSION)) return null;
27 return `Evolver ${version} is installed; network strategy recall needs ${MIN_EVOLVER_VERSION}+. `
28 + 'Run `npm install -g @evomap/evolver@latest`, then `evolver` once to restart the Proxy.';
29}
30
31export function pendingClaimUrl(text) {
32 try {
33 const parsed = new URL(String(text ?? '').trim());
34 const host = parsed.hostname.toLowerCase();
35 if (parsed.protocol !== 'https:') return null;
36 if (host !== 'evomap.ai' && !host.endsWith('.evomap.ai')) return null;
37 return parsed.toString();
38 } catch {
39 return null;
40 }
41}
42
43// Shared by every throttled notice, so callers pass a namespaced key. A claim
44// url carries a secret, so callers hash it before it becomes a key.
45export function noticeDecision(state, key, ttlMs, now) {
46 const previous = state?.[key];
47 if (typeof previous === 'number' && now - previous < ttlMs) return { isDue: false, state };
48 const next = { [key]: now };
49 for (const [existing, timestamp] of Object.entries(state ?? {})) {
50 if (existing !== key && typeof timestamp === 'number' && now - timestamp <= STATE_PRUNE_MS) next[existing] = timestamp;
51 }
52 return { isDue: true, state: next };
53}
54lib/recall.js 144 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4import { MIN_RELEVANCE, relevanceOf, sharedTerms } from './relevance.js';
5
6// `/asset/fetch` with text and no ids recalls whole assets in one round trip,
7// so the limit is how many candidates the Hub should rank for the prompt.
8const RECALL_LIMIT = 5;
9const MIN_PROMPT_CHARS = 8;
10const PROMPT_MAX_CHARS = 400;
11const STEP_MAX_CHARS = 400;
12const TITLE_MAX_CHARS = 80;
13const DEFAULT_MIN_SIMILARITY = 0.3;
14// Text recall skips the Hub's search tuning, so it returns genes too thin to
15// follow: two or three steps say too little to reuse. Longer strategies are
16// kept whole, each step capped at STEP_MAX_CHARS.
17const MIN_STRATEGY_STEPS = 4;
18const UNSCORED = -1;
19const EMPTY_MATCH = { ids: [], text: '' };
20
21function strategySteps(asset) {
22 const steps = asset?.strategy ?? asset?.payload?.strategy ?? asset?.gene?.strategy;
23 const list = Array.isArray(steps) ? steps : [steps];
24 return list
25 .filter((step) => typeof step === 'string' && step.trim())
26 .map((step) => step.trim().slice(0, STEP_MAX_CHARS));
27}
28
29// Only what the person typed is matched: notifications, peer messages and other
30// plugins' prompts are not the person's task, and Claude Code's own reminders
31// would drown the task in boilerplate.
32const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk', 'scheduled-trigger']);
33
34export function isPersonPrompt(origin) {
35 return origin === undefined || PERSON_ORIGINS.has(origin?.kind);
36}
37
38export function promptTextOf(text) {
39 return typeof text === 'string' ? text.trim().slice(0, PROMPT_MAX_CHARS) : '';
40}
41
42function recalledAssets(data) {
43 const found = [data?.assets, data?.results, data?.payload?.results].find(Array.isArray) ?? [];
44 return found.filter((asset) => asset && typeof asset.asset_id === 'string' && asset.asset_id);
45}
46
47const CJK_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
48const MIN_CJK_TITLE_CHARS = 5;
49
50function trimmedText(value) {
51 return typeof value === 'string' && value.trim() ? value.trim() : '';
52}
53
54// A Hub short_title is sometimes a truncated fragment: `Object`, `自动化小`.
55// Character count cannot separate those from a good title, because `智能缓存优化`
56// says as much in six characters as a Latin title says in forty.
57function looksLikeAName(text) {
58 if (!text) return false;
59 if (CJK_SCRIPT.test(text)) return [...text].length >= MIN_CJK_TITLE_CHARS;
60 return /\s/.test(text);
61}
62
63function readableNameOf(asset) {
64 const title = trimmedText(asset?.short_title);
65 if (looksLikeAName(title)) return title.slice(0, TITLE_MAX_CHARS);
66 const described = trimmedText(asset?.nl_summary) || trimmedText(asset?.summary);
67 if (described) return described.slice(0, TITLE_MAX_CHARS);
68 return title || asset?.asset_type || asset?.type || 'Gene';
69}
70
71function similarityOf(asset) {
72 return typeof asset.similarity === 'number' ? asset.similarity : UNSCORED;
73}
74
75// A Proxy's similarity score is advisory — the same question scored 0.88 asked
76// one way and 0.40 another — and a Proxy that reports none must not filter
77// everything out, so the floor applies only where a score exists. Relevance to
78// the prompt gates every candidate and breaks ties; the Hub's own order is not
79// trusted, since it weighs more than this prompt.
80function rankedCandidates(text, assets, listedIds, minSimilarity) {
81 return assets
82 .filter((asset) => !listedIds.has(asset.asset_id))
83 .filter((asset) => typeof asset.similarity !== 'number' || asset.similarity >= minSimilarity)
84 .map((asset) => ({ asset, relevance: relevanceOf(sharedTerms(text, asset)) }))
85 .filter(({ relevance }) => relevance >= MIN_RELEVANCE)
86 .sort((left, right) => similarityOf(right.asset) - similarityOf(left.asset) || right.relevance - left.relevance)
87 .map(({ asset }) => asset);
88}
89
90function bestRelevanceOf(text, assets, listedIds) {
91 return assets
92 .filter((asset) => !listedIds.has(asset.asset_id))
93 .reduce((best, asset) => Math.max(best, relevanceOf(sharedTerms(text, asset))), 0);
94}
95
96async function recalledData(proxyFetch, text) {
97 try {
98 const result = await proxyFetch('POST', '/asset/fetch', { text, limit: RECALL_LIMIT });
99 return result?.ok ? result.data : null;
100 } catch {
101 return null;
102 }
103}
104
105// Picks one strategy for the prompt, or none. An unreachable Proxy, a slow Hub
106// or a malformed body yields none rather than an error: recall must never cost
107// the turn. Only the steps are injected — a summary tells the model something
108// exists, the steps are what it can reuse. `onTrace` hears how the decision was
109// made, so it can be shown or kept without reaching the model.
110export async function recallStrategy(proxyFetch, text, { listedIds = new Set(), minSimilarity = DEFAULT_MIN_SIMILARITY, onTrace = (_trace) => {} } = {}) {
111 if (text.length < MIN_PROMPT_CHARS) {
112 onTrace({ outcome: 'short' });
113 return EMPTY_MATCH;
114 }
115
116 const recalled = await recalledData(proxyFetch, text);
117 if (!recalled) {
118 onTrace({ outcome: 'unavailable' });
119 return EMPTY_MATCH;
120 }
121
122 const assets = recalledAssets(recalled);
123 const ranked = rankedCandidates(text, assets, listedIds, minSimilarity);
124 for (const candidate of ranked) {
125 const steps = strategySteps(candidate);
126 if (steps.length < MIN_STRATEGY_STEPS) continue;
127
128 const name = readableNameOf(candidate);
129 onTrace({ outcome: 'injected', recalled: assets.length, eligible: ranked.length, name, relevance: relevanceOf(sharedTerms(text, candidate)) });
130 return {
131 ids: [candidate.asset_id],
132 name,
133 text: [
134 `[Evolution Memory] ${name} (EvoMap network):`,
135 ...steps.map((step, index) => `${index + 1}. ${step}`),
136 '',
137 `Apply it where it fits, then report the outcome with evolver_asset_reuse_result for ${candidate.asset_id}.`,
138 ].join('\n'),
139 };
140 }
141 onTrace({ outcome: 'skipped', recalled: assets.length, eligible: ranked.length, bestRelevance: bestRelevanceOf(text, assets, listedIds) });
142 return EMPTY_MATCH;
143}
144lib/proxy-response.js 62 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4import { normalizeLoopbackUrl } from './loopback.js';
5
6/** @typedef {{ ok: true, data: any } | { ok: false, error: string }} ProxyResult */
7
8export const DEFAULT_PROXY_PORT = '19820';
9
10const START_HINT =
11 'Start it by running `evolver` once inside a git repo (the CLI launches the Proxy). '
12 + 'Set the proxy_port option if you use a non-default port.';
13
14// The Proxy rotates its loopback URL and bearer token into ~/.evolver/settings.json;
15// a URL that is not loopback is ignored so the token never leaves the machine.
16export function proxySettingsFrom(settingsText, port) {
17 let url = null;
18 let token = null;
19 try {
20 const settings = JSON.parse(settingsText);
21 if (settings?.proxy?.url) url = normalizeLoopbackUrl(settings.proxy.url);
22 if (settings?.proxy?.token) token = String(settings.proxy.token);
23 } catch {
24 }
25 return { url: url ?? `http://127.0.0.1:${port || DEFAULT_PROXY_PORT}`, token };
26}
27
28function httpErrorHint(status, base, token) {
29 if (status === 401 || status === 403) {
30 return token
31 ? ' The Proxy token in ~/.evolver/settings.json was rejected; restart Evolver so it writes fresh settings.'
32 : ` No Proxy token was found and the request was rejected — another process may be using ${base}. ${START_HINT}`;
33 }
34 if (status === 404) return ` Endpoint not found at ${base} — upgrade or verify the Evolver Proxy.`;
35 return '';
36}
37
38/** @returns {ProxyResult} */
39export function timedOutResult(base, timeoutMs) {
40 return { ok: false, error: `Proxy request timed out after ${timeoutMs}ms. Evolver Proxy not reachable at ${base}. ${START_HINT}` };
41}
42
43/** @returns {ProxyResult} */
44export function unreachableResult(base, error) {
45 return { ok: false, error: `Proxy connection failed: ${error?.message ?? error}. Evolver Proxy not reachable at ${base}. ${START_HINT}` };
46}
47
48/** @returns {ProxyResult} */
49export function proxyResultOf(response, base, token) {
50 let data;
51 try {
52 data = response.text ? JSON.parse(response.text) : {};
53 } catch {
54 data = { raw: response.text };
55 }
56 if (response.ok) return { ok: true, data };
57 return {
58 ok: false,
59 error: `Proxy at ${base} returned HTTP ${response.status}: ${JSON.stringify(data)}.${httpErrorHint(response.status, base, token)}`,
60 };
61}
62lib/reuse.js 87 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4// An automatic report is weaker evidence than one the model made after
5// validating its own work: the strategy was put in front of the model, but
6// nothing proves it was followed. The reason says so, so the Hub can weigh it
7// against a reported reuse rather than mistaking it for one.
8function automaticReason(turn, lastCheck) {
9 const verdict = lastCheck?.failed
10 ? `the turn's last verification failed (\`${lastCheck.command}\`)`
11 : lastCheck
12 ? `the turn's last verification passed (\`${lastCheck.command}\`)`
13 : 'the turn ran no verification, so it counts as reaching its goal';
14 return `Injected by Evolver into Claude Code turn ${turn}; not confirmed as applied. Outcome: ${verdict}.`;
15}
16
17// A reuse counts as a success unless the turn missed its goal, and the only
18// evidence of a miss is its last test, build, lint or type-check failing.
19export function reuseStatusOf(lastCheck) {
20 return lastCheck?.failed ? 'failed' : 'success';
21}
22
23function correctionReason(turn) {
24 return `Revising the automatic verdict for Claude Code turn ${turn}: the next prompt `
25 + 'in the same session read as a correction, so the earlier reuse did not hold.';
26}
27
28// The Hub hashes the task id with the asset and the reporting node into the
29// event id that makes a retry idempotent, and its schema requires one. One id
30// per turn per session means re-sending a verdict is recognised, while a later
31// correction carries its own and is not mistaken for that retry.
32function reportId(sessionId, turn) {
33 return `claude-code:${sessionId || 'unknown'}:${turn ?? 'unknown'}`;
34}
35
36// `ok` only says the Proxy answered; the body carries whether a Hub ledger took
37// the report. Treating transport as outcome would mark an asset reported that no
38// ledger ever saw.
39async function postToHub(proxyFetch, { assetId, outcome, reason, taskId }) {
40 try {
41 const result = await proxyFetch('POST', '/asset/reuse-result', { asset_id: assetId, outcome, reason, task_id: taskId });
42 return result?.ok === true && result.data?.recorded !== false;
43 } catch {
44 return false;
45 }
46}
47
48// The local root_event is the half the candidate-reordering actuator reads. A
49// verdict counts as delivered when either ledger took it, so one being down does
50// not keep the asset pending forever.
51async function postOutcome(ledgers, { assetId, outcome, reason, taskId, sessionId }) {
52 const hub = await postToHub(ledgers.proxyFetch, { assetId, outcome, reason, taskId });
53 const local = await ledgers.recordLocally({ assetId, outcome, sessionId }).catch(() => false);
54 return hub || local;
55}
56
57export async function reportInjectedReuse(ledgers, { assetIds, lastCheck, turn, sessionId }) {
58 if (assetIds.length === 0) return [];
59 const reported = [];
60 for (const assetId of assetIds) {
61 const delivered = await postOutcome(ledgers, {
62 assetId,
63 outcome: reuseStatusOf(lastCheck),
64 reason: automaticReason(turn, lastCheck),
65 taskId: reportId(sessionId, turn),
66 sessionId,
67 });
68 if (delivered) reported.push(assetId);
69 }
70 return reported;
71}
72
73export async function reportReuseCorrection(ledgers, { assets, sessionId }) {
74 const corrected = [];
75 for (const { assetId, turn } of assets) {
76 const delivered = await postOutcome(ledgers, {
77 assetId,
78 outcome: 'failed',
79 reason: correctionReason(turn),
80 taskId: `${reportId(sessionId, turn)}:correction`,
81 sessionId,
82 });
83 if (delivered) corrected.push(assetId);
84 }
85 return corrected;
86}
87lib/signals.js 65 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4export const SIGNAL_KEYWORDS = {
5 perf_bottleneck: ['timeout', 'slow', 'latency', 'bottleneck', 'oom', 'out of memory', 'performance'],
6 capability_gap: ['not supported', 'unsupported', 'not implemented', 'missing feature', 'not available'],
7 log_error: ['error:', 'exception:', 'typeerror', 'referenceerror', 'syntaxerror', 'failed'],
8 user_feature_request: ['add feature', 'implement', 'new function', 'new module', 'please add'],
9 recurring_error: ['same error', 'still failing', 'not fixed', 'keeps failing', 'repeatedly'],
10 deployment_issue: ['deploy failed', 'build failed', 'ci failed', 'pipeline', 'rollback'],
11 test_failure: ['test failed', 'test failure', 'assertion', 'expect(', 'assert.'],
12};
13
14const CODE_LINE_PREFIXES = ['//', '#', '*', '{', '[', '}', ']', '/*'];
15// A keyword inside a statement describes what the code does, not what happened:
16// `expect(order).toBe(paid)` is a passing test being written, not a test failure.
17const CODE_STATEMENT = /^(?:[A-Za-z_$][\w$.]*\s*\(|(?:import|export|const|let|var|function|class|return|if|for|while|switch|throw|await|async)\b)/;
18
19function looksLikeCode(trimmedLine) {
20 return (
21 CODE_LINE_PREFIXES.some((prefix) => trimmedLine.startsWith(prefix)) ||
22 CODE_STATEMENT.test(trimmedLine) ||
23 /[;{}]$/.test(trimmedLine)
24 );
25}
26
27function proseOf(text) {
28 return text
29 .split('\n')
30 .filter((line) => {
31 const trimmed = line.trim();
32 return trimmed.length > 0 && !looksLikeCode(trimmed);
33 })
34 .join('\n')
35 .toLowerCase();
36}
37
38export function detectSignals(text) {
39 if (typeof text !== 'string' || text.length === 0) return [];
40
41 const prose = proseOf(text);
42 if (!prose) return [];
43
44 const found = new Set();
45 for (const [category, phrases] of Object.entries(SIGNAL_KEYWORDS)) {
46 if (phrases.some((phrase) => prose.includes(phrase))) found.add(category);
47 }
48 return [...found].sort();
49}
50
51// Only added lines describe this turn's intent; headers, hunk markers and
52// removed lines describe what the repository used to be.
53export function addedLines(diffBody) {
54 if (typeof diffBody !== 'string') return '';
55 return diffBody
56 .split('\n')
57 .filter((line) => line.startsWith('+') && !line.startsWith('+++'))
58 .map((line) => line.slice(1))
59 .join('\n');
60}
61
62export function detectSignalsInDiff(diffBody) {
63 return detectSignals(addedLines(diffBody));
64}
65lib/status-line.js 43 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4import { MIN_RELEVANCE } from './relevance.js';
5
6const NAME_MAX_CHARS = 40;
7const LINE_MAX_CHARS = 160;
8
9function plural(count, noun) {
10 return `${count} ${noun}${count === 1 ? '' : 's'}`;
11}
12
13export function recallStatusOf(trace) {
14 switch (trace?.outcome) {
15 case 'short':
16 return 'recall: prompt too short';
17 case 'unavailable':
18 return 'recall: Proxy gave no answer';
19 case 'waiting':
20 return `recall: still waiting after ${Math.round(trace.waitedMs / 1000)}s, lands next step`;
21 case 'dropped':
22 return 'recall: arrived after the turn was interrupted, dropped';
23 case 'injected':
24 return `recall: injected "${String(trace.name).slice(0, NAME_MAX_CHARS)}" (${plural(trace.recalled, 'hit')}, relevance ${trace.relevance})`;
25 case 'skipped':
26 return trace.recalled === 0
27 ? 'recall: no assets came back'
28 : `recall: ${plural(trace.recalled, 'hit')}, best relevance ${trace.bestRelevance} (needs ${MIN_RELEVANCE}), none injected`;
29 default:
30 return null;
31 }
32}
33
34export function captureStatusOf(receipt) {
35 if (typeof receipt !== 'string' || !receipt) return null;
36 return `capture: ${receipt.replace(/^\[Evolution\] Turn outcome recorded to /, '')}`;
37}
38
39export function statusLineOf(parts) {
40 const shown = parts.filter(Boolean);
41 return shown.length === 0 ? undefined : `evolver · ${shown.join(' · ')}`.slice(0, LINE_MAX_CHARS);
42}
43lib/turn-summary.js 48 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4import { referenceReuseSavings } from './savings.js';
5
6const NAME_MAX_CHARS = 48;
7
8function compactTokens(tokens) {
9 if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
10 if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
11 return String(tokens);
12}
13
14// Cache reads are the context replayed on every step at a tenth of the price;
15// counting them would report a long session's context, not this turn's work.
16function freshTokens(usage) {
17 if (!usage) return null;
18 const counted = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens']
19 .map((field) => Number(usage[field]))
20 .filter(Number.isFinite);
21 return counted.length === 0 ? null : counted.reduce((sum, value) => sum + value, 0);
22}
23
24function goalLine(lastCheck) {
25 if (!lastCheck) return '○ Goal counted as reached (no check ran)';
26 return lastCheck.failed
27 ? `✗ Goal not reached (last check failed: ${lastCheck.command})`
28 : `✓ Goal reached (last check passed: ${lastCheck.command})`;
29}
30
31function reuseLine({ reusedNames, lastCheck, changedLines, usage }) {
32 const names = reusedNames.map((name) => `"${String(name).slice(0, NAME_MAX_CHARS)}"`).join(', ');
33 const used = freshTokens(usage);
34 const usedPart = used === null ? '' : ` · this turn used ${compactTokens(used)} fresh tokens`;
35 if (lastCheck?.failed) return `reused EvoMap strategy ${names} · no savings counted${usedPart}`;
36 const saved = referenceReuseSavings(changedLines);
37 return `reused EvoMap strategy ${names} · est. ~${compactTokens(saved.tokens)} tokens saved (≈$${saved.usd.toFixed(2)})${usedPart}`;
38}
39
40// One line, shown only for a turn that reused a strategy: the desktop draws it
41// as a notice that renders no line breaks. It says whether the goal was
42// reached, judged by the last check as the reuse report is, and what the reuse
43// is estimated to have saved.
44export function turnSummaryOf(turn) {
45 if (!turn?.reusedNames?.length) return null;
46 return `${goalLine(turn.lastCheck)} · ${reuseLine(turn)}`;
47}
48lib/tools.js 273 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 EvoMap
3
4const MAX_RENDERED_FIELD_CHARS = 16_000;
5const ACK_MAX_MESSAGE_IDS = 50;
6
7function textList(value) {
8 if (Array.isArray(value)) return value.filter((item) => typeof item === 'string' && item.trim());
9 if (typeof value === 'string' && value.trim()) return [value.trim()];
10 return [];
11}
12
13function boundedText(value) {
14 const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
15 if (!text) return '';
16 return text.length <= MAX_RENDERED_FIELD_CHARS ? text : `${text.slice(0, MAX_RENDERED_FIELD_CHARS)}\n[truncated]`;
17}
18
19function renderAsset(asset) {
20 const lines = [`## ${asset.type ?? 'Asset'} ${asset.asset_id ?? ''}`.trim()];
21 if (asset.summary) lines.push('', boundedText(asset.summary));
22
23 const strategy = textList(asset.strategy ?? asset.payload?.strategy ?? asset.gene?.strategy);
24 if (strategy.length > 0) {
25 lines.push('', 'Strategy:');
26 strategy.forEach((step, index) => lines.push(`${index + 1}. ${step}`));
27 }
28
29 const validation = textList(asset.validation ?? asset.payload?.validation ?? asset.capsule?.validation);
30 if (validation.length > 0) {
31 lines.push('', 'Validation — run these to confirm the change worked:');
32 for (const check of validation) lines.push(`- ${check}`);
33 }
34
35 if (strategy.length === 0) {
36 const content = boundedText(asset.content ?? asset.payload?.content ?? asset.payload);
37 if (content) lines.push('', 'Reusable content:', content);
38 }
39 return lines.join('\n');
40}
41
42// Why the Proxy withheld an asset (absent, revoked, or a body that no longer
43// hashes to its id) does not reach here, so the missing list is named without a
44// guessed cause.
45function renderFetched(value) {
46 const assets = [value?.assets, value?.results, value?.payload?.results].find(Array.isArray) ?? [];
47 const missing = [value?.missing, value?.payload?.missing].find(Array.isArray) ?? [];
48 const parts = assets.map(renderAsset);
49 if (missing.length > 0) parts.push(`Not retrievable: ${missing.join(', ')}. The Proxy did not report why.`);
50 if (parts.length === 0) parts.push('No assets returned.');
51 parts.push('After applying an asset and validating the result, call evolver_asset_reuse_result.');
52 return parts.join('\n\n');
53}
54
55function renderJson(value) {
56 return JSON.stringify(value, null, 2);
57}
58
59function boundedInteger(value, fallback, minimum, maximum, name) {
60 const candidate = value ?? fallback;
61 if (!Number.isInteger(candidate) || candidate < minimum || candidate > maximum) {
62 throw new Error(`${name} must be an integer from ${minimum} through ${maximum}.`);
63 }
64 return candidate;
65}
66
67function nonEmptyString(value, name) {
68 if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`${name} must be a non-empty string.`);
69 return value.trim();
70}
71
72function nonEmptyArray(value, name) {
73 if (!Array.isArray(value) || value.length === 0) throw new Error(`${name} must contain at least one item.`);
74 return value;
75}
76
77function ackMessageIds(value) {
78 const ids = nonEmptyArray(value, 'message_ids').map((id, index) => nonEmptyString(id, `message_ids[${index}]`));
79 if (ids.length > ACK_MAX_MESSAGE_IDS) throw new Error(`message_ids must contain at most ${ACK_MAX_MESSAGE_IDS} ids.`);
80 return ids;
81}
82
83const STRINGS = { type: 'array', items: { type: 'string' } };
84
85function objectSchema(properties, required = []) {
86 return { type: 'object', properties, required, additionalProperties: false };
87}
88
89/**
90 * @typedef {{ method: string, path: string, body?: unknown }} ProxyRequest
91 * @typedef {{ name: string, description: string, inputSchema: Record<string, unknown>, request: (args: any) => ProxyRequest, render?: (data: any) => string }} EvolverTool
92 */
93
94export const REUSE_RESULT_TOOL = 'evolver_asset_reuse_result';
95
96/** @type {EvolverTool[]} */
97export const EVOLVER_TOOLS = [
98 {
99 name: 'evolver_status',
100 description:
101 'Get the EvoMap Proxy status: running state, node_id, pending inbound/outbound message counts, and last Hub sync time. Use this first to confirm the Proxy is up.',
102 inputSchema: objectSchema({}),
103 request: () => ({ method: 'GET', path: '/proxy/status' }),
104 },
105 {
106 name: 'evolver_search_assets',
107 description:
108 'Search the EvoMap network for reusable evolution assets. Pass `signals` for literal signal/error tags and `text` for natural-language summary matching; optionally narrow with `kind`, `category`, or `gene`. A `degraded: true` response came from the local cache because the Hub was unavailable.',
109 inputSchema: objectSchema({
110 signals: { ...STRINGS, description: 'Signal keywords, error codes, or trigger tags; any may match.' },
111 text: { type: 'string', description: 'Free-text task description matched against asset summaries.' },
112 kind: { type: 'string', enum: ['Gene', 'Capsule', 'EvolutionEvent', 'AntiGene'], description: 'Restrict to one asset kind.' },
113 category: { type: 'string', description: 'Gene category or EvolutionEvent intent.' },
114 gene: { type: 'string', description: 'Return Capsules belonging to this gene id.' },
115 limit: { type: 'integer', default: 5, description: 'Result count from 1 through 25.' },
116 }),
117 request: (args) => {
118 if (!args.signals && !args.text && !args.kind && !args.category && !args.gene) {
119 throw new Error('Provide at least one of signals, text, kind, category, or gene.');
120 }
121 if (args.signals !== undefined) nonEmptyArray(args.signals, 'signals');
122 return {
123 method: 'POST',
124 path: '/asset/search',
125 body: {
126 signals: args.signals,
127 text: args.text,
128 kind: args.kind,
129 category: args.category,
130 gene: args.gene,
131 limit: boundedInteger(args.limit, 5, 1, 25, 'limit'),
132 },
133 };
134 },
135 },
136 {
137 name: 'evolver_fetch_asset',
138 description:
139 'Fetch reusable content by asset id. The result presents each asset summary, strategy, validation, and a content fallback. After applying an asset, call evolver_asset_reuse_result with the verified outcome.',
140 inputSchema: objectSchema({ asset_ids: STRINGS }, ['asset_ids']),
141 request: (args) => ({ method: 'POST', path: '/asset/fetch', body: { asset_ids: nonEmptyArray(args.asset_ids, 'asset_ids') } }),
142 render: renderFetched,
143 },
144 {
145 name: 'evolver_publish_asset',
146 description:
147 'Publish Genes or Capsules to the EvoMap Hub for review. The Proxy queues them locally; poll `asset_submit_result` with evolver_poll for the decision.',
148 inputSchema: objectSchema({
149 assets: {
150 type: 'array',
151 items: objectSchema({
152 type: { type: 'string', enum: ['Gene', 'Capsule'] },
153 content: { type: 'string' },
154 summary: { type: 'string' },
155 signals: STRINGS,
156 }, ['type', 'content']),
157 },
158 }, ['assets']),
159 request: (args) => {
160 for (const [index, asset] of nonEmptyArray(args.assets, 'assets').entries()) {
161 nonEmptyString(asset?.content, `assets[${index}].content`);
162 }
163 return { method: 'POST', path: '/asset/submit', body: { assets: args.assets } };
164 },
165 },
166 {
167 name: REUSE_RESULT_TOOL,
168 description:
169 'Report the verified outcome after reusing a fetched asset. This closes the feedback loop, credits its author, and improves future ranking.',
170 inputSchema: objectSchema({
171 asset_id: { type: 'string', description: 'The reused asset id.' },
172 outcome: { type: 'string', enum: ['success', 'failed', 'mismatched', 'stale', 'unsafe'] },
173 reason: { type: 'string', description: 'Why the reuse produced this outcome.' },
174 time_saved_seconds: { type: 'number', description: 'Estimated wall-clock seconds saved.' },
175 task_id: { type: 'string', description: 'Caller-side task identity, when available.' },
176 }, ['asset_id', 'outcome']),
177 request: (args) => {
178 nonEmptyString(args.asset_id, 'asset_id');
179 if (args.time_saved_seconds !== undefined && (!Number.isFinite(args.time_saved_seconds) || args.time_saved_seconds < 0)) {
180 throw new Error('time_saved_seconds must be a non-negative number.');
181 }
182 return {
183 method: 'POST',
184 path: '/asset/reuse-result',
185 body: {
186 asset_id: args.asset_id,
187 outcome: args.outcome,
188 reason: args.reason,
189 time_saved_seconds: args.time_saved_seconds,
190 task_id: args.task_id,
191 },
192 };
193 },
194 },
195 {
196 name: 'evolver_distill_conversation',
197 description:
198 'Distill a concrete, verified capability from this conversation. Include reproducible strategy and validation evidence; persistence defaults on, while Hub publication requires publish=true.',
199 inputSchema: objectSchema({
200 summary: { type: 'string', description: 'What was solved and under which conditions.' },
201 title: { type: 'string', description: 'Short capability name.' },
202 strategy: { ...STRINGS, description: 'Reproducible implementation steps.' },
203 validation: { ...STRINGS, description: 'Checks that proved the result.' },
204 artifacts: { ...STRINGS, description: 'Files or paths produced.' },
205 signals: { ...STRINGS, description: 'Signal keywords this generalizes.' },
206 persist: { type: 'boolean', default: true, description: 'Keep the distilled asset locally; defaults to true.' },
207 publish: { type: 'boolean', default: false, description: 'Submit it to the Hub; defaults to false.' },
208 }, ['summary']),
209 request: (args) => {
210 nonEmptyString(args.summary, 'summary');
211 if (args.strategy !== undefined) nonEmptyArray(args.strategy, 'strategy');
212 if (args.validation !== undefined) nonEmptyArray(args.validation, 'validation');
213 return {
214 method: 'POST',
215 path: '/conversation/distill',
216 body: {
217 platform: 'claude-code',
218 title: args.title,
219 summary: args.summary,
220 strategy: args.strategy,
221 validation: args.validation,
222 artifacts: args.artifacts,
223 signals: args.signals,
224 persist: args.persist !== false,
225 publish: args.publish === true,
226 },
227 };
228 },
229 },
230 {
231 name: 'evolver_poll',
232 description:
233 'Poll the local mailbox by optional message type. Polling does not consume: the same messages come back on every call until evolver_ack retires them by id.',
234 inputSchema: objectSchema({
235 type: { type: 'string', description: 'Message type filter, for example asset_submit_result.' },
236 limit: { type: 'integer', default: 10, description: 'Result count from 1 through 50.' },
237 }),
238 request: (args) => ({
239 method: 'POST',
240 path: '/mailbox/poll',
241 body: { type: args.type, limit: boundedInteger(args.limit, 10, 1, 50, 'limit') },
242 }),
243 },
244 {
245 name: 'evolver_ack',
246 description:
247 'Acknowledge mailbox messages by id so evolver_poll stops returning them. Pass the ids from a previous evolver_poll result once the messages have been acted on.',
248 inputSchema: objectSchema({ message_ids: { ...STRINGS, description: 'Message ids from a previous evolver_poll result.' } }, ['message_ids']),
249 request: (args) => ({ method: 'POST', path: '/mailbox/ack', body: { message_ids: ackMessageIds(args.message_ids) } }),
250 },
251];
252
253const ENGINE_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'consent']);
254
255export function toolArguments(call) {
256 return Object.fromEntries(Object.entries(call ?? {}).filter(([key]) => !ENGINE_KEYS.has(key)));
257}
258
259/** @returns {ProxyRequest} */
260export function proxyRequestFor(tool, args) {
261 const unknown = Object.keys(args).filter((key) => !Object.hasOwn(tool.inputSchema.properties ?? {}, key));
262 if (unknown.length > 0) throw new Error(`Unknown argument${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
263 return tool.request(args);
264}
265
266export function toolNamed(name) {
267 return EVOLVER_TOOLS.find((tool) => tool.name === name) ?? null;
268}
269
270export function renderToolResult(tool, data) {
271 return (tool.render ?? renderJson)(data);
272}
273