@timqi/pier 0.0.7 → 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.
Files changed (36) hide show
  1. package/README.md +26 -9
  2. package/dist/agent/pi.js +103 -5
  3. package/dist/channels/conversations.js +10 -0
  4. package/dist/core/router.js +27 -11
  5. package/dist/db.js +30 -0
  6. package/dist/extensions/index.js +34 -0
  7. package/dist/extensions/web/anthropic.js +118 -0
  8. package/dist/extensions/web/artifacts.js +57 -0
  9. package/dist/extensions/web/content.js +130 -0
  10. package/dist/extensions/web/http.js +106 -0
  11. package/dist/extensions/web/index.js +9 -0
  12. package/dist/extensions/web/json.js +5 -0
  13. package/dist/extensions/web/language.js +47 -0
  14. package/dist/extensions/web/openai.js +112 -0
  15. package/dist/extensions/web/provider.js +121 -0
  16. package/dist/extensions/web/tools.js +284 -0
  17. package/dist/main.js +44 -6
  18. package/dist/paths.js +15 -0
  19. package/dist/settings.js +68 -13
  20. package/dist/web/instance.js +32 -8
  21. package/dist/web/providers.js +16 -0
  22. package/dist/web/public/assets/{ghostty-web-BhZV0Vvv.js → ghostty-web-C4N9kjtH.js} +1 -1
  23. package/dist/web/public/assets/index-DNCJJRSS.js +91 -0
  24. package/dist/web/public/assets/index-DYl1xk5y.css +2 -0
  25. package/dist/web/public/index.html +27 -4
  26. package/dist/web/public/manifest.webmanifest +11 -1
  27. package/dist/web/public/sw.js +109 -0
  28. package/dist/web/push.js +233 -0
  29. package/dist/web/server.js +51 -4
  30. package/dist/web/session-state.js +48 -9
  31. package/dist/web/terminal.js +34 -4
  32. package/dist/web/webpush.js +131 -0
  33. package/package.json +1 -1
  34. package/skills/pier-help/SKILL.md +12 -0
  35. package/dist/web/public/assets/index-BbwoGR-O.js +0 -90
  36. package/dist/web/public/assets/index-BlHvP59B.css +0 -2
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 → Providers after signing in
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: Providers (API-key/OAuth logins), Models (the menu of
47
- favored models agents are advised with), Channels (Slack Socket-Mode or
48
- Telegram bot tokens; chats are discovered when the bot first sees traffic,
49
- gated by the mention/bind rules you set), Agent files (Pi configuration),
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
- - **Providers** configures built-in or custom endpoints and API-key/OAuth login.
67
- Stored credentials are sealed in Pier's SQLite database; they are not written
68
- back to `models.json`.
69
- - **Agent files** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
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 ONLY file allowed to import @earendil-works/pi-*. Implements the
2
- // AgentFactory/AgentSession seam from src/core/types.ts on the Pi SDK.
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: [{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true }],
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) {
@@ -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
- existing.activeAt = Date.now();
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.channelId === "web" || key.channelId === "task")) {
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.channelId === "web" || key.channelId === "task"
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
- reached(session) {
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
- attached.activeAt = Date.now();
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
@@ -145,6 +145,36 @@ const MIGRATIONS = [
145
145
  ALTER TABLE session_state ADD COLUMN cwd TEXT;
146
146
  ALTER TABLE session_state ADD COLUMN title TEXT;
147
147
  ALTER TABLE session_state ADD COLUMN created_at INTEGER;
148
+ `,
149
+ // 5 — the workbench can reach a browser that is not open (web/push.ts).
150
+ `
151
+ -- One row per browser that asked to be notified, exactly as the Push API
152
+ -- described it; a dead endpoint is deleted when its service says so.
153
+ CREATE TABLE push_subscriptions (
154
+ endpoint TEXT PRIMARY KEY,
155
+ p256dh TEXT NOT NULL,
156
+ auth TEXT NOT NULL,
157
+ label TEXT NOT NULL,
158
+ created_at INTEGER NOT NULL
159
+ );
160
+
161
+ -- This instance's VAPID identity: one key pair, minted on first use. Every
162
+ -- subscription above is bound to it, so it is never rotated on its own.
163
+ CREATE TABLE push_identity (
164
+ id INTEGER PRIMARY KEY CHECK (id = 1),
165
+ public_key TEXT NOT NULL,
166
+ private_key TEXT NOT NULL,
167
+ created_at INTEGER NOT NULL
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;
148
178
  `,
149
179
  ];
150
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
+ }