From 0bce762f5717bc66f38d0bb164facfbd020637f9 Mon Sep 17 00:00:00 2001 From: forrestlinfeng Date: Sat, 26 Sep 2026 09:59:50 +0800 Subject: [PATCH] Stop tying the plugin to one dsh line, and ask for Evolver 2.0.39 dsh upgrades often, and the peer ranges said `<0.1.7` while 0.1.7-rc.2 is already published. The range never protected anything: dsh's loader does no version check, the profile installs with autoInstallPeers off, and the host hands the plugin its own dsh-llm / dsh-tools / dsh-skill at runtime. What can actually break is the four symbols the plugin imports -- createUserMessage, boundContextSummary, defineTool, BUNDLED_SKILL_RANK -- and all four are still exported in 0.1.7. So the ranges become `>=0.1.5-0`, and the guard moves to where breakage shows up: CI runs the suite against the `next` and `alpha` dist-tags as well as 0.1.5-rc.3, and runs daily, so an incompatible dsh release turns CI red before users upgrade into it. 95/95 pass on 0.1.5-rc.3, 0.1.7-alpha.2 and 0.1.7-rc.2. Evolver is the one dependency that does gate behaviour: a Proxy older than 2.0.39 answers every text recall empty, so priming silently injects nothing. The Proxy status does not carry its version, so the plugin reads `evolver --version` once at boot and, below 2.0.39, the first step of a session carries a notice with the upgrade command -- at most once a day per installed version. No evolver on PATH means no notice; /evolver-status covers that case and now names the minimum too. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +++---- README.md | 8 +++++-- assets/commands/status.md | 3 ++- package-lock.json | 6 ++--- package.json | 6 ++--- src/engine-version.js | 46 +++++++++++++++++++++++++++++++++++ src/index.js | 17 +++++++++---- test/apply.test.js | 34 ++++++++++++++++++++++++-- test/engine-version.test.js | 48 +++++++++++++++++++++++++++++++++++++ 9 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 src/engine-version.js create mode 100644 test/engine-version.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 607ca64..372c510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: pull_request: push: branches: [main] + schedule: + - cron: '17 3 * * *' permissions: contents: read @@ -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 @@ -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 diff --git a/README.md b/README.md index b976627..0083bd7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/assets/commands/status.md b/assets/commands/status.md index feb98d0..0bdb7c5 100644 --- a/assets/commands/status.md +++ b/assets/commands/status.md @@ -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. diff --git a/package-lock.json b/package-lock.json index fccaf48..f0aa643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,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": { diff --git a/package.json b/package.json index a99d5f9..8bdd24a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/engine-version.js b/src/engine-version.js new file mode 100644 index 0000000..49522e3 --- /dev/null +++ b/src/engine-version.js @@ -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.'; +} diff --git a/src/index.js b/src/index.js index e6ac236..436eac7 100644 --- a/src/index.js +++ b/src/index.js @@ -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'; @@ -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({ @@ -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 = @@ -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(); @@ -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)); @@ -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); } diff --git a/test/apply.test.js b/test/apply.test.js index 84a461f..d0b2871 100644 --- a/test/apply.test.js +++ b/test/apply.test.js @@ -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'; @@ -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() { @@ -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) => { diff --git a/test/engine-version.test.js b/test/engine-version.test.js new file mode 100644 index 0000000..27e5a24 --- /dev/null +++ b/test/engine-version.test.js @@ -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); +});