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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
pull_request:
push:
branches: [main]
schedule:
- cron: '17 3 * * *'

permissions:
contents: read
Expand Down Expand Up @@ -31,7 +33,7 @@ jobs:
strategy:
fail-fast: false
matrix:
dsh: ['0.1.5-rc.3', '0.1.6-alpha.2']
dsh: ['0.1.5-rc.3', 'next', 'alpha']
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
Expand All @@ -42,7 +44,5 @@ jobs:
- run: npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund "@deepseek-ai/dsh-llm@${DSH_VERSION}" "@deepseek-ai/dsh-skill@${DSH_VERSION}" "@deepseek-ai/dsh-tools@${DSH_VERSION}"
env:
DSH_VERSION: ${{ matrix.dsh }}
- run: node -e "for (const name of ['dsh-llm','dsh-skill','dsh-tools']) { const version = require('@deepseek-ai/' + name + '/package.json').version; if (version !== process.env.DSH_VERSION) throw new Error(name + ' resolved to ' + version); }"
env:
DSH_VERSION: ${{ matrix.dsh }}
- run: node -e "const versions = ['dsh-llm','dsh-skill','dsh-tools'].map((name) => require('@deepseek-ai/' + name + '/package.json').version); if (new Set(versions).size !== 1) throw new Error('dsh packages resolved to ' + versions.join(', ')); console.log('dsh ' + versions[0]);"
- run: npm test
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,12 @@ Environment overrides: `MEMORY_GRAPH_PATH`, `EVOLVER_WORKSPACE_ID`,

- Node.js 22.13 or newer.
- Git for turn capture.
- dsh `0.1.5-rc.2` or `0.1.6-alpha.1`.
- dsh `0.1.5` or newer. The plugin declares no upper bound: the host provides `dsh-llm`,
`dsh-tools`, and `dsh-skill` at runtime, and CI runs the suite daily against the newest
published dsh so an incompatible release is caught before users upgrade into it.
- Optional network tools: a local Proxy from `@evomap/evolver` 2.0.39 or newer, which is the first release whose `/asset/fetch` recalls by text.
When an older `evolver` is on `PATH`, the first step of a session carries a notice to
upgrade it, at most once a day per installed version.

