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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/recipe-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions src/import/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImportJob> {
const job = pendingImport(dir); if (!job) throw new Error('No pending import.');
const path = generationPath(dir, job.target);
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
})();
Expand Down
2 changes: 1 addition & 1 deletion src/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
Expand Down Expand Up @@ -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<string, unknown>): 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);
Expand Down
10 changes: 9 additions & 1 deletion test/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading