pi-optchat 0.6.6 → 0.6.8
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 +34 -9
- package/docs/victor-recipe.md +2 -1
- package/package.json +3 -2
- package/src/agent-view.ts +23 -6
- package/src/agents.ts +57 -33
- package/src/cache.ts +12 -22
- package/src/checkpoint.ts +2 -4
- package/src/compactor.ts +49 -9
- package/src/import/multi-select.ts +3 -3
- package/src/import/sources.ts +30 -15
- package/src/index.ts +30 -18
- package/src/inspector.ts +41 -19
- package/src/memory.ts +38 -23
- package/src/profiles.ts +33 -14
- package/src/runs.ts +14 -2
- package/src/settings-page.ts +160 -0
- package/src/settings.ts +42 -0
- package/src/transcript.ts +15 -4
- package/src/usage.ts +11 -11
- package/src/window-bridge.ts +4 -4
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
|
|
7
|
+
type Role = 'compactor' | 'subagent';
|
|
8
|
+
const ROLES: Record<Role, { label: string; description: string; applies: string }> = {
|
|
9
|
+
compactor: { label: 'Compactor model', description: 'Writes memory summaries, imports and connected-window handoffs. Shortcut: /optchat model.', applies: 'Applies to the next summary.' },
|
|
10
|
+
subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
|
|
11
|
+
};
|
|
12
|
+
const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
|
|
13
|
+
const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
|
|
14
|
+
/** The value column: the model, then whether it is the default (shown short, without its provider). */
|
|
15
|
+
const modelValue = (theme: Theme, role: Role, choice: ModelChoice) => `${showModel(choice)}${theme.fg('dim', showModel(choice) === showModel(defaults[role])
|
|
16
|
+
? ' default' : ` default ${defaults[role].model} · ${defaults[role].thinking}`)}`;
|
|
17
|
+
const show = (key: SettingKey, value: number | boolean) => {
|
|
18
|
+
const spec = SETTINGS[key];
|
|
19
|
+
return typeof value === 'boolean' ? value ? 'on' : 'off' : spec.kind === 'number' && spec.unit ? `${value} ${spec.unit}` : String(value);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface Options {
|
|
23
|
+
profile: string; config: ProfileConfig; models: string[];
|
|
24
|
+
/** Saves the whole profile config; throws if it can't be written. */
|
|
25
|
+
save: (config: ProfileConfig) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A titled step inside the page: what it changes, the control, and its keys. */
|
|
29
|
+
function step(theme: Theme, title: string, description: string, control: Component, hint: string, extra: Component[] = []) {
|
|
30
|
+
const box = new Container();
|
|
31
|
+
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),
|
|
32
|
+
new Spacer(1), control, ...extra, new Spacer(1), new Text(theme.fg('dim', hint), 0, 0)]) box.addChild(child);
|
|
33
|
+
return box;
|
|
34
|
+
}
|
|
35
|
+
const listTheme = (theme: Theme) => ({ selectedPrefix: (t: string) => theme.fg('accent', t), selectedText: (t: string) => theme.fg('accent', t),
|
|
36
|
+
description: (t: string) => theme.fg('muted', t), scrollInfo: (t: string) => theme.fg('muted', t),
|
|
37
|
+
noMatch: () => theme.fg('muted', ' No matching models. Pi lists the models you are logged in to.') });
|
|
38
|
+
|
|
39
|
+
/** Whole numbers only: typing replaces the value, Enter saves it, a bad value stays on screen with the reason. */
|
|
40
|
+
class NumberStep extends Container {
|
|
41
|
+
private readonly input: Input;
|
|
42
|
+
private readonly error = new Text('', 0, 0);
|
|
43
|
+
constructor(theme: Theme, key: NumberKey, current: number, submit: (value: number) => string | undefined, cancel: () => void) {
|
|
44
|
+
super();
|
|
45
|
+
const spec = SETTINGS[key];
|
|
46
|
+
// Empty with the current value as placeholder: typing starts a new value, Enter alone keeps the old one.
|
|
47
|
+
this.input = new Input({ placeholder: String(current), placeholderStyle: text => theme.fg('dim', text) }); this.input.focused = true;
|
|
48
|
+
this.input.onEscape = cancel;
|
|
49
|
+
this.input.onSubmit = text => {
|
|
50
|
+
if (!text.trim()) return cancel();
|
|
51
|
+
const value = /^\s*\d+\s*$/.test(text) ? Number(text) : NaN;
|
|
52
|
+
const problem = invalid(key, value) ?? submit(value);
|
|
53
|
+
if (problem) this.error.setText(theme.fg('error', problem));
|
|
54
|
+
};
|
|
55
|
+
this.addChild(step(theme, `${spec.label}${spec.unit ? ` (${spec.unit})` : ''}`, `${spec.description} Default ${show(key, spec.default)}.`,
|
|
56
|
+
this.input, 'Type a number · Enter to save · Esc to go back', [this.error]));
|
|
57
|
+
}
|
|
58
|
+
handleInput(data: string) { this.input.handleInput(data); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Model first (type to filter), then thinking level; Esc goes back a step. */
|
|
62
|
+
class ModelStep extends Container {
|
|
63
|
+
private active: { handleInput(data: string): void } = { handleInput: () => {} };
|
|
64
|
+
constructor(private readonly theme: Theme, private readonly role: Role, private readonly models: string[], private readonly current: ModelChoice,
|
|
65
|
+
private readonly submit: (choice: ModelChoice) => string | undefined, private readonly cancel: () => void) {
|
|
66
|
+
super();
|
|
67
|
+
this.pickModel();
|
|
68
|
+
}
|
|
69
|
+
private pickModel() {
|
|
70
|
+
const { theme, current } = this, filter = new Input();
|
|
71
|
+
const items: SelectItem[] = this.models.map(value => ({ value, label: value }));
|
|
72
|
+
let list = new SelectList(items, 10, listTheme(theme));
|
|
73
|
+
const build = () => {
|
|
74
|
+
list = new SelectList(filter.getValue() ? fuzzyFilter(items, filter.getValue(), i => i.value) : items, 10, listTheme(theme));
|
|
75
|
+
list.onSelect = item => this.pickThinking(item.value); list.onCancel = this.cancel;
|
|
76
|
+
return list;
|
|
77
|
+
};
|
|
78
|
+
build(); list.setSelectedIndex(Math.max(0, this.models.indexOf(modelName(current))));
|
|
79
|
+
filter.focused = true;
|
|
80
|
+
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]);
|
|
81
|
+
this.show(box, data => {
|
|
82
|
+
const keys = getKeybindings();
|
|
83
|
+
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);
|
|
84
|
+
filter.handleInput(data);
|
|
85
|
+
const index = box.children.indexOf(list); box.children[index] = build();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
private pickThinking(model: string) {
|
|
89
|
+
const { theme, current } = this, error = new Text('', 0, 0);
|
|
90
|
+
const list = new SelectList(THINKING.map(value => ({ value, label: value })), THINKING.length, listTheme(theme));
|
|
91
|
+
list.setSelectedIndex(Math.max(0, THINKING.indexOf(current.thinking)));
|
|
92
|
+
list.onCancel = () => this.pickModel();
|
|
93
|
+
list.onSelect = item => {
|
|
94
|
+
const thinking = THINKING.find(level => level === item.value);
|
|
95
|
+
const slash = model.indexOf('/');
|
|
96
|
+
const problem = thinking && this.submit({ provider: model.slice(0, slash), model: model.slice(slash + 1), thinking });
|
|
97
|
+
if (problem) error.setText(theme.fg('error', problem));
|
|
98
|
+
};
|
|
99
|
+
this.show(step(theme, 'Thinking level', `For ${model}.`, list, 'Enter to save · Esc to go back', [error]), data => list.handleInput(data));
|
|
100
|
+
}
|
|
101
|
+
private show(box: Component, input: (data: string) => void) { this.clear(); this.addChild(box); this.active = { handleInput: input }; }
|
|
102
|
+
handleInput(data: string) { this.active.handleInput(data); }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The page itself: Pi's settings list over this profile's config, one row per setting, saved on change. */
|
|
106
|
+
export function settingsPage(theme: Theme, o: Options, close: () => void, redraw = () => {}) {
|
|
107
|
+
const { config } = o;
|
|
108
|
+
const notice = new Text('', 2, 0);
|
|
109
|
+
const marker = (same: boolean, fallback: string) => theme.fg('dim', same ? ' default' : ` default ${fallback}`);
|
|
110
|
+
const value = (key: SettingKey, current: number | boolean) => `${show(key, current)}${marker(current === SETTINGS[key].default, show(key, SETTINGS[key].default))}`;
|
|
111
|
+
const apply = (patch: Partial<ProfileConfig>, what: string, applies: string) => {
|
|
112
|
+
try { o.save({ ...config, ...patch }); }
|
|
113
|
+
catch (error) { return `Could not save: ${error instanceof Error ? error.message : String(error)}`; }
|
|
114
|
+
Object.assign(config, patch);
|
|
115
|
+
notice.setText(theme.fg('success', `Saved ${what}. `) + theme.fg('muted', applies));
|
|
116
|
+
return undefined;
|
|
117
|
+
};
|
|
118
|
+
const models = (['compactor', 'subagent'] as const).map((role): SettingItem => {
|
|
119
|
+
const { label, description, applies } = ROLES[role];
|
|
120
|
+
return { id: role, label, description: `${description} Default ${showModel(defaults[role])}. ${applies}`,
|
|
121
|
+
currentValue: modelValue(theme, role, config[role]),
|
|
122
|
+
submenu: (_value, done) => new ModelStep(theme, role, o.models, config[role], choice => {
|
|
123
|
+
const problem = apply({ [role]: choice }, `${label.toLowerCase()} ${showModel(choice)}`, applies);
|
|
124
|
+
if (!problem) done(modelValue(theme, role, choice));
|
|
125
|
+
return problem;
|
|
126
|
+
}, () => done()),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
const settings = SETTING_KEYS.map((key): SettingItem => {
|
|
130
|
+
const spec = SETTINGS[key], base = { id: key, label: spec.label, description: `${spec.description} ${spec.applies}` };
|
|
131
|
+
if (isNumberKey(key)) return { ...base, currentValue: value(key, config[key]),
|
|
132
|
+
submenu: (_value, done) => new NumberStep(theme, key, config[key], next => {
|
|
133
|
+
const problem = apply({ [key]: next }, `${spec.label.toLowerCase()} ${show(key, next)}`, spec.applies);
|
|
134
|
+
if (!problem) done(value(key, next));
|
|
135
|
+
return problem;
|
|
136
|
+
}, () => done()) };
|
|
137
|
+
return { ...base, currentValue: value(key, config[key]), values: [value(key, true), value(key, false)] };
|
|
138
|
+
});
|
|
139
|
+
const list = new SettingsList([...models, ...settings], 10, {
|
|
140
|
+
label: (text, selected) => selected ? theme.fg('accent', text) : text,
|
|
141
|
+
value: (text, selected) => selected ? theme.fg('accent', text) : theme.fg('muted', text),
|
|
142
|
+
description: text => theme.fg('dim', text), cursor: theme.fg('accent', '→ '), hint: text => theme.fg('dim', text),
|
|
143
|
+
}, (id, next) => {
|
|
144
|
+
// Only the on/off rows change here; the others save from their own step.
|
|
145
|
+
const key = SETTING_KEYS.find(k => k === id);
|
|
146
|
+
if (!key || isNumberKey(key)) return;
|
|
147
|
+
const on = next === value(key, true);
|
|
148
|
+
const problem = apply({ [key]: on }, `${SETTINGS[key].label.toLowerCase()} ${on ? 'on' : 'off'}`, SETTINGS[key].applies);
|
|
149
|
+
if (problem) { list.updateValue(key, value(key, config[key])); notice.setText(theme.fg('error', problem)); }
|
|
150
|
+
}, close);
|
|
151
|
+
const page = new Container();
|
|
152
|
+
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),
|
|
153
|
+
new Text(theme.fg('dim', 'Saved as you change them · Victor\'s recipe by default, except Previous exchange and Summary size tolerance'), 1, 0),
|
|
154
|
+
new Spacer(1), list, notice, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
|
|
155
|
+
return Object.assign(page, { handleInput: (data: string) => { notice.setText(''); list.handleInput(data); redraw(); } });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function showSettings(ctx: ExtensionContext, o: Options) {
|
|
159
|
+
return ctx.ui.custom<void>((tui, theme, _keys, done) => settingsPage(theme, o, () => done(undefined), () => tui.requestRender()));
|
|
160
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
previousExchange: { kind: 'toggle', label: 'Previous exchange', default: true,
|
|
12
|
+
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.',
|
|
13
|
+
applies: 'Applies from the next turn.' },
|
|
14
|
+
previousExchangeKB: { kind: 'number', label: 'Previous exchange limit', default: 16, min: 1, unit: 'KB',
|
|
15
|
+
description: 'A larger last exchange, usually a big paste, is left out and the model zooms into memory instead.',
|
|
16
|
+
applies: 'Applies from the next turn.' },
|
|
17
|
+
summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
|
|
18
|
+
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.`,
|
|
19
|
+
applies: 'Applies to the next summary.' },
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type SettingKey = keyof typeof SETTINGS;
|
|
23
|
+
export type Settings = { -readonly [K in SettingKey]: (typeof SETTINGS)[K]['default'] extends boolean ? boolean : number };
|
|
24
|
+
export type NumberKey = { [K in SettingKey]: Settings[K] extends number ? K : never }[SettingKey];
|
|
25
|
+
export const SETTING_KEYS = Object.keys(SETTINGS) as SettingKey[];
|
|
26
|
+
export const isNumberKey = (key: SettingKey): key is NumberKey => SETTINGS[key].kind === 'number';
|
|
27
|
+
|
|
28
|
+
/** Why a value can't be used, or undefined if it can. Numbers are whole and at least their minimum. */
|
|
29
|
+
export function invalid(key: SettingKey, value: unknown) {
|
|
30
|
+
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.`; }
|
|
31
|
+
return typeof value === 'boolean' ? undefined : `${SETTINGS[key].label} must be true or false.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Missing keys take their defaults, so config files written before a setting existed keep working. */
|
|
35
|
+
export function readSettings(value: Record<string, unknown>): Settings {
|
|
36
|
+
return Object.fromEntries(SETTING_KEYS.map(key => {
|
|
37
|
+
const raw = Object.hasOwn(value, key) ? value[key] : SETTINGS[key].default, problem = invalid(key, raw);
|
|
38
|
+
if (problem) throw new Error(problem);
|
|
39
|
+
return [key, raw];
|
|
40
|
+
})) as Settings;
|
|
41
|
+
}
|
|
42
|
+
export const DEFAULT_SETTINGS = readSettings({});
|
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,18 +86,18 @@ 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
|
|
|
93
93
|
export function summarizeUsage(entries: UsageEntry[]) {
|
|
94
|
-
const total = emptyUsage(), groups = new Map<string, { role: UsageRole; model: string;
|
|
94
|
+
const total = emptyUsage(), groups = new Map<string, { role: UsageRole; model: string; usage: Usage }>();
|
|
95
95
|
for (const entry of entries) {
|
|
96
|
-
|
|
97
|
-
const key = `${entry.role}:${model}`;
|
|
96
|
+
// By model name alone: older compactor records lack a provider and must still land in the same row.
|
|
97
|
+
const key = `${entry.role}:${entry.model}`;
|
|
98
98
|
let group = groups.get(key);
|
|
99
|
-
if (!group) { group = { role: entry.role, model
|
|
100
|
-
|
|
99
|
+
if (!group) { group = { role: entry.role, model: entry.model, usage: emptyUsage() }; groups.set(key, group); }
|
|
100
|
+
addUsage(group.usage, entry.usage); addUsage(total, entry.usage);
|
|
101
101
|
}
|
|
102
102
|
return { total, groups: [...groups.values()] };
|
|
103
103
|
}
|
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;
|