diff --git a/assets/commands/status.md b/assets/commands/status.md index 0bdb7c5..71411a8 100644 --- a/assets/commands/status.md +++ b/assets/commands/status.md @@ -1,29 +1,50 @@ --- -description: Show Evolver health — Proxy status, evolution memory, workspace id, network claim, and full-engine availability. +description: Show Evolver health — Proxy, running engine version, recent strategy recalls, evolution memory, workspace id, network claim, and full-engine availability. --- -Report Evolver health as a short checklist. Use the available filesystem and shell tools for -the current platform; do not assume a POSIX shell. +Report Evolver health as a checklist grouped under the headings below. Use the available +filesystem and shell tools for the current platform; do not assume a POSIX shell. Never print +token values. -1. **Proxy** — call `evolver_status`. Show its node id, pending counts, and last sync. If - unreachable, say that running `evolver` once in a git repository starts the Proxy; local - recall and capture remain available. -2. **Network claim** — check `~/.evomap/claim_url`. If it contains an HTTPS `evomap.ai` +Call `evolver_status` once and use its fields throughout; it returns `proxy` (the Proxy's own +status, or `reachable: false` with the error) and the plugin's view (`plugin`, `runtime`, +`proxy_endpoint`, `engine`, `recall`). + +1. **Plugin and runtime** — plugin version, platform/arch, Node version. +2. **Proxy** — reachable or not. When reachable: `status`, `node_id`, `hub_mode`, + `hub_auth_status` (flag anything but `ok`), `last_sync_at` as a local time, `last_sync_error`, + pending inbound/outbound counts, and any `reauth_backoff_until` / `hello_rate_limit_until` + in the future. When unreachable: show the error, the endpoint it tried (`proxy_endpoint.url` + and `url_source`), and say that running `evolver` once in a git repository starts the Proxy; + local recall and capture remain available. +3. **Engine version** — `engine.running_version` (the Proxy actually serving, with + `proxy_endpoint.pid` and `started_at`) and `engine.path_version` (the `evolver` on PATH). + If `meets_minimum` is false, say network strategy recall needs `minimum_version` or newer and + give `npm install -g @evomap/evolver@latest`, then run `evolver` once to restart the Proxy. + If `path_differs_from_running` is true, say the running Proxy is a different install from the + one on PATH and should be restarted from the upgraded one. If both versions are null, say the + version could not be determined. +4. **Strategy recall** — enabled or not; settings (`wait_ms`, `timeout_ms`, `min_similarity`, + `max_strategy_chars`); the last recall (`last.status`, `last.at`, `last.duration_ms`) and the + per-status `counts` since dsh started. Explain the last status in one line: + `injected` (name the title, steps and chars), `no_candidates` (the network had nothing for that + prompt), `all_filtered` (list `dropped` reasons: `too_few_steps`, `too_long`, `low_similarity`, + `already_injected`), `proxy_error` (show the error), `skipped` (prompt too short). If `last` is + null, no prompt has been recalled in this dsh process yet. +5. **Network claim** — check `~/.evomap/claim_url`. If it contains an HTTPS `evomap.ai` link, report that the node is awaiting claim and show the link without modifying it. -3. **Turn capture** — report whether the working directory is a git repository. Turn +6. **Turn capture** — report whether the working directory is a git repository. Turn outcomes are derived from git diffs, so outside one they are not recorded; strategy injection from the network is unaffected either way. -4. **Evolution memory** — use an existing +7. **Evolution memory** — use an existing `/memory/evolution/memory_graph.jsonl` when present; otherwise inspect `~/.evolver/memory/evolution/memory_graph.jsonl`. Report the selected path and count of non-empty JSONL rows without printing their contents. -5. **Workspace id** — find the git root, and the `workspace/` directory inside it when one +8. **Workspace id** — find the git root, and the `workspace/` directory inside it when one exists; that path is the workspace root. The id is stored outside the repository, at `~/.evolver/state/workspace-`. Report only present/missing, never the id value. -6. **Full engine** — check whether `evolver` is installed and report its version. If absent, - say that `npm install -g @evomap/evolver` enables `/evolver-run`, `/evolver-review`, and - `/evolver-solidify`. If it is older than 2.0.39, say that network strategy recall needs - 2.0.39 or newer and give `npm install -g @evomap/evolver@latest` as the upgrade. +9. **Full engine** — if `engine.path_version` is null, say that `npm install -g @evomap/evolver` + enables `/evolver-run`, `/evolver-review`, and `/evolver-solidify`. -Finish with one line on overall readiness and the single next action, if any. +Finish with one line on overall readiness and the single most useful next action, if any. diff --git a/src/diagnostics.js b/src/diagnostics.js new file mode 100644 index 0000000..04642f1 --- /dev/null +++ b/src/diagnostics.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { readFileSync } from 'node:fs'; +import os from 'node:os'; + +import { MIN_EVOLVER_VERSION, isOlderThan } from './engine-version.js'; +import { STRATEGY_MAX_CHARS } from './prime.js'; +import { describeProxyEndpoint } from './proxy.js'; + +const PLUGIN_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; + +export function createRecallLog(now = () => Date.now()) { + const counts = {}; + let last = null; + return { + record(outcome, startedAt) { + counts[outcome.status] = (counts[outcome.status] ?? 0) + 1; + last = { ...outcome, at: new Date(now()).toISOString(), duration_ms: now() - startedAt }; + }, + snapshot() { + return { last, counts: { ...counts } }; + }, + }; +} + +function engineReport(pathVersion, runningVersion) { + const effective = runningVersion ?? pathVersion; + return { + path_version: pathVersion, + running_version: runningVersion, + minimum_version: MIN_EVOLVER_VERSION, + meets_minimum: effective ? !isOlderThan(effective, MIN_EVOLVER_VERSION) : null, + path_differs_from_running: Boolean(pathVersion && runningVersion && pathVersion !== runningVersion), + }; +} + +export async function pluginDiagnostics({ config, engineVersion, recallLog, port }) { + const endpoint = describeProxyEndpoint(port); + const runningVersion = typeof endpoint.running_version === 'string' ? endpoint.running_version : null; + return { + plugin: { name: '@evomap/dsh-evolver', version: PLUGIN_VERSION }, + runtime: { platform: process.platform, arch: process.arch, node: process.version, os_release: os.release() }, + proxy_endpoint: endpoint, + engine: engineReport(await engineVersion, runningVersion), + recall: { + enabled: config.assetPrimeEnabled !== false, + wait_ms: config.assetPrimeWaitMs ?? 6_000, + timeout_ms: config.assetPrimeTimeoutMs ?? 8_000, + min_similarity: config.assetPrimeMinSimilarity ?? 0.3, + max_strategy_chars: STRATEGY_MAX_CHARS, + ...recallLog.snapshot(), + }, + }; +} diff --git a/src/index.js b/src/index.js index 6411910..7c02a54 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import { EDIT_TOOL_NAMES, editedContent, editedPath } from './edited-content.js' import { MIN_EVOLVER_VERSION, installedEvolverVersion, upgradeNoticeText } from './engine-version.js'; import { noticeDue, pendingClaimUrl } from './onboarding.js'; import { hubGene, promptTextOf } from './prime.js'; +import { createRecallLog, pluginDiagnostics } from './diagnostics.js'; import { createProxyClient } from './proxy.js'; import { looksLikeCorrection } from './dissatisfaction.js'; import { correctableAssets, forgetSession, markCorrected, markReported, rememberInjected, unreportedAssets } from './injected-assets.js'; @@ -107,7 +108,7 @@ function afterWait(ms) { // that carries both. Workspace memory is a session fact and seeds once; the Hub // is re-queried per turn, because each prompt is a different task — bounded by // the ids already listed, so a repeat search adds nothing the model has seen. -function primeSteps(ctx, config, primeFetch, engineVersion) { +function primeSteps(ctx, config, primeFetch, engineVersion, recallLog) { const seeded = new WeakSet(); const searchedTurn = new WeakMap(); const listedAssets = new WeakMap(); @@ -136,10 +137,12 @@ function primeSteps(ctx, config, primeFetch, engineVersion) { searchedTurn.set(agent, turn); const listed = listedFor(agent); + const startedAt = Date.now(); const search = hubGene(primeFetch, promptTextOf(claimed), { signal, listedIds: listed, minSimilarity: config.assetPrimeMinSimilarity, + onOutcome: (outcome) => recallLog.record(outcome, startedAt), }); const inline = await Promise.race([search, afterWait(config.assetPrimeWaitMs ?? DEFAULT_PRIME_WAIT_MS)]); if (inline) { @@ -297,7 +300,10 @@ export function apply(ctx, config = {}) { const tracker = createTurnTracker(); const coordinator = createCaptureCoordinator(); - for (const tool of evolverTools(proxyFetch)) ctx.tools.register(tool); + const engineVersion = installedEvolverVersion(); + const recallLog = createRecallLog(); + const diagnostics = () => pluginDiagnostics({ config, engineVersion, recallLog, port: config.proxyPort }); + for (const tool of evolverTools(proxyFetch, { diagnostics })) ctx.tools.register(tool); ctx.inject(['skills'], (scoped) => { scoped.skills.registerProvider(() => evolverSkillProvider); @@ -307,7 +313,7 @@ export function apply(ctx, config = {}) { for (const command of evolverCommands()) scoped.commands.register(command); }); - primeSteps(ctx, config, primeFetch, installedEvolverVersion()); + primeSteps(ctx, config, primeFetch, engineVersion, recallLog); nudgeOnSignals(ctx, config.editToolNames ?? EDIT_TOOL_NAMES, tracker); captureOnTurnEnd(ctx, fallbackDir, config, tracker, coordinator, primeFetch); } diff --git a/src/prime.js b/src/prime.js index b834ec2..2913013 100644 --- a/src/prime.js +++ b/src/prime.js @@ -24,7 +24,7 @@ const DEFAULT_MIN_SIMILARITY = 0.3; // 9-15 step genes inject a median 2690 characters and the oversized pipeline // transcripts that motivated a cap run past 4700. const MIN_STRATEGY_STEPS = 4; -const STRATEGY_MAX_CHARS = 4000; +export const STRATEGY_MAX_CHARS = 4000; const UNSCORED = -1; const EMPTY_MATCH = { ids: [], text: '' }; @@ -94,19 +94,24 @@ function similarityOf(asset) { // and off-topic hits sit at 0.19–0.22 — and a Proxy that reports none at all // must not be filtered down to nothing. They rank by score rather than by the // order the Hub listed them in, since that order weighs more than this prompt. -function rankedCandidates(assets, listedIds, minSimilarity) { - return assets - .filter((asset) => !listedIds.has(asset.asset_id)) - .filter((asset) => typeof asset.similarity !== 'number' || asset.similarity >= minSimilarity) - .sort((left, right) => similarityOf(right) - similarityOf(left)); +function rankingRejection(asset, listedIds, minSimilarity) { + if (listedIds.has(asset.asset_id)) return 'already_injected'; + if (typeof asset.similarity === 'number' && asset.similarity < minSimilarity) return 'low_similarity'; + return null; +} + +function strategyRejection(steps) { + if (steps.length < MIN_STRATEGY_STEPS) return 'too_few_steps'; + if (strategyChars(steps) > STRATEGY_MAX_CHARS) return 'too_long'; + return null; } async function proxyJson(proxyFetch, path, body, signal) { try { const result = await proxyFetch('POST', path, body, signal); - return result?.ok ? result.data : null; - } catch { - return null; + return result?.ok ? { data: result.data } : { error: String(result?.error ?? 'Proxy returned no result') }; + } catch (error) { + return { error: String(error?.message ?? error) }; } } @@ -114,16 +119,61 @@ async function proxyJson(proxyFetch, path, body, signal) { // slow Hub, or a malformed body must cost the turn nothing but the deadline. // One asset's strategy is injected and nothing else — a summary only tells the // model that something exists, while the steps are what it can actually reuse. -export async function hubGene(proxyFetch, text, { signal, listedIds = new Set(), minSimilarity = DEFAULT_MIN_SIMILARITY } = {}) { - if (text.length < MIN_PROMPT_CHARS) return EMPTY_MATCH; +// Every lookup reports why it ended through `onOutcome`, because an empty match +// alone cannot tell a Proxy error from a recall that found nothing usable. +export async function hubGene(proxyFetch, text, { + signal, + listedIds = new Set(), + minSimilarity = DEFAULT_MIN_SIMILARITY, + onOutcome = () => {}, +} = {}) { + if (text.length < MIN_PROMPT_CHARS) { + onOutcome({ status: 'skipped', reason: 'prompt_too_short' }); + return EMPTY_MATCH; + } const recalled = await proxyJson(proxyFetch, '/asset/fetch', { text, limit: RECALL_LIMIT }, signal); - if (!recalled) return EMPTY_MATCH; + if (recalled.error) { + onOutcome({ status: 'proxy_error', error: recalled.error }); + return EMPTY_MATCH; + } - for (const candidate of rankedCandidates(recalledAssets(recalled), listedIds, minSimilarity)) { + const assets = recalledAssets(recalled.data); + if (assets.length === 0) { + onOutcome({ status: 'no_candidates' }); + return EMPTY_MATCH; + } + + const dropped = {}; + const drop = (reason) => { + dropped[reason] = (dropped[reason] ?? 0) + 1; + }; + const ranked = assets + .filter((asset) => { + const reason = rankingRejection(asset, listedIds, minSimilarity); + if (reason) drop(reason); + return !reason; + }) + .sort((left, right) => similarityOf(right) - similarityOf(left)); + + for (const candidate of ranked) { const steps = strategySteps(candidate); - if (steps.length < MIN_STRATEGY_STEPS || strategyChars(steps) > STRATEGY_MAX_CHARS) continue; + const reason = strategyRejection(steps); + if (reason) { + drop(reason); + continue; + } + onOutcome({ + status: 'injected', + candidates: assets.length, + dropped, + asset_id: candidate.asset_id, + title: readableNameOf(candidate), + steps: steps.length, + chars: strategyChars(steps), + similarity: typeof candidate.similarity === 'number' ? candidate.similarity : null, + }); return { ids: [candidate.asset_id], text: [ @@ -135,5 +185,6 @@ export async function hubGene(proxyFetch, text, { signal, listedIds = new Set(), ].join('\n'), }; } + onOutcome({ status: 'all_filtered', candidates: assets.length, dropped }); return EMPTY_MATCH; } diff --git a/src/proxy.js b/src/proxy.js index 68fb490..fc3b894 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -30,16 +30,41 @@ export function isLoopbackUrl(value) { return normalizeLoopbackUrl(value) !== null; } -function readProxySettings(port) { - let url = null; - let token = null; +function settingsPath() { + return join(homedir(), '.evolver', 'settings.json'); +} + +function readSettingsProxy() { try { - const settings = JSON.parse(readFileSync(join(homedir(), '.evolver', 'settings.json'), 'utf8')); - if (settings?.proxy?.url) url = normalizeLoopbackUrl(settings.proxy.url); - if (settings?.proxy?.token) token = String(settings.proxy.token); + const proxy = JSON.parse(readFileSync(settingsPath(), 'utf8'))?.proxy; + return proxy && typeof proxy === 'object' ? proxy : null; } catch { + return null; } - return { url: url ?? `http://127.0.0.1:${port}`, token }; +} + +function readProxySettings(port) { + const proxy = readSettingsProxy(); + const url = proxy?.url ? normalizeLoopbackUrl(proxy.url) : null; + const token = proxy?.token ? String(proxy.token) : null; + return { url: url ?? `http://127.0.0.1:${port}`, token, fromSettings: url !== null }; +} + +// The Proxy records its own version and pid in settings.json when it starts, so +// this is the version actually serving requests — which can differ from the +// `evolver` found on PATH when another install started the Proxy. +export function describeProxyEndpoint(port = process.env.EVOMAP_PROXY_PORT || DEFAULT_PORT) { + const proxy = readSettingsProxy(); + const { url, token, fromSettings } = readProxySettings(port); + const fieldOf = (key) => (typeof proxy?.[key] === 'string' || typeof proxy?.[key] === 'number' ? proxy[key] : null); + return { + url, + url_source: fromSettings ? settingsPath() : `default port ${port}`, + token_present: token !== null, + running_version: fieldOf('version'), + pid: fieldOf('pid'), + started_at: fieldOf('started_at'), + }; } const START_HINT = diff --git a/src/tools.js b/src/tools.js index 44f6912..9cb253c 100644 --- a/src/tools.js +++ b/src/tools.js @@ -114,15 +114,30 @@ function proxyTool(proxyFetch, definition) { }); } -export function evolverTools(proxyFetch) { +// The Proxy being down is itself the most common answer this tool gives, so its +// error is reported alongside the plugin's own view instead of failing the call. +function statusTool(proxyFetch, diagnostics) { + return defineTool({ + name: 'evolver_status', + description: + 'Report Evolver health: the Proxy status (running state, node_id, Hub auth and sync, pending messages) or the error reaching it, plus the plugin view — plugin and runtime versions, the Proxy endpoint and the evolver version actually running it, the outcome and counts of recent strategy recalls, and the recall settings. Use it to diagnose when another evolver tool fails or strategies stop being injected, not as a preflight.', + parameters: {}, + output: jsonOutput, + async execute(args, exec) { + rejectUnknownArguments(args, {}); + const [result, plugin] = await Promise.all([ + proxyFetch('GET', '/proxy/status', undefined, exec?.signal), + diagnostics(), + ]); + const proxy = result.ok ? { reachable: true, ...result.data } : { reachable: false, error: result.error }; + return plugin ? { proxy, ...plugin } : { proxy }; + }, + }); +} + +export function evolverTools(proxyFetch, { diagnostics = async () => null } = {}) { return [ - proxyTool(proxyFetch, { - name: 'evolver_status', - description: - 'Get the EvoMap Proxy status: running state, node_id, pending inbound/outbound message counts, and last Hub sync time. Use it to diagnose when another evolver tool fails, not as a preflight.', - parameters: {}, - request: () => ({ method: 'GET', path: '/proxy/status' }), - }), + statusTool(proxyFetch, diagnostics), proxyTool(proxyFetch, { name: 'evolver_search_assets', diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js new file mode 100644 index 0000000..bc6d58d --- /dev/null +++ b/test/diagnostics.test.js @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { createRecallLog, pluginDiagnostics } from '../src/diagnostics.js'; +import { hubGene } from '../src/prime.js'; +import { evolverTools } from '../src/tools.js'; + +const PROMPT = 'add a retry to the uploader'; +const steps = (count, text = 'step') => Array.from({ length: count }, (_, index) => `${text} ${index}.`); + +async function outcomeOf(proxyResult, options = {}) { + let outcome; + await hubGene(async () => { + if (proxyResult instanceof Error) throw proxyResult; + return proxyResult; + }, options.prompt ?? PROMPT, { ...options, onOutcome: (value) => { outcome = value; } }); + return outcome; +} + +test('a recall reports why it ended, so an empty match is never ambiguous', async () => { + assert.deepEqual(await outcomeOf({ ok: true, data: {} }, { prompt: 'hi' }), { status: 'skipped', reason: 'prompt_too_short' }); + + const down = await outcomeOf({ ok: false, error: 'Proxy request timed out.' }); + assert.equal(down.status, 'proxy_error'); + assert.match(down.error, /timed out/); + assert.equal((await outcomeOf(new Error('socket hang up'))).error, 'socket hang up'); + + assert.deepEqual(await outcomeOf({ ok: true, data: { assets: [] } }), { status: 'no_candidates' }); + + const filtered = await outcomeOf({ + ok: true, + data: { + assets: [ + { asset_id: 'sha256:thin', strategy: steps(3) }, + { asset_id: 'sha256:long', strategy: steps(12, 'x'.repeat(390)) }, + { asset_id: 'sha256:weak', similarity: 0.1, strategy: steps(5) }, + { asset_id: 'sha256:seen', strategy: steps(5) }, + ], + }, + }, { listedIds: new Set(['sha256:seen']) }); + assert.deepEqual(filtered, { + status: 'all_filtered', + candidates: 4, + dropped: { too_few_steps: 1, too_long: 1, low_similarity: 1, already_injected: 1 }, + }); +}); + +test('an injected recall names the asset and what it cost', async () => { + const injected = await outcomeOf({ + ok: true, + data: { assets: [{ asset_id: 'sha256:thin', similarity: 0.9, strategy: steps(2) }, { asset_id: 'sha256:fit', short_title: 'Retry with jittered backoff', similarity: 0.7, strategy: steps(4) }] }, + }); + assert.equal(injected.status, 'injected'); + assert.equal(injected.asset_id, 'sha256:fit'); + assert.equal(injected.title, 'Retry with jittered backoff'); + assert.equal(injected.steps, 4); + assert.equal(injected.similarity, 0.7); + assert.deepEqual(injected.dropped, { too_few_steps: 1 }); + assert.ok(injected.chars > 0); +}); + +test('the recall log keeps the last outcome with its timing and counts every status', () => { + let clock = 1_000; + const log = createRecallLog(() => clock); + log.record({ status: 'no_candidates' }, 400); + clock = 2_500; + log.record({ status: 'proxy_error', error: 'down' }, 2_000); + const { last, counts } = log.snapshot(); + assert.deepEqual(counts, { no_candidates: 1, proxy_error: 1 }); + assert.equal(last.status, 'proxy_error'); + assert.equal(last.duration_ms, 500); + assert.equal(last.at, new Date(2_500).toISOString()); +}); + +test('evolver_status reports an unreachable Proxy instead of failing, and still shows the plugin view', async () => { + const [status] = evolverTools(async () => ({ ok: false, error: 'Proxy connection failed: ECONNREFUSED.' }), { + diagnostics: async () => ({ plugin: { version: '9.9.9' } }), + }); + const report = await status.execute({}, {}); + assert.deepEqual(report.proxy, { reachable: false, error: 'Proxy connection failed: ECONNREFUSED.' }); + assert.equal(report.plugin.version, '9.9.9'); + + const [up] = evolverTools(async () => ({ ok: true, data: { node_id: 'node_x', status: 'running' } })); + assert.deepEqual(await up.execute({}, {}), { proxy: { reachable: true, node_id: 'node_x', status: 'running' } }); +}); + +test('diagnostics flag a running Proxy older than the minimum even when PATH has a newer evolver', async () => { + const home = process.env.HOME; + process.env.HOME = mkdtempSync(join(tmpdir(), 'evolver-diagnostics-home-')); + try { + mkdirSync(join(process.env.HOME, '.evolver')); + writeFileSync(join(process.env.HOME, '.evolver', 'settings.json'), JSON.stringify({ + proxy: { url: 'http://127.0.0.1:19999', token: 'secret-token', version: '2.0.30', pid: 4242, started_at: '2026-10-01T00:00:00.000Z' }, + })); + const report = await pluginDiagnostics({ + config: { assetPrimeWaitMs: 5_000 }, + engineVersion: Promise.resolve('2.0.39'), + recallLog: createRecallLog(), + }); + assert.equal(report.plugin.name, '@evomap/dsh-evolver'); + assert.equal(report.proxy_endpoint.url, 'http://127.0.0.1:19999'); + assert.equal(report.proxy_endpoint.token_present, true); + assert.equal(report.proxy_endpoint.pid, 4242); + assert.doesNotMatch(JSON.stringify(report), /secret-token/); + assert.deepEqual(report.engine, { + path_version: '2.0.39', + running_version: '2.0.30', + minimum_version: '2.0.39', + meets_minimum: false, + path_differs_from_running: true, + }); + assert.equal(report.recall.wait_ms, 5_000); + assert.equal(report.recall.max_strategy_chars, 4_000); + assert.equal(report.runtime.platform, process.platform); + } finally { + process.env.HOME = home; + } +});