pi-optchat 0.5.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/LICENSE +25 -0
- package/README.md +223 -0
- package/THIRD_PARTY_NOTICES.md +19 -0
- package/docs/victor-recipe.md +20 -0
- package/package.json +60 -0
- package/src/agents.ts +329 -0
- package/src/browser.ts +22 -0
- package/src/cache.ts +57 -0
- package/src/checkpoint.ts +18 -0
- package/src/compactor.ts +38 -0
- package/src/connected-window.ts +56 -0
- package/src/handoff.ts +67 -0
- package/src/import/guidance.ts +1 -0
- package/src/import/job.ts +104 -0
- package/src/import/multi-select.ts +98 -0
- package/src/import/sources.ts +278 -0
- package/src/import/ui.ts +105 -0
- package/src/inbox.ts +42 -0
- package/src/index.ts +395 -0
- package/src/inspector.ts +220 -0
- package/src/memory.ts +214 -0
- package/src/navigation.ts +65 -0
- package/src/profiles.ts +91 -0
- package/src/prompts.ts +5 -0
- package/src/runs.ts +113 -0
- package/src/tools.ts +13 -0
- package/src/transcript.ts +99 -0
- package/src/usage.ts +103 -0
- package/src/window-bridge.ts +170 -0
package/src/usage.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { AssistantMessage, Usage } from '@earendil-works/pi-ai';
|
|
5
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { record } from './cache.ts';
|
|
7
|
+
import { appendJson } from './memory.ts';
|
|
8
|
+
|
|
9
|
+
export type UsageRole = 'main' | 'subagent' | 'compactor' | 'import';
|
|
10
|
+
export const ranges = ['This session', 'Last hour', 'Today', 'Last 7 days', 'All time'] as const;
|
|
11
|
+
export type UsageRange = typeof ranges[number];
|
|
12
|
+
export interface UsageEntry {
|
|
13
|
+
id?: string; date: string; role: UsageRole; model: string; provider?: string;
|
|
14
|
+
session?: string; runId?: string; usage: Usage;
|
|
15
|
+
}
|
|
16
|
+
const nonnegative = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
17
|
+
function validUsage(value: unknown): value is Usage {
|
|
18
|
+
if (!record(value) || !record(value.cost)) return false;
|
|
19
|
+
const cost = value.cost;
|
|
20
|
+
return ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens'].every(k => nonnegative(value[k]))
|
|
21
|
+
&& ['input', 'output', 'cacheRead', 'cacheWrite', 'total'].every(k => nonnegative(cost[k]));
|
|
22
|
+
}
|
|
23
|
+
function validEntry(value: unknown): value is UsageEntry {
|
|
24
|
+
return record(value) && typeof value.date === 'string' && Number.isFinite(Date.parse(value.date))
|
|
25
|
+
&& ['main', 'subagent', 'compactor', 'import'].includes(String(value.role)) && typeof value.model === 'string'
|
|
26
|
+
&& ['id', 'provider', 'session', 'runId'].every(k => value[k] === undefined || typeof value[k] === 'string') && validUsage(value.usage);
|
|
27
|
+
}
|
|
28
|
+
export function emptyUsage(): Usage {
|
|
29
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
30
|
+
}
|
|
31
|
+
export function addUsage(total: Usage, usage: Usage) {
|
|
32
|
+
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens'] as const) total[key] += usage[key];
|
|
33
|
+
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'total'] as const) total.cost[key] += usage.cost[key];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One profile's ledger. Stable message IDs make resume/backfill idempotent. */
|
|
37
|
+
export class UsageLedger {
|
|
38
|
+
readonly entries: UsageEntry[] = [];
|
|
39
|
+
readonly warnings: string[] = [];
|
|
40
|
+
private readonly ids = new Set<string>();
|
|
41
|
+
private readonly cursors = new Map<string, number>();
|
|
42
|
+
private readonly file: string;
|
|
43
|
+
constructor(directory: string) {
|
|
44
|
+
this.file = join(directory, 'usage.jsonl');
|
|
45
|
+
let invalid = 0;
|
|
46
|
+
const contents = existsSync(this.file) ? readFileSync(this.file, 'utf8') : '';
|
|
47
|
+
for (const line of contents.split('\n')) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
try {
|
|
50
|
+
const entry: unknown = JSON.parse(line);
|
|
51
|
+
if (!validEntry(entry)) { invalid++; continue; }
|
|
52
|
+
if (entry.id && this.ids.has(entry.id)) continue;
|
|
53
|
+
this.entries.push(entry); if (entry.id) this.ids.add(entry.id);
|
|
54
|
+
} catch { invalid++; }
|
|
55
|
+
}
|
|
56
|
+
if (invalid) this.warnings.push(`${invalid} unreadable usage records excluded; totals may be incomplete.`);
|
|
57
|
+
if (contents && !contents.endsWith('\n')) appendFileSync(this.file, '\n');
|
|
58
|
+
}
|
|
59
|
+
add(entry: UsageEntry) {
|
|
60
|
+
if (entry.id && this.ids.has(entry.id)) return;
|
|
61
|
+
appendJson(this.file, entry);
|
|
62
|
+
this.entries.push(entry); if (entry.id) this.ids.add(entry.id);
|
|
63
|
+
}
|
|
64
|
+
compression(message: AssistantMessage, role: 'compactor' | 'import', session: string) {
|
|
65
|
+
this.add({ id: randomUUID(), date: new Date().toISOString(), role, session, provider: message.provider, model: message.model, usage: message.usage });
|
|
66
|
+
}
|
|
67
|
+
backfill(entries: SessionEntry[], session: string, role: 'main' | 'subagent' = 'main', runId?: string) {
|
|
68
|
+
const cursorKey = `${session}:${runId ?? ''}`;
|
|
69
|
+
const cursor = this.cursors.get(cursorKey) ?? 0;
|
|
70
|
+
for (const entry of entries.slice(cursor <= entries.length ? cursor : 0)) {
|
|
71
|
+
// Pi forks preserve entry IDs/timestamps. Never count inherited responses twice.
|
|
72
|
+
const id = `entry:${entry.id}:${entry.timestamp}`;
|
|
73
|
+
if (entry.type === 'message') {
|
|
74
|
+
const message = entry.message;
|
|
75
|
+
if ((message.role === 'assistant' || message.role === 'toolResult') && message.usage) this.add({ id,
|
|
76
|
+
date: new Date(message.timestamp).toISOString(), role, session, runId,
|
|
77
|
+
provider: message.role === 'assistant' ? message.provider : undefined,
|
|
78
|
+
model: message.role === 'assistant' ? message.model : 'tool usage (model unavailable)', usage: message.usage });
|
|
79
|
+
}
|
|
80
|
+
else if ((entry.type === 'usage' || entry.type === 'compaction' || entry.type === 'branch_summary') && entry.usage) {
|
|
81
|
+
this.add({ id, date: entry.timestamp, role, session, runId, model: 'session overhead', usage: entry.usage });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.cursors.set(cursorKey, entries.length);
|
|
85
|
+
}
|
|
86
|
+
select(range: UsageRange, session: string, now = Date.now()) {
|
|
87
|
+
const today = new Date(now); today.setHours(0, 0, 0, 0);
|
|
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);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function summarizeUsage(entries: UsageEntry[]) {
|
|
94
|
+
const total = emptyUsage(), groups = new Map<string, { role: UsageRole; model: string; requests: number; usage: Usage }>();
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
const model = entry.provider ? `${entry.provider}/${entry.model}` : entry.model;
|
|
97
|
+
const key = `${entry.role}:${model}`;
|
|
98
|
+
let group = groups.get(key);
|
|
99
|
+
if (!group) { group = { role: entry.role, model, requests: 0, usage: emptyUsage() }; groups.set(key, group); }
|
|
100
|
+
group.requests++; addUsage(group.usage, entry.usage); addUsage(total, entry.usage);
|
|
101
|
+
}
|
|
102
|
+
return { total, groups: [...groups.values()] };
|
|
103
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { chmodSync, existsSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { createConnection, createServer, type Socket } from 'node:net';
|
|
3
|
+
import type { Children } from './agents.ts';
|
|
4
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
5
|
+
import { record } from './cache.ts';
|
|
6
|
+
import { profileSocket } from './profiles.ts';
|
|
7
|
+
import { textContent } from './transcript.ts';
|
|
8
|
+
|
|
9
|
+
type Action = 'start' | 'say' | 'tell-main' | 'complete';
|
|
10
|
+
interface Request { kind: 'request'; id: number; action: Action; text?: string; cwd?: string }
|
|
11
|
+
interface Reply { kind: 'reply'; id: number; error?: string }
|
|
12
|
+
/** `from` marks the conversation itself (your messages and the agent's replies); other messages have no `from`. */
|
|
13
|
+
export interface WindowEvent { kind: 'event'; name: 'started' | 'message' | 'status' | 'finished'; text: string; from?: 'user' | 'agent' }
|
|
14
|
+
type Frame = Request | Reply | WindowEvent;
|
|
15
|
+
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
16
|
+
function parse(value: unknown): Frame {
|
|
17
|
+
if (!record(value)) throw new Error('Invalid window message');
|
|
18
|
+
const name = value.name, action = value.action, from = value.from;
|
|
19
|
+
if (value.kind === 'event' && (name === 'started' || name === 'message' || name === 'status' || name === 'finished') && typeof value.text === 'string')
|
|
20
|
+
return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined };
|
|
21
|
+
if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
|
|
22
|
+
if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string')) return { kind: 'reply', id: value.id, error: value.error };
|
|
23
|
+
if (value.kind === 'request' && (action === 'start' || action === 'say' || action === 'tell-main' || action === 'complete')
|
|
24
|
+
&& (value.text === undefined || typeof value.text === 'string') && (value.cwd === undefined || typeof value.cwd === 'string'))
|
|
25
|
+
return { kind: 'request', id: value.id, action, text: value.text, cwd: value.cwd };
|
|
26
|
+
throw new Error('Invalid window message');
|
|
27
|
+
}
|
|
28
|
+
/** Local JSONL protocol, bounded before parsing; the socket is accessible only by its OS user. */
|
|
29
|
+
function wire(socket: Socket, receive: (frame: Frame) => void) {
|
|
30
|
+
let buffer = '';
|
|
31
|
+
socket.setEncoding('utf8');
|
|
32
|
+
socket.on('error', () => {});
|
|
33
|
+
socket.on('data', (text: string) => {
|
|
34
|
+
buffer += text;
|
|
35
|
+
if (buffer.length > 4_000_000) { socket.destroy(); return; }
|
|
36
|
+
let newline: number;
|
|
37
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
38
|
+
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
39
|
+
try { receive(parse(JSON.parse(line))); } catch { socket.destroy(); return; }
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return (frame: Frame) => {
|
|
43
|
+
if (socket.destroyed) return;
|
|
44
|
+
if (socket.writableLength > 4_000_000) { socket.destroy(); return; }
|
|
45
|
+
socket.write(JSON.stringify(frame) + '\n');
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Call only while holding the profile lock. The owner hosts every child session. */
|
|
50
|
+
export async function serveWindows(directory: string, children: Children, available: () => boolean,
|
|
51
|
+
report: (text: string) => Promise<void>) {
|
|
52
|
+
const path = profileSocket(directory, 'windows');
|
|
53
|
+
if (existsSync(path)) unlinkSync(path); // A previous owner can no longer hold the profile lock.
|
|
54
|
+
const connections = new Set<Socket>();
|
|
55
|
+
const work = new Set<Promise<void>>();
|
|
56
|
+
let closing = false;
|
|
57
|
+
const server = createServer(socket => {
|
|
58
|
+
if (closing) { socket.destroy(); return; }
|
|
59
|
+
connections.add(socket);
|
|
60
|
+
let child: string | undefined, cursor = 0, firstUser = true, lastStatus = '', ending = false;
|
|
61
|
+
const said = new Set<string>(); // Main-agent guidance and child reports reach the session as user messages too.
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
let queue = Promise.resolve();
|
|
64
|
+
const track = (promise: Promise<void>) => { work.add(promise); void promise.finally(() => work.delete(promise)).catch(() => {}); };
|
|
65
|
+
const send = wire(socket, frame => {
|
|
66
|
+
if (frame.kind !== 'request') { socket.destroy(); return; }
|
|
67
|
+
queue = queue.then(async () => {
|
|
68
|
+
try {
|
|
69
|
+
if (socket.destroyed || ending) throw new Error('Conversation is closing');
|
|
70
|
+
if (frame.action === 'start') {
|
|
71
|
+
if (child) throw new Error('This window already has a conversation');
|
|
72
|
+
if (!available()) throw new Error('Profile is importing or shutting down; try again when it is ready');
|
|
73
|
+
if (!frame.text?.trim() || !frame.cwd) throw new Error('A request and working directory are required');
|
|
74
|
+
[child] = await children.spawn([{ task: frame.text, cwd: frame.cwd }], frame.cwd, controller.signal, undefined, true);
|
|
75
|
+
if (socket.destroyed) { await children.finish(child, closing ? 'owner-stopped' : 'disconnected'); return; }
|
|
76
|
+
send({ kind: 'event', name: 'started', text: child });
|
|
77
|
+
} else {
|
|
78
|
+
if (!child) throw new Error('Send your first request to start a conversation');
|
|
79
|
+
if (frame.action === 'complete') {
|
|
80
|
+
const live = children.live(child);
|
|
81
|
+
if (live) drainMessages(live.session.messages, live.info.task);
|
|
82
|
+
ending = true;
|
|
83
|
+
track(children.finish(child, 'complete').then(() => {}));
|
|
84
|
+
send({ kind: 'event', name: 'finished', text: 'Conversation ended by you. The original window is stopping remaining work and preparing the handoff.' });
|
|
85
|
+
} else {
|
|
86
|
+
if (!frame.text?.trim()) throw new Error('Message is empty');
|
|
87
|
+
if (frame.action === 'say') { said.add(frame.text.trim()); await children.tell(child, frame.text, 'user'); }
|
|
88
|
+
else await report(`[${child}] User message from connected window: ${frame.text}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
send({ kind: 'reply', id: frame.id });
|
|
92
|
+
} catch (error) { send({ kind: 'reply', id: frame.id, error: errorText(error) }); }
|
|
93
|
+
});
|
|
94
|
+
track(queue);
|
|
95
|
+
});
|
|
96
|
+
const drainMessages = (messages: AgentMessage[], task: string) => {
|
|
97
|
+
const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant');
|
|
98
|
+
while (cursor < displayable.length) {
|
|
99
|
+
const message = displayable[cursor++];
|
|
100
|
+
let text = textContent(message.content);
|
|
101
|
+
if (message.role === 'user' && firstUser) { text = task; firstUser = false; said.add(task.trim()); }
|
|
102
|
+
const from = message.role === 'assistant' ? 'agent' : said.has(text.trim()) ? 'user' : undefined;
|
|
103
|
+
if (text) send({ kind: 'event', name: 'message', from, text: `${text.slice(0, 200_000)}${text.length > 200_000 ? '\n[Display shortened; full text is saved in the transcript.]' : ''}` });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const timer = setInterval(() => {
|
|
107
|
+
if (!child || socket.destroyed || ending) return;
|
|
108
|
+
const live = children.live(child), info = children.history.records.get(child);
|
|
109
|
+
if (!info) return;
|
|
110
|
+
if (live) drainMessages(live.session.messages, info.task);
|
|
111
|
+
const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
|
|
112
|
+
const status = `${info.state === 'waiting' ? 'Awaiting user or child reports' : info.state} · ${info.model}\n${live ? [...live.tools.values()].map(t => t.name).join(', ') : ''}\n${preview}`;
|
|
113
|
+
if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text: status }); }
|
|
114
|
+
if (!live && info.handoff?.delivered) {
|
|
115
|
+
try { drainMessages(children.messages(child), info.task); }
|
|
116
|
+
catch (error) { send({ kind: 'event', name: 'message', text: `Could not read the final transcript: ${errorText(error)}` }); }
|
|
117
|
+
ending = true;
|
|
118
|
+
send({ kind: 'event', name: 'finished', text: info.handoff.text ?? 'Conversation ended.' });
|
|
119
|
+
}
|
|
120
|
+
}, 150);
|
|
121
|
+
socket.on('close', () => {
|
|
122
|
+
clearInterval(timer); controller.abort(); connections.delete(socket);
|
|
123
|
+
const cleanup = queue.then(async () => { if (child) await children.finish(child, closing ? 'owner-stopped' : 'disconnected'); });
|
|
124
|
+
track(cleanup);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(path, () => { server.off('error', reject); resolve(); }); });
|
|
128
|
+
chmodSync(path, 0o600);
|
|
129
|
+
return async () => {
|
|
130
|
+
closing = true;
|
|
131
|
+
const closed = new Promise<void>(resolve => server.close(() => resolve()));
|
|
132
|
+
for (const socket of connections) socket.destroy();
|
|
133
|
+
await closed;
|
|
134
|
+
await Promise.allSettled([...work]);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function connectWindow(directory: string, event: (event: WindowEvent) => void, disconnected: () => void) {
|
|
139
|
+
const socket = createConnection(profileSocket(directory, 'windows'));
|
|
140
|
+
let next = 0;
|
|
141
|
+
const pending = new Map<number, { resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
|
142
|
+
const send = wire(socket, frame => {
|
|
143
|
+
if (frame.kind === 'event') { event(frame); return; }
|
|
144
|
+
if (frame.kind !== 'reply') { socket.destroy(); return; }
|
|
145
|
+
const request = pending.get(frame.id); if (!request) return;
|
|
146
|
+
clearTimeout(request.timer); pending.delete(frame.id);
|
|
147
|
+
if (frame.error) request.reject(new Error(frame.error)); else request.resolve();
|
|
148
|
+
});
|
|
149
|
+
socket.on('close', () => {
|
|
150
|
+
for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('Connection to the original Pi window was lost')); }
|
|
151
|
+
pending.clear(); disconnected();
|
|
152
|
+
});
|
|
153
|
+
await new Promise<void>((resolve, reject) => {
|
|
154
|
+
socket.once('connect', resolve); socket.once('error', reject);
|
|
155
|
+
socket.setTimeout(5000, () => socket.destroy(new Error('Profile owner did not respond')));
|
|
156
|
+
});
|
|
157
|
+
socket.setTimeout(0);
|
|
158
|
+
return {
|
|
159
|
+
async request(action: Action, text?: string, cwd?: string) {
|
|
160
|
+
if (socket.destroyed) throw new Error('The original Pi window is disconnected');
|
|
161
|
+
if (text && text.length > 256_000) throw new Error('Connected-window messages are limited to 256,000 characters');
|
|
162
|
+
const id = next++;
|
|
163
|
+
await new Promise<void>((resolve, reject) => {
|
|
164
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error('Owner response timed out; closing this connection')); socket.destroy(); }, 120_000);
|
|
165
|
+
pending.set(id, { resolve, reject, timer }); send({ kind: 'request', id, action, text, cwd });
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
close() { socket.destroy(); },
|
|
169
|
+
};
|
|
170
|
+
}
|