In a non-git directory the plugin emits one notice per directory, does not create workspace
state, and records nothing. Network strategies are still injected there — they do not
Expand All @@ -173,7 +177,7 @@ npm test
npm pack --dry-run
```

CI validates Node 22 and 24 plus the declared DSH compatibility lines. The release workflow
CI validates Node 22 and 24, the oldest supported dsh line, and the newest published dsh. The release workflow
publishes only a tag whose version matches `package.json`; this task does not create that tag.

## License
Expand Down
3 changes: 2 additions & 1 deletion assets/commands/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ the current platform; do not assume a POSIX shell.
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`.
`/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.

Finish with one line on overall readiness and the single next action, if any.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1-rc.1",
"@deepseek-ai/dsh-llm": ">=0.1.5-0 <0.1.6-0 || >=0.1.6-0 <0.1.7-0",
"@deepseek-ai/dsh-skill": ">=0.1.5-0 <0.1.6-0 || >=0.1.6-0 <0.1.7-0",
"@deepseek-ai/dsh-tools": ">=0.1.5-0 <0.1.6-0 || >=0.1.6-0 <0.1.7-0",
"@deepseek-ai/dsh-llm": ">=0.1.5-0",
"@deepseek-ai/dsh-skill": ">=0.1.5-0",
"@deepseek-ai/dsh-tools": ">=0.1.5-0",
"@deepseek-ai/schemastery": "^3.18.2"
},
"peerDependenciesMeta": {
Expand Down
46 changes: 46 additions & 0 deletions src/engine-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import { execFile } from 'node:child_process';

// 2.0.39 is the first Proxy whose `/asset/fetch` recalls by text; an older one
// answers every recall empty, so priming silently never injects anything.
export const MIN_EVOLVER_VERSION = '2.0.39';

const PROBE_TIMEOUT_MS = 5_000;

function versionParts(text) {
const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ''));
return match ? match.slice(1, 4).map(Number) : null;
}

export function isOlderThan(version, minimum) {
const have = versionParts(version);
const need = versionParts(minimum);
if (!have || !need) return false;
for (let index = 0; index < 3; index += 1) {
if (have[index] !== need[index]) return have[index] < need[index];
}
return false;
}

export function installedEvolverVersion() {
return new Promise((resolve) => {
execFile(
'evolver',
['--version'],
{ timeout: PROBE_TIMEOUT_MS, encoding: 'utf8', shell: process.platform === 'win32' },
(error, stdout) => {
const parts = error ? null : versionParts(stdout);
resolve(parts ? parts.join('.') : null);
},
);
});
}

export function upgradeNoticeText(version) {
if (!version || !isOlderThan(version, MIN_EVOLVER_VERSION)) return null;
return `[Evolver] Evolver ${version} is installed, but this plugin needs ${MIN_EVOLVER_VERSION} or newer to recall `
+ 'network strategies. Upgrade with `npm install -g @evomap/evolver@latest`, then run `evolver` once to restart '
+ 'the Proxy. Local memory keeps working meanwhile.';
}
17 changes: 12 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { outcomeOfReason } from './capture.js';
import { evolverCommands } from './commands.js';
import { Config } from './config.js';
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 { createProxyClient } from './proxy.js';
Expand All @@ -25,6 +26,7 @@ export { Config };
export const inject = ['tools'];

const DEFAULT_PRIME_WAIT_MS = 6_000;
const UPGRADE_NOTICE_TTL_MS = 24 * 60 * 60 * 1000;

function pluginMessage(text, formed) {
return createUserMessage({
Expand Down Expand Up @@ -72,10 +74,15 @@ function createTurnTracker() {
};
}

function sessionMessages(agent, config, fallbackDir) {
const dir = sessionDir(agent?.session?.header?.cwd, fallbackDir);
async function sessionMessages(config, engineVersion) {
const messages = [];

const version = await engineVersion;
const upgrade = upgradeNoticeText(version);
if (upgrade && noticeDue(`evolver-version:${version}`, UPGRADE_NOTICE_TTL_MS)) {
messages.push(pluginMessage(upgrade, { form: 'notice', summary: `Upgrade Evolver to ${MIN_EVOLVER_VERSION} or newer.` }));
}

const claimUrl = config.claimNudgeEnabled ? pendingClaimUrl() : null;
if (claimUrl && noticeDue(claimUrl, config.claimNudgeTtlMs)) {
const text =
Expand All @@ -100,7 +107,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, fallbackDir, config, primeFetch, tracker) {
function primeSteps(ctx, config, primeFetch, engineVersion) {
const seeded = new WeakSet();
const searchedTurn = new WeakMap();
const listedAssets = new WeakMap();
Expand Down Expand Up @@ -176,7 +183,7 @@ function primeSteps(ctx, fallbackDir, config, primeFetch, tracker) {
const messages = [];
if (!seeded.has(agent)) {
seeded.add(agent);
messages.push(...sessionMessages(agent, config, fallbackDir));
messages.push(...await sessionMessages(config, engineVersion));
}
messages.push(...await hubMessages(agent, turn, decision.messages, signal));

Expand Down Expand Up @@ -300,7 +307,7 @@ export function apply(ctx, config = {}) {
for (const command of evolverCommands()) scoped.commands.register(command);
});

primeSteps(ctx, fallbackDir, config, primeFetch, tracker);
primeSteps(ctx, config, primeFetch, installedEvolverVersion());
nudgeOnSignals(ctx, config.editToolNames ?? EDIT_TOOL_NAMES, tracker);
captureOnTurnEnd(ctx, fallbackDir, config, tracker, coordinator, primeFetch);
}
34 changes: 32 additions & 2 deletions test/apply.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { delimiter, join } from 'node:path';
import { createServer } from 'node:http';
import { after, test } from 'node:test';

Expand All @@ -13,9 +13,21 @@ const missingClaimFile = join(mkdtempSync(join(tmpdir(), 'evolver-claim-')), 'mi
process.env.EVOLVER_CLAIM_URL_PATH = missingClaimFile;
const sharedStateDir = mkdtempSync(join(tmpdir(), 'evolver-notice-state-'));
process.env.EVOLVER_SESSION_STATE_DIR = sharedStateDir;
const originalPath = process.env.PATH;

function evolverOnPath(version) {
const dir = mkdtempSync(join(tmpdir(), 'evolver-bin-'));
const bin = join(dir, 'evolver');
writeFileSync(bin, `#!/bin/sh\necho ${version}\n`);
chmodSync(bin, 0o755);
process.env.PATH = `${dir}${delimiter}${originalPath}`;
}

