@timqi/pier 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +87 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack-tool.js +28 -3
  14. package/dist/channels/slack.js +20 -9
  15. package/dist/channels/telegram-api.js +3 -4
  16. package/dist/channels/telegram.js +37 -28
  17. package/dist/cli.js +177 -29
  18. package/dist/core/hub.js +36 -5
  19. package/dist/core/identity.js +5 -0
  20. package/dist/core/inbound-file.js +70 -0
  21. package/dist/core/inbox.js +32 -0
  22. package/dist/core/queue.js +9 -3
  23. package/dist/core/reply.js +20 -5
  24. package/dist/core/router.js +200 -14
  25. package/dist/core/types.js +53 -0
  26. package/dist/db.js +54 -8
  27. package/dist/drain.js +145 -0
  28. package/dist/main.js +180 -18
  29. package/dist/secrets.js +10 -6
  30. package/dist/service.js +192 -18
  31. package/dist/settings.js +77 -8
  32. package/dist/tasks/agent.js +41 -5
  33. package/dist/tasks/callbacks.js +29 -89
  34. package/dist/tasks/definitions.js +2 -6
  35. package/dist/tasks/execution.js +10 -1
  36. package/dist/tasks/groups.js +20 -49
  37. package/dist/tasks/messages.js +106 -21
  38. package/dist/tasks/outbox.js +157 -0
  39. package/dist/tasks/routes.js +6 -4
  40. package/dist/tasks/service.js +92 -22
  41. package/dist/tasks/store.js +48 -55
  42. package/dist/tasks/tool.js +19 -4
  43. package/dist/tasks/types.js +7 -0
  44. package/dist/update.js +146 -0
  45. package/dist/web/auth.js +89 -26
  46. package/dist/web/explorer.js +147 -0
  47. package/dist/web/files.js +28 -12
  48. package/dist/web/instance.js +165 -0
  49. package/dist/web/provider-flows.js +249 -0
  50. package/dist/web/providers.js +141 -0
  51. package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
  52. package/dist/web/public/assets/index-fASxMPr6.js +90 -0
  53. package/dist/web/public/icon-192.png +0 -0
  54. package/dist/web/public/icon-32.png +0 -0
  55. package/dist/web/public/icon-512.png +0 -0
  56. package/dist/web/public/icon-maskable-512.png +0 -0
  57. package/dist/web/public/icon-touch-192.png +0 -0
  58. package/dist/web/public/icon.svg +29 -11
  59. package/dist/web/public/index.html +50 -32
  60. package/dist/web/server.js +110 -120
  61. package/docs/deploy.md +142 -64
  62. package/package.json +1 -1
  63. package/skills/pier-boards/SKILL.md +16 -7
  64. package/skills/pier-help/SKILL.md +110 -0
  65. package/skills/pier-slack/SKILL.md +20 -3
  66. package/skills/pier-tasks/SKILL.md +19 -12
  67. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  68. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  69. package/dist/web/public/sw.js +0 -21
