diff --git a/README.md b/README.md index 7e337d6..a861f12 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,9 @@ Compression and subagents make extra model requests with your provider credentia | Memory search | off | Gives the agent, and subagents started or resumed after the change, a `search` tool over your original messages. Off is the recipe: zoom and date only. Applies from the next turn. | | Summary size tolerance | 640 bytes | The compactor is always asked for 512-byte lines; a longer line up to this size is kept instead of retried. 512 is the recipe's strict rule. | | Turn refresh threshold | 30% | Once the current turn's verbatim messages pass this share of the model's context window, they move into memory and the turn goes on with a fresh window (see [Good to know](#good-to-know)). Type 30, 30% or 0.3. 0% is off, as in the recipe. Applies from the next call. | -| Import: summaries at once | 8 | Summaries an import builds at once, 1 to 64. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider's rate limits harder (the compactor retries what is refused). Chat always runs 8. Applies to the next import. | +| Summaries at once | 8 | Summaries built at once, by chat compaction and by an import, 1 to 64. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider's rate limits harder (the compactor retries what is refused), so if your provider caps parallel requests, set it below that cap so the main turn has room. Applies to the next import, and to chat once the profile opens again. A `config.json` that still says `importJobs` keeps its value. | -Numbers must be whole numbers of at least 1 (512 for the summary size tolerance, at most 64 for Import: summaries at once); Turn refresh threshold is a percentage from 0% to 90%. Missing keys in an older `config.json` take their defaults. +Numbers must be whole numbers of at least 1 (512 for the summary size tolerance, at most 64 for Summaries at once); Turn refresh threshold is a percentage from 0% to 90%. Missing keys in an older `config.json` take their defaults. **Upgrading from 0.6.x:** subagent levels used to be fixed at 3 and now default to 1, so subagents no longer start their own subagents until you set Subagent levels to 2 or 3. diff --git a/docs/recipe-differences.md b/docs/recipe-differences.md index 358a167..a1611dd 100644 --- a/docs/recipe-differences.md +++ b/docs/recipe-differences.md @@ -23,13 +23,13 @@ The README has the summary table. This page has the detail: what changed in each - 512-byte summary nodes. By default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace (setting: Summary size tolerance). - A memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000. Between batches it only grows at its end, so each turn reads the last one's view from the cache. The view is saved and reloaded as it was. -- Binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. +- Binary merges, 8 compression workers by default (setting: Summaries at once), fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. ## Compaction view and scheduling Compactions get their own view, as in the 2026-10-08 revision: the memory view merged further, to between 16,000 and 32,000 bytes with the same sawtooth, ending at the line being built. -A message's summary starts once fewer than 8 lines before it are unsummarized, so up to 8 compactions run at once on one cached view. An import logs its messages the same way, waiting only while 8 messages and due merges are unbuilt, so the merges keep up. +A message's summary starts once fewer than N lines before it are unsummarized, so up to N compactions run at once on one cached view (N is the **Summaries at once** setting, 8 by default). An import logs its messages the same way, waiting only while that many messages and due merges are unbuilt, so the merges keep up. Where the recipe ends a compaction's view at the first line not summarized yet, OptChat shows that line as a placeholder and keeps the lines after it. Cutting there also hid the short lines that follow, such as an echo's own tool call, and an A/B on real turns scored those summaries lower (#101). diff --git a/src/import/job.ts b/src/import/job.ts index 633f56b..14777be 100644 --- a/src/import/job.ts +++ b/src/import/job.ts @@ -95,7 +95,7 @@ export function discardImport(dir: string) { rmSync(pendingFile(dir)); rmSync(generationPath(dir, job.target), { recursive: true, force: true }); } export interface ImportProgress { messages: number; total: number; summaries: number; error?: string } -export async function runImport(dir: string, compress: Compressor, signal: AbortSignal, jobs = DEFAULT_SETTINGS.importJobs, +export async function runImport(dir: string, compress: Compressor, signal: AbortSignal, jobs = DEFAULT_SETTINGS.jobs, progress: (state: ImportProgress) => void = () => {}): Promise { const job = pendingImport(dir); if (!job) throw new Error('No pending import.'); const path = generationPath(dir, job.target); @@ -113,7 +113,7 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort if (memory.root.length > plan.length || memory.root.some((e, i) => e.text !== plan[i].text)) throw new Error('Import staging data does not match its log; original memory remains intact.'); report(); // Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live - // messages is, so up to `jobs` summaries run at once (the "Import: summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error). + // messages is, so up to `jobs` summaries run at once (the "Summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error). for (const e of plan.slice(memory.root.length)) { await memory.settle(signal, 'ahead'); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); } await memory.settle(signal, 'tree'); signal.throwIfAborted(); // Close all writers before the single atomic pointer swap. The previous generation stays intact. diff --git a/src/index.ts b/src/index.ts index 2346a7a..0ed7af3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,7 +204,7 @@ export default function optchat(pi: ExtensionAPI) { const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => { usage.compression(message, 'compactor', sessionId); status(ctx); - }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error')); + }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'), undefined, config.jobs); openingMemory = memory; const inbox = new Inbox(dir); const recovered = pendingImport(dir) ? 0 : inbox.recover(memory); @@ -586,7 +586,7 @@ export default function optchat(pi: ExtensionAPI) { const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => { a.usage.compression(message, 'import', ctx.sessionManager.getSessionId()); }, () => a.config.summaryAcceptBytes); - const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.importJobs, progress), signal); + const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.jobs, progress), signal); ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.` : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info'); })(); diff --git a/src/memory.ts b/src/memory.ts index 02a3934..de94661 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -130,7 +130,7 @@ export class Memory { return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs); } /** A directory alone opens a FileStore there. `jobs`: summaries built at once, and how many unbuilt lines a new message's node - * waits behind. Chat keeps 8; imports pass the "Import: summaries at once" setting. */ + * waits behind. Chat and imports pass the "Summaries at once" setting. */ constructor(source: string | { directory: string; store: Store; saved: Saved }, private readonly compress: Compressor, private readonly warn: (s: string) => void = console.error, readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) { diff --git a/src/settings.ts b/src/settings.ts index 635d744..5e2b1c0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -23,9 +23,9 @@ export const SETTINGS = { summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes', description: `The compactor is always asked for ${NODE}-byte lines; a longer line up to this size is kept instead of retried. ${NODE}: strict, as in Victor's recipe. The default saves retries for a little more view space.`, applies: 'Applies to the next summary.' }, - importJobs: { kind: 'number', label: 'Import: summaries at once', default: 8, min: 1, max: 64, unit: '', - description: 'Summaries an import builds at once. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider\'s rate limits harder; the compactor retries what is refused. Chat itself always runs 8.', - applies: 'Applies to the next import.' }, + jobs: { kind: 'number', label: 'Summaries at once', default: 8, min: 1, max: 64, unit: '', + description: 'Summaries built at once, by chat compaction and by an import. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider\'s rate limits harder; the compactor retries what is refused. If your provider caps parallel requests, set it below that cap so the main turn has room.', + applies: 'Applies to the next import, and to chat once the profile opens again (e.g. after /reload).' }, refreshAt: { kind: 'percent', label: 'Turn refresh threshold', default: 0.3, min: 0, max: 0.9, description: 'Once the current turn\'s messages pass this share of the model\'s context window, they move into memory and the turn goes on with a fresh window: the rebuilt view, the task word for word, and its last tool call and result. 0%: off, as in Victor\'s recipe, and a long turn can run out of window. OPTCHAT_REFRESH_AT (a fraction, e.g. 0.3; 0 is off) overrides it. Subagents use Pi\'s own compaction at about the same point.', applies: 'Applies from the next call, and to subagents started or resumed after this.' }, @@ -54,6 +54,8 @@ export function invalid(key: SettingKey, value: unknown) { /** Missing keys take their defaults, so config files written before a setting existed keep working. */ export function readSettings(value: Record): Settings { + // "Summaries at once" was `importJobs` while it only covered imports; an older config.json keeps its value. + if (!Object.hasOwn(value, 'jobs') && Object.hasOwn(value, 'importJobs')) value = { ...value, jobs: value.importJobs }; return Object.fromEntries(SETTING_KEYS.map(key => { const raw = Object.hasOwn(value, key) ? value[key] : SETTINGS[key].default, problem = invalid(key, raw); if (problem) throw new Error(problem); diff --git a/test/settings.test.ts b/test/settings.test.ts index 256ff3a..d63767e 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -61,13 +61,21 @@ test('a config.json from before settings existed loads with the defaults, and se const changed = { ...loadConfig(dir), subagentLevels: 3, maxAgents: 12, previousExchange: false, previousExchangeKB: 4, memorySearch: true, summaryAcceptBytes: 512 }; saveConfig(dir, changed); assert.deepEqual(loadConfig(dir), changed); - for (const [key, value] of [['subagentLevels', 0], ['maxAgents', -1], ['previousExchangeKB', 1.5], ['subagentLevels', '3'], ['previousExchange', 'yes'], ['summaryAcceptBytes', 511], ['maxAgents', null], ['importJobs', 0], ['importJobs', 65]] as const) { + for (const [key, value] of [['subagentLevels', 0], ['maxAgents', -1], ['previousExchangeKB', 1.5], ['subagentLevels', '3'], ['previousExchange', 'yes'], ['summaryAcceptBytes', 511], ['maxAgents', null], ['jobs', 0], ['jobs', 65]] as const) { writeFileSync(join(dir, 'config.json'), JSON.stringify({ ...changed, [key]: value })); assert.throws(() => loadConfig(dir), /Invalid profile config/, `${key}: ${value}`); } } finally { removeDir(dir); } }); +test('a config.json from when Summaries at once was importJobs keeps its value', () => { + const dir = mkdtempSync(join(tmpdir(), 'optchat-config-')); + try { + writeFileSync(join(dir, 'config.json'), JSON.stringify({ compactor: defaults.compactor, subagent: defaults.subagent, importJobs: 3 })); + assert.equal(loadConfig(dir).jobs, 3); + } finally { removeDir(dir); } +}); + test('by default only the main agent starts subagents: a child gets no spawn or tell, nor search', async () => { const dir = mkdtempSync(join(tmpdir(), 'optchat-levels-')); const { children: c, releases, close } = await children(dir);