evolverOnPath('2.0.39');
after(() => {
delete process.env.EVOLVER_CLAIM_URL_PATH;
delete process.env.EVOLVER_SESSION_STATE_DIR;
process.env.PATH = originalPath;
});

function fakeContext() {
Expand Down Expand Up @@ -245,6 +257,24 @@ test('claim guidance is opt-in', async () => {
}
});

test('an Evolver older than 2.0.39 is asked to upgrade once a day', { skip: process.platform === 'win32' }, async () => {
const projectDir = gitDirectory('evolver-old-engine-');
const stateDir = mkdtempSync(join(tmpdir(), 'evolver-upgrade-state-'));
process.env.EVOLVER_SESSION_STATE_DIR = stateDir;
evolverOnPath('2.0.38');

try {
const { ctx, listeners } = fakeContext();
apply(ctx, Config({ projectDir, assetPrimeEnabled: false }));
const [notice] = await primedBy(listeners, fakeAgent({ id: 'old-engine-a', cwd: projectDir }).agent);
assert.match(notice.content[0].text, /Evolver 2\.0\.38 is installed.*npm install -g @evomap\/evolver@latest/s);
assert.deepEqual(await primedBy(listeners, fakeAgent({ id: 'old-engine-b', cwd: projectDir }).agent), []);
} finally {
process.env.EVOLVER_SESSION_STATE_DIR = sharedStateDir;
evolverOnPath('2.0.39');
}
});

test('a search slower than the wait budget injects itself instead of holding the step', async () => {
const projectDir = gitDirectory('evolver-slow-prime-');
const server = createServer((request, response) => {
Expand Down
48 changes: 48 additions & 0 deletions test/engine-version.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { afterEach, test } from 'node:test';

import { MIN_EVOLVER_VERSION, installedEvolverVersion, isOlderThan, upgradeNoticeText } from '../src/engine-version.js';

const previousPath = process.env.PATH;
afterEach(() => {
process.env.PATH = previousPath;
});

function evolverOnPath(script) {
const dir = mkdtempSync(join(tmpdir(), 'evolver-bin-'));
const bin = join(dir, 'evolver');
writeFileSync(bin, `#!/bin/sh\n${script}\n`);
chmodSync(bin, 0o755);
process.env.PATH = `${dir}${delimiter}${previousPath}`;
}

test('compares release numbers numerically, not as text', () => {
assert.equal(isOlderThan('2.0.38', '2.0.39'), true);
assert.equal(isOlderThan('2.0.39', '2.0.39'), false);
assert.equal(isOlderThan('2.0.100', '2.0.39'), false);
assert.equal(isOlderThan('2.1.0', '2.0.39'), false);
assert.equal(isOlderThan('1.99.99', '2.0.39'), true);
assert.equal(isOlderThan('not a version', '2.0.39'), false);
});

test('asks for an upgrade only below the minimum', () => {
assert.match(upgradeNoticeText('2.0.38'), /2\.0\.38.*2\.0\.39.*npm install -g @evomap\/evolver@latest/s);
assert.equal(upgradeNoticeText(MIN_EVOLVER_VERSION), null);
assert.equal(upgradeNoticeText('2.1.0'), null);
assert.equal(upgradeNoticeText(null), null);
});

test('reads the version the installed CLI prints', { skip: process.platform === 'win32' }, async () => {
evolverOnPath('echo "evolver v2.0.38"');
assert.equal(await installedEvolverVersion(), '2.0.38');
});

test('reports no version when the CLI is missing or fails', { skip: process.platform === 'win32' }, async () => {
evolverOnPath('exit 1');
assert.equal(await installedEvolverVersion(), null);
process.env.PATH = mkdtempSync(join(tmpdir(), 'evolver-empty-path-'));
assert.equal(await installedEvolverVersion(), null);
});
Loading