Skip to content
Merged
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
8 changes: 4 additions & 4 deletions lib/app/commands/drafts.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { failure, success } from './result.js';

const USAGE = 'Usage: /evolver-drafts [publish|discard <number>]';

async function discard({ drafts, clock }, draft) {
await drafts.update((list) => withoutDraft(list, draft.id, clock.now()));
async function discard({ drafts }, draft) {
await drafts.update((list) => withoutDraft(list, draft.id));
return success(`Discarded "${draft.title}".`);
}

async function publish(ports, draft) {
const request = submitRequestOf(draft);
const answered = await ports.proxy.request(request.method, request.path, request.body);
if (!answered.ok) return failure(`"${draft.title}" was not submitted: ${answered.error}`);
await ports.drafts.update((list) => withoutDraft(list, draft.id, ports.clock.now()));
await ports.drafts.update((list) => withoutDraft(list, draft.id));
return success(`Submitted "${draft.title}" to the EvoMap Hub for review (message ${answered.data?.message_id ?? 'pending'}).`);
}

Expand All @@ -25,7 +25,7 @@ export default {
hint: '[publish|discard <number>]',
async run(ports, { args }) {
const [action = '', selector = ''] = args.trim().split(/\s+/);
const list = withoutDraft(await ports.drafts.read(), null, ports.clock.now());
const list = await ports.drafts.read();
if (!action) return success(draftsListText(list));
if (action !== 'publish' && action !== 'discard') return failure(USAGE);
const draft = draftNamed(list, selector);
Expand Down
3 changes: 1 addition & 2 deletions lib/app/commands/status.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import { withoutDraft } from '../../distill.js';
import { engineNoticeOf } from '../../engine/notice.js';
import { SIGN_IN, signInOf } from '../../hub-login.js';
import { MIN_EVOLVER_VERSION } from '../../engine/version.js';
Expand Down Expand Up @@ -38,7 +37,7 @@ export default {
description: 'Evolver health: the Proxy, engine setup, Hub sign-in, distillation candidate and drafts.',
async run({ identity, proxy, engine, candidates, drafts, signIn, clock }, { sessionId, anySession }) {
const answered = await proxy.request('GET', '/proxy/status', undefined, { timeoutMs: 3_000 });
const waiting = withoutDraft(await drafts.read(), null, clock.now()).length;
const waiting = (await drafts.read()).length;
return success([
answered.ok ? proxyStatusText(answered.data) : answered.error,
engineLine(await engine.outcome(), identity.label),
Expand Down
2 changes: 1 addition & 1 deletion lib/app/distill-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async function post(ports, { sessionId, candidate }, args, usage, signal) {
const body = distillBody({ platform: ports.identity.agent, sessionId: taskSessionId(ports.identity, sessionId), candidate, args, usage });
const result = await ports.proxy.request('POST', '/conversation/distill', body, { timeoutMs: DISTILL_TIMEOUT_MS, signal });
const outcome = distillOutcomeOf(result, { title: args.title, sessionId }, ports.clock.now());
if (outcome.draft) await ports.drafts.update((drafts) => withDraft(drafts, outcome.draft, ports.clock.now()));
if (outcome.draft) await ports.drafts.update((drafts) => withDraft(drafts, outcome.draft));
return { ...outcome, ...(usage && { usage }) };
}

Expand Down
16 changes: 5 additions & 11 deletions lib/distill.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

import { containsSecret, withoutSecrets } from './redact.js';

export const DRAFTS_TTL_MS = 8 * 24 * 60 * 60 * 1000;
const MAX_DRAFTS = 20;
const MAX_TRACE_ROWS = 8;
const MAX_ARTIFACTS = 12;
const COMMAND_MAX_CHARS = 180;
Expand Down Expand Up @@ -136,23 +134,19 @@ export function draftOf(result, { title, sessionId }, now) {
};
}

function prunedDrafts(drafts, now) {
return (Array.isArray(drafts) ? drafts : []).filter((draft) => now - Number(draft?.createdAt) <= DRAFTS_TTL_MS);
}

export function skipReasonOf(result) {
if (result?.ok !== true) return String(result?.reason ?? 'skipped');
if (result.quality?.ok !== true) return String(result.quality?.reason ?? 'quality_gate');
return 'incomplete_draft';
}

export function withDraft(drafts, draft, now) {
const kept = prunedDrafts(drafts, now).filter((existing) => existing.id !== draft.id);
return [...kept, draft].slice(-MAX_DRAFTS);
export function withDraft(drafts, draft) {
const kept = (Array.isArray(drafts) ? drafts : []).filter((existing) => existing.id !== draft.id);
return [...kept, draft];
}

export function withoutDraft(drafts, id, now) {
return prunedDrafts(drafts, now).filter((draft) => draft.id !== id);
export function withoutDraft(drafts, id) {
return (Array.isArray(drafts) ? drafts : []).filter((draft) => draft.id !== id);
}

// `1` names the first listed draft; anything else is matched as an id prefix
Expand Down
23 changes: 23 additions & 0 deletions test/app/draft-retention.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { runCommand } from '../../lib/app/commands/index.js';
import { withDraft } from '../../lib/distill.js';
import { memoryPorts } from './memory-ports.js';

test('adding and listing drafts preserves old, legacy and more than twenty unreviewed drafts', async () => {
const { ports } = memoryPorts();
const earlier = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}`, title: `Old method ${i}`, summary: '', assets: [], createdAt: 0 }));
earlier.push({ id: 'legacy', title: 'Legacy method', summary: '', assets: [] });
await ports.drafts.update(() => earlier);
await ports.clock.sleep(9 * 24 * 60 * 60 * 1000);
const added = { id: 'new', title: 'New method', summary: '', assets: [], createdAt: ports.clock.now() };
await ports.drafts.update((list) => withDraft(list, added));
const list = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts' });
assert.match(list.text, /27 distilled drafts waiting/);
assert.match(list.text, /1\. Old method 0/);
assert.deepEqual(await ports.drafts.read(), [...earlier, added]);
const discarded = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts', args: 'discard 1' });
assert.equal(discarded.ok, true);
assert.deepEqual(await ports.drafts.read(), [...earlier.slice(1), added]);
});
17 changes: 9 additions & 8 deletions test/distill.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';

import {
DRAFTS_TTL_MS, distillPromptOf, distillRequestOf, draftNamed, draftOf, draftsListText, isDistillableTurn, lessonOf, skipReasonOf,
distillPromptOf, distillRequestOf, draftNamed, draftOf, draftsListText, isDistillableTurn, lessonOf, skipReasonOf,
submitRequestOf, withDraft, withoutDraft,
} from '../lib/distill.js';

Expand Down Expand Up @@ -87,15 +87,16 @@ test('a draft needs a passing quality gate and both assets', () => {
assert.equal(skipReasonOf({ ok: true, quality: { ok: false, reason: 'insufficient_reusable_signal' } }), 'insufficient_reusable_signal');
});

test('drafts are deduplicated by id, capped, and expire', () => {
test('drafts are deduplicated by id and retained until explicitly removed', () => {
const draft = (id, createdAt = 0) => ({ id, title: id, summary: '', assets: [], createdAt });
const once = withDraft(withDraft([], draft('a'), 0), draft('a', 1), 1);
const once = withDraft(withDraft([], draft('a')), draft('a', 1));
assert.deepEqual(once.map((d) => d.createdAt), [1]);
const many = Array.from({ length: 25 }, (_, i) => draft(`d${i}`)).reduce((list, d) => withDraft(list, d, 0), []);
assert.equal(many.length, 20);
assert.equal(many[0].id, 'd5');
assert.deepEqual(withDraft([draft('old')], draft('new', DRAFTS_TTL_MS + 1), DRAFTS_TTL_MS + 1).map((d) => d.id), ['new']);
assert.deepEqual(withoutDraft([draft('a'), draft('b')], 'a', 0).map((d) => d.id), ['b']);
const many = Array.from({ length: 25 }, (_, i) => draft(`d${i}`)).reduce((list, d) => withDraft(list, d), []);
assert.equal(many.length, 25);
assert.equal(many[0].id, 'd0');
const later = 9 * 24 * 60 * 60 * 1000;
assert.deepEqual(withDraft([draft('old')], draft('new', later)).map((d) => d.id), ['old', 'new']);
assert.deepEqual(withoutDraft([draft('a'), draft('b')], 'a').map((d) => d.id), ['b']);
});

test('a draft is named by its list number or a unique id prefix', () => {
Expand Down
Loading