@timqi/pier 0.0.8 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -9
- package/dist/agent/pi.js +103 -5
- package/dist/channels/conversations.js +10 -0
- package/dist/core/router.js +27 -11
- package/dist/db.js +9 -0
- package/dist/extensions/index.js +34 -0
- package/dist/extensions/web/anthropic.js +118 -0
- package/dist/extensions/web/artifacts.js +57 -0
- package/dist/extensions/web/content.js +130 -0
- package/dist/extensions/web/http.js +106 -0
- package/dist/extensions/web/index.js +9 -0
- package/dist/extensions/web/json.js +5 -0
- package/dist/extensions/web/language.js +47 -0
- package/dist/extensions/web/openai.js +112 -0
- package/dist/extensions/web/provider.js +121 -0
- package/dist/extensions/web/tools.js +284 -0
- package/dist/main.js +30 -5
- package/dist/paths.js +15 -0
- package/dist/settings.js +68 -13
- package/dist/web/instance.js +32 -8
- package/dist/web/providers.js +16 -0
- package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-C4N9kjtH.js} +1 -1
- package/dist/web/public/assets/index-DNCJJRSS.js +91 -0
- package/dist/web/public/assets/{index-gcSJ9QZ5.css → index-DYl1xk5y.css} +1 -1
- package/dist/web/public/index.html +2 -2
- package/dist/web/push.js +11 -2
- package/dist/web/server.js +41 -4
- package/dist/web/session-state.js +40 -9
- package/dist/web/terminal.js +34 -4
- package/package.json +1 -1
- package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
|
|
|
19
19
|
- Node 24 or newer (`node:sqlite` is used unflagged)
|
|
20
20
|
- Linux: Python 3, `make` and a C/C++ compiler for `node-pty`
|
|
21
21
|
- A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
|
|
22
|
-
login from Console → Settings →
|
|
22
|
+
login from Console → Settings → Models after signing in
|
|
23
23
|
- A user-writable global npm prefix if `pier update` should update a service
|
|
24
24
|
- Optional: the `sqlite3` CLI, for backups and password resets
|
|
25
25
|
|
|
@@ -43,10 +43,10 @@ is behind it — there is no default password and no unclaimed window. Lost it?
|
|
|
43
43
|
Open `http://localhost:3141`, sign in, then:
|
|
44
44
|
|
|
45
45
|
- **Console → Settings** — everything the instance is configured with, one
|
|
46
|
-
tab per topic:
|
|
47
|
-
|
|
48
|
-
Telegram bot tokens; chats are discovered
|
|
49
|
-
gated by the mention/bind rules you set),
|
|
46
|
+
tab per topic: Models (API-key/OAuth logins, and the menu of favored models
|
|
47
|
+
agents are advised with), Agent (Pi configuration, skills, extensions),
|
|
48
|
+
Channels (Slack Socket-Mode or Telegram bot tokens; chats are discovered
|
|
49
|
+
when the bot first sees traffic, gated by the mention/bind rules you set),
|
|
50
50
|
plus the public URL, password and master key
|
|
51
51
|
- **New session** — pick a directory; that is where the agent's shell runs
|
|
52
52
|
|
|
@@ -61,17 +61,34 @@ an existing Pi setup:
|
|
|
61
61
|
PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier serve
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
Pier exports that variable for the SDK, so anything it starts inherits it — a
|
|
65
|
+
shell in the Web Terminal included. A second Pier launched from there with its
|
|
66
|
+
own `PIER_HOME` derives its own agent directory rather than adopting the first
|
|
67
|
+
one's; setting `PI_CODING_AGENT_DIR` again on that command line still wins.
|
|
68
|
+
|
|
64
69
|
Console → Settings is the normal setup path:
|
|
65
70
|
|
|
66
|
-
- **
|
|
67
|
-
|
|
68
|
-
back to `models.json`.
|
|
69
|
-
|
|
71
|
+
- **Models** configures built-in or custom endpoints and API-key/OAuth login,
|
|
72
|
+
then pins the few models this deployment favors. Stored credentials are
|
|
73
|
+
sealed in Pier's SQLite database; they are not written back to `models.json`.
|
|
74
|
+
**Test** sends one real request on a model you pick and shows both halves of
|
|
75
|
+
it — the body as the provider received it, and what came back — so a wrong
|
|
76
|
+
base URL, a revoked key, a gateway rewriting the request or a model the
|
|
77
|
+
endpoint never had says so here instead of in a session.
|
|
78
|
+
- **Agent** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
|
|
70
79
|
`models.json` structure in the Pi agent directory — globally, or per project
|
|
71
80
|
scope, where it also shows that project's `.pi/skills` and `.pi/extensions`
|
|
72
81
|
resources. Changes apply when a session next opens; saving here recycles the
|
|
73
82
|
idle ones for you, and **Settings → Instance → Reload** does it for files
|
|
74
83
|
something else changed — an agent, or an editor on the box.
|
|
84
|
+
The same tab lists the extensions Pier ships with, under Global — they live
|
|
85
|
+
inside the package, so there is nothing to install and an update never
|
|
86
|
+
touches your own `extensions` directory, and if an extension of yours
|
|
87
|
+
already registers the same tool, Pier's copy stands down. `web` is the one
|
|
88
|
+
shipped today: the public web through the provider's own hosted web tools —
|
|
89
|
+
`web_search` on an authenticated Anthropic or OpenAI model, `web_fetch` on an
|
|
90
|
+
Anthropic one (OpenAI hosts no fetch tool), and no other key or service. The
|
|
91
|
+
page says which tools a switch adds and what each needs before you flip it.
|
|
75
92
|
|
|
76
93
|
On first credential access, Pier imports an existing `auth.json` into its sealed
|
|
77
94
|
store and renames the source to `auth.json.imported`. Literal provider keys left
|
package/dist/agent/pi.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// AgentFactory/AgentSession seam from src/core/types.ts on the
|
|
3
|
-
// No Pi type may appear in an exported signature.
|
|
1
|
+
// The only file outside src/extensions allowed to import @earendil-works/pi-*.
|
|
2
|
+
// Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
|
|
3
|
+
// Pi SDK. No Pi type may appear in an exported signature.
|
|
4
4
|
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { inlineExtensions } from "../extensions/index.js";
|
|
5
6
|
import { logger } from "../log.js";
|
|
6
7
|
import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
7
8
|
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
@@ -13,6 +14,13 @@ const log = logger("agent");
|
|
|
13
14
|
* a tool error the agent can retry with an explicit longer timeout, instead of
|
|
14
15
|
* killing the whole run. */
|
|
15
16
|
const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
17
|
+
/** A probe nobody is watching is a hung page: the Console waits on this. */
|
|
18
|
+
const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
|
|
19
|
+
/** An ordinary budget, not a token: a 1-token cap is a request no real turn
|
|
20
|
+
* ever makes, and answers about it are answers about a different request. */
|
|
21
|
+
const PROVIDER_CHECK_MAX_TOKENS = 8192;
|
|
22
|
+
/** Neither half of a probe is worth more than a screen. */
|
|
23
|
+
const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
|
|
16
24
|
/** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
|
|
17
25
|
const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work, verify results, and state what you could not check.
|
|
18
26
|
|
|
@@ -35,6 +43,28 @@ const bashTimeoutDefault = (pi) => {
|
|
|
35
43
|
}
|
|
36
44
|
});
|
|
37
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* A bundled extension stands down when a copy on disk already registers one of
|
|
48
|
+
* its tools. Pi loads both and reports the clash as a diagnostic nobody reads,
|
|
49
|
+
* leaving two tools of the same name and no way to tell which one answered;
|
|
50
|
+
* the copy the user put there wins, and the journal says so (§5b).
|
|
51
|
+
*/
|
|
52
|
+
export const standDownShadowed = (base) => {
|
|
53
|
+
const inline = (ext) => ext.path.startsWith("<inline:");
|
|
54
|
+
const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
|
|
55
|
+
if (!onDisk.size)
|
|
56
|
+
return base;
|
|
57
|
+
return {
|
|
58
|
+
...base,
|
|
59
|
+
extensions: base.extensions.filter((ext) => {
|
|
60
|
+
const clash = inline(ext) && [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
|
|
61
|
+
if (!clash || !clash.length)
|
|
62
|
+
return true;
|
|
63
|
+
log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from disk`);
|
|
64
|
+
return false;
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
38
68
|
export class PiSession {
|
|
39
69
|
pi;
|
|
40
70
|
pinned;
|
|
@@ -177,6 +207,7 @@ export class PiAgentFactory {
|
|
|
177
207
|
credentials;
|
|
178
208
|
providerConfig;
|
|
179
209
|
pinned;
|
|
210
|
+
enabledExtensions;
|
|
180
211
|
constructor(extraTools = [],
|
|
181
212
|
/** Appended as a virtual context file, so Pi's own prompt stays intact.
|
|
182
213
|
* Read per session, not captured once: it carries settings a user can
|
|
@@ -193,13 +224,17 @@ export class PiAgentFactory {
|
|
|
193
224
|
credentials, providerConfig = new PiConfigStore(),
|
|
194
225
|
/** Operator-pinned models (Console → Settings → Models), surfaced first in
|
|
195
226
|
* every picker. A getter for the same reason `instructions` is one. */
|
|
196
|
-
pinned = () => []
|
|
227
|
+
pinned = () => [],
|
|
228
|
+
/** Which bundled extensions the Console has switched on. A getter for the
|
|
229
|
+
* same reason again: the toggle takes effect on the next session open. */
|
|
230
|
+
enabledExtensions = () => []) {
|
|
197
231
|
this.extraTools = extraTools;
|
|
198
232
|
this.instructions = instructions;
|
|
199
233
|
this.skillPaths = skillPaths;
|
|
200
234
|
this.credentials = credentials;
|
|
201
235
|
this.providerConfig = providerConfig;
|
|
202
236
|
this.pinned = pinned;
|
|
237
|
+
this.enabledExtensions = enabledExtensions;
|
|
203
238
|
}
|
|
204
239
|
/** One runtime for the whole process; catalogs are global, not per session. */
|
|
205
240
|
catalog;
|
|
@@ -276,6 +311,65 @@ export class PiAgentFactory {
|
|
|
276
311
|
};
|
|
277
312
|
});
|
|
278
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* One real request on the model the operator named. `configured` only ever
|
|
316
|
+
* meant "a credential is stored", and a wrong base URL, a revoked key, a
|
|
317
|
+
* gateway rewriting the request and a model this endpoint has never heard of
|
|
318
|
+
* all look identical until a turn fails hours later.
|
|
319
|
+
*
|
|
320
|
+
* The request goes out through a fetch of our own for one reason: what a
|
|
321
|
+
* provider (or a proxy in front of it) was actually sent, and what it
|
|
322
|
+
* actually said, is the answer here — a summary of either would be Pier's
|
|
323
|
+
* word for someone else's.
|
|
324
|
+
*/
|
|
325
|
+
async check(providerId, modelId) {
|
|
326
|
+
const started = Date.now();
|
|
327
|
+
const signal = AbortSignal.timeout(PROVIDER_CHECK_TIMEOUT_MS);
|
|
328
|
+
let request = "";
|
|
329
|
+
let body = Promise.resolve("");
|
|
330
|
+
const recorded = async (input, init) => {
|
|
331
|
+
request = typeof init?.body === "string" ? init.body : "";
|
|
332
|
+
const response = await globalThis.fetch(input, init);
|
|
333
|
+
// Cloned, not consumed: the SDK still needs to read the real stream.
|
|
334
|
+
body = response.clone().text().then(clip, () => "");
|
|
335
|
+
return response;
|
|
336
|
+
};
|
|
337
|
+
const answered = (text, ok) => ({
|
|
338
|
+
ok,
|
|
339
|
+
model: modelId,
|
|
340
|
+
ms: Date.now() - started,
|
|
341
|
+
request: clip(request),
|
|
342
|
+
response: text,
|
|
343
|
+
});
|
|
344
|
+
try {
|
|
345
|
+
const runtime = await this.refreshedRuntime();
|
|
346
|
+
const model = runtime.getModel(providerId, modelId);
|
|
347
|
+
if (!model)
|
|
348
|
+
throw new Error(`unknown model: ${providerId}/${modelId}`);
|
|
349
|
+
const answer = await runtime.completeSimple(model, { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, { maxTokens: PROVIDER_CHECK_MAX_TOKENS, signal, fetch: recorded });
|
|
350
|
+
// A refusal can arrive as a message rather than a throw; the stop reason
|
|
351
|
+
// is the only thing separating it from an answer.
|
|
352
|
+
const refused = answer.stopReason === "error" || answer.stopReason === "aborted";
|
|
353
|
+
if (refused) {
|
|
354
|
+
throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
|
|
355
|
+
}
|
|
356
|
+
const text = answer.content
|
|
357
|
+
.filter((part) => part.type === "text")
|
|
358
|
+
.map((part) => part.text)
|
|
359
|
+
.join("")
|
|
360
|
+
.trim();
|
|
361
|
+
// An empty answer is still an answer; say which kind of nothing it was.
|
|
362
|
+
return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
366
|
+
log.warn(`provider check failed for ${providerId}/${modelId}`, err);
|
|
367
|
+
const raw = await body;
|
|
368
|
+
return answered(signal.aborted
|
|
369
|
+
? `no answer within ${PROVIDER_CHECK_TIMEOUT_MS / 1000}s (${error})`
|
|
370
|
+
: raw || error, false);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
279
373
|
async setup(input) {
|
|
280
374
|
const builtins = await this.builtinIds();
|
|
281
375
|
if (input.kind === "builtin" && !builtins.has(input.id)) {
|
|
@@ -344,7 +438,11 @@ export class PiAgentFactory {
|
|
|
344
438
|
// Pi's generic default, preserving the user's later instruction layer.
|
|
345
439
|
systemPromptOverride: pierSystemPrompt,
|
|
346
440
|
additionalSkillPaths: this.skillPaths,
|
|
347
|
-
extensionFactories: [
|
|
441
|
+
extensionFactories: [
|
|
442
|
+
{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
|
|
443
|
+
...inlineExtensions(this.enabledExtensions()),
|
|
444
|
+
],
|
|
445
|
+
extensionsOverride: standDownShadowed,
|
|
348
446
|
agentsFilesOverride: (current) => {
|
|
349
447
|
const content = this.instructions();
|
|
350
448
|
return {
|
|
@@ -29,6 +29,16 @@ export class ConversationStore {
|
|
|
29
29
|
session_id = excluded.session_id, updated_at = excluded.updated_at
|
|
30
30
|
`).run(key.channelId, key.conversationId, sessionId, Date.now());
|
|
31
31
|
}
|
|
32
|
+
/** Which channel owns this session, durably — the router's own answer is
|
|
33
|
+
* in-memory and becomes undefined the moment an idle session is evicted, so
|
|
34
|
+
* a surface asking "was this turn already delivered to a chat?" long after
|
|
35
|
+
* the turn cannot use it. Sessions with no row are nobody's conversation. */
|
|
36
|
+
channelOf(sessionId) {
|
|
37
|
+
const row = this.db.prepare(`
|
|
38
|
+
SELECT channel_id FROM conversations WHERE session_id = ? LIMIT 1
|
|
39
|
+
`).get(sessionId);
|
|
40
|
+
return row?.channel_id;
|
|
41
|
+
}
|
|
32
42
|
/** Drop a mapping whose session Pi no longer has, so the next message
|
|
33
43
|
* starts a fresh one instead of failing forever. */
|
|
34
44
|
forget(key) {
|
package/dist/core/router.js
CHANGED
|
@@ -17,6 +17,11 @@ const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}
|
|
|
17
17
|
function keyOf(key) {
|
|
18
18
|
return `${key.channelId}:${key.conversationId}`;
|
|
19
19
|
}
|
|
20
|
+
/** `web:<id>` and `task:<id>` are two names for one session id, and neither is
|
|
21
|
+
* a chat — no Channel is registered under them. So they share a lock in
|
|
22
|
+
* `ensure`, and which of the two a session records costs nothing but the
|
|
23
|
+
* answer to "what is it answering". */
|
|
24
|
+
const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
|
|
20
25
|
export class Router {
|
|
21
26
|
hub;
|
|
22
27
|
resolve;
|
|
@@ -101,6 +106,9 @@ export class Router {
|
|
|
101
106
|
*/
|
|
102
107
|
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now(), { includeWatched = false } = {}) {
|
|
103
108
|
let evicted = 0;
|
|
109
|
+
// Snapshot on purpose: this loop awaits dispose(), so another turn may
|
|
110
|
+
// attach or drop a session while it is suspended.
|
|
111
|
+
// oxlint-disable-next-line unicorn/no-useless-spread
|
|
104
112
|
for (const [id, attached] of [...this.bySession]) {
|
|
105
113
|
if (attached.session.state === "streaming")
|
|
106
114
|
continue;
|
|
@@ -162,7 +170,7 @@ export class Router {
|
|
|
162
170
|
const existing = this.bySession.get(session.id);
|
|
163
171
|
if (existing?.session === session) {
|
|
164
172
|
this.byKey.set(keyOf(key), session);
|
|
165
|
-
|
|
173
|
+
this.reached(session, key);
|
|
166
174
|
return;
|
|
167
175
|
}
|
|
168
176
|
if (existing) {
|
|
@@ -288,16 +296,14 @@ export class Router {
|
|
|
288
296
|
let session = this.byKey.get(keyOf(key));
|
|
289
297
|
// Web and task conversation ids are session ids. Reuse an attached
|
|
290
298
|
// instance so two surfaces never open the same Pi transcript twice.
|
|
291
|
-
if (!session && (key
|
|
299
|
+
if (!session && isAlias(key)) {
|
|
292
300
|
session = this.bySession.get(key.conversationId)?.session;
|
|
293
301
|
if (session)
|
|
294
302
|
this.byKey.set(keyOf(key), session);
|
|
295
303
|
}
|
|
296
304
|
if (!session) {
|
|
297
305
|
// Aliases share one lock: web:<id> and task:<id> must not each open one.
|
|
298
|
-
const lock = key
|
|
299
|
-
? `session:${key.conversationId}`
|
|
300
|
-
: keyOf(key);
|
|
306
|
+
const lock = isAlias(key) ? `session:${key.conversationId}` : keyOf(key);
|
|
301
307
|
const inflight = this.opening.get(lock);
|
|
302
308
|
// A second caller rides the first one's resolve — which attaches before
|
|
303
309
|
// this continuation runs, having awaited it first — and registers its own
|
|
@@ -305,7 +311,7 @@ export class Router {
|
|
|
305
311
|
if (inflight) {
|
|
306
312
|
session = await inflight;
|
|
307
313
|
this.byKey.set(keyOf(key), session);
|
|
308
|
-
return this.reached(session);
|
|
314
|
+
return this.reached(session, key);
|
|
309
315
|
}
|
|
310
316
|
try {
|
|
311
317
|
// Inside the try: a resolver that throws synchronously is the same
|
|
@@ -323,7 +329,7 @@ export class Router {
|
|
|
323
329
|
}
|
|
324
330
|
this.attach(key, session);
|
|
325
331
|
}
|
|
326
|
-
return this.reached(session);
|
|
332
|
+
return this.reached(session, key);
|
|
327
333
|
}
|
|
328
334
|
/** A session that would not open has no event stream of its own to report on
|
|
329
335
|
* — unless its id is what we were asked for, which is what a web or task key
|
|
@@ -343,11 +349,21 @@ export class Router {
|
|
|
343
349
|
.catch((e) => log.error(`could not report it to ${key.channelId}`, e));
|
|
344
350
|
}
|
|
345
351
|
/** Reached for, so not idle — every surface that uses a session comes
|
|
346
|
-
* through `ensure`, including the ones that only read it.
|
|
347
|
-
|
|
352
|
+
* through `ensure`, including the ones that only read it.
|
|
353
|
+
*
|
|
354
|
+
* Also where a session learns which of its two aliases is current: a task
|
|
355
|
+
* callback (tasks/outbox.ts) opens a workbench session under `task:<id>`
|
|
356
|
+
* whenever nothing had it attached, and the key from that first attach used
|
|
357
|
+
* to stand forever — so the workbench's own next turn was still "a task",
|
|
358
|
+
* and the notification for it (web/push.ts) was never sent. A chat key is
|
|
359
|
+
* never overwritten: that one is also where turn-ends are delivered. */
|
|
360
|
+
reached(session, key) {
|
|
348
361
|
const attached = this.bySession.get(session.id);
|
|
349
|
-
if (attached)
|
|
350
|
-
|
|
362
|
+
if (!attached)
|
|
363
|
+
return session;
|
|
364
|
+
attached.activeAt = Date.now();
|
|
365
|
+
if (isAlias(key) && isAlias(attached.key))
|
|
366
|
+
attached.key = key;
|
|
351
367
|
return session;
|
|
352
368
|
}
|
|
353
369
|
async dispatch(msg) {
|
package/dist/db.js
CHANGED
|
@@ -166,6 +166,15 @@ const MIGRATIONS = [
|
|
|
166
166
|
private_key TEXT NOT NULL,
|
|
167
167
|
created_at INTEGER NOT NULL
|
|
168
168
|
);
|
|
169
|
+
`,
|
|
170
|
+
// 6 — Projects keeps the order the workbench was put in, by hand.
|
|
171
|
+
`
|
|
172
|
+
-- Manual order, both nullable: a row nobody has dragged sorts on top of the
|
|
173
|
+
-- list it belongs to, so a fresh database needs no backfill. sort places a
|
|
174
|
+
-- session inside its project; project_sort places the project, carried on
|
|
175
|
+
-- every one of its rows because a project is a cwd, not a table.
|
|
176
|
+
ALTER TABLE session_state ADD COLUMN sort INTEGER;
|
|
177
|
+
ALTER TABLE session_state ADD COLUMN project_sort INTEGER;
|
|
169
178
|
`,
|
|
170
179
|
];
|
|
171
180
|
let shared;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The extensions Pier ships with — the list, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// An extension is Pi-shaped by construction (it takes an ExtensionAPI), so
|
|
4
|
+
// this area is the second one allowed to import the Pi SDK. Nothing outside
|
|
5
|
+
// agent/ imports it: the Console sees names and summaries, which agent/ hands
|
|
6
|
+
// over as plain data through the ConfigStore seam.
|
|
7
|
+
//
|
|
8
|
+
// Bundled rather than dropped in <agentDir>/extensions because a copy on disk
|
|
9
|
+
// has an owner problem — an update either clobbers the user's edits or skips
|
|
10
|
+
// them forever. These ship inside the package, load as inline factories, and
|
|
11
|
+
// stand down when a copy on disk already registers the same tools.
|
|
12
|
+
import web from "./web/index.js";
|
|
13
|
+
export const BUNDLED = [
|
|
14
|
+
{
|
|
15
|
+
name: "web",
|
|
16
|
+
summary: "The public web through the provider's own hosted web tools — no extra " +
|
|
17
|
+
"key, no second service, no new dependency.",
|
|
18
|
+
tools: [
|
|
19
|
+
{ name: "web_search", needs: "an authenticated Anthropic or OpenAI model" },
|
|
20
|
+
{ name: "web_fetch", needs: "an authenticated Anthropic model — OpenAI hosts no fetch tool" },
|
|
21
|
+
],
|
|
22
|
+
factory: web,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
/** The catalog a surface may show: no Pi types, nothing it cannot render. */
|
|
26
|
+
export const bundledInfo = (enabled) => BUNDLED.map(({ name, summary, tools }) => ({
|
|
27
|
+
name,
|
|
28
|
+
summary,
|
|
29
|
+
tools,
|
|
30
|
+
enabled: enabled.includes(name),
|
|
31
|
+
}));
|
|
32
|
+
/** The enabled ones as Pi inline extensions; unknown names are not ours. */
|
|
33
|
+
export const inlineExtensions = (enabled) => BUNDLED.filter((ext) => enabled.includes(ext.name))
|
|
34
|
+
.map(({ name, factory }) => ({ name, factory }));
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { postJson } from "./http.js";
|
|
2
|
+
import { isObject } from "./json.js";
|
|
3
|
+
const tokensFrom = (value) => {
|
|
4
|
+
const usage = isObject(value) ? value : {};
|
|
5
|
+
const count = (field) => (typeof field === "number" ? field : 0);
|
|
6
|
+
return { input: count(usage.input_tokens), output: count(usage.output_tokens) };
|
|
7
|
+
};
|
|
8
|
+
const NATIVE_TOOL_TYPES = {
|
|
9
|
+
web_search: "web_search_20250305",
|
|
10
|
+
web_fetch: "web_fetch_20250910",
|
|
11
|
+
};
|
|
12
|
+
const findCode = (value) => {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
for (const item of value) {
|
|
15
|
+
const code = findCode(item);
|
|
16
|
+
if (code)
|
|
17
|
+
return code;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (!isObject(value))
|
|
22
|
+
return undefined;
|
|
23
|
+
return typeof value.error_code === "string" ? value.error_code : findCode(value.content);
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Every server-tool failure in the turn. A list, not the first one, and not a
|
|
27
|
+
* throw: these arrive per invocation — the third search can fail while the
|
|
28
|
+
* first two are in the transcript and the briefing is written from them. This
|
|
29
|
+
* used to abort the whole call on any of them, which threw away a good answer
|
|
30
|
+
* over `max_uses_exceeded`, a code we provoke ourselves by budgeting the
|
|
31
|
+
* searches the prompt then asks for.
|
|
32
|
+
*/
|
|
33
|
+
function toolErrors(content) {
|
|
34
|
+
const codes = [];
|
|
35
|
+
for (const block of content) {
|
|
36
|
+
if (!isObject(block) || typeof block.type !== "string")
|
|
37
|
+
continue;
|
|
38
|
+
if (!block.type.endsWith("_tool_result"))
|
|
39
|
+
continue;
|
|
40
|
+
const code = findCode(block.content);
|
|
41
|
+
if (code)
|
|
42
|
+
codes.push(`${block.type}: ${code}`);
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(codes)];
|
|
45
|
+
}
|
|
46
|
+
/** Whether anything usable came back at all: prose the model wrote, or a tool
|
|
47
|
+
* result that is not itself an error. This is what decides failure now. */
|
|
48
|
+
function hasContent(content) {
|
|
49
|
+
return content.some((block) => {
|
|
50
|
+
if (!isObject(block) || typeof block.type !== "string")
|
|
51
|
+
return false;
|
|
52
|
+
if (block.type === "text")
|
|
53
|
+
return typeof block.text === "string" && block.text.trim() !== "";
|
|
54
|
+
if (!block.type.endsWith("_tool_result"))
|
|
55
|
+
return false;
|
|
56
|
+
return findCode(block.content) === undefined;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export async function callNativeTool(request, name, prompt, options, signal,
|
|
60
|
+
/** Progress for the surface the call came from: a hosted search is tens of
|
|
61
|
+
* seconds of nothing otherwise (§5b). */
|
|
62
|
+
note) {
|
|
63
|
+
const tool = {
|
|
64
|
+
type: NATIVE_TOOL_TYPES[name],
|
|
65
|
+
name,
|
|
66
|
+
max_uses: options.maxUses,
|
|
67
|
+
};
|
|
68
|
+
if (options.allowedDomains?.length)
|
|
69
|
+
tool.allowed_domains = options.allowedDomains;
|
|
70
|
+
if (options.blockedDomains?.length)
|
|
71
|
+
tool.blocked_domains = options.blockedDomains;
|
|
72
|
+
if (name === "web_fetch") {
|
|
73
|
+
tool.citations = { enabled: true };
|
|
74
|
+
tool.max_content_tokens = options.maxContentTokens ?? 20_000;
|
|
75
|
+
}
|
|
76
|
+
const messages = [{ role: "user", content: prompt }];
|
|
77
|
+
const accumulated = [];
|
|
78
|
+
const spent = { input: 0, output: 0 };
|
|
79
|
+
for (let continuation = 0; continuation < 3; continuation++) {
|
|
80
|
+
if (continuation)
|
|
81
|
+
note?.(`still working — round ${continuation + 1}`);
|
|
82
|
+
const data = await postJson("Anthropic", request.url, request.headers, {
|
|
83
|
+
model: request.model,
|
|
84
|
+
max_tokens: request.maxTokens,
|
|
85
|
+
messages,
|
|
86
|
+
tools: [tool],
|
|
87
|
+
...(continuation === 0 ? { tool_choice: { type: "tool", name } } : {}),
|
|
88
|
+
}, signal);
|
|
89
|
+
if (!Array.isArray(data.content)) {
|
|
90
|
+
throw new Error("Anthropic returned an invalid Messages response");
|
|
91
|
+
}
|
|
92
|
+
accumulated.push(...data.content);
|
|
93
|
+
const round = tokensFrom(data.usage);
|
|
94
|
+
spent.input += round.input;
|
|
95
|
+
spent.output += round.output;
|
|
96
|
+
if (data.stop_reason === "pause_turn") {
|
|
97
|
+
messages.push({ role: "assistant", content: data.content });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const errors = toolErrors(accumulated);
|
|
101
|
+
const used = accumulated.some((block) => isObject(block) &&
|
|
102
|
+
(block.type === "server_tool_use" || block.type === `${name}_tool_result`));
|
|
103
|
+
// Only now, with the whole turn in hand, is "this failed" answerable.
|
|
104
|
+
if (!used)
|
|
105
|
+
throw new Error(errors.join("; ") || `Claude did not invoke ${name}`);
|
|
106
|
+
if (!hasContent(accumulated)) {
|
|
107
|
+
throw new Error(errors.join("; ") || `${name} returned nothing usable`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
content: accumulated,
|
|
111
|
+
model: request.model,
|
|
112
|
+
...(typeof data.stop_reason === "string" ? { stopReason: data.stop_reason } : {}),
|
|
113
|
+
usage: spent,
|
|
114
|
+
errors,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`${name} exceeded the continuation limit`);
|
|
118
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A fetched page kept whole on disk, so the digest in the transcript is never
|
|
2
|
+
// the only copy: the model gets the distillate, the path gets the document.
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { logger } from "../../log.js";
|
|
7
|
+
import { pierPath } from "../../paths.js";
|
|
8
|
+
const log = logger("web");
|
|
9
|
+
const ARTIFACT_DIR = process.env.PIER_WEB_ARTIFACT_DIR?.trim() ||
|
|
10
|
+
pierPath("artifacts", "web");
|
|
11
|
+
const RETENTION_DAYS = Number(process.env.PIER_WEB_ARTIFACT_DAYS) || 30;
|
|
12
|
+
/** Retention is measured in days, so sweeping more than hourly is a directory
|
|
13
|
+
* walk per fetch buying nothing. Per process; a restart sweeps again. */
|
|
14
|
+
const PRUNE_EVERY_MS = 3_600_000;
|
|
15
|
+
let prunedAt = 0;
|
|
16
|
+
/** Drops expired artifacts and the temp files a crashed run left behind. */
|
|
17
|
+
async function prune() {
|
|
18
|
+
prunedAt = Date.now();
|
|
19
|
+
const cutoff = Date.now() - RETENTION_DAYS * 86_400_000;
|
|
20
|
+
const names = await readdir(ARTIFACT_DIR);
|
|
21
|
+
await Promise.all(names.map(async (name) => {
|
|
22
|
+
const file = join(ARTIFACT_DIR, name);
|
|
23
|
+
const info = await stat(file).catch(() => undefined);
|
|
24
|
+
if (info?.isFile() && info.mtimeMs < cutoff)
|
|
25
|
+
await rm(file, { force: true });
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function displayUrl(url) {
|
|
29
|
+
const redacted = new URL(url);
|
|
30
|
+
for (const key of redacted.searchParams.keys()) {
|
|
31
|
+
if (/token|key|secret|signature|credential|auth/i.test(key)) {
|
|
32
|
+
redacted.searchParams.set(key, "REDACTED");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return redacted.toString();
|
|
36
|
+
}
|
|
37
|
+
export async function saveArtifact(url, text, retrievedAt) {
|
|
38
|
+
await mkdir(ARTIFACT_DIR, { recursive: true, mode: 0o700 });
|
|
39
|
+
const host = url.hostname.replace(/[^a-zA-Z0-9.-]+/g, "-").slice(0, 80) || "page";
|
|
40
|
+
const hash = createHash("sha256").update(url.toString()).digest("hex").slice(0, 16);
|
|
41
|
+
const path = join(ARTIFACT_DIR, `${host}-${hash}.md`);
|
|
42
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
43
|
+
const header = [
|
|
44
|
+
`Source: ${displayUrl(url)}`,
|
|
45
|
+
`Retrieved: ${retrievedAt || new Date().toISOString()}`,
|
|
46
|
+
"",
|
|
47
|
+
].join("\n");
|
|
48
|
+
await writeFile(temporary, `${header}${text}`, { encoding: "utf8", mode: 0o600 });
|
|
49
|
+
await rename(temporary, path);
|
|
50
|
+
// Housekeeping must not fail a successful fetch, but it must not vanish either.
|
|
51
|
+
if (Date.now() - prunedAt >= PRUNE_EVERY_MS) {
|
|
52
|
+
await prune().catch((error) => {
|
|
53
|
+
log.warn("artifact prune failed", error);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return path;
|
|
57
|
+
}
|