Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions assets/commands/status.md
Original file line number Diff line number Diff line change
@@ -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
`<workspace>/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-<first 16 hex of sha256(workspace root)>`. 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.
55 changes: 55 additions & 0 deletions src/diagnostics.js
Original file line number Diff line number Diff line change
@@ -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(),
},
};
}
12 changes: 9 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
79 changes: 65 additions & 14 deletions src/prime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' };

Expand Down Expand Up @@ -94,36 +94,86 @@ 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) };
}
}

// Priming sits on the critical path of every answer: an unreachable Proxy, a
// 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: [
Expand All @@ -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;
}
39 changes: 32 additions & 7 deletions src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
31 changes: 23 additions & 8 deletions src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading