@timqi/pier 0.0.29 → 0.1.1

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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/drain.js CHANGED
@@ -1,20 +1,12 @@
1
1
  // A graceful restart: refuse new work, let running turns finish, and write
2
- // down what the deadline had to cut off so the next boot can tell the chats.
3
- //
4
- // The trigger is SIGUSR2 (main.ts); systemd's `Restart=always` is the "start
5
- // again" half. SIGTERM stays the fast path systemd expects — this file is only
6
- // the slow one. Nothing here is persisted for its own sake: everything durable
7
- // (transcripts, the chat → session map, task runs) already survives a restart,
8
- // so the ledger below holds only the one thing that would otherwise vanish
9
- // silently — turns and queued messages the deadline aborted (§5b).
2
+ // down what the deadline cut off so the next boot can tell the chats (§5).
3
+ // Everything else durable already survives a restart.
10
4
  import { logger } from "./log.js";
11
5
  const log = logger("drain");
12
- /** How long running turns may take before they are aborted. Generous: a turn
13
- * can be a subagent fan-out, and an abort still persists the partial work. */
14
- export const DRAIN_DEADLINE_MS = 5 * 60_000;
6
+ /** Generous: a turn can be a subagent fan-out. */
7
+ const DRAIN_DEADLINE_MS = 5 * 60_000;
15
8
  const POLL_MS = 1_000;
16
- /** Shared cleanup window after the deadline. All sessions use the same clock,
17
- * so N hung seams still cost at most this long rather than N times as long. */
9
+ /** Shared across sessions, so N hung seams cost this long, not N times it. */
18
10
  const CLEANUP_BOUND_MS = 10_000;
19
11
  /** What the dying process owes the chats, held for the next one to deliver. */
20
12
  export class RestartLedger {
@@ -38,13 +30,9 @@ export class RestartLedger {
38
30
  this.db.prepare("DELETE FROM restart_ledger WHERE id = ?").run(id);
39
31
  }
40
32
  }
