@huseynsnmz/pi-suite 0.2.2 → 0.2.4
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/CHANGELOG.md +34 -0
- package/README.md +13 -4
- package/package.json +12 -2
- package/packages/board/README.md +1 -1
- package/packages/board/package.json +2 -1
- package/packages/commands/README.md +1 -1
- package/packages/commands/src/index.ts +22 -0
- package/packages/frame/README.md +8 -2
- package/packages/frame/docs/reference.md +1 -1
- package/packages/frame/src/index.ts +8 -3
- package/packages/frame/src/kit/settings/files.ts +8 -8
- package/packages/frame/src/kit/settings/store.ts +3 -1
- package/packages/frame/src/ui/editor.ts +64 -7
- package/packages/frame/src/ui/history-search.ts +21 -0
- package/packages/frame/src/ui/pi-entries.ts +2 -0
- package/packages/frame/src/ui/thinking.ts +34 -0
- package/packages/memory/README.md +20 -1
- package/packages/memory/src/index.ts +60 -1
- package/packages/memory/src/mastermind.ts +110 -0
- package/packages/memory/src/playbook.ts +92 -0
- package/packages/modes/README.md +11 -3
- package/packages/modes/skills/review-permissions/SKILL.md +11 -2
- package/packages/modes/src/index.ts +29 -11
- package/packages/modes/src/judge-gate.ts +78 -7
- package/packages/modes/src/modes.ts +4 -1
- package/packages/modes/src/review/apply.mjs +1 -1
- package/packages/modes/src/review/collect.mjs +45 -2
- package/packages/modes/src/workdir.ts +105 -0
- package/packages/router/README.md +30 -9
- package/packages/router/src/config.ts +30 -28
- package/packages/router/src/index.ts +70 -13
- package/packages/router/src/route.ts +16 -10
- package/packages/subagent/LICENSE +21 -0
- package/packages/subagent/README.md +17 -0
- package/packages/subagent/package.json +22 -0
- package/packages/subagent/src/index.ts +76 -0
- package/packages/subscription/LICENSE +21 -0
- package/packages/subscription/README.md +60 -0
- package/packages/subscription/package.json +23 -0
- package/packages/subscription/src/anchors.ts +75 -0
- package/packages/subscription/src/billing.ts +39 -0
- package/packages/subscription/src/catalog.ts +310 -0
- package/packages/subscription/src/cc-sessions.ts +122 -0
- package/packages/subscription/src/config.ts +34 -0
- package/packages/subscription/src/convert.ts +161 -0
- package/packages/subscription/src/index.ts +69 -0
- package/packages/subscription/src/mcp.ts +78 -0
- package/packages/subscription/src/models.ts +65 -0
- package/packages/subscription/src/prompt.ts +57 -0
- package/packages/subscription/src/provider.ts +617 -0
- package/packages/subscription/src/session.ts +294 -0
- package/packages/subscription/src/util.ts +73 -0
- package/packages/web/README.md +5 -5
- package/packages/web/src/config.ts +13 -23
- package/packages/web/src/index.ts +11 -7
- package/packages/web/src/reader.ts +15 -4
- package/packages/web/src/validate.ts +2 -2
- package/src/index.ts +93 -11
- package/src/settings-ui.ts +63 -0
- package/src/settings.ts +117 -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
|
+
}
|
package/packages/web/README.md
CHANGED
|
@@ -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.
|
|
12
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.4
|
|
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
|
|
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
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
52
|
-
if (raw.dnsFilter !== undefined && !DNS_FILTERS.includes(raw.dnsFilter as DnsFilter)) throw new Error(`dnsFilter in
|
|
53
|
-
if (raw.readerModel !== undefined && (typeof raw.readerModel !== "string" || !raw.readerModel.includes("/"))) throw new Error("readerModel in
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|