From 750e8d686f9a31e8d53882ba42d36f448dfd8f0a Mon Sep 17 00:00:00 2001 From: forrestlinfeng Date: Wed, 23 Sep 2026 15:02:52 +0800 Subject: [PATCH] Write each reuse verdict where something actually reads it Every verdict this plugin has produced went nowhere. The Proxy forwards /asset/reuse-result to the Hub, the Hub has no such route -- its OpenAPI describes 407 paths under /a2a/ and none of them is this one -- and the Proxy has no ingestor of its own, so nothing was written locally either. On this machine root_events.jsonl holds 1873 value.inject lines and zero value.reuse_* lines: the injection half has been recording all along and the return half never existed. The consumers were never the problem. summarizeReuseOutcomes and pruneCandidates are written and correct, and orchestrator.ts folds their counts into a soft re-order of future candidates under EVOLVER_REUSE_SIGNAL, which defaults on. All of it reads local root events. It was simply starved. So the verdict is now written to both: the Proxy as before, and a root event through evolver-core's Ingestor -- value.reuse_hit for a success, and value.reuse_outcome carrying the verdict otherwise, which is the split the accounting expects. A report counts as delivered when either ledger took it, so a Hub without the route no longer leaves an asset pending forever. This adds the plugin's first runtime dependency. @evomap/evolver-core is not optional for this: Ingestor is the repository's single write entrypoint for root events, and EventStore -- single-writer, file-locked, monotonic seq, fsync, schema-validated, 4096-byte lines -- is deliberately not exported. Hand-rolling the append would be forging the one thing that file guarantees. It is imported lazily inside a catch, so an install that cannot resolve it degrades to the old behaviour instead of breaking a turn. The koffi native dependency it lists is loaded lazily from a benchmark module this path never touches; the global install here does not have koffi at all and works. cycleId is dsh:. deriveReuseEntries drops an entry missing either audit anchor, and a task id is neither. Verified end to end against the live Proxy before wiring: two events written, read back, pruneCandidates naming the failed asset. Three tests cover it; neutering the local write fails the one that matters. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- package-lock.json | 422 +++++++++++++++++++++++++++++++++++++++++++- package.json | 3 + src/index.js | 2 + src/local-ledger.js | 55 ++++++ src/reuse.js | 23 ++- test/apply.test.js | 78 ++++++-- 7 files changed, 565 insertions(+), 20 deletions(-) create mode 100644 src/local-ledger.js diff --git a/README.md b/README.md index 06d160f..c0eafb2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Powered by the [Genome Evolution Protocol](https://evomap.ai) and | --- | --- | --- | | Asset priming | `agent/pre-step`, once per turn | Searches the Proxy with that turn's own prompt — only the text the person typed — fetches the two leading matches that clear the similarity line and have a strategy, in one call, and injects the strategy of the first that actually came back. Ids the Hub declined to deliver are remembered for a day and skipped, so a slot is not spent twice on one. A lookup slower than the wait budget is injected into the next step instead of holding the current one. Assets already injected in this session are skipped. | | Signal detection | successful `tools/result` for `write`, `edit`, or `str_replace_editor` | Tags the turn with improvement signals and injects one bounded notice per file/signal set. | -| Reuse feedback | `session/event` → `turn/end` | Reports each strategy injected during that turn to the Proxy with the turn's outcome, marked as automatic and unconfirmed. Injected asset ids are written to `~/.evolver/state/` as they are injected and read back at report time, so a report survives the process dying in between. Reports are keyed on the asset id alone. An asset the model reported itself with `evolver_asset_reuse_result` is left alone. | +| Reuse feedback | `session/event` → `turn/end` | Reports each strategy injected during that turn to the Proxy with the turn's outcome, marked as automatic and unconfirmed. Injected asset ids are written to `~/.evolver/state/` as they are injected and read back at report time, so a report survives the process dying in between. Reports are keyed on the asset id alone, and each verdict is written twice: to the Proxy, and as a `value.reuse_hit` / `value.reuse_outcome` root event in `~/.evomap/evolution/root_events.jsonl`. The local write is the one that closes the loop — the actuator that re-orders future candidates reads root events, not the Hub. An asset the model reported itself with `evolver_asset_reuse_result` is left alone. | | Verdict correction | `agent/pre-step` | When the prompt that opens a later turn in the same session plainly says the last answer did not hold, the verdicts already sent for that session are revised to `failed` — each asset at most once, since the Hub counts every report it receives. | | Capture | `session/event` → `turn/end` | Collects staged, unstaged, and untracked work asynchronously; records the real turn outcome once; drains at `session/flush`. | | Tools | `ctx.tools.register` | Connects directly to the local Evolver Proxy for status, search, fetch, reuse feedback, distillation, publication, and mailbox polling. | diff --git a/package-lock.json b/package-lock.json index 6e6951b..fccaf48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@evomap/dsh-evolver", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@evomap/evolver-core": "2.0.38" + }, "devDependencies": { "@deepseek-ai/cordis": "^4.0.2", "@deepseek-ai/dsh-llm": "0.1.5-rc.3", @@ -58,9 +61,9 @@ } }, "node_modules/@deepseek-ai/cosmokit": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.3.tgz", - "integrity": "sha512-qBo+ronVM6Eu2WNVJXi8JcMiqZ19T9BRIpV+5qJUFPXjGH/Z0QKcQMC/IZJ7L394YTOtJgcovbk9qP0w2GsBXQ==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", + "integrity": "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==", "dev": true, "license": "MIT" }, @@ -309,6 +312,327 @@ "@standard-schema/spec": "^1.1.0" } }, + "node_modules/@evomap/evolver-core": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/@evomap/evolver-core/-/evolver-core-2.0.38.tgz", + "integrity": "sha512-/niM1ByHQ1V1XfKpCUMOFDgquYPkN8X8+mP1oZzCSbt6M9pabE2ymx6ZzY23zQw+9CDgB7UN30ewYuhVteaiEA==", + "dependencies": { + "@evomap/gep-sdk": "1.14.0", + "ajv": "^8.20.0", + "koffi": "3.2.1", + "ulid": "^2.3.0", + "zod": "^3.24.1" + }, + "engines": { + "node": "^22.13.0 || >=23.4.0" + } + }, + "node_modules/@evomap/evolver-core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@evomap/gep-sdk": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@evomap/gep-sdk/-/gep-sdk-1.14.0.tgz", + "integrity": "sha512-iOaLUd90HVMrwhg5NGK4f7oktno8AtssUnEc4PKYThGrhh1TDEuQf0+JFYzzbgyAxrFgymPKGhfi/Gp6CTspfw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.2.1.tgz", + "integrity": "sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.2.1.tgz", + "integrity": "sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.2.1.tgz", + "integrity": "sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.2.1.tgz", + "integrity": "sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.2.1.tgz", + "integrity": "sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.2.1.tgz", + "integrity": "sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.2.1.tgz", + "integrity": "sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.2.1.tgz", + "integrity": "sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.2.1.tgz", + "integrity": "sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.2.1.tgz", + "integrity": "sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.2.1.tgz", + "integrity": "sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.2.1.tgz", + "integrity": "sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.2.1.tgz", + "integrity": "sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.2.1.tgz", + "integrity": "sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.2.1.tgz", + "integrity": "sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.2.1.tgz", + "integrity": "sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.2.1.tgz", + "integrity": "sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.2.1.tgz", + "integrity": "sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -316,6 +640,98 @@ "dev": true, "license": "MIT" }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/koffi": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.2.1.tgz", + "integrity": "sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.2.1", + "@koromix/koffi-android-x64": "3.2.1", + "@koromix/koffi-darwin-arm64": "3.2.1", + "@koromix/koffi-darwin-x64": "3.2.1", + "@koromix/koffi-freebsd-arm64": "3.2.1", + "@koromix/koffi-freebsd-ia32": "3.2.1", + "@koromix/koffi-freebsd-x64": "3.2.1", + "@koromix/koffi-linux-arm": "3.2.1", + "@koromix/koffi-linux-arm64": "3.2.1", + "@koromix/koffi-linux-ia32": "3.2.1", + "@koromix/koffi-linux-loong64": "3.2.1", + "@koromix/koffi-linux-riscv64": "3.2.1", + "@koromix/koffi-linux-x64": "3.2.1", + "@koromix/koffi-openbsd-ia32": "3.2.1", + "@koromix/koffi-openbsd-x64": "3.2.1", + "@koromix/koffi-win32-arm64": "3.2.1", + "@koromix/koffi-win32-ia32": "3.2.1", + "@koromix/koffi-win32-x64": "3.2.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ulid": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz", + "integrity": "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==", + "license": "MIT", + "bin": { + "ulid": "bin/cli.js" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index b517c30..a99d5f9 100644 --- a/package.json +++ b/package.json @@ -65,5 +65,8 @@ "bundle": { "patch": "./cordis.patch.yml" } + }, + "dependencies": { + "@evomap/evolver-core": "2.0.38" } } diff --git a/src/index.js b/src/index.js index 2e87363..8fdbff2 100644 --- a/src/index.js +++ b/src/index.js @@ -173,6 +173,7 @@ function primeSteps(ctx, fallbackDir, config, primeFetch, tracker) { if (assets.length === 0) return; reportReuseCorrection(primeFetch, { assets, + sessionId: sessionKey, signal, onCorrected: (assetId) => markCorrected(sessionKey, assetId), }).catch(() => {}); @@ -258,6 +259,7 @@ function captureOnTurnEnd(ctx, fallbackDir, config, tracker, coordinator, primeF outcome, turn: injectedTurn, reasonKind, + sessionId, onReported: (assetId, status) => markReported(sessionId, assetId, status), }).catch(() => {}); } diff --git a/src/local-ledger.js b/src/local-ledger.js new file mode 100644 index 0000000..282d109 --- /dev/null +++ b/src/local-ledger.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +const HIT_EVENT = 'value.reuse_hit'; +const OUTCOME_EVENT = 'value.reuse_outcome'; +const MAX_TITLE_CHARS = 80; + +// Keyed by path, not a single instance: the log lives under the home directory, +// and a process that sees a different one must not keep writing to the first. +const byPath = new Map(); + +function loadIngestor() { + return import('@evomap/evolver-core') + .then(({ events }) => { + const path = events.rootEventsPath(); + let ingestor = byPath.get(path); + if (!ingestor) { + ingestor = new events.Ingestor({ path }); + byPath.set(path, ingestor); + } + return ingestor; + }) + .catch(() => null); +} + +function cycleIdOf(sessionId) { + return `dsh:${sessionId || 'unknown'}`; +} + +// The verdict has to become a root_event to be worth anything: the actuator +// that re-orders candidates reads root_events, not the Hub, and a Hub without +// the route records nothing at all. Success and the negative verdicts ride +// different events by design -- the ledger derives savings from one and the +// keep/prune signal from the other. +export async function recordReuseLocally({ assetId, outcome, sessionId }) { + if (!assetId || !outcome) return false; + const ingestor = await loadIngestor(); + if (!ingestor) return false; + + const cycleId = cycleIdOf(sessionId); + const success = outcome === 'success'; + try { + await ingestor.ingest({ + type: success ? HIT_EVENT : OUTCOME_EVENT, + human: { + title: `dsh reuse ${outcome}`.slice(0, MAX_TITLE_CHARS), + detail: `cycle ${cycleId}`, + }, + payload: success ? { assetId, cycleId } : { assetId, cycleId, outcome }, + }); + return true; + } catch { + return false; + } +} diff --git a/src/reuse.js b/src/reuse.js index 7099922..5b61923 100644 --- a/src/reuse.js +++ b/src/reuse.js @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap +import { recordReuseLocally } from './local-ledger.js'; + // An automatic report is weaker evidence than one the model made after // validating its own work: the strategy was put in front of the model, but // nothing proves it was followed. The reason says so, so the Hub can weigh it @@ -24,7 +26,7 @@ function correctionReason(turn) { // reason:"hub 404"}` because no reuse-result route exists on the Hub. Treating // the transport as the outcome would mark an asset reported that no ledger ever // saw, and the on-disk record would make that mistake permanent. -async function postOutcome(proxyFetch, { assetId, outcome, reason, signal }) { +async function postToHub(proxyFetch, { assetId, outcome, reason, signal }) { try { const result = await proxyFetch('POST', '/asset/reuse-result', { asset_id: assetId, outcome, reason }, signal); return result?.ok === true && result.data?.recorded !== false; @@ -33,13 +35,24 @@ async function postOutcome(proxyFetch, { assetId, outcome, reason, signal }) { } } +// The local root_event is the half that pays off today. The actuator that +// re-orders candidates reads root_events, not the Hub, and the Hub has no +// reuse-result route to read from anyway. A verdict counts as delivered when +// either ledger took it, so one of them being down does not keep the asset +// pending forever. +async function postOutcome(proxyFetch, { assetId, outcome, reason, sessionId, signal }) { + const hub = await postToHub(proxyFetch, { assetId, outcome, reason, signal }); + const local = await recordReuseLocally({ assetId, outcome, sessionId }); + return hub || local; +} + export async function reportInjectedReuse(proxyFetch, options) { - const { assetIds, outcome, turn, reasonKind, signal, onReported } = options; + const { assetIds, outcome, turn, reasonKind, sessionId, signal, onReported } = options; if (!outcome || assetIds.length === 0) return []; const reported = []; for (const assetId of assetIds) { - if (!await postOutcome(proxyFetch, { assetId, outcome: outcome.status, reason: automaticReason(turn, reasonKind), signal })) continue; + if (!await postOutcome(proxyFetch, { assetId, outcome: outcome.status, reason: automaticReason(turn, reasonKind), sessionId, signal })) continue; reported.push(assetId); onReported?.(assetId, outcome.status); } @@ -47,10 +60,10 @@ export async function reportInjectedReuse(proxyFetch, options) { } export async function reportReuseCorrection(proxyFetch, options) { - const { assets, signal, onCorrected } = options; + const { assets, sessionId, signal, onCorrected } = options; const corrected = []; for (const { assetId, turn } of assets) { - if (!await postOutcome(proxyFetch, { assetId, outcome: 'failed', reason: correctionReason(turn), signal })) continue; + if (!await postOutcome(proxyFetch, { assetId, outcome: 'failed', reason: correctionReason(turn), sessionId, signal })) continue; corrected.push(assetId); onCorrected?.(assetId); } diff --git a/test/apply.test.js b/test/apply.test.js index 3d50745..5519dfa 100644 --- a/test/apply.test.js +++ b/test/apply.test.js @@ -659,8 +659,8 @@ test('an ordinary follow-up prompt leaves the earlier verdict alone', async () = } }); -test('a report the ledger rejected is not marked sent, so it can go again', async () => { - const projectDir = gitDirectory('evolver-rejected-'); +test('a verdict the Hub has no route for still reaches the local ledger', async () => { + const projectDir = gitDirectory('evolver-localledger-'); const requests = []; const server = createServer((request, response) => { let body = ''; @@ -679,30 +679,38 @@ test('a report the ledger rejected is not marked sent, so it can go again', asyn }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const home = process.env.HOME; - process.env.HOME = mkdtempSync(join(tmpdir(), 'evolver-rejected-home-')); + process.env.HOME = mkdtempSync(join(tmpdir(), 'evolver-localledger-home-')); try { const { ctx, listeners } = fakeContext(); apply(ctx, Config({ projectDir, proxyPort: server.address().port })); - const { agent } = fakeAgent({ sessionId: 'session-rejected', cwd: projectDir }); + const { agent } = fakeAgent({ sessionId: 'session-local', cwd: projectDir }); await primedBy(listeners, agent, 'add a retry to the uploader', 1, 1); - const reports = () => requests.filter((request) => request.path === '/asset/reuse-result'); - listeners.get('session/event')(agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }); await untilRequest(requests, '/asset/reuse-result'); - assert.equal(reports().length, 1); + await new Promise((resolve) => { setTimeout(resolve, 300); }); + + const log = join(process.env.HOME, '.evomap', 'evolution', 'root_events.jsonl'); + assert.ok(existsSync(log), 'the root event log is written even though the Hub has no route'); + const events = readFileSync(log, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + const hits = events.filter((event) => event.type === 'value.reuse_hit'); + assert.equal(hits.length, 1); + assert.equal(hits[0].payload.assetId, 'sha256:unlanded'); + assert.equal(hits[0].payload.cycleId, 'dsh:session-local'); listeners.get('session/event')(agent.session, { type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }); - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(reports().length, 2, 'a rejected report stays pending instead of being recorded as sent'); - assert.equal(reports()[1].body.asset_id, 'sha256:unlanded'); + await new Promise((resolve) => { setTimeout(resolve, 300); }); + assert.equal( + readFileSync(log, 'utf8').trim().split('\n').filter((line) => line.includes('value.reuse_hit')).length, + 1, + 'a verdict that landed locally is not counted twice', + ); } finally { process.env.HOME = home; server.close(); } }); - test('a slot spent on an asset the Hub would not deliver is not spent on it again', async () => { const projectDir = gitDirectory('evolver-undeliverable-'); const requests = []; @@ -791,3 +799,51 @@ test("a self-report the Hub refused does not retire the plugin's own", async () server.close(); } }); + +test('a correction writes the negative half of the signal, not another hit', async () => { + const projectDir = gitDirectory('evolver-negative-'); + const requests = []; + const server = createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { body += chunk; }); + request.on('end', () => { + requests.push({ path: request.url, body: JSON.parse(body) }); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify( + request.url === '/asset/search' + ? { results: [{ asset_type: 'Gene', asset_id: 'sha256:wrong', has_strategy: true, similarity: 0.9 }] } + : request.url === '/asset/fetch' + ? { assets: [{ asset_id: 'sha256:wrong', strategy: ['Did not hold.'] }] } + : { recorded: false, reason: 'hub 404' }, + )); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const home = process.env.HOME; + process.env.HOME = mkdtempSync(join(tmpdir(), 'evolver-negative-home-')); + + try { + const { ctx, listeners } = fakeContext(); + apply(ctx, Config({ projectDir, proxyPort: server.address().port })); + const { agent } = fakeAgent({ sessionId: 'session-negative', cwd: projectDir }); + + await primedBy(listeners, agent, 'add a retry to the uploader', 1, 1); + listeners.get('session/event')(agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }); + await untilRequest(requests, '/asset/reuse-result'); + await new Promise((resolve) => { setTimeout(resolve, 300); }); + + await primedBy(listeners, agent, '还是不行,报一样的错', 2, 1); + await new Promise((resolve) => { setTimeout(resolve, 400); }); + + const log = join(process.env.HOME, '.evomap', 'evolution', 'root_events.jsonl'); + const events = readFileSync(log, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + const negatives = events.filter((event) => event.type === 'value.reuse_outcome'); + assert.equal(negatives.length, 1); + assert.equal(negatives[0].payload.outcome, 'failed'); + assert.equal(negatives[0].payload.assetId, 'sha256:wrong'); + assert.equal(events.filter((event) => event.type === 'value.reuse_hit').length, 1); + } finally { + process.env.HOME = home; + server.close(); + } +});