Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { feedbackLabelOf } from './feedback-label.js';
import { forgetSession, rememberInjected, 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';
Expand Down Expand Up @@ -89,6 +90,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);
Expand All @@ -115,11 +136,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) {
Expand Down
49 changes: 37 additions & 12 deletions src/prime.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -42,15 +43,15 @@ 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;
for (const block of message?.content ?? []) {
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) {
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions src/recall-query.js
Original file line number Diff line number Diff line change
@@ -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);
}
34 changes: 34 additions & 0 deletions test/apply.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,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();
}
});
74 changes: 73 additions & 1 deletion test/prime.test.js
Original file line number Diff line number Diff line change
@@ -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 = [];
Expand Down Expand Up @@ -280,3 +280,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'));
});
47 changes: 47 additions & 0 deletions test/recall-query.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading