@huseynsnmz/pi-suite 0.2.2 → 0.2.3

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +8 -1
  3. package/package.json +6 -1
  4. package/packages/board/README.md +1 -1
  5. package/packages/commands/README.md +1 -1
  6. package/packages/commands/src/index.ts +22 -0
  7. package/packages/frame/README.md +8 -2
  8. package/packages/frame/docs/reference.md +1 -1
  9. package/packages/frame/src/index.ts +8 -3
  10. package/packages/frame/src/kit/settings/files.ts +8 -8
  11. package/packages/frame/src/kit/settings/store.ts +3 -1
  12. package/packages/frame/src/ui/editor.ts +64 -7
  13. package/packages/frame/src/ui/history-search.ts +21 -0
  14. package/packages/frame/src/ui/pi-entries.ts +2 -0
  15. package/packages/frame/src/ui/thinking.ts +34 -0
  16. package/packages/memory/README.md +1 -1
  17. package/packages/modes/README.md +2 -2
  18. package/packages/modes/src/index.ts +3 -6
  19. package/packages/router/README.md +29 -9
  20. package/packages/router/src/config.ts +28 -27
  21. package/packages/router/src/index.ts +61 -13
  22. package/packages/router/src/route.ts +16 -10
  23. package/packages/subscription/LICENSE +21 -0
  24. package/packages/subscription/README.md +60 -0
  25. package/packages/subscription/package.json +23 -0
  26. package/packages/subscription/src/anchors.ts +75 -0
  27. package/packages/subscription/src/billing.ts +39 -0
  28. package/packages/subscription/src/catalog.ts +310 -0
  29. package/packages/subscription/src/cc-sessions.ts +122 -0
  30. package/packages/subscription/src/config.ts +34 -0
  31. package/packages/subscription/src/convert.ts +161 -0
  32. package/packages/subscription/src/index.ts +69 -0
  33. package/packages/subscription/src/mcp.ts +78 -0
  34. package/packages/subscription/src/models.ts +65 -0
  35. package/packages/subscription/src/prompt.ts +57 -0
  36. package/packages/subscription/src/provider.ts +617 -0
  37. package/packages/subscription/src/session.ts +294 -0
  38. package/packages/subscription/src/util.ts +73 -0
  39. package/packages/web/README.md +5 -5
  40. package/packages/web/src/config.ts +13 -23
  41. package/packages/web/src/index.ts +11 -7
  42. package/packages/web/src/reader.ts +15 -4
  43. package/packages/web/src/validate.ts +2 -2
  44. package/src/index.ts +92 -10
  45. package/src/settings-ui.ts +63 -0
  46. package/src/settings.ts +116 -15
@@ -0,0 +1,294 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
4
+ import type { Tool, ToolResultMessage } from "@earendil-works/pi-ai";
5
+ import { MCP_PREFIX, MCP_SERVER, toolUseId } from "./convert.ts";
6
+ import { type ToolResult, ToolServer } from "./mcp.ts";
7
+ import type { ThinkingOption } from "./models.ts";
8
+ import { AsyncQueue } from "./util.ts";
9
+
10
+ export interface SessionSpec {
11
+ cwd: string;
12
+ model: string;
13
+ systemPrompt: string;
14
+ tools: Tool[];
15
+ toolsHash: string;
16
+ thinkingKey: string;
17
+ thinking: ThinkingOption;
18
+ effort?: "low" | "medium" | "high" | "xhigh" | "max";
19
+ maxOutputTokens?: number;
20
+ /** pi asked for no prompt caching (cacheRetention "none"). */
21
+ noCache?: boolean;
22
+ /** Resume this Claude Code session, cut after `at` when given. Omit for a fresh session. */
23
+ resume?: { sessionId: string; at?: string };
24
+ pathToClaudeCodeExecutable?: string;
25
+ debug?: (msg: string) => void;
26
+ }
27
+
28
+ const CLIENT_APP = `pi-suite-subscription/${JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version}`;
29
+
30
+ type CallToolResult = ToolResult;
31
+ export type StartQuery = typeof import("@anthropic-ai/claude-agent-sdk").query;
32
+
33
+ /** What the provider needs from a Claude Code session; LiveSession is the real one. */
34
+ export interface ClaudeSession {
35
+ readonly spec: SessionSpec;
36
+ readonly sessionId: string;
37
+ readonly systemPrompt: string;
38
+ model: string;
39
+ chain: string;
40
+ count: number;
41
+ awaiting: Set<string>;
42
+ servedByClaude: Set<string>;
43
+ dead: boolean;
44
+ /** A provider call is reading this session's events; no other call may attach. */
45
+ busy: boolean;
46
+ lastUsed: number;
47
+ readonly events: AsyncQueue<SDKMessage>;
48
+ readonly exited: Promise<void>;
49
+ readonly stderr: string;
50
+ send(content: any[], priority?: "now" | "next" | "later"): void;
51
+ deliver(result: ToolResultMessage): void;
52
+ /** Transcript-entry uuid streamed for an API assistant message; waits briefly for its final SDK frame. */
53
+ entryUuid(messageId: string): Promise<string | undefined>;
54
+ setModel(model: string): Promise<void>;
55
+ interrupt(): Promise<void>;
56
+ close(): void;
57
+ }
58
+
59
+ /** Environment for the Claude Code child process. */
60
+ export function childEnv(base: Record<string, string | undefined>, spec: SessionSpec): Record<string, string | undefined> {
61
+ return {
62
+ ...base,
63
+ // Subscription auth: no inherited key or cloud-provider switch may move Claude Code to API billing.
64
+ ANTHROPIC_API_KEY: undefined,
65
+ ANTHROPIC_AUTH_TOKEN: undefined,
66
+ // The login goes to Anthropic, not to a gateway this shell happens to name.
67
+ ANTHROPIC_BASE_URL: undefined,
68
+ ANTHROPIC_CUSTOM_HEADERS: undefined,
69
+ CLAUDE_CODE_USE_BEDROCK: undefined,
70
+ CLAUDE_CODE_USE_VERTEX: undefined,
71
+ CLAUDE_CODE_USE_FOUNDRY: undefined,
72
+ // Declare this process honestly as an Agent SDK app, even when pi itself runs inside Claude Code.
73
+ CLAUDECODE: undefined,
74
+ CLAUDE_CODE_ENTRYPOINT: undefined,
75
+ CLAUDE_AGENT_SDK_CLIENT_APP: CLIENT_APP,
76
+ // pi owns context management: no compaction, no output-size cap on tool results.
77
+ DISABLE_AUTO_COMPACT: "1",
78
+ DISABLE_COMPACT: "1",
79
+ MAX_MCP_OUTPUT_TOKENS: "10000000",
80
+ MCP_TOOL_TIMEOUT: String(7 * 24 * 3600 * 1000),
81
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
82
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
83
+ CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
84
+ ...(spec.noCache ? { DISABLE_PROMPT_CACHING: "1" } : {}),
85
+ ...(spec.maxOutputTokens ? { CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(spec.maxOutputTokens) } : {}),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * One Claude Code process in streaming-input mode, holding one conversation. pi owns the agent
91
+ * loop: Claude Code's tool calls reach pi's tools through an in-process MCP server whose
92
+ * handlers park until pi delivers the tool results on its next provider call.
93
+ */
94
+ export class LiveSession implements ClaudeSession {
95
+ readonly spec: SessionSpec;
96
+ readonly sessionId: string;
97
+ readonly systemPrompt: string;
98
+ model: string;
99
+ /** Chain hash and length of the pi transcript this process has consumed. */
100
+ chain: string;
101
+ count: number;
102
+ /** Tool calls of the last step that pi has not answered yet. */
103
+ awaiting = new Set<string>();
104
+ /** Tool calls Claude Code answered itself (unknown tool names); pi's results for them are ignored. */
105
+ servedByClaude = new Set<string>();
106
+ dead = false;
107
+ busy = false;
108
+ lastUsed = Date.now();
109
+ readonly events = new AsyncQueue<SDKMessage>();
110
+ /** Resolves once the Claude Code process has exited. */
111
+ readonly exited: Promise<void>;
112
+ private markExited!: () => void;
113
+
114
+ private readonly q: Query;
115
+ private readonly input = new AsyncQueue<SDKUserMessage>();
116
+ private readonly parked = new Map<string, { resolve: (r: CallToolResult) => void; reject: (e: Error) => void }>();
117
+ private readonly ready = new Map<string, CallToolResult>();
118
+ /** Last transcript-entry uuid streamed for each top-level API assistant message. */
119
+ private readonly assistantEntries = new Map<string, string>();
120
+ private readonly entryWaiters = new Map<string, Set<(uuid: string | undefined) => void>>();
121
+ private stderrTail = "";
122
+
123
+ constructor(spec: SessionSpec, chain: string, count: number, query: StartQuery) {
124
+ this.spec = spec;
125
+ this.model = spec.model;
126
+ this.systemPrompt = spec.systemPrompt;
127
+ this.chain = chain;
128
+ this.count = count;
129
+ this.sessionId = spec.resume?.sessionId ?? randomUUID();
130
+ this.exited = new Promise((r) => {
131
+ this.markExited = r;
132
+ });
133
+
134
+ const persist = spec.tools.length > 0;
135
+
136
+ this.q = query({
137
+ prompt: this.input,
138
+ options: {
139
+ cwd: spec.cwd,
140
+ model: spec.model,
141
+ systemPrompt: spec.systemPrompt,
142
+ tools: [],
143
+ mcpServers: spec.tools.length > 0 ? { [MCP_SERVER]: { type: "sdk", name: MCP_SERVER, instance: this.mcpServer(spec.tools) as never } } : {},
144
+ allowedTools: spec.tools.map((t) => MCP_PREFIX + t.name),
145
+ // Only pi's tools are allowed, and they wait for pi. Anything else Claude Code tries is denied.
146
+ permissionMode: "dontAsk",
147
+ settingSources: [],
148
+ strictMcpConfig: true,
149
+ includePartialMessages: true,
150
+ verbatimPrompts: true,
151
+ persistSession: persist,
152
+ title: "pi",
153
+ thinking: spec.thinking,
154
+ ...(spec.effort ? { effort: spec.effort } : {}),
155
+ ...(spec.resume ? { resume: spec.resume.sessionId, ...(spec.resume.at ? { resumeSessionAt: spec.resume.at } : {}) } : { sessionId: this.sessionId }),
156
+ ...(spec.pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable: spec.pathToClaudeCodeExecutable } : {}),
157
+ env: childEnv(process.env, spec),
158
+ stderr: (d) => {
159
+ this.stderrTail = (this.stderrTail + d).slice(-2000);
160
+ },
161
+ },
162
+ });
163
+ void this.pump();
164
+ }
165
+
166
+ private mcpServer(tools: Tool[]): ToolServer {
167
+ return new ToolServer(MCP_SERVER, tools, async (id) => {
168
+ if (!id) return { content: [{ type: "text", text: "pi bridge: tool call without a tool_use id" }], isError: true };
169
+ const done = this.ready.get(id);
170
+ if (done) {
171
+ this.ready.delete(id);
172
+ return done;
173
+ }
174
+ return new Promise<CallToolResult>((resolve, reject) => this.parked.set(id, { resolve, reject }));
175
+ });
176
+ }
177
+
178
+ private async pump(): Promise<void> {
179
+ try {
180
+ for await (const m of this.q) {
181
+ const sdkMessage = m as any;
182
+ if (sdkMessage.type === "assistant" && !sdkMessage.parent_tool_use_id && sdkMessage.message?.id && sdkMessage.uuid) this.rememberEntry(sdkMessage.message.id, sdkMessage.uuid);
183
+ if (m.type === "user" && !m.parent_tool_use_id && Array.isArray(m.message.content)) {
184
+ // A tool_result Claude Code produced without asking pi (e.g. an unknown tool name).
185
+ for (const b of m.message.content as any[]) {
186
+ if (b.type === "tool_result" && !this.parkedOrDelivered(b.tool_use_id)) this.servedByClaude.add(b.tool_use_id);
187
+ }
188
+ }
189
+ this.events.push(m);
190
+ }
191
+ } catch (e) {
192
+ this.events.push({ type: "pi_error", error: errorText(e, this.stderrTail) } as any);
193
+ } finally {
194
+ this.dead = true;
195
+ for (const messageId of [...this.entryWaiters.keys()]) this.settleEntryWaiters(messageId, undefined);
196
+ this.events.close();
197
+ for (const p of this.parked.values()) p.reject(new Error("Claude Code session ended"));
198
+ this.parked.clear();
199
+ this.markExited();
200
+ }
201
+ }
202
+
203
+ private rememberEntry(messageId: string, uuid: string): void {
204
+ this.assistantEntries.set(messageId, uuid);
205
+ this.settleEntryWaiters(messageId, uuid);
206
+ }
207
+
208
+ private settleEntryWaiters(messageId: string, uuid: string | undefined): void {
209
+ const waiters = this.entryWaiters.get(messageId);
210
+ if (!waiters) return;
211
+ this.entryWaiters.delete(messageId);
212
+ for (const resolve of waiters) resolve(uuid);
213
+ }
214
+
215
+ async entryUuid(messageId: string): Promise<string | undefined> {
216
+ const known = this.assistantEntries.get(messageId);
217
+ if (known || this.dead) return known;
218
+ return new Promise((resolve) => {
219
+ const done = (uuid: string | undefined) => {
220
+ clearTimeout(timer);
221
+ resolve(uuid ?? this.assistantEntries.get(messageId));
222
+ };
223
+ const timer = setTimeout(() => this.settleEntryWaiters(messageId, undefined), 500);
224
+ timer.unref?.();
225
+ const waiters = this.entryWaiters.get(messageId) ?? new Set<(uuid: string | undefined) => void>();
226
+ waiters.add(done);
227
+ this.entryWaiters.set(messageId, waiters);
228
+ });
229
+ }
230
+
231
+ private delivered = new Set<string>();
232
+ private parkedOrDelivered(id: string): boolean {
233
+ return this.parked.has(id) || this.delivered.has(id) || this.ready.has(id);
234
+ }
235
+
236
+ /** Start a user turn (or continue after tool results when resuming). */
237
+ send(content: any[], priority?: "now" | "next" | "later"): void {
238
+ this.lastUsed = Date.now();
239
+ this.input.push({
240
+ type: "user",
241
+ message: { role: "user", content },
242
+ parent_tool_use_id: null,
243
+ ...(priority ? { priority } : {}),
244
+ } as SDKUserMessage);
245
+ }
246
+
247
+ /** Answer a parked (or soon to be issued) MCP tool call with pi's result. */
248
+ deliver(result: ToolResultMessage): void {
249
+ const id = toolUseId(result.toolCallId);
250
+ this.awaiting.delete(id);
251
+ if (this.servedByClaude.has(id)) return;
252
+ this.delivered.add(id);
253
+ const r: CallToolResult = {
254
+ content: result.content.map((c) => (c.type === "text" ? { type: "text", text: c.text } : { type: "image", data: c.data, mimeType: c.mimeType })),
255
+ ...(result.isError ? { isError: true } : {}),
256
+ };
257
+ if (r.content.length === 0) r.content.push({ type: "text", text: "(no output)" });
258
+ const p = this.parked.get(id);
259
+ if (p) {
260
+ this.parked.delete(id);
261
+ p.resolve(r);
262
+ } else this.ready.set(id, r);
263
+ }
264
+
265
+ async setModel(model: string): Promise<void> {
266
+ if (model === this.model) return;
267
+ await this.q.setModel(model);
268
+ this.model = model;
269
+ }
270
+
271
+ async interrupt(): Promise<void> {
272
+ try {
273
+ await this.q.interrupt();
274
+ } catch {}
275
+ }
276
+
277
+ close(): void {
278
+ if (this.dead) return;
279
+ this.dead = true;
280
+ this.input.close();
281
+ try {
282
+ this.q.close();
283
+ } catch {}
284
+ }
285
+
286
+ get stderr(): string {
287
+ return this.stderrTail;
288
+ }
289
+ }
290
+
291
+ export function errorText(e: unknown, stderr = ""): string {
292
+ const msg = e instanceof Error ? e.message : String(e);
293
+ return stderr.trim() ? `${msg}\n${stderr.trim().split("\n").slice(-5).join("\n")}` : msg;
294
+ }
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** Unbounded single-consumer async queue. `close()` ends iteration after buffered items drain. */
4
+ export class AsyncQueue<T> implements AsyncIterable<T> {
5
+ private items: T[] = [];
6
+ private waiters: ((r: IteratorResult<T>) => void)[] = [];
7
+ private closed = false;
8
+
9
+ push(item: T): void {
10
+ if (this.closed) return;
11
+ const w = this.waiters.shift();
12
+ if (w) w({ value: item, done: false });
13
+ else this.items.push(item);
14
+ }
15
+
16
+ close(): void {
17
+ this.closed = true;
18
+ for (const w of this.waiters.splice(0)) w({ value: undefined as never, done: true });
19
+ }
20
+
21
+ /** Takes out the waiting items that match, and leaves the rest in order. */
22
+ take(matches: (item: T) => boolean): T[] {
23
+ const taken = this.items.filter(matches);
24
+ this.items = this.items.filter((item) => !matches(item));
25
+ return taken;
26
+ }
27
+
28
+ get isClosed(): boolean {
29
+ return this.closed;
30
+ }
31
+
32
+ /** Next item, or undefined once closed and drained. Rejects on abort. */
33
+ next(signal?: AbortSignal): Promise<T | undefined> {
34
+ if (this.items.length > 0) return Promise.resolve(this.items.shift());
35
+ if (this.closed) return Promise.resolve(undefined);
36
+ return new Promise((resolve, reject) => {
37
+ const onAbort = () => {
38
+ const i = this.waiters.indexOf(waiter);
39
+ if (i >= 0) this.waiters.splice(i, 1);
40
+ reject(new Error("aborted"));
41
+ };
42
+ const waiter = (r: IteratorResult<T>) => {
43
+ signal?.removeEventListener("abort", onAbort);
44
+ resolve(r.done ? undefined : r.value);
45
+ };
46
+ if (signal?.aborted) return reject(new Error("aborted"));
47
+ signal?.addEventListener("abort", onAbort, { once: true });
48
+ this.waiters.push(waiter);
49
+ });
50
+ }
51
+
52
+ async *[Symbol.asyncIterator](): AsyncIterator<T> {
53
+ for (;;) {
54
+ const item = await this.next();
55
+ if (item === undefined && this.closed && this.items.length === 0) return;
56
+ yield item as T;
57
+ }
58
+ }
59
+ }
60
+
61
+ /** JSON with sorted object keys, so equal values hash equally regardless of key order. */
62
+ export function stableStringify(value: unknown): string {
63
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
64
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
65
+ const keys = Object.keys(value as object)
66
+ .filter((k) => (value as Record<string, unknown>)[k] !== undefined)
67
+ .sort();
68
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(",")}}`;
69
+ }
70
+
71
+ export function sha(text: string): string {
72
+ return createHash("sha256").update(text).digest("hex").slice(0, 32);
73
+ }
@@ -9,19 +9,19 @@ A web page can hold instructions aimed at the AI reading it. So a separate model
9
9
  ## Install
10
10
 
11
11
  ```bash
12
- pi install npm:@huseynsnmz/pi-suite@0.2.2
12
+ pi install npm:@huseynsnmz/pi-suite@0.2.3
13
13
  ```
14
14
 
15
15
  `web_fetch` works straight away. It reads pages with the session's model, unless you set `readerModel` or use [pi-router](https://github.com/huseynsnmz/pi-suite/tree/main/packages/router), which can choose the reader. `web_search` stays off until you choose where searches go.
16
16
 
17
17
  ## Search
18
18
 
19
- Everything you search for leaves your machine, so nothing is chosen for you. Settings go in `~/.pi/agent/web.json`, and are read when Pi starts, so restart it after editing the file.
19
+ Everything you search for leaves your machine, so nothing is chosen for you. Settings go in `~/.pi/agent/the web settings`, and are read when Pi starts, so restart it after editing the file.
20
20
 
21
21
  **Your own SearXNG server** needs no account, but it does pass your searches on to the search engines it uses:
22
22
 
23
23
  ```json
24
- { "searxngUrl": "http://localhost:8888" }
24
+ { "web": { "searxngUrl": "http://localhost:8888" } }
25
25
  ```
26
26
 
27
27
  SearXNG only answers in JSON if you list it under `search.formats` in its `settings.yml`. Public SearXNG servers almost always refuse this.
@@ -29,7 +29,7 @@ SearXNG only answers in JSON if you list it under `search.formats` in its `setti
29
29
  **markdown.new** is free and needs no key: 30 searches a minute and 500 a day for your IP, with a shared cap across everyone using it. Your searches go to its operator, then Serper and Google. This is behind a flag:
30
30
 
31
31
  ```json
32
- { "companySearch": true }
32
+ { "web": { "companySearch": true } }
33
33
  ```
34
34
 
35
35
  ## Settings
@@ -49,7 +49,7 @@ SearXNG only answers in JSON if you list it under `search.formats` in its `setti
49
49
  | `strictUrls` | Set to `true` to be asked about every URL the agent wasn't given |
50
50
  | `askNewDomains` | Set to `true` to be asked before the first fetch from each site |
51
51
 
52
- `/web` shows what's in use, and `/web reader` lets you pick the page reader from your models. It saves to `web.json` and applies straight away. If `web.json` can't be read or has a wrong value, the web tools stay off and Pi says why.
52
+ `/web` shows what's in use, and `/web reader` lets you pick the page reader from your models. It saves it and applies straight away. If the web settings can't be read or has a wrong value, the web tools stay off and Pi says why.
53
53
 
54
54
  ## What it checks
55
55
 
@@ -1,6 +1,5 @@
1
- /** Settings live in the agent folder only: a project's own file could point searches at its author's server. */
2
- import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
1
+ /** Settings live in the web section of ~/.pi/agent/suite.json only: a project's own file could point searches at its author's server. */
2
+ import { readSection, writeSection } from "../../../src/settings.ts";
4
3
  import type { DnsFilter } from "./dnsfilter.ts";
5
4
 
6
5
  export type SearchProvider = { name: "searxng"; url: string } | { name: "markdown.new" };
@@ -33,24 +32,24 @@ const DNS_FILTERS: DnsFilter[] = ["cloudflare-family", "cloudflare-security", "o
33
32
  function strings(raw: Record<string, unknown>, key: string): string[] {
34
33
  const value = raw[key];
35
34
  if (value === undefined) return [];
36
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`${key} in web.json must be a list of strings`);
35
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`${key} in suite.json's web section must be a list of strings`);
37
36
  return value.map((item: string) => item.toLowerCase());