41
- /**
42
- * Resolve when the process may exit: every turn settled and every task run
43
- * terminal, or the deadline reached and the stragglers aborted into the
44
- * ledger. The caller (main.ts) owns what happens next — the ordinary shutdown,
45
- * minus aborting task runs: the boot-time interrupted marking is the recovery
46
- * path (tasks/service.ts start()), not a teardown race against dying channels.
47
- */
33
+ /** Resolves when the process may exit: everything settled, or the deadline
34
+ * reached and the stragglers aborted into the ledger. Task runs are left to
35
+ * the boot-time interrupted marking (tasks/service.ts). */
48
36
  export async function drainForRestart(deps, deadlineMs = DRAIN_DEADLINE_MS, pollMs = POLL_MS, cleanupBoundMs = CLEANUP_BOUND_MS) {
49
37
  const { router, tasks, ledger } = deps;
50
38
  router.beginDrain();
@@ -53,29 +41,38 @@ export async function drainForRestart(deps, deadlineMs = DRAIN_DEADLINE_MS, poll
53
41
  let lastReport = "";
54
42
  for (;;) {
55
43
  // Sleep first: a prompt accepted just before the gate closed may not have
56
- // flipped its session to streaming yet, and exiting on that blink would
57
- // cut off the very turn the drain exists to protect.
44
+ // flipped its session to streaming yet.
58
45
  await new Promise((resolve) => setTimeout(resolve, pollMs));
59
46
  const busy = router.busy();
60
47
  const runs = tasks.activeRunCount();
61
48
  if (busy.length === 0 && runs === 0) {
62
49
  log.info("drained — nothing running");
50
+ await queuesToLedger(router, ledger, new Set(), Date.now() + cleanupBoundMs);
63
51
  return;
64
52
  }
53
+ const turns = busy.filter((b) => !b.sending);
54
+ const sends = busy.filter((b) => b.sending);
65
55
  if (Date.now() >= deadline) {
66
- log.warn(`drain deadline after ${String(Math.round(deadlineMs / 1000))}s — aborting ${String(busy.length)} turn(s); ` +
67
- `${String(runs)} task run(s) will be marked interrupted at boot`);
56
+ log.warn(`drain deadline after ${String(Math.round(deadlineMs / 1000))}s — aborting ${String(turns.length)} turn(s), ` +
57
+ `${String(sends.length)} reply(ies) still sending; ${String(runs)} task run(s) will be marked interrupted at boot`);
58
+ // A send cannot be aborted, only owned up to: the exit will cut it off.
59
+ for (const { key } of sends) {
60
+ ledger.record({
61
+ channelId: key.channelId, conversationId: key.conversationId,
62
+ note: "Pier restarted while sending the last answer — it may have arrived incomplete; the session transcript has all of it.",
63
+ });
64
+ }
68
65
  const cleanupDeadline = Date.now() + cleanupBoundMs;
69
- await Promise.all(busy.map(({ session, key }) => abortToLedger(session, key, ledger, cleanupDeadline)));
66
+ await Promise.all(turns.map(({ session, key }) => abortToLedger(session, key, ledger, cleanupDeadline)));
67
+ await queuesToLedger(router, ledger, new Set(turns.map(({ session }) => session.id)), cleanupDeadline);
70
68
  return;
71
69
  }
72
- const report = `draining: ${String(busy.length)} turn(s), ${String(runs)} active task run(s)`;
70
+ const report = `draining: ${String(turns.length)} turn(s), ${String(sends.length)} reply(ies) sending, ${String(runs)} active task run(s)`;
73
71
  if (report !== lastReport)
74
72
  log.info((lastReport = report));
75
73
  }
76
74
  }
77
- /** A seam call the deadline cannot wait on forever: a hang or a rejection is
78
- * logged and answered with the fallback, and cleanup moves on. */
75
+ /** A hang or a rejection is logged and answered with the fallback. */
79
76
  async function bounded(work, ms, what, fallback) {
80
77
  let timer;
81
78
  const timeout = new Promise((resolve) => {
@@ -99,37 +96,47 @@ async function bounded(work, ms, what, fallback) {
99
96
  clearTimeout(timer);
100
97
  }
101
98
  }
102
- /** Write the chat's entry, then abort the turn. The ledger comes first so a
103
- * hung abort cannot cost the note; the abort persists the partial transcript;
104
- * the pending queue would just vanish, so its texts ride along. */
99
+ /** Ledger first, so a hung abort cannot cost the note; the pending queue would
100
+ * just vanish, so its texts ride along. */
105
101
  async function abortToLedger(session, key, ledger, cleanupDeadline) {
106
102
  const remaining = () => Math.max(0, cleanupDeadline - Date.now());
107
- const queued = await bounded(session.pendingQueue(), remaining(), `queue snapshot of session ${session.id}`, { steering: [], followUp: [] });
108
- const pending = [...queued.steering, ...queued.followUp];
109
- // A web or task key has no chat to write to: the transcript shows the
110
- // aborted turn, and a task run's interruption is reported by its callback
111
- // recovery. Only a dropped queue would be invisible there, so it is at
112
- // least logged.
103
+ recordRestartNote(ledger, session, key, await queueSnapshot(session, remaining()));
104
+ await bounded(session.abort(), remaining(), `abort of session ${session.id}`, undefined);
105
+ }
106
+ /** Pi's queue lives only in the runtime, so the exit ends it whether a turn was
107
+ * running or not: an attached session nobody was waiting on still owes its
108
+ * chat the texts it never got to (§5). `handled` are the aborted turns, whose
109
+ * note already carries their queue. */
110
+ async function queuesToLedger(router, ledger, handled, cleanupDeadline) {
111
+ await Promise.all(router.attachedSessions()
112
+ .filter(({ session }) => !handled.has(session.id))
113
+ .map(async ({ session, key }) => {
114
+ const pending = await queueSnapshot(session, Math.max(0, cleanupDeadline - Date.now()));
115
+ if (pending.length)
116
+ recordRestartNote(ledger, session, key, pending);
117
+ }));
118
+ }
119
+ async function queueSnapshot(session, boundMs) {
120
+ const queued = await bounded(session.pendingQueue(), boundMs, `queue snapshot of session ${session.id}`, { steering: [], followUp: [] });
121
+ return [...queued.steering, ...queued.followUp];
122
+ }
123
+ function recordRestartNote(ledger, session, key, pending) {
124
+ // A web or task key has no chat: the transcript shows the aborted turn, and
125
+ // only a dropped queue would be invisible, so that is logged.
113
126
  if (key.channelId === "web" || key.channelId === "task") {
114
127
  if (pending.length) {
115
128
  log.warn(`session ${session.id}: ${String(pending.length)} queued message(s) dropped by the restart`);
116
129
  }
130
+ return;
117
131
  }
118
- else {
119
- const note = [
120
- "Pier restarted before this turn finished — the last message may be unanswered.",
121
- ...(pending.length ? ["Queued and not delivered:", ...pending.map((text) => `> ${text}`)] : []),
122
- ].join("\n");
123
- ledger.record({ channelId: key.channelId, conversationId: key.conversationId, note });
124
- }
125
- await bounded(session.abort(), remaining(), `abort of session ${session.id}`, undefined);
132
+ const note = [
133
+ "Pier restarted before this turn finished — the last message may be unanswered.",
134
+ ...(pending.length ? ["Queued and not delivered:", ...pending.map((text) => `> ${text}`)] : []),
135
+ ].join("\n");
136
+ ledger.record({ channelId: key.channelId, conversationId: key.conversationId, note });
126
137
  }
127
- /**
128
- * Deliver what a previous process wrote on its way out. Runs at boot once the
129
- * adapters are up, and again on a Console unlock. Each entry is removed only
130
- * after confirmed delivery. A missing adapter or a thrown notification keeps
131
- * the debt for the next start: a duplicate apology is preferable to silence.
132
- */
138
+ /** Each entry is removed only after confirmed delivery: a duplicate apology is
139
+ * preferable to silence. */
133
140
  export async function deliverLedger(ledger, notify) {
134
141
  for (const entry of ledger.list()) {
135
142
  const target = `${entry.channelId}:${entry.conversationId}`;
@@ -1,14 +1,6 @@
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.
1
+ // The extensions Pier ships with. Bundled rather than dropped in
2
+ // <agentDir>/extensions because a copy on disk has an owner problem: an update
3
+ // either clobbers the user's edits or skips them forever.
12
4
  import web from "./web/index.js";
13
5
  export const BUNDLED = [
14
6
  {
@@ -22,14 +22,8 @@ const findCode = (value) => {
22
22
  return undefined;
23
23
  return typeof value.error_code === "string" ? value.error_code : findCode(value.content);
24
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
- */
25
+ /** A list, not a throw: the third search can fail (`max_uses_exceeded`, which
26
+ * our own budget provokes) while the briefing is written from the first two. */
33
27
  function toolErrors(content) {
34
28
  const codes = [];
35
29
  for (const block of content) {
@@ -58,7 +52,7 @@ function hasContent(content) {
58
52
  }
59
53
  export async function callNativeTool(request, name, prompt, options, signal,
60
54
  /** Progress for the surface the call came from: a hosted search is tens of
61
- * seconds of nothing otherwise (§5b). */
55
+ * seconds of nothing otherwise (§5). */
62
56
  note) {
63
57
  const tool = {
64
58
  type: NATIVE_TOOL_TYPES[name],
@@ -38,11 +38,8 @@ const digest = (value) => createHash("sha256").update(value).digest("hex").slice
38
38
  export async function saveArtifact(url, text, retrievedAt) {
39
39
  await mkdir(ARTIFACT_DIR, { recursive: true, mode: 0o700 });
40
40
  const host = url.hostname.replace(/[^a-zA-Z0-9.-]+/g, "-").slice(0, 80) || "page";
41
- // The URL alone is not the file's identity: a page fetched again is a
42
- // different document, and keying on the URL overwrote the copy an older
43
- // transcript's `artifactPath` still points at — the one promise this file
44
- // makes. Content decides, so a refetch that changed writes a new file and one
45
- // that did not costs nothing.
41
+ // Content in the key: keying on the URL alone would overwrite the copy an
42
+ // older transcript's `artifactPath` still points at.
46
43
  const path = join(ARTIFACT_DIR, `${host}-${digest(url.toString())}-${digest(text)}.md`);
47
44
  const temporary = `${path}.${randomUUID()}.tmp`;
48
45
  const header = [
@@ -1,17 +1,9 @@
1
- // What a provider's answer becomes on the way to the model: sources, results,
2
- // the queries actually searched, usage, and the text of a fetched document.
3
- // One shape for both backends, so a tool renders its answer once instead of
4
- // per wire format — anthropic.ts and openai.ts parse into these, and nothing
5
- // past this file knows which one replied.
1
+ // What a provider's answer becomes on the way to the model: one shape for both
2
+ // backends, so nothing past this file knows which one replied.
6
3
  import { isObject } from "./json.js";
7
4
  import { languageLabel } from "./language.js";
8
- /**
9
- * The one reader of a cited page, wherever it turns up: an Anthropic search
10
- * result, a citation on a text block, a fetch result, an OpenAI action source.
11
- * All four spell it `{url, title?}` (OpenAI sometimes as a bare string), all
12
- * four had their own copy of this, and they disagreed about the fallback
13
- * title. Keyed by url; the first real title wins over a url used as one.
14
- */
5
+ /** The one reader of a cited page: `{url, title?}`, or a bare string from
6
+ * OpenAI. Keyed by url; the first real title wins over a url used as one. */
15
7
  export function putSource(into, value) {
16
8
  const url = typeof value === "string"
17
9
  ? value
@@ -49,7 +41,7 @@ export function sourcesFrom(content) {
49
41
  return [...sources.values()].map(({ title, url }) => ({ title, url }));
50
42
  }
51
43
  /** Only what the search itself returned, in the order it ranked them. */
52
- export function searchResultsFrom(content) {
44
+ function searchResultsFrom(content) {
53
45
  const results = new Map();
54
46
  for (const block of content) {
55
47
  if (!isObject(block) || block.type !== "web_search_tool_result")
@@ -61,7 +53,7 @@ export function searchResultsFrom(content) {
61
53
  }
62
54
  return [...results.values()];
63
55
  }
64
- export function searchQueriesFrom(content) {
56
+ function searchQueriesFrom(content) {
65
57
  const queries = [];
66
58
  for (const block of content) {
67
59
  if (!isObject(block) || block.type !== "server_tool_use" || block.name !== "web_search") {
@@ -33,12 +33,8 @@ function retryDelay(response, attempt) {
33
33
  // instant, so a fixed backoff has them all come back at the same instant too.
34
34
  return Math.round(500 * 2 ** attempt * (0.5 + Math.random()));
35
35
  }
36
- /**
37
- * Interruptible, because the backoff is inside the caller's deadline: a
38
- * `retry-after` sleep of up to 20s followed by a whole further request is how a
39
- * 90-second ceiling turned into two minutes. Rejects on abort; the caller
40
- * reports the failure that caused the backoff, which is the useful half.
41
- */
36
+ /** Interruptible: the backoff is inside the caller's deadline, and a 20s
37
+ * `retry-after` plus another request would overrun a 90-second ceiling. */
42
38
  const sleep = (ms, signal) => new Promise((resolve, reject) => {
43
39
  if (signal?.aborted)
44
40
  return reject(signal.reason);
@@ -1,8 +1,6 @@
1
- // The language-preservation policy in words: what the model is told to search
2
- // in, and how to tell afterwards whether it did. This is the reason the
3
- // extension exists at all — a hosted search that quietly translates a Chinese
4
- // query answers a question nobody asked — so the policy is one file, and the
5
- // audit that checks it reads from the same one.
1
+ // The language-preservation policy: what the model is told to search in, and
2
+ // the audit that checks it read from the same file. A hosted search that
3
+ // quietly translates a Chinese query answers a question nobody asked.
6
4
  export function searchPrompt(query, mode) {
7
5
  const policy = mode === "preserve"
8
6
  ? "Use only the original language. Later searches may refine wording in that language, but must not translate or transliterate it."
@@ -18,22 +16,14 @@ export function searchPrompt(query, mode) {
18
16
  "Do not describe your process.",
19
17
  ].join("\n");
20
18
  }
21
- /**
22
- * The audit rule: the backend must stay in the query's language. Verbatim echo is
23
- * not the test — OpenAI's hosted search always composes its own wording, and
24
- * demanding an exact match there would buy a second search on every call.
25
- */
19
+ /** Verbatim echo is not the test: OpenAI's hosted search always composes its
20
+ * own wording, and an exact match would buy a second search on every call. */
26
21
  export function preservesLanguage(query, searched) {
27
22
  return searched !== undefined && languageLabel(searched) === languageLabel(query);
28
23
  }
29
- /**
30
- * A script, not a language, and named as loosely as the audit needs: it only
31
- * has to tell "the backend stayed where the query was" from "it translated".
32
- * Kana before Han, because Japanese is mostly Han characters and the reverse
33
- * order labelled 「東京 の天気」 Chinese in the warning it printed. Kanji-only
34
- * Japanese is still indistinguishable from Chinese here, and no ordering fixes
35
- * that — it needs a dictionary, which this is deliberately not.
36
- */
24
+ /** A script, not a language. Kana before Han: Japanese is mostly Han
25
+ * characters. Kanji-only Japanese stays indistinguishable from Chinese; that
26
+ * needs a dictionary, which this is deliberately not. */
37
27
  export function languageLabel(text) {
38
28
  if (/\p{Script=Hiragana}|\p{Script=Katakana}/u.test(text))
39
29
  return "Japanese";
@@ -61,7 +61,7 @@ function parse(data, output, model) {
61
61
  };
62
62
  }
63
63
  export async function webSearchViaResponses(request, prompt, options, signal,
64
- /** Progress for the surface the call came from (§5b). */
64
+ /** Progress for the surface the call came from (§5). */
65
65
  note) {
66
66
  const tool = { type: "web_search" };
67
67
  const filters = {};
@@ -9,15 +9,8 @@ const DEFAULT_MODEL = {
9
9
  };
10
10
  /** The one escape hatch: an endpoint that has neither default model. */
11
11
  const CONFIGURED_MODEL = process.env.PIER_WEB_MODEL?.trim();
12
- /**
13
- * Candidates for a backend, best first: the configured tool model, the
14
- * backend's cheap default, then the session's own model when it happens to be
15
- * on the right API. Every one of those is a model somebody named — there is
16
- * deliberately no "any other model on this API" step, because the model a
17
- * search runs on decides its cost, its refusals and its results, and picking
18
- * an unnamed one on the user's behalf is how a search ends up on whatever
19
- * unreleased id a gateway happened to list first.
20
- */
12
+ /** Every candidate is a model somebody named; no "any other model on this API"
13
+ * step, or a search ends up on whatever unreleased id a gateway listed first. */
21
14
  function candidates(ctx, backend) {
22
15
  const api = BACKEND_API[backend];
23
16
  const registry = ctx.modelRegistry;
@@ -78,10 +71,8 @@ async function target(ctx, backend, model, outputTokens) {
78
71
  if (!hasAuthHeader(headers))
79
72
  throw new Error(`No ${backend} authentication resolved`);
80
73
  const limit = typeof model.maxTokens === "number" && model.maxTokens > 0 ? model.maxTokens : 4096;
81
- // Responses spends reasoning tokens out of `max_output_tokens` too, so the
82
- // same budget buys a fraction of the prose there — a reasoning model can burn
83
- // the lot and return an empty answer. The caller asks for what it wants to
84
- // read; this is the one place that knows which wire it goes out on.
74
+ // Responses spends reasoning tokens out of `max_output_tokens` too; a
75
+ // reasoning model can burn the lot and return an empty answer.
85
76
  const wanted = backend === "openai" ? outputTokens * 2 : outputTokens;
86
77
  return {
87
78
  backend,
@@ -91,11 +82,7 @@ async function target(ctx, backend, model, outputTokens) {
91
82
  maxTokens: Math.max(128, Math.min(wanted, limit)),
92
83
  };
93
84
  }
94
- /**
95
- * `capable` narrows the backends that can serve the call — web_fetch is an
96
- * Anthropic-only server tool, so it passes ["anthropic"]. `requested` is the
97
- * caller's explicit choice; without one both are tried in order.
98
- */
85
+ /** `capable`: web_fetch is an Anthropic-only server tool. */
99
86
  export async function resolveTarget(ctx, outputTokens, capable = ["anthropic", "openai"], requested) {
100
87
  const wanted = (requested ? [requested] : capable).filter((backend) => capable.includes(backend));
101
88
  if (!wanted.length) {
@@ -1,8 +1,5 @@
1
- // The two tools as the model sees them: web_search and web_fetch — their
2
- // parameters, which backend answers a call, and what comes back when one
3
- // cannot. Every parameter is context the model pays for on every turn, so the
4
- // surface is deliberately small; the wire formats behind it are anthropic.ts
5
- // and openai.ts, and the answer's shape is content.ts.
1
+ // The two tools as the model sees them. Every parameter is context the model
2
+ // pays for on every turn, so the surface is deliberately small.
6
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
7
4
  import { Type } from "typebox";
8
5
  import { callNativeTool } from "./anthropic.js";
@@ -15,29 +12,13 @@ const DEFAULT_CONTEXT_CHARS = 6_000;
15
12
  const SEARCH_RESULTS = 8;
16
13
  /** `mode: "full"` is "the document", not "the transcript's whole budget". */
17
14
  const FULL_MAX_CHARS = 60_000;
18
- /**
19
- * What we pay the search model to generate. It has to cover the whole assistant
20
- * turn — the search calls it makes plus the briefing it writes — and it must
21
- * not be the binding constraint: `DEFAULT_CONTEXT_CHARS` is what we are willing
22
- * to hand back (6k characters, which is ~1.5k English tokens and ~4k Chinese
23
- * ones), so a budget below that only produces briefings that stop mid-sentence.
24
- * It used to be 900, half the smaller of those, and then 2k, which is the same
25
- * bug in Chinese: it covered the English reading of 6k characters and cut every
26
- * CJK briefing at the point this comment claimed was fixed. So the budget is
27
- * the *larger* reading plus room for the search calls themselves. Output tokens
28
- * are not the cost here either — a hosted search is worth an order of magnitude
29
- * more than the prose about it — and a truncated answer is paid for twice.
30
- */
15
+ /** Must cover the CJK reading of `DEFAULT_CONTEXT_CHARS` (6k chars ≈ 4k
16
+ * Chinese tokens, ~1.5k English) plus the search calls, or briefings stop
17
+ * mid-sentence. A hosted search costs an order of magnitude more than the prose. */
31
18
  const SEARCH_TOKENS = 4_500;
32
- /**
33
- * Same rule for a fetch, and one dial for it: `mode` says how much of the page
34
- * matters, so it decides all three sizes — what the provider fetches, what the
35
- * model may generate, and how much of the digest reaches the caller. They were
36
- * two tool parameters (`max_context_chars`, `max_content_tokens`) that only
37
- * ever restated the mode, and every parameter is read by the model on every
38
- * turn. `full` returns the document itself, so its digest budget is an
39
- * acknowledgement — generation nobody reads.
40
- */
19
+ /** One dial: `mode` decides all three sizes, since separate parameters only
20
+ * ever restated it. `full` returns the document, so its digest budget is an
21
+ * acknowledgement nobody reads. */
41
22
  const FETCH_LIMITS = {
42
23
  concise: { fetch: 10_000, generate: 1_200, digest: 6_000 },
43
24
  thorough: { fetch: 25_000, generate: 3_500, digest: 12_000 },
@@ -53,14 +34,8 @@ function clampText(text, maxChars) {
53
34
  return text;
54
35
  return `${text.slice(0, maxChars).trimEnd()}\n\n[truncated ${text.length - maxChars} characters]`;
55
36
  }
56
- /**
57
- * One ceiling for the whole tool call. The per-request timeout in http.ts is
58
- * not one: three attempts, times up to three continuation rounds, times a
59
- * language-audit retry, is tens of minutes — and Pi puts no timeout of its own
60
- * on a custom tool, so that is a turn held open with nothing to show. An
61
- * aborted caller signal already stops the retry loop, so this is the only
62
- * thing needed to bound it.
63
- */
37
+ /** The per-request timeout is not a ceiling: attempts × continuation rounds ×
38
+ * the audit retry is tens of minutes, and Pi puts no timeout on a custom tool. */
64
39
  const CALL_CEILING_MS = 90_000;
65
40
  const ceiling = (signal) => {
66
41
  const own = AbortSignal.timeout(CALL_CEILING_MS);
@@ -78,11 +53,6 @@ async function runSearch(run) {
78
53
  const result = await callNativeTool(target, "web_search", prompt, { maxUses, ...domains }, signal, note);
79
54
  return searchOutcomeFrom(result.content, result.model, target.backend, result);
80
55
  }
81
- // Every parameter here is read by the model on every turn it might search, so
82
- // each one is a standing cost. `max_uses` and `max_results` were knobs nobody
83
- // turned: the first did nothing on the OpenAI backend and had to say so out
84
- // loud in its own results, and the second only sliced a list the caller can
85
- // read the whole of.
86
56
  export const webSearch = defineTool({
87
57
  name: "web_search",
88
58
  label: "Web Search",
@@ -116,11 +86,8 @@ export const webSearch = defineTool({
116
86
  let outcome = await runSearch({ ...run, mode, maxUses: searchRounds(mode) });
117
87
  const wantedLanguage = languageLabel(params.query);
118
88
  const strayed = (o) => o.queries.filter((q) => !preservesLanguage(params.query, q.query)).map((q) => q.query);
119
- // The prompt pins the first query verbatim, so auditing only that one
120
- // audits the query that cannot fail. `preserve` promised every search
121
- // stays in the language, so it audits all of them; auto and expand buy
122
- // English supplements on purpose, so there the first query still decides
123
- // and the strays are named in `details` instead of warned about.
89
+ // `preserve` promised every search stays in the language; auto and expand
90
+ // buy English supplements on purpose, so there only the first query decides.
124
91
  const inLanguage = (o, auditAll) => auditAll
125
92
  ? o.queries.length > 0 && strayed(o).length === 0
126
93
  : preservesLanguage(params.query, o.queries[0]?.query);
@@ -147,7 +114,7 @@ export const webSearch = defineTool({
147
114
  : "";
148
115
  // A briefing that stopped at the output ceiling reads exactly like a
149
116
  // finished one; the caller decides whether to ask again, but only if it
150
- // is told (§5b).
117
+ // is told (§5).
151
118
  const cut = outcome.truncated
152
119
  ? "Warning: the briefing hit the search model's output limit and stops mid-sentence."
153
120
  : "";
@@ -240,12 +207,9 @@ export const webFetch = defineTool({
240
207
  : document.text
241
208
  ? clampText(document.text, limits.digest)
242
209
  : "Fetch completed.";
243
- // `full` means the document, not the digest — but not without a ceiling:
244
- // max_content_tokens is the model's to choose and reaches 100k, which is
245
- // a transcript nobody can read and a context nobody can afford. The whole
246
- // copy is on disk either way, and the note below points at it. A question
247
- // is still answered, above the document; "OK" is not, it is the receipt
248
- // for a digest we asked it not to write.
210
+ // `full` still has a ceiling: 100k tokens is a context nobody can afford,
211
+ // and the whole copy is on disk. "OK" is the receipt for a digest we
212
+ // asked it not to write, not an answer.
249
213
  const output = mode !== "full" ? distilled : [
250
214
  question && answer ? clampText(answer, FETCH_LIMITS.concise.digest) : "",
251
215
  document.text ? clampText(document.text, limits.digest) : "",
@@ -291,17 +255,9 @@ function parsePublicUrl(value) {
291
255
  url.hash = "";
292
256
  return url;
293
257
  }
294
- /**
295
- * Throwing is the only way to report a failed tool call: Pi's agent loop marks
296
- * the result an error when `execute` throws and ignores an `isError` field in a
297
- * returned result (`agent-loop.js`: `return { result, isError: false }`). These
298
- * tools used to return one, so every refusal they reported — a bad URL, a dead
299
- * endpoint, a hosted tool that never ran — was recorded as a success with an
300
- * apology in it.
301
- *
302
- * Our own ceiling also looks like a cancellation from the outside; say which one
303
- * it was, or the caller reads "aborted" and cannot tell whether it did that.
304
- */
258
+ /** Throwing is the only way to report a failed tool call: Pi ignores an
259
+ * `isError` field in a returned result (`agent-loop.js`). Our own ceiling
260
+ * looks like a cancellation from outside, so say which one it was. */
305
261
  function fail(error, until, caller) {
306
262
  const message = error instanceof Error ? error.message : String(error);
307
263
  const gaveUp = until?.aborted && !caller?.aborted;
package/dist/lock.js ADDED
@@ -0,0 +1,98 @@
1
+ // One Pier per instance directory: the ownership claim, taken before anything
2
+ // under PIER_HOME is opened. A pid file, because Node's stdlib has no advisory
3
+ // locking and the pid is what makes a crashed holder's claim reclaimable.
4
+ import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { PIER_LOCK } from "./paths.js";
7
+ /** Unreadable, gone, or not a pid: no holder anyone can name, so the file is
8
+ * not evidence of one. */
9
+ function holder(file) {
10
+ try {
11
+ const pid = Number(readFileSync(file, "utf8").trim());
12
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /** EPERM is a live process owned by someone else; only ESRCH proves it is gone. */
19
+ function alive(pid) {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ }
24
+ catch (err) {
25
+ return err.code === "EPERM";
26
+ }
27
+ }
28
+ /** The pid is written to a private file and hard-linked into place: the link
29
+ * either creates the lock with the pid already in it or fails EEXIST, so no
30
+ * racer can ever read an empty lock. */
31
+ function take(path) {
32
+ const mine = `${path}.${String(process.pid)}`;
33
+ writeFileSync(mine, `${String(process.pid)}\n`, { mode: 0o600 });
34
+ try {
35
+ linkSync(mine, path);
36
+ }
37
+ catch (err) {
38
+ if (err.code === "EEXIST")
39
+ return null;
40
+ throw err;
41
+ }
42
+ finally {
43
+ rmSync(mine, { force: true });
44
+ }
45
+ // Only ours: a stale-lock takeover elsewhere may have replaced the file.
46
+ return {
47
+ release: () => {
48
+ if (holder(path) === process.pid)
49
+ rmSync(path, { force: true });
50
+ },
51
+ };
52
+ }
53
+ /** Moves the stale file aside under a name only this pid uses — never an unlink
54
+ * in place — then checks it moved the inode it read; a racer's fresh claim that
55
+ * landed in between goes straight back. ENOENT is the other reclaimer winning. */
56
+ function reclaim(path, fd) {
57
+ const aside = `${path}.${String(process.pid)}.stale`;
58
+ try {
59
+ renameSync(path, aside);
60
+ }
61
+ catch (err) {
62
+ if (err.code === "ENOENT")
63
+ return;
64
+ throw err;
65
+ }
66
+ if (statSync(aside, { bigint: true }).ino === fstatSync(fd, { bigint: true }).ino)
67
+ rmSync(aside, { force: true });
68
+ else
69
+ renameSync(aside, path);
70
+ }
71
+ export function acquireInstanceLock(path = PIER_LOCK) {
72
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
73
+ const first = take(path);
74
+ if (first)
75
+ return first;
76
+ // Held open across the reclaim: an open inode cannot be reused by a new file,
77
+ // so the comparison in `reclaim` is exact.
78
+ let fd;
79
+ try {
80
+ fd = openSync(path, "r");
81
+ }
82
+ catch {
83
+ fd = undefined;
84
+ }
85
+ if (fd !== undefined) {
86
+ try {
87
+ const pid = holder(fd);
88
+ if (pid !== null && alive(pid))
89
+ return { heldBy: pid };
90
+ reclaim(path, fd);
91
+ }
92
+ finally {
93
+ closeSync(fd);
94
+ }
95
+ }
96
+ // The retry can only lose to another start that claimed the same directory.
97
+ return take(path) ?? { heldBy: holder(path) };
98
+ }