diff --git a/src/index.js b/src/index.js index fc01373..0c94ed9 100644 --- a/src/index.js +++ b/src/index.js @@ -14,9 +14,10 @@ import { feedbackLabelOf } from './feedback-label.js'; import { forgetSession, rememberInjected, takeOrphanedUnreported, takeUnreported } from './injected-assets.js'; import { recordReuseLocally } from './local-ledger.js'; import { noticeDue, pendingClaimUrl } from './onboarding.js'; -import { hubGene, promptTextOf } from './prime.js'; +import { hubGene, promptTextOf, taskWithOpening } from './prime.js'; import { resolveProjectDir, sessionDir } from './project-dir.js'; import { createProxyClient } from './proxy.js'; +import { TASK_MAX_CHARS, rewrittenQueryOf } from './recall-query.js'; import { createReportQueue } from './report-queue.js'; import { rememberForRetention, retentionReportsOf, takeDueRetentions } from './retention-pending.js'; import { correctionReportOf, immediateReportOf, reportedReuseOf } from './reuse.js'; @@ -93,6 +94,26 @@ function primeSteps(ctx, config, primeFetch, engineSetup, recallLog, onPrompt) { const seeded = new WeakSet(); const searchedTurn = new WeakMap(); const listedAssets = new WeakMap(); + const openingPrompts = new WeakMap(); + let llm = null; + ctx.inject(['llm'], (scoped) => { + llm = scoped.llm; + }); + + const rewriteFor = (agent) => { + const { provider, model } = agent.options ?? {}; + if (!llm || !provider || !model) return null; + return (task, signal) => rewrittenQueryOf(llm, { provider, model }, task, signal); + }; + + const taskOf = (agent, prompt) => { + const opening = openingPrompts.get(agent); + if (!opening) { + openingPrompts.set(agent, prompt); + return prompt; + } + return taskWithOpening(opening, prompt); + }; const listedFor = (agent) => { let listed = listedAssets.get(agent); @@ -119,11 +140,17 @@ function primeSteps(ctx, config, primeFetch, engineSetup, recallLog, onPrompt) { const listed = listedFor(agent); const startedAt = Date.now(); - const search = hubGene(primeFetch, promptTextOf(claimed), { + const prompt = promptTextOf(claimed, TASK_MAX_CHARS); + const search = hubGene(primeFetch, prompt, { + task: taskOf(agent, prompt), signal, listedIds: listed, minSimilarity: config.assetPrimeMinSimilarity, - onOutcome: (outcome) => recallLog.record(outcome, startedAt), + rewrite: rewriteFor(agent), + onOutcome: (outcome) => { + recallLog.record(outcome, startedAt); + if (outcome.rewrite_error) ctx.logger?.warn?.(`evolver recall searched with the prompt prefix: the query rewrite failed: ${outcome.rewrite_error}`); + }, }); const inline = await Promise.race([search, afterWait(config.assetPrimeWaitMs ?? DEFAULT_PRIME_WAIT_MS)]); if (inline) { diff --git a/src/prime.js b/src/prime.js index 4084ea9..c044baf 100644 --- a/src/prime.js +++ b/src/prime.js @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap +import { TASK_MAX_CHARS } from './recall-query.js'; import { withoutSecrets } from './redact.js'; // One round trip, not two: since evolver 2.0.39 `/asset/fetch` recalls by text @@ -42,7 +43,7 @@ function strategyChars(steps) { // A step's batch also carries dsh's own injections — the runtime-context // snapshot and the skill catalog — and searching with those drowns the task in // boilerplate, so only what the person typed is matched against. -export function promptTextOf(messages) { +export function promptTextOf(messages, maxChars = PROMPT_MAX_CHARS) { const parts = []; for (const message of messages ?? []) { if (message?.source?.kind !== 'user') continue; @@ -50,7 +51,7 @@ export function promptTextOf(messages) { if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) parts.push(block.text.trim()); } } - return withoutSecrets(parts.join('\n')).trim().slice(0, PROMPT_MAX_CHARS); + return withoutSecrets(parts.join('\n')).trim().slice(0, maxChars); } function recalledAssets(data) { @@ -114,24 +115,48 @@ async function proxyJson(proxyFetch, path, body, signal) { } } +export function taskWithOpening(opening, prompt, maxChars = TASK_MAX_CHARS) { + if (opening === prompt) return prompt; + return `${opening.slice(0, Math.max(0, maxChars - prompt.length - 2))}\n\n${prompt}`.slice(0, maxChars); +} + +// The prompt prefix is the query whenever there is no rewrite, or the rewrite +// fails; a failure is reported with the outcome, never swallowed. +async function searchQueryOf(rewrite, promptText, taskText, signal) { + const prefix = promptText.slice(0, PROMPT_MAX_CHARS); + if (!rewrite) return { query: prefix, logged: {} }; + try { + const query = await rewrite(taskText, signal); + return { query, logged: { query_source: 'rewritten', query } }; + } catch (error) { + return { query: prefix, logged: { query_source: 'prompt_prefix', rewrite_error: String(error?.message ?? error) } }; + } +} + // Priming sits on the critical path of every answer: an unreachable Proxy, a -// slow Hub, or a malformed body must cost the turn nothing but the deadline. -// One asset's strategy is injected and nothing else — a summary only tells the -// model that something exists, while the steps are what it can actually reuse. -// Every lookup reports why it ended through `onOutcome`, because an empty match -// alone cannot tell a Proxy error from a recall that found nothing usable. -export async function hubGene(proxyFetch, text, { +// slow Hub, a failed rewrite, or a malformed body must cost the turn nothing +// but the deadline. One asset's strategy is injected and nothing else — a +// summary only tells the model that something exists, while the steps are what +// it can actually reuse. Every lookup reports why it ended through `onOutcome`, +// because an empty match alone cannot tell a Proxy error from a recall that +// found nothing usable. The rewrite reads `task`, which a caller widens beyond +// this turn's prompt: a follow-up such as "continue" says nothing about the work. +export async function hubGene(proxyFetch, promptText, { + task: taskText = promptText, signal, listedIds = new Set(), minSimilarity = DEFAULT_MIN_SIMILARITY, - onOutcome = () => {}, + rewrite = null, + onOutcome: onSearchOutcome = () => {}, } = {}) { - if (text.length < MIN_PROMPT_CHARS) { - onOutcome({ status: 'skipped', reason: 'prompt_too_short' }); + if (promptText.length < MIN_PROMPT_CHARS) { + onSearchOutcome({ status: 'skipped', reason: 'prompt_too_short' }); return EMPTY_MATCH; } - const recalled = await proxyJson(proxyFetch, '/asset/fetch', { text }, signal); + const { query, logged } = await searchQueryOf(rewrite, promptText, taskText, signal); + const onOutcome = (outcome) => onSearchOutcome({ ...outcome, ...logged }); + const recalled = await proxyJson(proxyFetch, '/asset/fetch', { text: query }, signal); if (recalled.error) { onOutcome({ status: 'proxy_error', error: recalled.error }); return EMPTY_MATCH; diff --git a/src/recall-query.js b/src/recall-query.js new file mode 100644 index 0000000..8067e6a --- /dev/null +++ b/src/recall-query.js @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { createUserMessage } from '@deepseek-ai/dsh-llm'; + +export const TASK_MAX_CHARS = 6_000; +const QUERY_MAX_CHARS = 400; +// Room for a reasoning model to think before it answers with one short line. +const REWRITE_MAX_TOKENS = 2_000; +const REWRITE_TIMEOUT_MS = 12_000; + +// The prompt the offline replay over the 499-task bank was measured with; +// rewording it invalidates that measurement. +const REWRITE_PROMPT = `Rewrite the task below into ONE search query for a library of reusable problem-solving playbooks. +Keep only what the task is about: the concrete problem, the domain, and the kind of data or system involved. +Drop role statements, file paths, tool or environment instructions, output-format and saving rules. +Write it in English, at most 200 characters. Answer with the query only. + +## Task +{task}`; + +async function answerOf(llm, options) { + let text = ''; + for await (const chunk of llm.stream(options)) { + if (chunk.type === 'text-delta') text += chunk.text; + if (chunk.type === 'finish' && chunk.reason?.failure) throw new Error(chunk.reason.failure.message); + } + return text; +} + +// The session's own provider and model write the query, so recall needs no +// key, endpoint or model of its own and costs one short call on the route the +// person already chose. +export async function rewrittenQueryOf(llm, { provider, model }, task, signal) { + const timeout = AbortSignal.timeout(REWRITE_TIMEOUT_MS); + const prompt = REWRITE_PROMPT.replace('{task}', () => task.slice(0, TASK_MAX_CHARS)); + const answer = await answerOf(llm, { + provider, + model, + messages: [createUserMessage({ content: [{ type: 'text', text: prompt }] })], + maxTokens: REWRITE_MAX_TOKENS, + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + const query = answer.trim().replace(/^"+|"+$/g, '').trim(); + if (!query) throw new Error('rewrite came back empty'); + return query.slice(0, QUERY_MAX_CHARS); +} diff --git a/test/apply.test.js b/test/apply.test.js index d6254fa..1b8eba7 100644 --- a/test/apply.test.js +++ b/test/apply.test.js @@ -339,3 +339,37 @@ test('a fetched asset renders as reusable prose, not the raw envelope', async () assert.doesNotMatch(text, /signals_match|source_node_id|gdi_score/); }); + +test('a turn is searched with a rewritten query only when the session has an llm service and a model route', async () => { + const projectDir = projectDirectory('evolver-rewrite-'); + const proxy = await startProxy(() => ({ assets: [] })); + const restoreHome = freshHome('evolver-rewrite-home-'); + + try { + const routes = []; + const { ctx, listeners } = fakeContext(); + ctx.llm = { + async *stream({ provider, model }) { + routes.push({ provider, model }); + yield { type: 'text-delta', index: 0, text: 'retry uploads with backoff' }; + yield { type: 'finish', reason: { kind: 'stop' } }; + }, + }; + apply(ctx, Config({ projectDir, proxyPort: proxy.port })); + + const { agent: routed } = fakeAgent({ id: 'routed', cwd: projectDir }); + routed.options = { provider: 'session-provider', model: 'session-model' }; + await primedBy(listeners, routed, 'add a retry to the uploader', 1); + const { agent: unrouted } = fakeAgent({ id: 'unrouted', cwd: projectDir }); + await primedBy(listeners, unrouted, 'add a retry to the uploader', 1); + + assert.deepEqual(routes, [{ provider: 'session-provider', model: 'session-model' }]); + assert.deepEqual( + proxy.requests.filter((request) => request.path === '/asset/fetch').map((request) => request.body), + [{ text: 'retry uploads with backoff' }, { text: 'add a retry to the uploader' }], + ); + } finally { + restoreHome(); + proxy.close(); + } +}); diff --git a/test/prime.test.js b/test/prime.test.js index 50f3e0b..1f11021 100644 --- a/test/prime.test.js +++ b/test/prime.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { hubGene, promptTextOf } from '../src/prime.js'; +import { hubGene, promptTextOf, taskWithOpening } from '../src/prime.js'; function stubProxy(recalled) { const calls = []; @@ -278,3 +278,75 @@ test('a broad prompt whose genes all run past eight steps still gets one injecte assert.deepEqual(ids, ['sha256:fifteen']); assert.match(text, /^15\. Step 14: /m); }); + +const LONG_TASK = `You are a careful analyst. ${'Reconcile the ledger against the bank export. '.repeat(40)}`; + +function stubRewrite({ query = 'reconcile ledger against bank export', error } = {}) { + const tasks = []; + const rewrite = async (task) => { + tasks.push(task); + if (error) throw error; + return query; + }; + return { rewrite, tasks }; +} + +test('with a rewrite, the Hub is searched with the rewritten query and one asset is still injected', async () => { + const { proxyFetch, calls } = stubProxy({ assets: [ASSET, { ...ASSET, asset_id: 'sha256:def' }] }); + const { rewrite, tasks } = stubRewrite(); + const outcomes = []; + + const { ids } = await hubGene(proxyFetch, LONG_TASK, { rewrite, onOutcome: (outcome) => outcomes.push(outcome) }); + + assert.deepEqual(tasks, [LONG_TASK]); + assert.deepEqual(calls[0].body, { text: 'reconcile ledger against bank export' }); + assert.equal(ids.length, 1); + assert.equal(outcomes[0].status, 'injected'); + assert.equal(outcomes[0].query_source, 'rewritten'); + assert.equal(outcomes[0].query, 'reconcile ledger against bank export'); +}); + +test('a failed rewrite searches with the prompt prefix and says why', async () => { + const { proxyFetch, calls } = stubProxy({ assets: [ASSET] }); + const { rewrite } = stubRewrite({ error: new Error('provider returned 503') }); + const outcomes = []; + + const { ids } = await hubGene(proxyFetch, LONG_TASK, { rewrite, onOutcome: (outcome) => outcomes.push(outcome) }); + + assert.equal(calls[0].body.text, LONG_TASK.slice(0, 400)); + assert.deepEqual(ids, ['sha256:abc']); + assert.equal(outcomes[0].query_source, 'prompt_prefix'); + assert.equal(outcomes[0].rewrite_error, 'provider returned 503'); +}); + +test('without a rewrite the prompt prefix is the query and nothing extra is logged', async () => { + const { proxyFetch, calls } = stubProxy({ assets: [ASSET] }); + const outcomes = []; + await hubGene(proxyFetch, LONG_TASK, { onOutcome: (outcome) => outcomes.push(outcome) }); + assert.deepEqual(calls[0].body, { text: LONG_TASK.slice(0, 400) }); + assert.equal('query_source' in outcomes[0], false); +}); + +test('the rewrite reads the widened task, while a fallback searches with this turn\'s prompt alone', async () => { + const { proxyFetch, calls } = stubProxy({ assets: [ASSET] }); + const { rewrite, tasks } = stubRewrite({ error: new Error('down') }); + const task = `${LONG_TASK}\n\nContinue with the task from where you stopped.`; + + await hubGene(proxyFetch, 'Continue with the task from where you stopped.', { task, rewrite }); + assert.deepEqual(tasks, [task]); + assert.equal(calls[0].body.text, 'Continue with the task from where you stopped.'); +}); + +test('the task text for recall keeps up to six thousand characters, the default still four hundred', () => { + const messages = [{ content: [{ type: 'text', text: 'y'.repeat(7_000) }], source: { kind: 'user' } }]; + assert.equal(promptTextOf(messages).length, 400); + assert.equal(promptTextOf(messages, 6_000).length, 6_000); +}); + +test('a follow-up turn is rewritten with the opening prompt in front of it, and the follow-up always fits', () => { + assert.equal(taskWithOpening('fix the ledger', 'fix the ledger'), 'fix the ledger'); + assert.equal(taskWithOpening('fix the ledger', 'continue'), 'fix the ledger\n\ncontinue'); + const widened = taskWithOpening('x'.repeat(6_000), 'continue'); + assert.equal(widened.length, 6_000); + assert.ok(widened.endsWith('\n\ncontinue')); +}); diff --git a/test/recall-query.test.js b/test/recall-query.test.js new file mode 100644 index 0000000..984fc42 --- /dev/null +++ b/test/recall-query.test.js @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { rewrittenQueryOf } from '../src/recall-query.js'; + +function stubLlm(chunks) { + const calls = []; + const llm = { + async *stream(options) { + calls.push(options); + yield* chunks; + }, + }; + return { llm, calls }; +} + +const ROUTE = { provider: 'session-provider', model: 'session-model' }; + +test('the rewrite runs on the session\'s own provider and model and returns the bare query', async () => { + const { llm, calls } = stubLlm([ + { type: 'reasoning-delta', index: 0, text: 'thinking' }, + { type: 'text-delta', index: 1, text: ' "reconcile ledger ' }, + { type: 'text-delta', index: 1, text: 'against bank export"\n' }, + { type: 'finish', reason: { kind: 'stop' } }, + ]); + + assert.equal(await rewrittenQueryOf(llm, ROUTE, 't'.repeat(9_000)), 'reconcile ledger against bank export'); + assert.equal(calls[0].provider, 'session-provider'); + assert.equal(calls[0].model, 'session-model'); + const prompt = calls[0].messages[0].content[0].text; + assert.match(prompt, /^Rewrite the task below into ONE search query/); + assert.ok(prompt.endsWith(`## Task\n${'t'.repeat(6_000)}`)); +}); + +test('a task with dollar signs is placed into the prompt verbatim', async () => { + const { llm, calls } = stubLlm([{ type: 'text-delta', index: 0, text: 'q' }, { type: 'finish', reason: { kind: 'stop' } }]); + await rewrittenQueryOf(llm, ROUTE, 'cost is $& and $1'); + assert.ok(calls[0].messages[0].content[0].text.endsWith('## Task\ncost is $& and $1')); +}); + +test('a failed or empty rewrite throws, so the caller can fall back to the prompt prefix', async () => { + const failed = stubLlm([{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider returned 503', code: 'http' } } }]); + await assert.rejects(rewrittenQueryOf(failed.llm, ROUTE, 'task'), /provider returned 503/); + + const empty = stubLlm([{ type: 'text-delta', index: 0, text: ' "" ' }, { type: 'finish', reason: { kind: 'stop' } }]); + await assert.rejects(rewrittenQueryOf(empty.llm, ROUTE, 'task'), /came back empty/); +});