38
37
  }
39
38
 
40
39
  function flag(raw: Record<string, unknown>, key: string): boolean {
41
40
  const value = raw[key];
42
41
  if (value === undefined) return false;
43
- if (typeof value !== "boolean") throw new Error(`${key} in web.json must be true or false`);
42
+ if (typeof value !== "boolean") throw new Error(`${key} in suite.json's web section must be true or false`);
44
43
  return value;
45
44
  }
46
45
 
47
46
  export function parseConfig(raw: Record<string, unknown>): WebConfig {
48
47
  const companySearch = flag(raw, "companySearch");
49
- if (raw.provider !== undefined && typeof raw.provider !== "string") throw new Error("provider in web.json must be a name");
48
+ if (raw.provider !== undefined && typeof raw.provider !== "string") throw new Error("provider in suite.json's web section must be a name");
50
49
  const wanted = typeof raw.provider === "string" ? raw.provider : companySearch ? "markdown.new" : "searxng";
51
- if (raw.searxngUrl !== undefined && (typeof raw.searxngUrl !== "string" || !/^https?:\/\//.test(raw.searxngUrl))) throw new Error("searxngUrl in web.json must be an http or https URL");
52
- if (raw.dnsFilter !== undefined && !DNS_FILTERS.includes(raw.dnsFilter as DnsFilter)) throw new Error(`dnsFilter in web.json must be one of ${DNS_FILTERS.join(", ")}`);
53
- if (raw.readerModel !== undefined && (typeof raw.readerModel !== "string" || !raw.readerModel.includes("/"))) throw new Error("readerModel in web.json must be written provider/id");
50
+ if (raw.searxngUrl !== undefined && (typeof raw.searxngUrl !== "string" || !/^https?:\/\//.test(raw.searxngUrl))) throw new Error("searxngUrl in suite.json's web section must be an http or https URL");
51
+ if (raw.dnsFilter !== undefined && !DNS_FILTERS.includes(raw.dnsFilter as DnsFilter)) throw new Error(`dnsFilter in suite.json's web section must be one of ${DNS_FILTERS.join(", ")}`);
52
+ if (raw.readerModel !== undefined && (typeof raw.readerModel !== "string" || !raw.readerModel.includes("/"))) throw new Error("readerModel in suite.json's web section must be written provider/id");
54
53
  const url = typeof raw.searxngUrl === "string" ? raw.searxngUrl.replace(/\/+$/, "") : null;
55
54
 
56
55
  let searchProvider: SearchProvider | null = null;
@@ -66,7 +65,7 @@ export function parseConfig(raw: Record<string, unknown>): WebConfig {
66
65
  }
67
66
 
68
67
  const minConfidence = raw.minConfidence ?? 4;
69
- if (typeof minConfidence !== "number" || !Number.isInteger(minConfidence) || minConfidence < 1 || minConfidence > 10) throw new Error("minConfidence in web.json must be a whole number from 1 to 10");
68
+ if (typeof minConfidence !== "number" || !Number.isInteger(minConfidence) || minConfidence < 1 || minConfidence > 10) throw new Error("minConfidence in suite.json's web section must be a whole number from 1 to 10");
70
69
 
71
70
  const [provider, ...id] = typeof raw.readerModel === "string" ? raw.readerModel.split("/") : [];
72
71
  return {
@@ -86,24 +85,15 @@ export function parseConfig(raw: Record<string, unknown>): WebConfig {
86
85
  }
87
86
 
88
87
  export function loadConfig(agentDir: string): WebConfig {
89
- const path = join(agentDir, "web.json");
90
- if (!existsSync(path)) return parseConfig({});
91
- const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
92
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${path} must hold a JSON object`);
93
- return parseConfig(raw as Record<string, unknown>);
88
+ return parseConfig(readSection(agentDir, "web"));
94
89
  }
95
90
 
96
- /** Changes only the reader in the file, so a setting this doesn't know about survives. A rename keeps a crash from leaving half a file. */
91
+ /** Changes only the reader, so a setting this doesn't know about survives. */
97
92
  export function saveReaderModel(agentDir: string, model: string | null): WebConfig {
98
- const path = join(agentDir, "web.json");
99
- const raw: unknown = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
100
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${path} must hold a JSON object`);
101
- const next = { ...(raw as Record<string, unknown>) };
93
+ const next = { ...readSection(agentDir, "web") };
102
94
  if (model === null) delete next.readerModel;
103
95
  else next.readerModel = model;
104
96
  const config = parseConfig(next);
105
- const temp = `${path}.${process.pid}.tmp`;
106
- writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`);
107
- renameSync(temp, path);
97
+ writeSection(agentDir, "web", next);
108
98
  return config;
109
99
  }
@@ -6,7 +6,7 @@ import { blockedLinks, checkSite } from "./dnsfilter.ts";
6
6
  import { toText } from "./extract.ts";
7
7
  import { isIP } from "node:net";
8
8
  import { fetchPublic } from "./net.ts";
9
- import { askReader, READ_CHARS } from "./reader.ts";
9
+ import { askReader, READ_CHARS, type RoutedReader } from "./reader.ts";
10
10
  import { linkIsClean, looksLikeInstructions, urlsIn } from "./sanitize.ts";
11
11
  import { search } from "./search.ts";
12
12
  import { checkQuery, checkQuestion, checkUrl, givenSites, onSite, urlConcern, wasOffered } from "./validate.ts";
@@ -79,7 +79,7 @@ export default function piWeb(pi: ExtensionAPI): void {
79
79
  const known = [...givenSites(entries), ...config.trustedDomains, ...config.allowDomains, ...approvedSites].some((site) => onSite(host, site));
80
80
  const written = writtenOnSite.get(host) ?? 0;
81
81
  if (!known || written >= MAX_WRITTEN_PER_SITE) {
82
- if (!ctx.hasUI) throw new Error(`The agent wrote this address itself, on a site nobody has named or approved, and there's nobody here to approve it. Add ${host} to trustedDomains in web.json, or use a link you were given`);
82
+ if (!ctx.hasUI) throw new Error(`The agent wrote this address itself, on a site nobody has named or approved, and there's nobody here to approve it. Add ${host} to trustedDomains in /suite, or use a link you were given`);
83
83
  const why = known ? `The agent has written ${written} addresses of its own on this site.` : "The agent found this site itself. Allowing it lets the agent fetch from it for the rest of this session.";
84
84
  if (!(await ctx.ui.confirm("Fetch from this site?", `${host}\n\n${url.href}\n\n${why}`))) throw new Error(`The user declined fetching from ${host}`);
85
85
  approvedSites.add(host);
@@ -145,7 +145,7 @@ export default function piWeb(pi: ExtensionAPI): void {
145
145
  const key = `${url.href}\n${offset}\n${question}`;
146
146
  const known = answers.get(key);
147
147
  const fresh = known && Date.now() - known.at < CACHE_MS;
148
- const answer = fresh ? known.text : await withoutBlockedLinks(await askReader(ctx, config, question, part, signal, routerReader(ctx)), signal);
148
+ const answer = fresh ? known.text : await withoutBlockedLinks(await askReader(ctx, config, question, part, signal, routerProbe(ctx)), signal);
149
149
  if (!fresh) remember(answers, key, answer);
150
150
  const left = page.text.length - offset - part.length;
151
151
  return reply(`From ${url.hostname}, not checked against anything else:\n${answer}${looksLikeInstructions(part) ? WARNING : ""}${left > 0 ? `\n\n${left} characters unread. Fetch again with offset ${offset + part.length} for more.` : ""}`, [...urlsIn(answer), ...pageLinks(part)]);
@@ -172,7 +172,7 @@ export default function piWeb(pi: ExtensionAPI): void {
172
172
  const links = clean.filter((link) => !blocked.has(link));
173
173
  if (!links.length) return reply(clean.length ? "Every result was on a site blocked by the site filter." : "No results.");
174
174
  const part = results.text.slice(0, SEARCH_CHARS);
175
- const answer = await withoutBlockedLinks(await askReader(ctx, config, question, part, signal, routerReader(ctx)), signal);
175
+ const answer = await withoutBlockedLinks(await askReader(ctx, config, question, part, signal, routerProbe(ctx)), signal);
176
176
  return reply(`${answer}${looksLikeInstructions(part) ? WARNING : ""}\n\nLinks:\n${links.map((link) => `- ${link}`).join("\n")}`, [...links, ...urlsIn(answer), ...pageLinks(part)], [...new Set(links.map((link) => new URL(link).hostname))]);
177
177
  },
178
178
  });
@@ -181,12 +181,16 @@ export default function piWeb(pi: ExtensionAPI): void {
181
181
  const SESSION_MODEL = "The session's model";
182
182
 
183
183
  /** Whoever answers fills in the object, so it doesn't matter which loads first. */
184
- function routerReader(ctx: ExtensionContext): string | undefined {
184
+ function routerProbe(ctx: ExtensionContext): RoutedReader | undefined {
185
185
  // Only while it's selected: a page must not go to a provider this session didn't choose.
186
186
  if (ctx.model?.api !== "pi-virtual") return undefined;
187
- const probe: { model?: string } = {};
187
+ const probe: { model?: string; thinking?: RoutedReader["thinking"] } = {};
188
188
  pi.events.emit("pi-router:reader-model", probe);
189
- return probe.model;
189
+ return probe.model === undefined ? undefined : { model: probe.model, thinking: probe.thinking };
190
+ }
191
+
192
+ function routerReader(ctx: ExtensionContext): string | undefined {
193
+ return routerProbe(ctx)?.model;
190
194
  }
191
195
 
192
196
  async function pickReader(ctx: ExtensionContext): Promise<void> {
@@ -1,4 +1,5 @@
1
1
  /** The separate model that reads web text. It has no tools, so the most a page can do to it is spoil its answer. */
2
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
2
3
  import { randomBytes } from "node:crypto";
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import type { WebConfig } from "./config.ts";
@@ -50,11 +51,19 @@ function readerModel(ctx: ExtensionContext, config: WebConfig, routed: string |
50
51
  return ctx.modelRegistry.find(routed.slice(0, at), routed.slice(at + 1));
51
52
  }
52
53
 
53
- export async function askReader(ctx: ExtensionContext, config: WebConfig, question: string, text: string, signal?: AbortSignal, routed?: string): Promise<string> {
54
- const model = readerModel(ctx, config, routed);
55
- if (!model) throw new Error("No model to read the page with, so it wasn't read. Set readerModel in web.json");
54
+ export interface RoutedReader {
55
+ model: string;
56
+ thinking?: ThinkingLevel | "off";
57
+ }
58
+
59
+ export async function askReader(ctx: ExtensionContext, config: WebConfig, question: string, text: string, signal?: AbortSignal, routed?: RoutedReader): Promise<string> {
60
+ const model = readerModel(ctx, config, routed?.model);
61
+ if (!model) throw new Error("No model to read the page with, so it wasn't read. Set readerModel in /suite");
56
62
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
57
63
  if (!auth.ok) throw new Error(`No key for the page reader ${model.provider}/${model.id}`);
64
+ // Only for the model the router chose, as suite.json's own reader has no level to go by.
65
+ const level = routed?.model === `${model.provider}/${model.id}` ? routed.thinking : undefined;
66
+ const reasoning = level === "off" ? undefined : level;
58
67
  // Through the registry, because the session's model can be a virtual one.
59
68
  const response = await ctx.modelRegistry.streamSimple(
60
69
  model,
@@ -62,12 +71,14 @@ export async function askReader(ctx: ExtensionContext, config: WebConfig, questi
62
71
  systemPrompt: SYSTEM,
63
72
  messages: [{ role: "user", content: [{ type: "text", text: readerPrompt(question, text) }], timestamp: Date.now() }],
64
73
  },
65
- { signal, maxTokens: 2000, cacheRetention: "none" },
74
+ // Reasoning uses the same tokens as the answer, and could leave none for it.
75
+ { signal, maxTokens: reasoning === undefined ? 2000 : 16000, cacheRetention: "none", ...(reasoning === undefined ? {} : { reasoning }) },
66
76
  ).result();
67
77
  if (response.stopReason === "error" || response.stopReason === "aborted") {
68
78
  // Not the provider's message, as some repeat part of the request, and that's page text.
69
79
  throw new Error(`The page reader failed (${response.stopReason}), so the page wasn't read`);
70
80
  }
71
81
  const raw = response.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
82
+ if (raw.trim() === "") throw new Error("The page reader gave no answer, so the page wasn't read");
72
83
  return checkAnswer(raw, text, config.minConfidence);
73
84
  }
@@ -29,7 +29,7 @@ export function checkQuery(query: string, privateWords: string[] = []): string {
29
29
  if (LOCAL_PATH.test(out)) throw new Error("This query holds a path on this machine, and it would leave this machine");
30
30
  if (EMAIL.test(out)) throw new Error("This query holds an email address, and it would leave this machine");
31
31
  const lower = out.toLowerCase();
32
- if (privateWords.some((word) => word && lower.includes(word))) throw new Error("This query holds a word set as private in web.json, and it would leave this machine");
32
+ if (privateWords.some((word) => word && lower.includes(word))) throw new Error("This query holds a word set as private in /suite, and it would leave this machine");
33
33
  return out;
34
34
  }
35
35
 
@@ -157,7 +157,7 @@ export function urlConcern(url: URL, privateWords: string[] = []): string | null
157
157
  const lowerPath = path.toLowerCase();
158
158
  if (new RegExp(LOCAL_PATH.source, "i").test(path) || EMAIL.test(path)) return "holds a local path or email address";
159
159
  const lower = `${url.hostname}${lowerPath}`;
160
- if (privateWords.some((word) => word && lower.includes(word))) return "holds a word set as private in web.json";
160
+ if (privateWords.some((word) => word && lower.includes(word))) return "holds a word set as private in /suite";
161
161
  return null;
162
162
  }
163
163