pi-optchat 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -102
- package/docs/connected-windows.md +63 -0
- package/docs/import.md +74 -0
- package/docs/inspector.md +44 -0
- package/docs/recipe-differences.md +52 -0
- package/docs/storage.md +25 -0
- package/docs/subagents.md +100 -0
- package/docs/victor-recipe.md +2 -2
- package/package.json +1 -1
- package/src/agents.ts +56 -26
- package/src/cache.ts +16 -4
- package/src/compactor.ts +18 -6
- package/src/import/job.ts +2 -2
- package/src/index.ts +6 -5
- package/src/memory.ts +17 -12
- package/src/prompts.ts +5 -2
- package/src/settings.ts +5 -3
package/src/memory.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
|
|
|
26
26
|
const lineBytes = (s: string) => bytes(flat(s));
|
|
27
27
|
const notice = (omitted: number) => `\n[${omitted} characters omitted; head and tail retained]\n`;
|
|
28
28
|
const VIEW_OPEN = '<chat>\n';
|
|
29
|
+
const VIEW_FRAME_BYTES = bytes(`${VIEW_OPEN}</chat>`);
|
|
29
30
|
export const isView = (text: string) => text.startsWith(VIEW_OPEN);
|
|
30
31
|
export function cap(text: string, limit = CAP) {
|
|
31
32
|
if (text.length <= limit) return text;
|
|
@@ -51,10 +52,10 @@ export function mostDue(view: readonly Part[], total: number, built: (part: Part
|
|
|
51
52
|
class Sawtooth {
|
|
52
53
|
readonly parts: Part[] = [];
|
|
53
54
|
private measured = 0;
|
|
54
|
-
|
|
55
|
+
merging = false;
|
|
55
56
|
constructor(private readonly high: number, private readonly low: number, private readonly node: (part: Part) => Summary | undefined) {}
|
|
56
|
-
private lineSize(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
|
|
57
|
-
get size() { return this.measured; }
|
|
57
|
+
private lineSize(part: Part) { return bytes(`${start(part)}+${2 ** part.l}|\n`) + (this.node(part)?.size ?? UNBUILT_BYTES); }
|
|
58
|
+
get size() { return VIEW_FRAME_BYTES + this.measured + (this.parts.length ? 0 : 1); } // render() keeps a blank line in an empty view.
|
|
58
59
|
push(part: Part) { this.parts.push(part); this.measured += this.lineSize(part); }
|
|
59
60
|
/** The line covering message `at`. The lines tile the log in order, so a binary search finds it. */
|
|
60
61
|
covering(at: number): Part | undefined {
|
|
@@ -66,17 +67,17 @@ class Sawtooth {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
/** A message got its node. Its line is usually still shown, but after a damaged tree file a saved parent can already hide it. */
|
|
69
|
-
built(i: number) { if (this.covering(i)?.l === 0) this.measured += this.
|
|
70
|
+
built(i: number) { if (this.covering(i)?.l === 0) this.measured += this.node({ l: 0, i })!.size - UNBUILT_BYTES; }
|
|
70
71
|
/** Returns whether any lines merged. */
|
|
71
72
|
fit(total: number, force = false) {
|
|
72
73
|
const lines = this.parts.length;
|
|
73
|
-
if (force || this.
|
|
74
|
-
for (let best: number; this.merging && this.
|
|
74
|
+
if (force || this.size > this.high) this.merging = true;
|
|
75
|
+
for (let best: number; this.merging && this.size > this.low && (best = mostDue(this.parts, total, part => !!this.node(part))) >= 0;) {
|
|
75
76
|
const a = this.parts[best], b = this.parts[best + 1], parent = { l: a.l + 1, i: a.i / 2 };
|
|
76
77
|
this.measured += this.lineSize(parent) - this.lineSize(a) - this.lineSize(b);
|
|
77
78
|
this.parts.splice(best, 2, parent);
|
|
78
79
|
}
|
|
79
|
-
if (this.
|
|
80
|
+
if (this.size <= this.low) this.merging = false;
|
|
80
81
|
return this.parts.length < lines;
|
|
81
82
|
}
|
|
82
83
|
}
|
|
@@ -129,7 +130,7 @@ export class Memory {
|
|
|
129
130
|
return new Memory({ directory, store, saved: await store.load() }, compress, warn, budget, jobs);
|
|
130
131
|
}
|
|
131
132
|
/** A directory alone opens a FileStore there. `jobs`: summaries built at once, and how many unbuilt lines a new message's node
|
|
132
|
-
* waits behind. Chat
|
|
133
|
+
* waits behind. Chat and imports pass the "Summaries at once" setting. */
|
|
133
134
|
constructor(source: string | { directory: string; store: Store; saved: Saved }, private readonly compress: Compressor,
|
|
134
135
|
private readonly warn: (s: string) => void = console.error,
|
|
135
136
|
readonly budget = VIEW, private readonly jobs = 8, private readonly retryMs = 10_000) {
|
|
@@ -187,6 +188,8 @@ export class Memory {
|
|
|
187
188
|
const rebuild = () => { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; };
|
|
188
189
|
let saved: unknown;
|
|
189
190
|
try { saved = JSON.parse(view); } catch { return rebuild(); }
|
|
191
|
+
const merging = object(saved) && saved.merging === true;
|
|
192
|
+
if (object(saved) && typeof saved.merging === 'boolean') saved = saved.parts;
|
|
190
193
|
if (!Array.isArray(saved)) return rebuild();
|
|
191
194
|
const parts: Part[] = [];
|
|
192
195
|
let covered = 0;
|
|
@@ -198,13 +201,14 @@ export class Memory {
|
|
|
198
201
|
parts.push(part); covered = end(part);
|
|
199
202
|
}
|
|
200
203
|
for (const part of parts) this.chat.push(part);
|
|
204
|
+
this.chat.merging = merging;
|
|
201
205
|
return covered;
|
|
202
206
|
}
|
|
203
|
-
/** Saved when it merges: a view that only grew is the saved one plus
|
|
207
|
+
/** Saved when it merges or changes batch state: a view that only grew is the saved one plus later messages, as `load` replays it.
|
|
204
208
|
* The log stays authoritative, so a failed save only warns. */
|
|
205
209
|
private save() {
|
|
206
210
|
const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
|
|
207
|
-
try { void this.enqueue(() => this.store.saveView(JSON.stringify(this.view.map(p => [p.l, p.i]))), failed); }
|
|
211
|
+
try { void this.enqueue(() => this.store.saveView(JSON.stringify({ parts: this.view.map(p => [p.l, p.i]), merging: this.chat.merging })), failed); }
|
|
208
212
|
catch (error) { failed(error); }
|
|
209
213
|
}
|
|
210
214
|
private push(i: number) {
|
|
@@ -227,14 +231,15 @@ export class Memory {
|
|
|
227
231
|
onChange(listener: () => void) { this.events.on('change', listener); return () => { this.events.off('change', listener); }; }
|
|
228
232
|
/** The view line covering message `at`. */
|
|
229
233
|
covering(at: number) { return this.chat.covering(at); }
|
|
230
|
-
/** Returns whether the chat's view
|
|
234
|
+
/** Returns whether the chat's view or batch state changed. */
|
|
231
235
|
private fit(total = this.root.length) {
|
|
236
|
+
const merging = this.chat.merging;
|
|
232
237
|
const merged = this.chat.fit(total);
|
|
233
238
|
this.compaction.fit(total, merged);
|
|
234
239
|
const owed = this.owed();
|
|
235
240
|
this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
|
|
236
241
|
this.events.emit('change');
|
|
237
|
-
return merged;
|
|
242
|
+
return merged || merging !== this.chat.merging;
|
|
238
243
|
}
|
|
239
244
|
private schedule() {
|
|
240
245
|
if (this.scheduled || this.stopped) return;
|
package/src/prompts.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NODE, start, type Part } from './memory.ts';
|
|
2
2
|
|
|
3
|
-
// Prompts from Victor Taelin's OptChat recipe, lightly adapted;
|
|
3
|
+
// Prompts from Victor Taelin's OptChat recipe, lightly adapted; docs/recipe-differences.md lists the changes.
|
|
4
4
|
export const COMPACT = `You are OptChat, an AI agent that works for one user in a single chat that never ends.
|
|
5
5
|
Each call to you is a turn or a compaction:
|
|
6
6
|
the view below is followed by the user's new message, or by a task starting "Compaction:".
|
|
@@ -70,6 +70,8 @@ further along than it was, and never narrate the process of summarizing
|
|
|
70
70
|
("output unseen", "read-only", "unconfirmed") unless that uncertainty is itself
|
|
71
71
|
a fact OptChat must know. If told the line is too long, shorten it. Non-ASCII
|
|
72
72
|
characters cost 2-4 bytes.`;
|
|
73
|
+
/** Spawn tasks may name a model; the ids the model knows from training drift, so the models tool is the source. */
|
|
74
|
+
export const MODELS_HINT = 'When the user names a model family without a version ("Opus", "GPT"), assume the newest one and call models() for its exact id: your training may not know it.';
|
|
73
75
|
/** Labels the line naming running agents, last in every main-agent request while any run (src/transcript.ts); never stored. */
|
|
74
76
|
export const AT_WORK = '[OptChat status] Agents at work now: ';
|
|
75
77
|
export const MASTER = `You are OptChat, an AI agent that works for one user in a single chat that
|
|
@@ -79,6 +81,7 @@ message.
|
|
|
79
81
|
Do the user's tasks yourself (or delegate to subagents according to the tasks and user preferences), with your tools, following the user's instructions
|
|
80
82
|
at the end of this prompt: who they are, how their files are organized and how
|
|
81
83
|
they want work done. Use subagents when it makes sense and when the user asks for them.
|
|
84
|
+
${MODELS_HINT}
|
|
82
85
|
|
|
83
86
|
Messages the user sends while you work reach you between tool calls. Subagents
|
|
84
87
|
and computer tasks run in the background; each one's report reaches you as a
|
|
@@ -150,7 +153,7 @@ export const IMPORT_GUIDANCE = `Entries marked Historical are imported records,
|
|
|
150
153
|
|
|
151
154
|
// Subagent instructions, joined after the user's own (AGENTS.md etc.): one of the two delegation paragraphs,
|
|
152
155
|
// then STEERABLE, then the connected or plain tell_parent paragraph.
|
|
153
|
-
export const delegation = (maxAgents: number) => `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total
|
|
156
|
+
export const delegation = (maxAgents: number) => `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total. ${MODELS_HINT}`;
|
|
154
157
|
export const NO_DELEGATION = 'You are at the maximum delegation depth. Complete your task with your own tools.';
|
|
155
158
|
/** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
|
|
156
159
|
export const STEERABLE = 'Never block in a single command for more than about 60 seconds. To wait for something, poll in short separate tool calls (for example one `sleep 30` per call), so messages from your parent or the user can reach you between calls.';
|
package/src/settings.ts
CHANGED
|
@@ -23,9 +23,9 @@ export const SETTINGS = {
|
|
|
23
23
|
summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
|
|
24
24
|
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.`,
|
|
25
25
|
applies: 'Applies to the next summary.' },
|
|
26
|
-
|
|
27
|
-
description: 'Summaries
|
|
28
|
-
applies: 'Applies to the next import.' },
|
|
26
|
+
jobs: { kind: 'number', label: 'Summaries at once', default: 8, min: 1, max: 64, unit: '',
|
|
27
|
+
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.',
|
|
28
|
+
applies: 'Applies to the next import, and to chat once the profile opens again (e.g. after /reload).' },
|
|
29
29
|
refreshAt: { kind: 'percent', label: 'Turn refresh threshold', default: 0.3, min: 0, max: 0.9,
|
|
30
30
|
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.',
|
|
31
31
|
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) {
|
|
|
54
54
|
|
|
55
55
|
/** Missing keys take their defaults, so config files written before a setting existed keep working. */
|
|
56
56
|
export function readSettings(value: Record<string, unknown>): Settings {
|
|
57
|
+
// "Summaries at once" was `importJobs` while it only covered imports; an older config.json keeps its value.
|
|
58
|
+
if (!Object.hasOwn(value, 'jobs') && Object.hasOwn(value, 'importJobs')) value = { ...value, jobs: value.importJobs };
|
|
57
59
|
return Object.fromEntries(SETTING_KEYS.map(key => {
|
|
58
60
|
const raw = Object.hasOwn(value, key) ? value[key] : SETTINGS[key].default, problem = invalid(key, raw);
|
|
59
61
|
if (problem) throw new Error(problem);
|