@@ -0,0 +1,32 @@
1
+ // Inbound user files: bytes land on disk exactly once.
2
+ //
3
+ // A photo pasted on the web, dropped in Telegram or uploaded to Slack used to
4
+ // travel as base64 through the seam into the transcript, where it was re-sent
5
+ // with every provider request until compaction. Now the adapter (or the web
6
+ // upload route) saves the bytes under `$PIER_HOME/inbox/<channel>/` and the
7
+ // prompt carries only a marker line (core/inbound-file.ts owns that grammar),
8
+ // so the agent reads a file only when it decides the file is worth looking at.
9
+ import { mkdir, writeFile } from "node:fs/promises";
10
+ import { randomBytes } from "node:crypto";
11
+ import { basename, join } from "node:path";
12
+ import { pierPath } from "../paths.js";
13
+ import { safeName } from "./inbound-file.js";
14
+ /** Where every inbound file lives; web/files.ts allowlists this root. */
15
+ export const INBOX_DIR = pierPath("inbox");
16
+ /**
17
+ * Write one inbound file and return its absolute path. The timestamp-random
18
+ * prefix keeps concurrent saves collision-free (`wx` turns the impossible
19
+ * collision into an error instead of an overwrite) and makes `ls` read as a
20
+ * timeline. Owner-only modes: uploads are private conversation content on a
21
+ * possibly shared machine. Nothing is ever deleted here — pruning the inbox
22
+ * is the operator's call (docs/deploy.md).
23
+ */
24
+ export async function saveInbound(channelId, name, mimeType, bytes) {
25
+ // The channel id is ours ("web" | "telegram" | "slack"), not user input,
26
+ // but basename() keeps a future id honest.
27
+ const dir = join(INBOX_DIR, basename(channelId));
28
+ await mkdir(dir, { recursive: true, mode: 0o700 });
29
+ const path = join(dir, `${String(Date.now())}-${randomBytes(3).toString("hex")}-${safeName(name, mimeType)}`);
30
+ await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
31
+ return path;
32
+ }
@@ -1,11 +1,17 @@
1
1
  // The whole queue policy. Fixed by docs/architecture.md — do not add options.
2
2
  export function decide(msg, state) {
3
+ // An explicit mode takes the text verbatim: IM sends steer for every
4
+ // message, so a leading "!" there is content, not a control prefix —
5
+ // consuming it silently rewrote what the person typed.
6
+ if (msg.mode === "steer" || msg.mode === "followUp") {
7
+ return { action: state === "idle" ? "prompt" : msg.mode, text: msg.text };
8
+ }
9
+ // On auto the "!" is a control prefix and always consumed — whether the
10
+ // turn happened to end first must not decide if it was content. Idle just
11
+ // means there is nothing to steer, so it degenerates to a prompt.
3
12
  const steerPrefixed = msg.text.startsWith("!");
4
13
  const text = steerPrefixed ? msg.text.slice(1).trimStart() : msg.text;
5
14
  if (state === "idle")
6
15
  return { action: "prompt", text };
7
- if (msg.mode === "steer" || msg.mode === "followUp") {
8
- return { action: msg.mode, text };
9
- }
10
16
  return { action: steerPrefixed ? "steer" : "followUp", text };
11
17
  }
@@ -13,7 +13,7 @@
13
13
  * into the factory). Both halves of the feature live in this file: the syntax
14
14
  * the agent is told to emit, and the parser that reads it back.
15
15
  */
16
- export const REPLY_SURFACE_PROMPT = `## Pier chat surface
16
+ const REPLY_SURFACE_PROMPT = `## Pier chat surface
17
17
 
18
18
  Your replies render in a chat UI (web and IM). Three optional markdown
19
19
  conventions:
@@ -25,15 +25,19 @@ conventions:
25
25
  - **Attachments** — link a file you produced by absolute \`file://\` URL:
26
26
  \`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
27
27
  other files as a download card; only files inside the session's working
28
- directory are readable.
28
+ directory or Pier's inbox are readable. The same convention runs inbound: a
29
+ user message ending in \`[name](file:///…)\` lines is carrying files the
30
+ sender attached, already saved to disk — read one only when it matters to
31
+ the task; every read puts its content in your context for good.
29
32
  - **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
30
33
  remains no message is sent. In a group chat you are handed every message,
31
34
  including humans talking to each other: stay silent rather than acknowledge
32
35
  what was not addressed to you.
33
36
 
34
37
  A message may start with \`[name<id> time]\` — the sender, added by Pier, not
35
- typed by them. It appears only when the speaker or the day changes, so the last
36
- one still applies. Use that \`id\` to mention someone; never ask for their own.
38
+ typed by them. It appears only on a change new speaker, a ~10-minute gap, a
39
+ new day so the last one still applies; a gap alone shows as time only, like
40
+ \`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
37
41
  `;
38
42
  /**
39
43
  * The contract above plus the two facts about *this* deployment that an agent
@@ -124,6 +128,17 @@ export function cjkFriendly(markdown) {
124
128
  });
125
129
  return out.replace(/\uE010(\d+)\uE011/g, (_m, i) => stash[Number(i)] ?? "");
126
130
  }
131
+ /**
132
+ * The one wording for a turn that said nothing, wherever it is shown — the
133
+ * chat surfaces and the task result path. The reason arrives pre-escaped
134
+ * because each surface escapes for its own markup; the label itself contains
135
+ * nothing any of them escape.
136
+ */
137
+ export const quietLabel = (silence) => silence ? `stayed silent — ${silence}` : "no reply";
138
+ /** One policy for "did this turn actually reply": options count as a reply —
139
+ * the buttons are the answer, so a turn that is only its options is not
140
+ * "nothing". Every surface decides through here. */
141
+ export const isSilentReply = (reply) => !reply.text.trim() && reply.suggestions.length === 0;
127
142
  /** How a reasoning level is spelled wherever a human reads it. */
128
143
  export const thinkingLabel = (level) => level === "xhigh" ? "Extra high" : level[0].toUpperCase() + level.slice(1);
129
144
  /** 1200 → "1.2K", 12_000 → "12K" — absolute token counts read badly inline. */
@@ -159,7 +174,6 @@ export const formatTurnMeta = (meta) => `${formatDuration(meta.durationMs)} · $
159
174
  const BLOCK = /(?:^|\n)[ \t]*-{3,}[ \t]*\r?\n((?:[ \t]*\[[^\]\r\n]+\][ \t]*(?:[||][ \t]*)?)+)\s*$/;
160
175
  const TOKEN = /\[([^\]\r\n]+)\]/g;
161
176
  const MAX_SUGGESTIONS = 5;
162
- /** Split an assistant turn's markdown into renderable text + next-step labels. */
163
177
  /**
164
178
  * A deliberate non-answer. In a group thread the agent is handed every message,
165
179
  * and most of them are two humans talking; a bot that replies to each one is
@@ -183,6 +197,7 @@ export function silentReason(markdown) {
183
197
  .filter(Boolean);
184
198
  return reasons.length ? reasons.join(" · ") : undefined;
185
199
  }
200
+ /** Split an assistant turn's markdown into renderable text + next-step labels. */
186
201
  export function splitReply(rawMarkdown, meta) {
187
202
  // Every surface that renders this goes through here, and every CommonMark
188
203
  // parser has some version of the CJK emphasis hole — so the repair belongs
@@ -1,11 +1,17 @@
1
- // Conversation → session routing plus event wiring. In-memory for v1;
2
- // persistence arrives with task storage (docs/plans/bootstrap.md step 4).
1
+ // Conversation → session routing plus event wiring. In-memory on purpose:
2
+ // the durable chat session map lives in channels/conversations.ts.
3
3
  import { logger } from "../log.js";
4
4
  import { EventHub } from "./hub.js";
5
5
  import { SenderPrefix, withPrefix } from "./identity.js";
6
6
  import { decide } from "./queue.js";
7
7
  import { splitReply } from "./reply.js";
8
8
  const log = logger("core");
9
+ /** How long a session may sit idle in memory before it is let go. Generous on
10
+ * purpose: eviction is a memory measure, and re-opening one costs a Pi
11
+ * resume plus the transcript being read back. */
12
+ const IDLE_TTL_MS = 30 * 60_000;
13
+ /** Sweep interval. Nothing here is urgent, so it is coarse. */
14
+ const SWEEP_MS = 5 * 60_000;
9
15
  /** An error goes into a chat window, so it is trimmed to something readable. */
10
16
  const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
11
17
  function keyOf(key) {
@@ -16,9 +22,17 @@ export class Router {
16
22
  resolve;
17
23
  byKey = new Map();
18
24
  bySession = new Map();
25
+ /** Resolves in flight, so two surfaces asking at once share one session
26
+ * object instead of opening a second Pi runtime on the same transcript.
27
+ * Web and task keys collapse to the session id they both name. */
28
+ opening = new Map();
19
29
  channels = new Map();
20
30
  /** Who each session last heard from, so a header costs tokens only on news. */
21
31
  senders = new SenderPrefix();
32
+ /** Set by a graceful restart (src/drain.ts). Usually never unset, because
33
+ * the process exits when the drain ends — `endDrain` exists for the one
34
+ * caller that drains *speculatively* and may not get to exit. */
35
+ draining = false;
22
36
  constructor(hub,
23
37
  /** Create or resume the session owning a conversation (wired in main.ts). */
24
38
  resolve) {
@@ -55,6 +69,67 @@ export class Router {
55
69
  });
56
70
  });
57
71
  }
72
+ /**
73
+ * Report something that happened *to* a session rather than in it: a task
74
+ * result that could not be delivered, say. Its conversation is told when one
75
+ * is attached — an agent that was promised an answer and a human watching the
76
+ * same thread learn it is not coming from the same place they were waiting.
77
+ * Otherwise the hub carries it for the web timeline.
78
+ */
79
+ reportTo(sessionId, message) {
80
+ const key = this.conversationOf(sessionId);
81
+ if (key)
82
+ this.report(sessionId, key, message);
83
+ else
84
+ this.hub.emit(sessionId, { type: "error", message });
85
+ }
86
+ /**
87
+ * Let go of every session that has been idle too long, so a process serving
88
+ * IM threads and task runs for weeks does not hold one live Pi runtime per
89
+ * conversation it ever saw. Only the in-memory attachment goes: the durable
90
+ * conversation → session mapping stays, so the next message resumes the very
91
+ * same transcript (channels/conversations.ts).
92
+ *
93
+ * Skipped for anything that would notice: a streaming turn, and a session
94
+ * someone is still watching over SSE.
95
+ *
96
+ * `includeWatched` is the one caller that may take a watched session too:
97
+ * configuration a session reads only when it opens has just changed, and the
98
+ * session most likely to need it is the one open in the tab that changed it.
99
+ * A turn in flight is still never touched — the exemption that stands is the
100
+ * one about interrupting work, not the one about being looked at.
101
+ */
102
+ async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now(), { includeWatched = false } = {}) {
103
+ let evicted = 0;
104
+ for (const [id, attached] of [...this.bySession]) {
105
+ if (attached.session.state === "streaming")
106
+ continue;
107
+ if (!includeWatched && this.hub.hasSubscribers(id))
108
+ continue;
109
+ if (now - attached.activeAt < ttlMs)
110
+ continue;
111
+ this.bySession.delete(id);
112
+ this.forgetKeys(attached.session);
113
+ attached.unsubscribe();
114
+ this.senders.forget(id);
115
+ this.hub.dropReplay(id);
116
+ evicted += 1;
117
+ log.info(`evicted idle session ${id} (${keyOf(attached.key)})`);
118
+ // Best-effort: a runtime that will not shut down must not keep the
119
+ // sweeper from releasing the rest.
120
+ await attached.session.dispose().catch((err) => log.error(`disposing session ${id} failed`, err));
121
+ }
122
+ return evicted;
123
+ }
124
+ /** Run evictIdle on a timer. Returns the stop function (main.ts owns it). */
125
+ startIdleEviction() {
126
+ // Unref'd: a sweep pending is never a reason for the process to stay up.
127
+ const timer = setInterval(() => {
128
+ void this.evictIdle().catch((err) => log.error("idle sweep failed", err));
129
+ }, SWEEP_MS);
130
+ timer.unref();
131
+ return () => clearInterval(timer);
132
+ }
58
133
  stateOf(sessionId) {
59
134
  return this.bySession.get(sessionId)?.session.state;
60
135
  }
@@ -74,21 +149,43 @@ export class Router {
74
149
  conversationOf(sessionId) {
75
150
  return this.bySession.get(sessionId)?.key;
76
151
  }
152
+ /** Every key that points at this session object. One session can be reached
153
+ * under more than one — `web:<id>` and `task:<id>` name the same session —
154
+ * and a key left behind hands out a session that is no longer live. */
155
+ forgetKeys(session) {
156
+ for (const [key, held] of this.byKey)
157
+ if (held === session)
158
+ this.byKey.delete(key);
159
+ }
77
160
  /** Attach an existing session to a conversation and wire its events. */
78
161
  attach(key, session) {
79
- this.byKey.set(keyOf(key), session);
80
162
  const existing = this.bySession.get(session.id);
81
- if (existing?.session === session)
163
+ if (existing?.session === session) {
164
+ this.byKey.set(keyOf(key), session);
165
+ existing.activeAt = Date.now();
82
166
  return;
83
- this.bySession.set(session.id, { session, key, stateSince: Date.now() });
167
+ }
168
+ if (existing) {
169
+ // Two live objects on one transcript: both would write it and both would
170
+ // answer the chat. Single-flight `ensure` closes the race that makes
171
+ // this, so reaching here is a bug worth seeing — the replaced one is
172
+ // silenced and unreachable, rather than left answering under aliases
173
+ // nobody knows are stale.
174
+ log.warn(`session ${session.id} replaced while attached to ${keyOf(existing.key)}`);
175
+ existing.unsubscribe();
176
+ this.forgetKeys(existing.session);
177
+ }
178
+ this.byKey.set(keyOf(key), session);
84
179
  log.info(`attached ${keyOf(key)} → session ${session.id}`);
85
- session.subscribe((payload) => {
180
+ const unsubscribe = session.subscribe((payload) => {
86
181
  this.hub.emit(session.id, payload);
87
182
  // Run state is workspace-visible: every client's session list shows it.
88
183
  if (payload.type === "state") {
89
184
  const attached = this.bySession.get(session.id);
185
+ // Every turn passes through here, so this is also where a session
186
+ // proves to the sweeper that it is still in use.
90
187
  if (attached)
91
- attached.stateSince = Date.now();
188
+ attached.stateSince = attached.activeAt = Date.now();
92
189
  this.hub.emitWorkspace({
93
190
  type: "session-state",
94
191
  sessionId: session.id,
@@ -132,10 +229,50 @@ export class Router {
132
229
  }
133
230
  }
134
231
  });
232
+ this.bySession.set(session.id, {
233
+ session,
234
+ key,
235
+ stateSince: Date.now(),
236
+ activeAt: Date.now(),
237
+ unsubscribe,
238
+ });
135
239
  }
136
240
  async abort(sessionId) {
137
241
  await this.bySession.get(sessionId)?.session.abort();
138
242
  }
243
+ /** Refuse new work from every surface; in-flight turns keep running. */
244
+ beginDrain() {
245
+ this.draining = true;
246
+ }
247
+ /** Take work again. The auto-updater closes the gate *before* handing over,
248
+ * so a turn cannot slip in behind the idle check — and when the handover
249
+ * never happens, a Pier left refusing every message forever would be a far
250
+ * worse outcome than the race it was avoiding. */
251
+ endDrain() {
252
+ this.draining = false;
253
+ }
254
+ /** For surfaces that mutate state before dispatching (the web's edit and
255
+ * queue-deliver routes): ask first, so a refused dispatch cannot cost a
256
+ * rewound transcript or a cleared queue. */
257
+ isDraining() {
258
+ return this.draining;
259
+ }
260
+ /** The drain gate, throwing. Told to the chat directly (5b): an adapter's
261
+ * dispatch catch only logs, and the web caller gets the throw. */
262
+ refuseDraining(key) {
263
+ const message = "Pier is restarting — this message was not taken; send it again in a moment.";
264
+ this.channels.get(key.channelId)
265
+ ?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
266
+ .catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
267
+ throw new Error(message);
268
+ }
269
+ /** Attached sessions still mid-turn — what the drain waits on, and what its
270
+ * deadline snapshots into the ledger. */
271
+ busy() {
272
+ return [...this.bySession.values()]
273
+ .filter((attached) => attached.session.state === "streaming")
274
+ .map((attached) => ({ session: attached.session, key: attached.key }));
275
+ }
139
276
  /**
140
277
  * The session already attached to a conversation, if any. Never creates one:
141
278
  * a channel's stop or settings command must not be what opens a session.
@@ -157,31 +294,80 @@ export class Router {
157
294
  this.byKey.set(keyOf(key), session);
158
295
  }
159
296
  if (!session) {
297
+ // 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);
301
+ const inflight = this.opening.get(lock);
302
+ // A second caller rides the first one's resolve — which attaches before
303
+ // this continuation runs, having awaited it first — and registers its own
304
+ // key against the session that came back.
305
+ if (inflight) {
306
+ session = await inflight;
307
+ this.byKey.set(keyOf(key), session);
308
+ return this.reached(session);
309
+ }
160
310
  try {
161
- session = await this.resolve(key);
311
+ // Inside the try: a resolver that throws synchronously is the same
312
+ // failure as one that rejects, and reports the same way.
313
+ const opening = this.resolve(key);
314
+ this.opening.set(lock, opening);
315
+ session = await opening;
162
316
  }
163
317
  catch (err) {
164
- // The one failure with nowhere to report itself: there is no session id
165
- // to emit under yet, and every caller only sees a rejected promise.
166
- log.error(`could not open a session for ${keyOf(key)}`, err);
318
+ this.unopened(key, err);
167
319
  throw err;
168
320
  }
321
+ finally {
322
+ this.opening.delete(lock);
323
+ }
169
324
  this.attach(key, session);
170
325
  }
326
+ return this.reached(session);
327
+ }
328
+ /** A session that would not open has no event stream of its own to report on
329
+ * — unless its id is what we were asked for, which is what a web or task key
330
+ * is. Otherwise the chat that is waiting is told directly. Callers still get
331
+ * the rejection; this is only so the waiting side is not left with nothing. */
332
+ unopened(key, err) {
333
+ log.error(`could not open a session for ${keyOf(key)}`, err);
334
+ const message = truncate(`could not open a session: ${String(err)}`);
335
+ // A web or task key names the session that would not open, so its own
336
+ // stream is where the waiting surface is looking; an IM key names a chat.
337
+ if (key.channelId === "web" || key.channelId === "task") {
338
+ this.reportTo(key.conversationId, message);
339
+ return;
340
+ }
341
+ this.channels.get(key.channelId)
342
+ ?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
343
+ .catch((e) => log.error(`could not report it to ${key.channelId}`, e));
344
+ }
345
+ /** 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) {
348
+ const attached = this.bySession.get(session.id);
349
+ if (attached)
350
+ attached.activeAt = Date.now();
171
351
  return session;
172
352
  }
173
353
  async dispatch(msg) {
354
+ // Before ensure — a drain must not be what opens a session …
355
+ if (this.draining)
356
+ this.refuseDraining(msg.key);
174
357
  const session = await this.ensure(msg.key);
358
+ // … and after — a dispatch that was inside a slow ensure when the gate
359
+ // closed must not start the turn the drain just declared finished with.
360
+ if (this.draining)
361
+ this.refuseDraining(msg.key);
175
362
  const { action, text } = decide(msg, session.state);
176
363
  // A group chat is many people talking into one session; without a speaker
177
364
  // line the agent cannot tell them apart or mention anyone back. Emitted
178
365
  // only when the speaker or the clock says something new.
179
366
  const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
180
- log.debug(`${action} ${keyOf(msg.key)} → session ${session.id}` +
181
- ` (${String(prompt.length)} chars, ${String(msg.images?.length ?? 0)} images)`);
367
+ log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
182
368
  // Turn outcomes flow through the event stream; a rejected call surfaces
183
369
  // there too, never as a thrown exception across the seam.
184
- session[action](prompt, msg.images).catch((err) => {
370
+ session[action](prompt).catch((err) => {
185
371
  this.report(session.id, msg.key, String(err));
186
372
  });
187
373
  return { sessionId: session.id };
@@ -5,3 +5,56 @@
5
5
  * drift, and boundary validators use isThinkingLevel instead of their own copy. */
6
6
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
7
7
  export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
8
+ // Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
9
+ // non-Pi backend is bound to them by this seam.
10
+ export const PROVIDER_APIS = [
11
+ "openai-completions",
12
+ "openai-responses",
13
+ "anthropic-messages",
14
+ "google-generative-ai",
15
+ ];
16
+ export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
17
+ /** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
18
+ * write and web/ pre-checks them at its HTTP boundary, and neither may import
19
+ * the other. Throws the message the surface shows. */
20
+ export function validateProviderSetup(input) {
21
+ if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
22
+ throw new Error("invalid provider id");
23
+ }
24
+ if (input.endpoint) {
25
+ if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
26
+ throw new Error("invalid endpoint");
27
+ }
28
+ validateEndpoint(input.endpoint);
29
+ }
30
+ if (input.kind === "builtin")
31
+ return;
32
+ if (!input.endpoint)
33
+ throw new Error("custom provider endpoint required");
34
+ if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
35
+ throw new Error("invalid provider name");
36
+ }
37
+ if (!isProviderApi(input.api))
38
+ throw new Error("unsupported provider API");
39
+ if (!input.models.length || input.models.length > 100)
40
+ throw new Error("1-100 models required");
41
+ const ids = input.models.map((model) => model.id);
42
+ if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
43
+ throw new Error("model ids must be non-empty, trimmed and unique");
44
+ }
45
+ }
46
+ export function validateEndpoint(endpoint) {
47
+ let url;
48
+ try {
49
+ url = new URL(endpoint);
50
+ }
51
+ catch {
52
+ throw new Error("endpoint must be an http(s) URL");
53
+ }
54
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
55
+ throw new Error("endpoint must be an http(s) URL");
56
+ }
57
+ if (url.username || url.password || url.search || url.hash) {
58
+ throw new Error("endpoint must not contain credentials, query or fragment");
59
+ }
60
+ }
package/dist/db.js CHANGED
@@ -126,6 +126,18 @@ const MIGRATIONS = [
126
126
  key TEXT PRIMARY KEY,
127
127
  value TEXT NOT NULL
128
128
  );
129
+ `,
130
+ // 3 — what a restart's drain deadline cut off, told to the chat at next boot.
131
+ `
132
+ -- Written only when a graceful restart aborts a still-running turn; the next
133
+ -- boot delivers each row and clears it once delivered. Owned by drain.ts.
134
+ CREATE TABLE restart_ledger (
135
+ id INTEGER PRIMARY KEY,
136
+ channel_id TEXT NOT NULL,
137
+ conversation_id TEXT NOT NULL,
138
+ note TEXT NOT NULL,
139
+ created_at INTEGER NOT NULL
140
+ );
129
141
  `,
