From 759db1e0737f9720e8795d5cd41e7fd13db7166a Mon Sep 17 00:00:00 2001 From: forrestlinfeng Date: Wed, 23 Sep 2026 17:45:45 +0800 Subject: [PATCH] Fall back to the description when the Hub's title is a fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #24 put short_title in the header and a live session showed what that costs when the Hub's title is truncated: [Evolution Memory] Object (EvoMap network): 1. Initialize pool with configurable minimum and maximum object capacity limits … `Object` is what got cut off, not what the gene is. Measured across forty titles from eight queries, two were fragments and a character floor cannot find them: `自动化小` is a truncation at four characters, and `智能缓存优化` is a complete title at six. Chinese says as much in six characters as a Latin title says in forty, so length is the wrong instrument. A title now has to look like a name: more than one word if it is Latin, five characters or more if it is CJK. `Object` fails on the first and `智能缓存优化` passes on the second. Whatever fails gives way to nl_summary, present on 39 of the 40 measured, cut to the same 80 characters the title was. That does put a description in the header, which #8 kept out. The bound is what makes it different: 80 characters is a name-length excerpt standing in for a name, not the 214-character median recitation #8 rejected. Four tests; removing the fragment check fails two of them. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/prime.js | 25 ++++++++++++++++--- test/prime.test.js | 62 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c436b82..0066d40 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Powered by the [Genome Evolution Protocol](https://evomap.ai) and | Seam | dsh event | Behaviour | | --- | --- | --- | -| 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, announced by the asset's short title rather than its hash. The hash appears once, on the line that asks for the outcome, because that call needs it. 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. | +| 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, announced by the asset's short title rather than its hash, falling back to its description when the Hub's title is a truncated fragment. The hash appears once, on the line that asks for the outcome, because that call needs it. 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, 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. | diff --git a/src/prime.js b/src/prime.js index f6958a3..f699d85 100644 --- a/src/prime.js +++ b/src/prime.js @@ -50,11 +50,28 @@ function fetchedById(data) { return new Map(found.filter((asset) => asset?.asset_id).map((asset) => [asset.asset_id, asset])); } +const CJK_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; +const MIN_CJK_TITLE_CHARS = 5; + +function trimmedText(value) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +// A Hub short_title is sometimes a truncated fragment: `Object`, `自动化小`. +// Character count cannot separate those from a good title, because `智能缓存优化` +// says as much in six characters as a Latin title says in forty. +function looksLikeAName(text) { + if (!text) return false; + if (CJK_SCRIPT.test(text)) return [...text].length >= MIN_CJK_TITLE_CHARS; + return /\s/.test(text); +} + function readableNameOf(hit, asset) { - for (const candidate of [hit?.short_title, asset?.short_title]) { - if (typeof candidate === 'string' && candidate.trim()) return candidate.trim().slice(0, TITLE_MAX_CHARS); - } - return hit?.asset_type ?? 'Gene'; + const title = trimmedText(hit?.short_title) || trimmedText(asset?.short_title); + if (looksLikeAName(title)) return title.slice(0, TITLE_MAX_CHARS); + const described = trimmedText(hit?.nl_summary) || trimmedText(asset?.summary); + if (described) return described.slice(0, TITLE_MAX_CHARS); + return title || hit?.asset_type || 'Gene'; } function similarityOf(hit) { diff --git a/test/prime.test.js b/test/prime.test.js index f3f0153..fdcd957 100644 --- a/test/prime.test.js +++ b/test/prime.test.js @@ -45,11 +45,11 @@ test('one asset is fetched and injected as its strategy alone', async () => { assert.deepEqual(calls[1].body, { asset_ids: ['sha256:abc', 'sha256:second'] }); assert.equal(calls[0].signal, controller.signal); assert.deepEqual(ids, ['sha256:abc']); - assert.match(text, /\[Evolution Memory\] Gene \(EvoMap network\)/); + assert.match(text, /\[Evolution Memory\] Retry the upload with backoff\. \(EvoMap network\)/); assert.match(text, /evolver_asset_reuse_result for sha256:abc\./); assert.match(text, /^1\. Measure the failure rate first\.$/m); assert.match(text, /^2\. Add jittered backoff\.$/m); - assert.doesNotMatch(text, /Retry the upload with backoff|npm test|sha256:second/); + assert.doesNotMatch(text, /npm test|sha256:second/); assert.match(text, /evolver_asset_reuse_result/); }); @@ -267,14 +267,64 @@ test('a named gene is announced by its name, and its hash only where it gets use assert.doesNotMatch(text, /no business being in front of the model|Also long/); }); -test('an unnamed asset falls back to its type rather than reciting a summary', async () => { +test('an asset with neither a name nor a description falls back to its type', async () => { const { proxyFetch } = stubProxy({ search: { results: [{ asset_id: 'sha256:plain', asset_type: 'Capsule', has_strategy: true, similarity: 0.9 }] }, - fetch: { assets: [{ asset_id: 'sha256:plain', summary: 'A summary that must stay out of the prompt.', strategy: ['Do it.'] }] }, + fetch: { assets: [{ asset_id: 'sha256:plain', strategy: ['Do it.'] }] }, }); const { text } = await hubGene(proxyFetch, 'add a retry to the uploader'); - assert.match(text, /^\[Evolution Memory\] Capsule \(EvoMap network\):$/m); - assert.doesNotMatch(text, /must stay out of the prompt/); +}); + +test('a one-word title is a truncation, not a name, so the summary stands in', async () => { + const { proxyFetch } = stubProxy({ + search: { + results: [{ + asset_id: 'sha256:fragment', asset_type: 'Gene', has_strategy: true, similarity: 0.9, + short_title: 'Object', + nl_summary: 'A generic object pool for reusing expensive resources such as database connections, so they are not rebuilt per request.', + }], + }, + fetch: { assets: [{ asset_id: 'sha256:fragment', strategy: ['Initialize the pool.'] }] }, + }); + + const { text } = await hubGene(proxyFetch, 'reuse database connections'); + const header = text.split('\n')[0]; + + assert.doesNotMatch(header, /\] Object \(/, 'Object is what got truncated, not what the gene is'); + assert.match(header, /A generic object pool for reusing expensive resources/); + assert.ok(header.length <= 120, 'the stand-in is a name-length excerpt, not the whole summary'); +}); + +test('a short CJK title is a name, and is not mistaken for a fragment', async () => { + const { proxyFetch } = stubProxy({ + search: { + results: [{ + asset_id: 'sha256:cjk', asset_type: 'Gene', has_strategy: true, similarity: 0.9, + short_title: '智能缓存优化', + nl_summary: '这个基因用于优化缓存命中率。', + }], + }, + fetch: { assets: [{ asset_id: 'sha256:cjk', strategy: ['预热缓存。'] }] }, + }); + + const { text } = await hubGene(proxyFetch, '这个服务的缓存命中率太低了,帮我优化一下'); + assert.match(text.split('\n')[0], /^\[Evolution Memory\] 智能缓存优化 \(EvoMap network\):$/); +}); + +test('a truncated CJK title gives way to the summary as well', async () => { + const { proxyFetch } = stubProxy({ + search: { + results: [{ + asset_id: 'sha256:cut', asset_type: 'Gene', has_strategy: true, similarity: 0.9, + short_title: '自动化小', + nl_summary: '这个基因能自动帮你创作小红书笔记并一键发布。', + }], + }, + fetch: { assets: [{ asset_id: 'sha256:cut', strategy: ['先定选题。'] }] }, + }); + + const { text } = await hubGene(proxyFetch, '帮我写一篇小红书的种草笔记'); + assert.match(text.split('\n')[0], /这个基因能自动帮你创作小红书笔记/); });