pi-optchat 0.6.7 → 0.7.0
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 +46 -20
- package/docs/victor-recipe.md +5 -4
- package/package.json +4 -3
- package/src/agents.ts +88 -41
- package/src/cache.ts +12 -22
- package/src/checkpoint.ts +2 -4
- package/src/compactor.ts +21 -10
- package/src/handoff.ts +2 -2
- package/src/import/job.ts +26 -11
- package/src/import/multi-select.ts +3 -3
- package/src/import/sources.ts +75 -21
- package/src/import/ui.ts +1 -1
- package/src/index.ts +76 -36
- package/src/memory.ts +61 -34
- package/src/profiles.ts +33 -14
- package/src/prompts.ts +1 -1
- package/src/report-message.ts +8 -4
- package/src/runs.ts +14 -2
- package/src/settings-page.ts +164 -0
- package/src/settings.ts +48 -0
- package/src/tools.ts +28 -1
- package/src/transcript.ts +15 -4
- package/src/usage.ts +6 -6
- package/src/window-bridge.ts +4 -4
package/src/report-message.ts
CHANGED
|
@@ -4,8 +4,12 @@ import { REPORT_TYPE, textContent } from './transcript.ts';
|
|
|
4
4
|
|
|
5
5
|
const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
|
|
6
6
|
|
|
7
|
+
/** Sent with a report: `count` subagent reports joined in one message, when one spawn's reports arrive together. */
|
|
8
|
+
export interface ReportDetails { count?: number }
|
|
9
|
+
|
|
7
10
|
/** Split "[id] Message from subagent (still running): body" into a short label and the body. */
|
|
8
|
-
export function reportParts(text: string) {
|
|
11
|
+
export function reportParts(text: string, count = 1) {
|
|
12
|
+
if (count > 1) return { label: `${count} subagent reports`, body: text };
|
|
9
13
|
const match = HEAD.exec(text);
|
|
10
14
|
if (!match) return { label: 'subagent', body: text };
|
|
11
15
|
const kind = match[2] === undefined ? 'report' : match[2].startsWith('Message') ? 'still running' : 'connected window';
|
|
@@ -21,8 +25,8 @@ function darkBackground(theme: Theme) {
|
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/** Background traffic: a dark neutral box with dim text, so it never looks like something the user typed. */
|
|
24
|
-
export function reportBox(text: string, outputPad: number, theme: Theme) {
|
|
25
|
-
const { label, body } = reportParts(text);
|
|
28
|
+
export function reportBox(text: string, outputPad: number, theme: Theme, count?: number) {
|
|
29
|
+
const { label, body } = reportParts(text, count);
|
|
26
30
|
const box = new Box(outputPad, 1, darkBackground(theme));
|
|
27
31
|
box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
|
|
28
32
|
box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
|
|
@@ -30,5 +34,5 @@ export function reportBox(text: string, outputPad: number, theme: Theme) {
|
|
|
30
34
|
}
|
|
31
35
|
export const isReport = (text: string) => HEAD.test(text);
|
|
32
36
|
export function registerReportRenderer(pi: ExtensionAPI) {
|
|
33
|
-
pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => reportBox(textContent(message.content), outputPad, theme));
|
|
37
|
+
pi.registerMessageRenderer<ReportDetails | undefined>(REPORT_TYPE, (message, { outputPad }, theme) => reportBox(textContent(message.content), outputPad, theme, message.details?.count));
|
|
34
38
|
}
|
package/src/runs.ts
CHANGED
|
@@ -19,6 +19,18 @@ export interface RunInfo {
|
|
|
19
19
|
/** `from` is missing on runs saved before senders were recorded. */
|
|
20
20
|
guidance: { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager' }[];
|
|
21
21
|
}
|
|
22
|
+
const moves: Record<RunState, readonly RunState[]> = {
|
|
23
|
+
running: ['waiting', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
|
|
24
|
+
waiting: ['running', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
|
|
25
|
+
stopping: ['stopped', 'failed', 'interrupted'],
|
|
26
|
+
// A finished run only takes the outcome of a connected handoff.
|
|
27
|
+
completed: ['interrupted'], failed: ['completed', 'interrupted'], stopped: ['completed', 'interrupted'], interrupted: ['completed'],
|
|
28
|
+
};
|
|
29
|
+
/** Every run state change goes through here, so a late stop cannot rewrite a finished run. Returns whether the run is now in `to` (true for a repeat, so a second stop can still abort a child that ignored the first). */
|
|
30
|
+
export function transition(run: RunInfo, to: RunState) {
|
|
31
|
+
if (run.state !== to && !moves[run.state].includes(to)) return false;
|
|
32
|
+
run.state = to; return true;
|
|
33
|
+
}
|
|
22
34
|
export const isActiveRun = (run: RunInfo) => run.state === 'running' || run.state === 'waiting' || run.state === 'stopping';
|
|
23
35
|
function isRun(value: unknown): value is RunInfo {
|
|
24
36
|
return record(value) && ['id', 'task', 'cwd', 'model', 'thinking', 'parentSession'].every(k => typeof value[k] === 'string')
|
|
@@ -31,7 +43,7 @@ function isRun(value: unknown): value is RunInfo {
|
|
|
31
43
|
&& (value.handoff === undefined || record(value.handoff) && ['complete', 'disconnected', 'owner-stopped', 'failed'].includes(String(value.handoff.reason))
|
|
32
44
|
&& (value.handoff.text === undefined || typeof value.handoff.text === 'string')
|
|
33
45
|
&& (value.handoff.delivered === undefined || typeof value.handoff.delivered === 'boolean'))
|
|
34
|
-
&& typeof value.depth === 'number' && Number.isInteger(value.depth) && value.depth >= 1
|
|
46
|
+
&& typeof value.depth === 'number' && Number.isInteger(value.depth) && value.depth >= 1
|
|
35
47
|
&& (value.parentId === undefined || typeof value.parentId === 'string')
|
|
36
48
|
&& Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)) && (g.from === undefined || g.from === 'user' || g.from === 'manager'));
|
|
37
49
|
}
|
|
@@ -57,7 +69,7 @@ export class RunHistory {
|
|
|
57
69
|
this.records.set(run.id, run);
|
|
58
70
|
if (isActiveRun(run)) {
|
|
59
71
|
if (run.connected) run.handoff ??= { reason: 'owner-stopped' };
|
|
60
|
-
run
|
|
72
|
+
transition(run, 'interrupted'); run.ended = Date.now();
|
|
61
73
|
run.report = 'Pi closed before this agent finished. Its partial transcript is retained.';
|
|
62
74
|
for (const g of run.guidance) if (g.state === 'queued') g.state = 'undelivered';
|
|
63
75
|
this.save(run);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Container, fuzzyFilter, getKeybindings, Input, SelectList, SettingsList, Spacer, Text, type Component, type SelectItem, type SettingItem } from '@earendil-works/pi-tui';
|
|
2
|
+
import { DynamicBorder, type ExtensionContext, type Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { defaults, THINKING, type ProfileConfig } from './profiles.ts';
|
|
4
|
+
import { invalid, isNumberKey, SETTING_KEYS, SETTINGS, type NumberKey, type SettingKey } from './settings.ts';
|
|
5
|
+
import type { ModelChoice } from './compactor.ts';
|
|
6
|
+
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
7
|
+
|
|
8
|
+
type Role = 'compactor' | 'subagent';
|
|
9
|
+
const ROLES: Record<Role, { label: string; description: string; applies: string }> = {
|
|
10
|
+
compactor: { label: 'Compactor model', description: 'Writes memory summaries, imports and connected-window handoffs. Shortcut: /optchat model.', applies: 'Applies to the next summary.' },
|
|
11
|
+
subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
|
|
12
|
+
};
|
|
13
|
+
const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
|
|
14
|
+
const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
|
|
15
|
+
/** The value column: the model, then whether it is the default (shown short, without its provider). */
|
|
16
|
+
const modelValue = (theme: Theme, role: Role, choice: ModelChoice) => `${showModel(choice)}${theme.fg('dim', showModel(choice) === showModel(defaults[role])
|
|
17
|
+
? ' default' : ` default ${defaults[role].model} · ${defaults[role].thinking}`)}`;
|
|
18
|
+
const show = (key: SettingKey, value: number | boolean) => {
|
|
19
|
+
const spec = SETTINGS[key];
|
|
20
|
+
return typeof value === 'boolean' ? value ? 'on' : 'off' : spec.kind === 'number' && spec.unit ? `${value} ${spec.unit}` : String(value);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface Options {
|
|
24
|
+
profile: string; config: ProfileConfig;
|
|
25
|
+
/** Each model with the thinking levels Pi can send it (Sonnet 5.5 has no "off"). */
|
|
26
|
+
models: { name: string; thinking: readonly ThinkingLevel[] }[];
|
|
27
|
+
/** Saves the whole profile config; throws if it can't be written. */
|
|
28
|
+
save: (config: ProfileConfig) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A titled step inside the page: what it changes, the control, and its keys. */
|
|
32
|
+
function step(theme: Theme, title: string, description: string, control: Component, hint: string, extra: Component[] = []) {
|
|
33
|
+
const box = new Container();
|
|
34
|
+
for (const child of [new Text(theme.bold(theme.fg('accent', title)), 0, 0), new Spacer(1), new Text(theme.fg('muted', description), 0, 0),
|
|
35
|
+
new Spacer(1), control, ...extra, new Spacer(1), new Text(theme.fg('dim', hint), 0, 0)]) box.addChild(child);
|
|
36
|
+
return box;
|
|
37
|
+
}
|
|
38
|
+
const listTheme = (theme: Theme) => ({ selectedPrefix: (t: string) => theme.fg('accent', t), selectedText: (t: string) => theme.fg('accent', t),
|
|
39
|
+
description: (t: string) => theme.fg('muted', t), scrollInfo: (t: string) => theme.fg('muted', t),
|
|
40
|
+
noMatch: () => theme.fg('muted', ' No matching models. Pi lists the models you are logged in to.') });
|
|
41
|
+
|
|
42
|
+
/** Whole numbers only: typing replaces the value, Enter saves it, a bad value stays on screen with the reason. */
|
|
43
|
+
class NumberStep extends Container {
|
|
44
|
+
private readonly input: Input;
|
|
45
|
+
private readonly error = new Text('', 0, 0);
|
|
46
|
+
constructor(theme: Theme, key: NumberKey, current: number, submit: (value: number) => string | undefined, cancel: () => void) {
|
|
47
|
+
super();
|
|
48
|
+
const spec = SETTINGS[key];
|
|
49
|
+
// Empty with the current value as placeholder: typing starts a new value, Enter alone keeps the old one.
|
|
50
|
+
this.input = new Input({ placeholder: String(current), placeholderStyle: text => theme.fg('dim', text) }); this.input.focused = true;
|
|
51
|
+
this.input.onEscape = cancel;
|
|
52
|
+
this.input.onSubmit = text => {
|
|
53
|
+
if (!text.trim()) return cancel();
|
|
54
|
+
const value = /^\s*\d+\s*$/.test(text) ? Number(text) : NaN;
|
|
55
|
+
const problem = invalid(key, value) ?? submit(value);
|
|
56
|
+
if (problem) this.error.setText(theme.fg('error', problem));
|
|
57
|
+
};
|
|
58
|
+
this.addChild(step(theme, `${spec.label}${spec.unit ? ` (${spec.unit})` : ''}`, `${spec.description} Default ${show(key, spec.default)}.`,
|
|
59
|
+
this.input, 'Type a number · Enter to save · Esc to go back', [this.error]));
|
|
60
|
+
}
|
|
61
|
+
handleInput(data: string) { this.input.handleInput(data); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Model first (type to filter), then thinking level; Esc goes back a step. */
|
|
65
|
+
class ModelStep extends Container {
|
|
66
|
+
private active: { handleInput(data: string): void } = { handleInput: () => {} };
|
|
67
|
+
constructor(private readonly theme: Theme, private readonly role: Role, private readonly models: Options['models'], private readonly current: ModelChoice,
|
|
68
|
+
private readonly submit: (choice: ModelChoice) => string | undefined, private readonly cancel: () => void) {
|
|
69
|
+
super();
|
|
70
|
+
this.pickModel();
|
|
71
|
+
}
|
|
72
|
+
private pickModel() {
|
|
73
|
+
const { theme, current } = this, filter = new Input();
|
|
74
|
+
const items: SelectItem[] = this.models.map(({ name }) => ({ value: name, label: name }));
|
|
75
|
+
let list = new SelectList(items, 10, listTheme(theme));
|
|
76
|
+
const build = () => {
|
|
77
|
+
list = new SelectList(filter.getValue() ? fuzzyFilter(items, filter.getValue(), i => i.value) : items, 10, listTheme(theme));
|
|
78
|
+
list.onSelect = item => this.pickThinking(item.value); list.onCancel = this.cancel;
|
|
79
|
+
return list;
|
|
80
|
+
};
|
|
81
|
+
build(); list.setSelectedIndex(Math.max(0, this.models.findIndex(m => m.name === modelName(current))));
|
|
82
|
+
filter.focused = true;
|
|
83
|
+
const box = step(theme, ROLES[this.role].label, `${ROLES[this.role].description} Now ${showModel(current)}.`, filter, 'Type to filter · Enter to choose · Esc to go back', [new Spacer(1), list]);
|
|
84
|
+
this.show(box, data => {
|
|
85
|
+
const keys = getKeybindings();
|
|
86
|
+
if ((['tui.select.up', 'tui.select.down', 'tui.select.confirm', 'tui.select.cancel'] as const).some(k => keys.matches(data, k))) return list.handleInput(data);
|
|
87
|
+
filter.handleInput(data);
|
|
88
|
+
const index = box.children.indexOf(list); box.children[index] = build();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
private pickThinking(model: string) {
|
|
92
|
+
const { theme, current } = this, error = new Text('', 0, 0);
|
|
93
|
+
const levels = this.models.find(m => m.name === model)?.thinking ?? THINKING;
|
|
94
|
+
const list = new SelectList(levels.map(value => ({ value, label: value })), levels.length, listTheme(theme));
|
|
95
|
+
list.setSelectedIndex(Math.max(0, levels.indexOf(current.thinking)));
|
|
96
|
+
list.onCancel = () => this.pickModel();
|
|
97
|
+
list.onSelect = item => {
|
|
98
|
+
const thinking = levels.find(level => level === item.value);
|
|
99
|
+
const slash = model.indexOf('/');
|
|
100
|
+
const problem = thinking && this.submit({ provider: model.slice(0, slash), model: model.slice(slash + 1), thinking });
|
|
101
|
+
if (problem) error.setText(theme.fg('error', problem));
|
|
102
|
+
};
|
|
103
|
+
this.show(step(theme, 'Thinking level', `For ${model}.`, list, 'Enter to save · Esc to go back', [error]), data => list.handleInput(data));
|
|
104
|
+
}
|
|
105
|
+
private show(box: Component, input: (data: string) => void) { this.clear(); this.addChild(box); this.active = { handleInput: input }; }
|
|
106
|
+
handleInput(data: string) { this.active.handleInput(data); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The page itself: Pi's settings list over this profile's config, one row per setting, saved on change. */
|
|
110
|
+
export function settingsPage(theme: Theme, o: Options, close: () => void, redraw = () => {}) {
|
|
111
|
+
const { config } = o;
|
|
112
|
+
const notice = new Text('', 2, 0);
|
|
113
|
+
const marker = (same: boolean, fallback: string) => theme.fg('dim', same ? ' default' : ` default ${fallback}`);
|
|
114
|
+
const value = (key: SettingKey, current: number | boolean) => `${show(key, current)}${marker(current === SETTINGS[key].default, show(key, SETTINGS[key].default))}`;
|
|
115
|
+
const apply = (patch: Partial<ProfileConfig>, what: string, applies: string) => {
|
|
116
|
+
try { o.save({ ...config, ...patch }); }
|
|
117
|
+
catch (error) { return `Could not save: ${error instanceof Error ? error.message : String(error)}`; }
|
|
118
|
+
Object.assign(config, patch);
|
|
119
|
+
notice.setText(theme.fg('success', `Saved ${what}. `) + theme.fg('muted', applies));
|
|
120
|
+
return undefined;
|
|
121
|
+
};
|
|
122
|
+
const models = (['compactor', 'subagent'] as const).map((role): SettingItem => {
|
|
123
|
+
const { label, description, applies } = ROLES[role];
|
|
124
|
+
return { id: role, label, description: `${description} Default ${showModel(defaults[role])}. ${applies}`,
|
|
125
|
+
currentValue: modelValue(theme, role, config[role]),
|
|
126
|
+
submenu: (_value, done) => new ModelStep(theme, role, o.models, config[role], choice => {
|
|
127
|
+
const problem = apply({ [role]: choice }, `${label.toLowerCase()} ${showModel(choice)}`, applies);
|
|
128
|
+
if (!problem) done(modelValue(theme, role, choice));
|
|
129
|
+
return problem;
|
|
130
|
+
}, () => done()),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
const settings = SETTING_KEYS.map((key): SettingItem => {
|
|
134
|
+
const spec = SETTINGS[key], base = { id: key, label: spec.label, description: `${spec.description} ${spec.applies}` };
|
|
135
|
+
if (isNumberKey(key)) return { ...base, currentValue: value(key, config[key]),
|
|
136
|
+
submenu: (_value, done) => new NumberStep(theme, key, config[key], next => {
|
|
137
|
+
const problem = apply({ [key]: next }, `${spec.label.toLowerCase()} ${show(key, next)}`, spec.applies);
|
|
138
|
+
if (!problem) done(value(key, next));
|
|
139
|
+
return problem;
|
|
140
|
+
}, () => done()) };
|
|
141
|
+
return { ...base, currentValue: value(key, config[key]), values: [value(key, true), value(key, false)] };
|
|
142
|
+
});
|
|
143
|
+
const list = new SettingsList([...models, ...settings], 10, {
|
|
144
|
+
label: (text, selected) => selected ? theme.fg('accent', text) : text,
|
|
145
|
+
value: (text, selected) => selected ? theme.fg('accent', text) : theme.fg('muted', text),
|
|
146
|
+
description: text => theme.fg('dim', text), cursor: theme.fg('accent', '→ '), hint: text => theme.fg('dim', text),
|
|
147
|
+
}, (id, next) => {
|
|
148
|
+
// Only the on/off rows change here; the others save from their own step.
|
|
149
|
+
const key = SETTING_KEYS.find(k => k === id);
|
|
150
|
+
if (!key || isNumberKey(key)) return;
|
|
151
|
+
const on = next === value(key, true);
|
|
152
|
+
const problem = apply({ [key]: on }, `${SETTINGS[key].label.toLowerCase()} ${on ? 'on' : 'off'}`, SETTINGS[key].applies);
|
|
153
|
+
if (problem) { list.updateValue(key, value(key, config[key])); notice.setText(theme.fg('error', problem)); }
|
|
154
|
+
}, close);
|
|
155
|
+
const page = new Container();
|
|
156
|
+
for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', 'OptChat settings'))}${theme.fg('muted', ` · ${o.profile}`)}`, 1, 0),
|
|
157
|
+
new Text(theme.fg('dim', 'Saved as you change them · Victor\'s recipe by default, except Previous exchange and Summary size tolerance'), 1, 0),
|
|
158
|
+
new Spacer(1), list, notice, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
|
|
159
|
+
return Object.assign(page, { handleInput: (data: string) => { notice.setText(''); list.handleInput(data); redraw(); } });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function showSettings(ctx: ExtensionContext, o: Options) {
|
|
163
|
+
return ctx.ui.custom<void>((tui, theme, _keys, done) => settingsPage(theme, o, () => done(undefined), () => tui.requestRender()));
|
|
164
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { NODE } from './memory.ts';
|
|
2
|
+
|
|
3
|
+
/** Per-profile choices where OptChat departs from Victor's recipe. Each default is the recipe's behaviour unless noted. */
|
|
4
|
+
export const SETTINGS = {
|
|
5
|
+
subagentLevels: { kind: 'number', label: 'Subagent levels', default: 1, min: 1, unit: '',
|
|
6
|
+
description: 'How many levels of subagents. 1: only the main agent starts subagents, as in Victor\'s recipe. 2: those subagents may start their own, and so on.',
|
|
7
|
+
applies: 'Applies to subagents started or resumed after this.' },
|
|
8
|
+
maxAgents: { kind: 'number', label: 'Max active agents', default: 8, min: 1, unit: '',
|
|
9
|
+
description: 'Subagents running at once in this profile, all levels together. It also caps how deep a chain can go.',
|
|
10
|
+
applies: 'Applies to the next spawn.' },
|
|
11
|
+
groupReports: { kind: 'toggle', label: 'Group subagent reports', default: true,
|
|
12
|
+
description: 'On: the subagents started by one spawn report together, in one message once the last of them finishes, as in Victor\'s recipe. Off: each subagent reports as soon as it finishes.',
|
|
13
|
+
applies: 'Applies to the next spawn.' },
|
|
14
|
+
previousExchange: { kind: 'toggle', label: 'Previous exchange', default: true,
|
|
15
|
+
description: 'Send your last request and its answer in full with the next turn, so a follow-up reads the exact wording. Off: memory view only, as in Victor\'s recipe. On by default: a study found it costs little and saves zooms.',
|
|
16
|
+
applies: 'Applies from the next turn.' },
|
|
17
|
+
previousExchangeKB: { kind: 'number', label: 'Previous exchange limit', default: 16, min: 1, unit: 'KB',
|
|
18
|
+
description: 'A larger last exchange, usually a big paste, is left out and the model zooms into memory instead.',
|
|
19
|
+
applies: 'Applies from the next turn.' },
|
|
20
|
+
memorySearch: { kind: 'toggle', label: 'Memory search', default: false,
|
|
21
|
+
description: 'Give agents a search tool that finds original messages by plain text, newest first, for exact names, numbers, paths or errors the view doesn\'t show. Off: zoom and date only, as in Victor\'s recipe. Turning it on or off makes the next turn re-cache its prompt once.',
|
|
22
|
+
applies: 'Applies from the next turn, and to subagents started or resumed after this.' },
|
|
23
|
+
summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
|
|
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
|
+
applies: 'Applies to the next summary.' },
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type SettingKey = keyof typeof SETTINGS;
|
|
29
|
+
export type Settings = { -readonly [K in SettingKey]: (typeof SETTINGS)[K]['default'] extends boolean ? boolean : number };
|
|
30
|
+
export type NumberKey = { [K in SettingKey]: Settings[K] extends number ? K : never }[SettingKey];
|
|
31
|
+
export const SETTING_KEYS = Object.keys(SETTINGS) as SettingKey[];
|
|
32
|
+
export const isNumberKey = (key: SettingKey): key is NumberKey => SETTINGS[key].kind === 'number';
|
|
33
|
+
|
|
34
|
+
/** Why a value can't be used, or undefined if it can. Numbers are whole and at least their minimum. */
|
|
35
|
+
export function invalid(key: SettingKey, value: unknown) {
|
|
36
|
+
if (isNumberKey(key)) { const { label, min } = SETTINGS[key]; return Number.isSafeInteger(value) && Number(value) >= min ? undefined : `${label} must be a whole number of ${min} or more.`; }
|
|
37
|
+
return typeof value === 'boolean' ? undefined : `${SETTINGS[key].label} must be true or false.`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Missing keys take their defaults, so config files written before a setting existed keep working. */
|
|
41
|
+
export function readSettings(value: Record<string, unknown>): Settings {
|
|
42
|
+
return Object.fromEntries(SETTING_KEYS.map(key => {
|
|
43
|
+
const raw = Object.hasOwn(value, key) ? value[key] : SETTINGS[key].default, problem = invalid(key, raw);
|
|
44
|
+
if (problem) throw new Error(problem);
|
|
45
|
+
return [key, raw];
|
|
46
|
+
})) as Settings;
|
|
47
|
+
}
|
|
48
|
+
export const DEFAULT_SETTINGS = readSettings({});
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import { Type } from 'typebox';
|
|
2
|
-
import type
|
|
2
|
+
import { flat, start, type Memory } from './memory.ts';
|
|
3
3
|
export const result = (text: string) => ({ content: [{ type: 'text' as const, text }], details: {} });
|
|
4
|
+
export const SEARCH_PAGE = 20;
|
|
5
|
+
const SNIPPET = 200;
|
|
6
|
+
/** Added after the view doc when Memory search is on. */
|
|
7
|
+
export const SEARCH_DOC = '\n\nsearch(text) finds the original messages that contain text, newest first. Use it for an exact name, number, PR, path or error the view doesn\'t show, then zoom(id, 1) to read a hit. A hit is one message: zoom around it too, especially the messages after it, where the outcome usually is.';
|
|
8
|
+
|
|
9
|
+
/** One page of hits, each with its id, the view line holding it, its date and a snippet around the first match; at most ~5 KB. */
|
|
10
|
+
export function searchPage(memory: Memory, text: string, before?: number) {
|
|
11
|
+
const hits = memory.search(text, before), older = before === undefined ? '' : 'older ';
|
|
12
|
+
if (!hits.length) return `No ${older}messages contain "${text}".`;
|
|
13
|
+
const page = hits.slice(0, SEARCH_PAGE), needle = text.toLowerCase();
|
|
14
|
+
const lines = page.map(entry => {
|
|
15
|
+
// Cut the original text, whose match may span lines, and flatten only the cut.
|
|
16
|
+
const at = Math.max(0, entry.text.toLowerCase().indexOf(needle) - SNIPPET / 4);
|
|
17
|
+
const snippet = flat(entry.text.slice(at, at + SNIPPET).replace(/^[\udc00-\udfff]|[\ud800-\udbff]$/g, ''));
|
|
18
|
+
// The live view, which may have folded since the turn's snapshot: its lines are built, so zoom always opens them.
|
|
19
|
+
const line = memory.covering(entry.i); // Named only when the hit is inside a summary line.
|
|
20
|
+
return `${entry.i}${line?.l ? ` (in ${start(line)}+${2 ** line.l})` : ''} · ${new Date(entry.date).toString().slice(0, 21)} · ${entry.kind}: ${at ? '…' : ''}${snippet}${at + SNIPPET < entry.text.length ? '…' : ''}`;
|
|
21
|
+
});
|
|
22
|
+
const more = hits.length > page.length ? `\nOlder matches: search again with before: ${page[page.length - 1].i}.` : '';
|
|
23
|
+
return `${hits.length} ${older}${hits.length === 1 ? 'message contains' : 'messages contain'} "${text}", newest first:\n${lines.join('\n')}${more}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
4
26
|
export function memoryTools(memory: () => Memory) {
|
|
5
27
|
return [
|
|
6
28
|
{ name: 'zoom', label: 'Zoom memory', description: 'Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole.',
|
|
@@ -11,3 +33,8 @@ export function memoryTools(memory: () => Memory) {
|
|
|
11
33
|
async execute(_id: string, args: { id: number }) { return result(memory().date(args.id)); } },
|
|
12
34
|
];
|
|
13
35
|
}
|
|
36
|
+
|
|
37
|
+
export const searchTool = (memory: () => Memory) => ({ name: 'search', label: 'Search memory',
|
|
38
|
+
description: `Find the original messages that contain text (plain text, any case), newest first, ${SEARCH_PAGE} at a time; before: id continues with older ones. zoom(id, 1) gives a hit whole.`,
|
|
39
|
+
parameters: Type.Object({ text: Type.String({ minLength: 1 }), before: Type.Optional(Type.Integer({ minimum: 0 })) }),
|
|
40
|
+
async execute(_id: string, args: { text: string; before?: number }) { return result(searchPage(memory(), args.text, args.before)); } });
|
package/src/transcript.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
2
2
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import { getCurrentSystemMessage, type SystemMessage, type UserMessage } from '@earendil-works/pi-ai';
|
|
4
|
-
import { cap, type Memory } from './memory.ts';
|
|
4
|
+
import { CAP, cap, type Memory } from './memory.ts';
|
|
5
5
|
import { record } from './cache.ts';
|
|
6
|
+
import { DEFAULT_SETTINGS } from './settings.ts';
|
|
6
7
|
|
|
7
8
|
export const RUN_BOUNDARY = 'optchat.run';
|
|
8
9
|
/** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
|
|
@@ -44,7 +45,7 @@ export function logMessage(memory: Memory, message: AgentMessage, receipt?: stri
|
|
|
44
45
|
export function boundedMessage(message: AgentMessage): AgentMessage {
|
|
45
46
|
if (message.role !== 'toolResult') return message;
|
|
46
47
|
const text = message.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
47
|
-
if (text.length <=
|
|
48
|
+
if (text.length <= CAP) return message;
|
|
48
49
|
return { ...message, content: [{ type: 'text', text: cap(text) }, ...message.content.filter(c => c.type === 'image')] };
|
|
49
50
|
}
|
|
50
51
|
/** The caller supplies a single settled run, including any steering after text-only replies. */
|
|
@@ -63,8 +64,18 @@ function completedExchange(history: readonly AgentMessage[]) {
|
|
|
63
64
|
return [...requests, { ...answer, content }];
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
67
|
+
/** Default limit in bytes of text (16 KB, ~4,000 tokens). Most exchanges are 1-7 KB; a larger one, usually a big paste, is
|
|
68
|
+
* left out entirely and the model falls back to its summaries in the memory view, zooming for the full text if it needs it. */
|
|
69
|
+
export const PREVIOUS_EXCHANGE = DEFAULT_SETTINGS.previousExchangeKB * 1000;
|
|
70
|
+
|
|
71
|
+
/** The latest successful run on this branch, skipping failed and unfinished runs; none if it is over `limit` bytes. */
|
|
72
|
+
export function previousExchange(branch: readonly SessionEntry[], limit = PREVIOUS_EXCHANGE) {
|
|
73
|
+
const exchange = latestExchange(branch);
|
|
74
|
+
const bytes = exchange.reduce((sum, message) => sum + Buffer.byteLength(textContent(message.content)), 0);
|
|
75
|
+
return bytes <= limit ? exchange : [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function latestExchange(branch: readonly SessionEntry[]) {
|
|
68
79
|
let end = -1;
|
|
69
80
|
let legacyEnd = branch.length;
|
|
70
81
|
const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' && entry.customType === REPORT_TYPE ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
|
package/src/usage.ts
CHANGED
|
@@ -36,27 +36,27 @@ export function addUsage(total: Usage, usage: Usage) {
|
|
|
36
36
|
/** One profile's ledger. Stable message IDs make resume/backfill idempotent. */
|
|
37
37
|
export class UsageLedger {
|
|
38
38
|
readonly entries: UsageEntry[] = [];
|
|
39
|
-
|
|
39
|
+
private invalid = 0;
|
|
40
40
|
private readonly ids = new Set<string>();
|
|
41
41
|
private readonly cursors = new Map<string, number>();
|
|
42
42
|
private readonly file: string;
|
|
43
43
|
constructor(directory: string) {
|
|
44
44
|
this.file = join(directory, 'usage.jsonl');
|
|
45
|
-
let invalid = 0;
|
|
46
45
|
const contents = existsSync(this.file) ? readFileSync(this.file, 'utf8') : '';
|
|
47
46
|
for (const line of contents.split('\n')) {
|
|
48
47
|
if (!line.trim()) continue;
|
|
49
48
|
try {
|
|
50
49
|
const entry: unknown = JSON.parse(line);
|
|
51
|
-
if (!validEntry(entry)) { invalid++; continue; }
|
|
50
|
+
if (!validEntry(entry)) { this.invalid++; continue; }
|
|
52
51
|
if (entry.id && this.ids.has(entry.id)) continue;
|
|
53
52
|
this.entries.push(entry); if (entry.id) this.ids.add(entry.id);
|
|
54
|
-
} catch { invalid++; }
|
|
53
|
+
} catch { this.invalid++; }
|
|
55
54
|
}
|
|
56
|
-
if (invalid) this.warnings.push(`${invalid} unreadable usage records excluded; totals may be incomplete.`);
|
|
57
55
|
if (contents && !contents.endsWith('\n')) appendFileSync(this.file, '\n');
|
|
58
56
|
}
|
|
57
|
+
get warnings() { return this.invalid ? [`${this.invalid} unreadable usage records excluded; totals may be incomplete.`] : []; }
|
|
59
58
|
add(entry: UsageEntry) {
|
|
59
|
+
if (!validEntry(entry)) { this.invalid++; return; }
|
|
60
60
|
if (entry.id && this.ids.has(entry.id)) return;
|
|
61
61
|
appendJson(this.file, entry);
|
|
62
62
|
this.entries.push(entry); if (entry.id) this.ids.add(entry.id);
|
|
@@ -86,7 +86,7 @@ export class UsageLedger {
|
|
|
86
86
|
select(range: UsageRange, session: string, now = Date.now()) {
|
|
87
87
|
const today = new Date(now); today.setHours(0, 0, 0, 0);
|
|
88
88
|
const since = range === 'Last hour' ? now - 3600_000 : range === 'Today' ? today.getTime() : range === 'Last 7 days' ? now - 7 * 86400_000 : 0;
|
|
89
|
-
return this.entries.filter(e => range === 'This session' ? e.session === session : Date.parse(e.date) >= since && Date.parse(e.date) <= now);
|
|
89
|
+
return this.entries.filter(e => range === 'This session' ? e.session === session : range === 'All time' || (Date.parse(e.date) >= since && Date.parse(e.date) <= now));
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
package/src/window-bridge.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { chmodSync
|
|
1
|
+
import { chmodSync } from 'node:fs';
|
|
2
2
|
import { createConnection, createServer, type Socket } from 'node:net';
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
4
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
5
5
|
import { record } from './cache.ts';
|
|
6
|
-
import { profileSocket } from './profiles.ts';
|
|
6
|
+
import { checkSocketPath, profileSocket, removeStaleSocket } from './profiles.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
8
8
|
import { isActiveRun } from './runs.ts';
|
|
9
9
|
|
|
@@ -83,8 +83,8 @@ function wire(socket: Socket, receive: (frame: Frame) => void) {
|
|
|
83
83
|
/** Call only while holding the profile lock. The owner hosts every child session. */
|
|
84
84
|
export async function serveWindows(directory: string, children: Children, available: () => boolean,
|
|
85
85
|
report: (text: string) => Promise<void>) {
|
|
86
|
-
const path = profileSocket(directory, 'windows');
|
|
87
|
-
|
|
86
|
+
const path = profileSocket(directory, 'windows'); checkSocketPath(path);
|
|
87
|
+
removeStaleSocket(path); // A previous owner can no longer hold the profile lock.
|
|
88
88
|
const connections = new Set<Socket>();
|
|
89
89
|
const work = new Set<Promise<void>>();
|
|
90
90
|
let closing = false;
|