pi-aside 0.0.0-stage → 0.2.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/src/thread.ts ADDED
@@ -0,0 +1,331 @@
1
+ // A side thread (docs/design.md §4.4, §4.5; docs/ux.md §3.2): questions and answers over one fork, read-only tools,
2
+ // follow-ups that fork again when the main session has moved, and accounting.
3
+
4
+ import { createFindTool, createGrepTool, createLsTool, createReadTool } from "@earendil-works/pi-coding-agent";
5
+ import { type Fork, type ForkEnv, gainedSince, makeFork, type SendResult, send, type Tier } from "./fork.ts";
6
+ import type { Observer } from "./observe.ts";
7
+ import { parseReply, type ParsedReply } from "./reply.ts";
8
+ import type { UsageQueue } from "./usage.ts";
9
+
10
+ // biome-ignore lint: Pi's message types are used structurally.
11
+ type Any = any;
12
+
13
+ export const MAX_TOOL_ROUNDS = 8;
14
+ export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
15
+
16
+ export function notice(options: { routeCheck: boolean }): string {
17
+ const lines = [
18
+ "<aside>",
19
+ "The user is asking this on the side, in a temporary side conversation. The main agent keeps working and does not see this exchange. Nothing said here reaches the main session unless the user sends it back.",
20
+ "Answer the question directly and briefly, in the language of the question. Only the read-only tools read, grep, find and ls run here; any other tool call fails.",
21
+ ];
22
+ if (options.routeCheck) {
23
+ lines.push(
24
+ "Start your reply with one line that is exactly `route: side` or `route: agent`. Write `route: agent` only when the message is really addressed to the main agent: it asks the agent to do, stop or change something (for example \"can you also add tests?\", or \"why are you deleting the tests?\" meaning stop). Otherwise write `route: side`, then answer.",
25
+ );
26
+ }
27
+ lines.push(
28
+ "If, and only if, your answer shows that the main agent should change what it is doing, end with one line `handoff: <one short instruction to the agent, in the user's language>`. Otherwise do not write that line.",
29
+ "</aside>",
30
+ );
31
+ return lines.join("\n");
32
+ }
33
+
34
+ export interface TurnView {
35
+ question: string;
36
+ /** The answer text so far, route and handoff lines included. */
37
+ raw: string;
38
+ done: boolean;
39
+ error?: string;
40
+ /** A dim activity line, such as `reading src/a.ts`. */
41
+ activity?: string;
42
+ /** The model's (summarized) thinking in the current request, while no answer text has arrived. */
43
+ thinking?: string;
44
+ /** When the question was asked, and when the answer's first text arrived (ms since the epoch). */
45
+ startedAt: number;
46
+ firstTextAt?: number;
47
+ /** The thinking level the aside used, when lower than the main session's. */
48
+ effort?: string;
49
+ tier?: Tier;
50
+ reason?: string;
51
+ cost: number;
52
+ read: number;
53
+ written: number;
54
+ /** The main response's cacheable prefix at the fork (T0). */
55
+ predicted?: number;
56
+ aborted?: boolean;
57
+ }
58
+
59
+ export interface ThreadHost {
60
+ env: ForkEnv;
61
+ observer: Observer;
62
+ usage: UsageQueue;
63
+ cwd: () => string;
64
+ sessionId: () => string;
65
+ onUpdate: () => void;
66
+ }
67
+
68
+ function textOf(message: Any): string {
69
+ if (!message || !Array.isArray(message.content)) return "";
70
+ return message.content
71
+ .filter((c: Any) => c?.type === "text")
72
+ .map((c: Any) => c.text)
73
+ .join("");
74
+ }
75
+
76
+ function thinkingOf(message: Any): string {
77
+ if (!message || !Array.isArray(message.content)) return "";
78
+ return message.content
79
+ .filter((c: Any) => c?.type === "thinking" && typeof c.thinking === "string")
80
+ .map((c: Any) => c.thinking)
81
+ .join("\n\n");
82
+ }
83
+
84
+ function activityFor(call: Any): string {
85
+ const args = call.arguments ?? {};
86
+ switch (call.name) {
87
+ case "read":
88
+ return `reading ${args.path ?? ""}`.trim();
89
+ case "grep":
90
+ return `searching ${args.pattern ?? ""}`.trim();
91
+ case "find":
92
+ return `finding ${args.pattern ?? ""}`.trim();
93
+ case "ls":
94
+ return `listing ${args.path ?? "."}`;
95
+ default:
96
+ return `${call.name} is not available in an aside`;
97
+ }
98
+ }
99
+
100
+ /** Count the branch entries that put something into the model context. */
101
+ function contextEntryCount(sessionManager: Any): number {
102
+ const branch: Any[] = sessionManager.getBranch();
103
+ return branch.filter((e) => e.type === "message" || e.type === "custom_message" || e.type === "compaction" || e.type === "branch_summary").length;
104
+ }
105
+
106
+ export class AsideThread {
107
+ fork: Fork | undefined;
108
+ /** Q1, A1 (with tool rounds), Q2, … over the fork's base. */
109
+ private exchange: unknown[] = [];
110
+ readonly turns: TurnView[] = [];
111
+ private controller: AbortController | undefined;
112
+ private contextCount = 0;
113
+ private observationSeq: number | undefined;
114
+ readonly epoch: number;
115
+ readonly sessionId: string;
116
+
117
+ constructor(private readonly host: ThreadHost) {
118
+ this.epoch = host.observer.epoch;
119
+ this.sessionId = host.sessionId();
120
+ }
121
+
122
+ get busy(): boolean {
123
+ return this.controller !== undefined;
124
+ }
125
+
126
+ get current(): TurnView | undefined {
127
+ return this.turns.at(-1);
128
+ }
129
+
130
+ parsed(turn: TurnView | undefined = this.current): ParsedReply | undefined {
131
+ return turn ? parseReply(turn.raw, turn.done) : undefined;
132
+ }
133
+
134
+ abort(): void {
135
+ this.controller?.abort();
136
+ }
137
+
138
+ /** Whether the main session gained context since this thread's last request. */
139
+ private mainMoved(): boolean {
140
+ if (!this.fork) return true;
141
+ const latest = this.host.observer.choose();
142
+ if (latest && latest.seq !== this.observationSeq) return true;
143
+ return contextEntryCount(this.host.env.sessionManager) !== this.contextCount;
144
+ }
145
+
146
+ private quotedExchange(): string {
147
+ const parts: string[] = [];
148
+ for (const turn of this.turns.slice(0, -1)) {
149
+ if (turn.aborted || turn.error) continue;
150
+ const reply = parseReply(turn.raw, true);
151
+ parts.push(`Q: ${turn.question}\nA: ${reply.body}`);
152
+ }
153
+ return parts.length > 0 ? `Earlier in this side conversation:\n\n${parts.join("\n\n")}\n\n` : "";
154
+ }
155
+
156
+ /** Ask a question in this thread. Resolves when the answer is complete, failed or aborted. */
157
+ async ask(question: string, options: { routeCheck: boolean; onRoute?: (route: "side" | "agent") => void }): Promise<TurnView> {
158
+ const turn: TurnView = { question, raw: "", done: false, startedAt: Date.now(), cost: 0, read: 0, written: 0 };
159
+ this.turns.push(turn);
160
+ const controller = new AbortController();
161
+ this.controller = controller;
162
+ const signal = controller.signal;
163
+ const update = () => this.host.onUpdate();
164
+ let routeReported = false;
165
+ const reportRoute = () => {
166
+ if (routeReported || !options.onRoute) return;
167
+ const parsed = parseReply(turn.raw, turn.done);
168
+ if (parsed.route) {
169
+ routeReported = true;
170
+ options.onRoute(parsed.route);
171
+ }
172
+ };
173
+ try {
174
+ let prefix = "";
175
+ if (this.mainMoved()) {
176
+ if (this.fork) prefix = this.quotedExchange();
177
+ turn.activity = "waiting for the main request";
178
+ update();
179
+ await this.host.observer.settle(signal);
180
+ if (signal.aborted) throw new DOMException("aborted", "AbortError");
181
+ const latest = this.host.observer.latest;
182
+ const observation = this.host.observer.choose();
183
+ const why = latest && !latest.usable ? latest.reason : undefined;
184
+ turn.activity = undefined;
185
+ this.fork = await makeFork(this.host.env, observation, why);
186
+ this.exchange = [];
187
+ this.observationSeq = observation?.seq;
188
+ this.contextCount = contextEntryCount(this.host.env.sessionManager);
189
+ }
190
+ const fork = this.fork!;
191
+ turn.tier = fork.tier;
192
+ turn.reason = fork.reason;
193
+ turn.effort = fork.effort;
194
+ turn.predicted = fork.observation?.cacheablePrefix;
195
+ const q = {
196
+ role: "user",
197
+ content: [{ type: "text", text: `${notice(options)}\n\n${prefix}${question}` }],
198
+ timestamp: Date.now(),
199
+ };
200
+ const added: unknown[] = [q];
201
+ const tools = this.tools();
202
+ let textBefore = "";
203
+ for (let round = 0; ; round++) {
204
+ const result: SendResult = await send(this.host.env, fork, [...this.exchange, ...added], signal, (event: Any) => {
205
+ if (event?.partial) {
206
+ turn.raw = textBefore + textOf(event.partial);
207
+ turn.thinking = thinkingOf(event.partial) || undefined;
208
+ if (turn.firstTextAt === undefined && turn.raw.trim() !== "") turn.firstTextAt = Date.now();
209
+ reportRoute();
210
+ update();
211
+ }
212
+ });
213
+ const message = result.message;
214
+ turn.tier = result.tier;
215
+ turn.reason = result.reason ?? turn.reason;
216
+ turn.effort = fork.effort;
217
+ turn.thinking = undefined;
218
+ this.account(fork, result, turn);
219
+ added.push(message);
220
+ turn.raw = textBefore + textOf(message);
221
+ textBefore = turn.raw;
222
+ if (message.stopReason === "aborted") throw new DOMException("aborted", "AbortError");
223
+ if (message.stopReason === "error") {
224
+ turn.error = message.errorMessage ?? "the request failed";
225
+ break;
226
+ }
227
+ reportRoute();
228
+ if (routeReported && parseReply(turn.raw, false).route === "agent") break;
229
+ const calls = (message.content ?? []).filter((c: Any) => c?.type === "toolCall");
230
+ if (calls.length === 0 || message.stopReason !== "toolUse") break;
231
+ if (round + 1 >= MAX_TOOL_ROUNDS) {
232
+ turn.error = `stopped after ${MAX_TOOL_ROUNDS} tool rounds`;
233
+ break;
234
+ }
235
+ for (const call of calls) {
236
+ turn.activity = activityFor(call);
237
+ update();
238
+ added.push(await this.runTool(tools, call, signal));
239
+ }
240
+ turn.activity = undefined;
241
+ if (textBefore && !textBefore.endsWith("\n")) textBefore += "\n\n";
242
+ update();
243
+ }
244
+ this.exchange.push(...added);
245
+ } catch (error) {
246
+ if (signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
247
+ turn.aborted = true;
248
+ } else {
249
+ turn.error = error instanceof Error ? error.message : String(error);
250
+ }
251
+ } finally {
252
+ turn.done = true;
253
+ turn.activity = undefined;
254
+ turn.thinking = undefined;
255
+ if (this.controller === controller) this.controller = undefined;
256
+ reportRoute();
257
+ update();
258
+ }
259
+ return turn;
260
+ }
261
+
262
+ private tools(): Record<string, Any> {
263
+ const cwd = this.host.cwd();
264
+ return {
265
+ read: createReadTool(cwd),
266
+ grep: createGrepTool(cwd),
267
+ find: createFindTool(cwd),
268
+ ls: createLsTool(cwd),
269
+ };
270
+ }
271
+
272
+ private async runTool(tools: Record<string, Any>, call: Any, signal: AbortSignal): Promise<unknown> {
273
+ const result = (content: string, isError: boolean) => ({
274
+ role: "toolResult",
275
+ toolCallId: call.id,
276
+ toolName: call.name,
277
+ content: [{ type: "text", text: content }],
278
+ isError,
279
+ timestamp: Date.now(),
280
+ });
281
+ const tool = (READ_ONLY_TOOLS as readonly string[]).includes(call.name) ? tools[call.name] : undefined;
282
+ if (!tool) return result(`${call.name} is not available in an aside. Only read, grep, find and ls run here.`, true);
283
+ try {
284
+ const args = tool.prepareArguments ? tool.prepareArguments(call.arguments) : call.arguments;
285
+ const output = await tool.execute(call.id, args, signal);
286
+ return {
287
+ role: "toolResult",
288
+ toolCallId: call.id,
289
+ toolName: call.name,
290
+ content: output?.content ?? [],
291
+ isError: false,
292
+ timestamp: Date.now(),
293
+ };
294
+ } catch (error) {
295
+ return result(error instanceof Error ? error.message : String(error), true);
296
+ }
297
+ }
298
+
299
+ private account(fork: Fork, result: SendResult, turn: TurnView): void {
300
+ const message = result.message;
301
+ const usage = message?.usage;
302
+ if (!usage) return;
303
+ turn.cost += usage.cost?.total ?? 0;
304
+ turn.read += usage.cacheRead ?? 0;
305
+ turn.written += usage.cacheWrite ?? 0;
306
+ // A request that failed before reaching the provider reports nothing.
307
+ if (!(usage.input || usage.output || usage.cacheRead || usage.cacheWrite)) return;
308
+ const parts = [
309
+ `tier=${result.tier}`,
310
+ result.reason ? `reason=${JSON.stringify(result.reason)}` : undefined,
311
+ fork.observation?.cacheablePrefix !== undefined ? `predicted=${fork.observation.cacheablePrefix}` : undefined,
312
+ `read=${usage.cacheRead ?? 0}`,
313
+ `written=${usage.cacheWrite ?? 0}`,
314
+ fork.pHash ? `p=sha256:${fork.pHash}` : undefined,
315
+ ].filter(Boolean);
316
+ this.host.usage.push({
317
+ sessionId: this.sessionId,
318
+ provider: message.provider,
319
+ model: message.responseModel ?? message.model,
320
+ usage,
321
+ note: `pi-aside ${parts.join(" ")}`,
322
+ });
323
+ }
324
+
325
+ /** The thread's base was forked from this session and nothing reset it since. */
326
+ valid(observer: Observer, sessionId: string): boolean {
327
+ return this.epoch === observer.epoch && this.sessionId === sessionId;
328
+ }
329
+ }
330
+
331
+ export { gainedSince };
@@ -0,0 +1,103 @@
1
+ // Transport for the aside's requests (docs/design.md §4.2, "Routing: the next-request rule"): the request options
2
+ // and the attribution headers the main session's next request would get, synthesized from the current settings,
3
+ // never copied from the observation; plus the currency check.
4
+
5
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
6
+
7
+ type AnyModel = { provider: string; id: string; api: string; baseUrl: string; [key: string]: unknown };
8
+
9
+ const OPENROUTER_HOST = "openrouter.ai";
10
+ const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
11
+ const CLOUDFLARE_API_HOST = "api.cloudflare.com";
12
+ const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com";
13
+ const OPENCODE_HOST = "opencode.ai";
14
+
15
+ function matchesHost(baseUrl: string, host: string): boolean {
16
+ try {
17
+ return new URL(baseUrl).hostname === host;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function isTruthyEnvFlag(value: string): boolean {
24
+ return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
25
+ }
26
+
27
+ /** Replica of Pi's `isInstallTelemetryEnabled` (telemetry.js, Pi 1.1.0–1.3.0). */
28
+ export function telemetryEnabled(settings: SettingsManager, env: string | undefined = process.env.PI_TELEMETRY): boolean {
29
+ return env !== undefined ? isTruthyEnvFlag(env) : settings.getEnableInstallTelemetry();
30
+ }
31
+
32
+ /** Replica of Pi's `mergeProviderAttributionHeaders` (provider-attribution.js, Pi 1.1.0–1.3.0): D ⊕ X. */
33
+ export function mergeAttributionHeaders(
34
+ model: AnyModel,
35
+ settings: SettingsManager,
36
+ sessionId: string | undefined,
37
+ ...sources: Array<Record<string, unknown> | undefined>
38
+ ): Record<string, unknown> | undefined {
39
+ const merged: Record<string, unknown> = {};
40
+ if (
41
+ sessionId &&
42
+ (model.provider === "opencode" || model.provider === "opencode-go" || matchesHost(model.baseUrl, OPENCODE_HOST))
43
+ ) {
44
+ merged["x-opencode-session"] = sessionId;
45
+ merged["x-opencode-client"] = "pi";
46
+ }
47
+ if (telemetryEnabled(settings)) {
48
+ if (model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST)) {
49
+ merged["HTTP-Referer"] = "https://pi.dev";
50
+ merged["X-OpenRouter-Title"] = "pi";
51
+ merged["X-OpenRouter-Categories"] = "cli-agent";
52
+ } else if (model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST)) {
53
+ merged["X-BILLING-INVOKE-ORIGIN"] = "Pi";
54
+ } else if (
55
+ model.provider === "cloudflare-workers-ai" ||
56
+ model.provider === "cloudflare-ai-gateway" ||
57
+ matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) ||
58
+ matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST)
59
+ ) {
60
+ merged["User-Agent"] = "pi-coding-agent";
61
+ }
62
+ }
63
+ for (const source of sources) if (source) Object.assign(merged, source);
64
+ return Object.keys(merged).length > 0 ? merged : undefined;
65
+ }
66
+
67
+ /** The current settings, read through Pi's own settings code. */
68
+ export function currentSettings(getSettings: () => unknown): SettingsManager {
69
+ return SettingsManager.inMemory(structuredClone(getSettings()) as never);
70
+ }
71
+
72
+ export interface RequestOptions {
73
+ timeoutMs: number | undefined;
74
+ websocketConnectTimeoutMs: number | undefined;
75
+ maxRetries: number | undefined;
76
+ maxRetryDelayMs: number | undefined;
77
+ }
78
+
79
+ /** The options Pi's `buildRequestOptions` (sdk.js) gives the main session's next request. */
80
+ export function requestOptions(settings: SettingsManager): RequestOptions {
81
+ const retry = settings.getProviderRetrySettings();
82
+ const idle = settings.getHttpIdleTimeoutMs();
83
+ return {
84
+ timeoutMs: retry.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
85
+ websocketConnectTimeoutMs: settings.getWebSocketConnectTimeoutMs(),
86
+ maxRetries: retry.maxRetries,
87
+ maxRetryDelayMs: retry.maxRetryDelayMs,
88
+ };
89
+ }
90
+
91
+ /** Snapshot of the process environment for the currency check. */
92
+ export function envSnapshot(): Map<string, string | undefined> {
93
+ return new Map(Object.entries(process.env));
94
+ }
95
+
96
+ export function envUnchanged(snapshot: Map<string, string | undefined>): boolean {
97
+ const keys = Object.keys(process.env);
98
+ if (keys.length !== snapshot.size) return false;
99
+ for (const key of keys) {
100
+ if (!snapshot.has(key) || snapshot.get(key) !== process.env[key]) return false;
101
+ }
102
+ return true;
103
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,84 @@
1
+ // Accounting (docs/design.md §4.6): one Pi usage entry per aside request, written only while the main agent is
2
+ // idle, because a usage entry advances the session leaf.
3
+
4
+ export interface QueuedUsage {
5
+ sessionId: string;
6
+ provider: string;
7
+ model: string;
8
+ usage: unknown;
9
+ note: string;
10
+ }
11
+
12
+ export interface UsageHost {
13
+ /** Whether the main agent is idle, and the current session id; undefined when unavailable. */
14
+ state(): { idle: boolean; sessionId: string } | undefined;
15
+ append(entry: QueuedUsage): void;
16
+ }
17
+
18
+ export class UsageQueue {
19
+ private queue: QueuedUsage[] = [];
20
+ private timer: ReturnType<typeof setInterval> | undefined;
21
+ dropped = 0;
22
+
23
+ constructor(private readonly host: UsageHost) {}
24
+
25
+ get size(): number {
26
+ return this.queue.length;
27
+ }
28
+
29
+ push(entry: QueuedUsage): void {
30
+ this.queue.push(entry);
31
+ this.drain();
32
+ if (this.queue.length > 0 && !this.timer) {
33
+ this.timer = setInterval(() => this.drain(), 250);
34
+ this.timer.unref?.();
35
+ }
36
+ }
37
+
38
+ /** Append every queued entry if the agent is idle in the entries' session. */
39
+ drain(): void {
40
+ if (this.queue.length === 0) {
41
+ this.stop();
42
+ return;
43
+ }
44
+ let state: { idle: boolean; sessionId: string } | undefined;
45
+ try {
46
+ state = this.host.state();
47
+ } catch {
48
+ state = undefined;
49
+ }
50
+ if (!state?.idle) return;
51
+ const keep: QueuedUsage[] = [];
52
+ for (const entry of this.queue) {
53
+ if (entry.sessionId !== state.sessionId) {
54
+ keep.push(entry);
55
+ continue;
56
+ }
57
+ try {
58
+ this.host.append(entry);
59
+ } catch {
60
+ keep.push(entry);
61
+ }
62
+ }
63
+ this.queue = keep;
64
+ if (this.queue.length === 0) this.stop();
65
+ }
66
+
67
+ /**
68
+ * Before a session switch, fork or shutdown: write the entries if the session is idle, otherwise drop and count
69
+ * them. Returns the number dropped.
70
+ */
71
+ flushOrDrop(): number {
72
+ this.drain();
73
+ const dropped = this.queue.length;
74
+ this.dropped += dropped;
75
+ this.queue = [];
76
+ this.stop();
77
+ return dropped;
78
+ }
79
+
80
+ stop(): void {
81
+ if (this.timer) clearInterval(this.timer);
82
+ this.timer = undefined;
83
+ }
84
+ }