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(); + } +});