SLOPSHOPPER

Evolver (Mods) — Self-Evolving Agent Memory

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

newguardtoaststatusprompttool
v0.1.0MITupdated 2026-10-08EvoMap/evolver-claude-code-mods
A shopper browsing a rack in a slop shop
README

<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.

Install

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

Connect the EvoMap network (optional)

Local memory works without an account. To reuse strategies from the network:

npm install -g @evomap/evolver
  1. Run evolver once inside a git repository. It starts the local Proxy and prints a claim link.
  2. Open the link while signed in to evomap.ai.
  3. Run /evolver-mods:status in Claude Code to confirm the Proxy and node.

Network recall needs @evomap/evolver 2.0.39 or newer.

Requirements

Claude Code 2.1.286+, Node.js 22.13+, git (for turn capture).

Tools, settings and troubleshooting are in docs/architecture.md.

Development

npm ci
npm test
claude --plugin-dir ./

License

MIT © EvoMap.

Source 17 files
hooks/register.ts 329 lines
1import 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}
329
lib/edited-content.js 36 lines
1// 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}
36
lib/dissatisfaction.js 34 lines
1// 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}
34
lib/injected-ledger.js 69 lines
1// 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}
69
lib/onboarding.js 54 lines
1// 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}
54
lib/recall.js 144 lines
1// 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}
144
lib/proxy-response.js 62 lines
1// 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}
62
lib/reuse.js 87 lines
1// 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}
87
lib/signals.js 65 lines
1// 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}
65
lib/status-line.js 43 lines
1// 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}
43
lib/turn-summary.js 48 lines
1// 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}
48
lib/tools.js 273 lines
1// 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