130
142
  ];
131
143
  let shared;
@@ -134,6 +146,17 @@ let shared;
134
146
  * defaults to it; a test passes `openDb(":memory:")` instead.
135
147
  */
136
148
  export const pierDb = () => (shared ??= openDb(PIER_DB));
149
+ /** A release-level restore point, taken while the service is stopped even when
150
+ * the release has no schema migration. The previous complete copy stays put if
151
+ * writing its replacement fails. */
152
+ export function backupDb(path = PIER_DB) {
153
+ if (!existsSync(path))
154
+ return undefined;
155
+ const bak = `${path}.release.bak`;
156
+ copyDatabase(path, bak);
157
+ log.info(`pre-update backup: ${bak}`);
158
+ return bak;
159
+ }
137
160
  /** Open a database, bring it to the current schema, and lock down its files.
138
161
  * `migrations` is injectable only so tests can exercise an upgrade — there is
139
162
  * exactly one real list. */
@@ -141,10 +164,11 @@ export function openDb(path, migrations = MIGRATIONS) {
141
164
  if (path !== ":memory:")
142
165
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
143
166
  const db = new DatabaseSync(path);
144
- // Outside the transaction below: journal_mode is a property of the file, and
145
- // SQLite refuses to change it inside one.
146
- db.exec("PRAGMA journal_mode = WAL");
167
+ // Timeout first: two processes booting together contend on the WAL switch
168
+ // itself. Outside the transaction below: journal_mode is a property of the
169
+ // file, and SQLite refuses to change it inside one.
147
170
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
171
+ db.exec("PRAGMA journal_mode = WAL");
148
172
  migrate(db, path, migrations);
149
173
  if (path !== ":memory:")
150
174
  restrict(path);
@@ -175,8 +199,6 @@ function migrate(db, path, migrations) {
175
199
  }
176
200
  if (at === target)
177
201
  return;
178
- if (at > 0 && path !== ":memory:")
179
- snapshot(db, path, at);
180
202
  // One transaction for the statements *and* the version number: a crash
181
203
  // between them would leave a database whose version describes a schema it
182
204
  // does not have, which is worse than a crash.
@@ -188,9 +210,24 @@ function migrate(db, path, migrations) {
188
210
  const { user_version: locked } = db.prepare("PRAGMA user_version").get();
189
211
  if (locked >= target) {
190
212
  db.exec("ROLLBACK");
213
+ if (locked > target) {
214
+ throw new Error(`${path} advanced to schema ${locked} while this Pier was waiting; it speaks ${target}`);
215
+ }
191
216
  log.info(`schema already at ${locked}, migrated by another process`);
192
217
  return;
193
218
  }
219
+ // Keep the write lock while a second, read-only connection copies the last
220
+ // committed state. That connection may VACUUM while this one holds a RESERVED
221
+ // lock; other Pier starts wait here instead of racing on the shared .tmp.
222
+ if (locked > 0 && path !== ":memory:") {
223
+ try {
224
+ snapshot(path, locked);
225
+ }
226
+ catch (err) {
227
+ db.exec("ROLLBACK");
228
+ throw err;
229
+ }
230
+ }
194
231
  let step = locked;
195
232
  try {
196
233
  for (; step < target; step++)
@@ -221,14 +258,23 @@ function migrate(db, path, migrations) {
221
258
  * old snapshot nor a complete new one. A rename is atomic: the `.bak` name only
222
259
  * ever refers to a finished copy.
223
260
  */
224
- function snapshot(db, path, at) {
261
+ function snapshot(path, at) {
225
262
  const bak = `${path}.v${at}.bak`;
263
+ copyDatabase(path, bak);
264
+ log.info(`pre-migration backup: ${bak}`);
265
+ }
266
+ function copyDatabase(path, bak) {
226
267
  const tmp = `${bak}.tmp`;
227
268
  rmSync(tmp, { force: true }); // a previous crash may have left one
228
- db.exec(`VACUUM INTO '${tmp.replaceAll("'", "''")}'`);
269
+ const source = new DatabaseSync(path, { readOnly: true });
270
+ try {
271
+ source.exec(`VACUUM INTO '${tmp.replaceAll("'", "''")}'`);
272
+ }
273
+ finally {
274
+ source.close();
275
+ }
229
276
  chmodSync(tmp, 0o600); // it holds everything the 0600 database holds
230
277
  renameSync(tmp, bak);
231
- log.info(`pre-migration backup: ${bak}`);
232
278
  }
233
279
  /** Snapshots beside the database, newest schema first. */
234
280
  function backups(path) {