@timqi/pier 0.0.9 โ†’ 0.0.16

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 (67) hide show
  1. package/README.md +7 -1
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +190 -31
  5. package/dist/boards/boards.js +65 -16
  6. package/dist/boards/pier.css +1 -1
  7. package/dist/channels/attach.js +87 -0
  8. package/dist/channels/control.js +2 -2
  9. package/dist/channels/lark-api.js +38 -0
  10. package/dist/channels/lark-outbound.js +11 -2
  11. package/dist/channels/slack-api.js +36 -0
  12. package/dist/channels/slack-outbound.js +12 -2
  13. package/dist/channels/slack-tool.js +49 -9
  14. package/dist/channels/telegram-api.js +21 -2
  15. package/dist/channels/telegram.js +23 -8
  16. package/dist/cli.js +34 -0
  17. package/dist/core/identity.js +18 -0
  18. package/dist/core/inbound-file.js +3 -1
  19. package/dist/core/reply.js +2 -1
  20. package/dist/core/router.js +72 -0
  21. package/dist/db.js +78 -0
  22. package/dist/extensions/index.js +5 -2
  23. package/dist/extensions/web/artifacts.js +7 -2
  24. package/dist/extensions/web/content.js +5 -0
  25. package/dist/extensions/web/language.js +5 -0
  26. package/dist/extensions/web/tools.js +33 -8
  27. package/dist/limits.js +14 -0
  28. package/dist/main.js +47 -6
  29. package/dist/paths.js +6 -1
  30. package/dist/settings.js +44 -0
  31. package/dist/tasks/agent.js +23 -4
  32. package/dist/tasks/callbacks.js +20 -1
  33. package/dist/tasks/command.js +15 -0
  34. package/dist/tasks/definitions.js +60 -12
  35. package/dist/tasks/execution.js +9 -1
  36. package/dist/tasks/groups.js +8 -4
  37. package/dist/tasks/messages.js +10 -2
  38. package/dist/tasks/routes.js +4 -0
  39. package/dist/tasks/runs.js +7 -2
  40. package/dist/tasks/service.js +22 -6
  41. package/dist/tasks/store.js +4 -0
  42. package/dist/tasks/tool.js +0 -12
  43. package/dist/tasks/types.js +4 -0
  44. package/dist/tools-task.js +155 -0
  45. package/dist/tools.js +875 -0
  46. package/dist/web/auth.js +5 -3
  47. package/dist/web/explorer.js +15 -2
  48. package/dist/web/files.js +1 -1
  49. package/dist/web/instance.js +165 -36
  50. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js โ†’ ghostty-web-C4ivXTBE.js} +1 -1
  51. package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
  52. package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
  53. package/dist/web/public/index.html +5 -8
  54. package/dist/web/public/sw.js +4 -0
  55. package/dist/web/push.js +22 -7
  56. package/dist/web/repos.js +75 -0
  57. package/dist/web/server.js +145 -64
  58. package/dist/web/session-state.js +33 -51
  59. package/dist/web/types.js +5 -0
  60. package/docs/deploy.md +12 -3
  61. package/package.json +1 -1
  62. package/skills/pier-boards/SKILL.md +23 -13
  63. package/skills/pier-help/SKILL.md +1 -1
  64. package/skills/pier-slack/SKILL.md +21 -1
  65. package/skills/pier-tasks/SKILL.md +2 -2
  66. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  67. package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
@@ -6,6 +6,7 @@
6
6
  // adapter keeps the ๐Ÿ‘€ receipts, because those are about the turn ending, not
7
7
  // about what was said.
8
8
  import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
9
+ import { sendAttachments, splitAttachments } from "./attach.js";
9
10
  import { button, buttonRow, card, chunk, footer, LARK_MAX, markdown, OFFER_PREFIX, withFooter, withoutButtons, } from "./lark-render.js";
10
11
  /** How many sent cards the retire cache remembers (avibe keeps 200). */
11
12
  const SENT_CACHE = 200;
@@ -37,11 +38,14 @@ export class LarkOutbound {
37
38
  * platform โ€” the card is remembered so retire() can rebuild it without them.
38
39
  */
39
40
  async reply(root, reply) {
40
- const text = reply.text.trim();
41
+ // A file the agent linked lives on Pier's machine, so the link is dead in
42
+ // Lark: the bytes are uploaded instead and the label stays in the text.
43
+ const { text: spoken, paths } = splitAttachments(reply.text);
44
+ const text = spoken.trim();
41
45
  const meta = reply.meta ? formatTurnMeta(reply.meta) : "";
42
46
  const quiet = isSilentReply(reply) ? quietLabel(reply.silence) : "";
43
47
  const note = [quiet, meta].filter(Boolean).join(" ยท ");
44
- if (!(text || reply.suggestions.length || note))
48
+ if (!(text || reply.suggestions.length || note || paths.length))
45
49
  return;
46
50
  const row = reply.suggestions.length
47
51
  ? buttonRow(reply.suggestions.map((label, index) => button(label, { key: `${OFFER_PREFIX}${index}`, root, label })))
@@ -62,6 +66,11 @@ export class LarkOutbound {
62
66
  if (last && row && messageId)
63
67
  this.remember(messageId, card(elements));
64
68
  }
69
+ // Attachments follow the words, so the card introducing them is above
70
+ // them; anything that could not be sent says so in the thread.
71
+ const lost = await sendAttachments(paths, (file) => this.api.uploadFile(root, file), this.log);
72
+ if (lost)
73
+ await this.api.replyCard(root, card([markdown(lost)]));
65
74
  }
66
75
  /**
67
76
  * Take the buttons off a card one option was just taken from โ€” the rest
@@ -293,4 +293,40 @@ export class SlackApi {
293
293
  // Bounded mid-stream: the event's size metadata is the platform's word.
294
294
  return { bytes: await readCapped(res.body, maxBytes), mimeType };
295
295
  }
296
+ /**
297
+ * Three calls, because that is what Slack's current upload is: ask for a
298
+ * one-shot URL, POST the bytes to it (that host is not the Web API and
299
+ * answers with plain text, not JSON), then tell Slack where the file goes.
300
+ * `files.upload` did it in one, and is retired.
301
+ */
302
+ async uploadFile(channel, threadTs, file) {
303
+ // A read method: form-encoded, or Slack ignores the body (see read()).
304
+ const slot = await this.read("files.getUploadURLExternal", { filename: file.name, length: file.bytes.length }).catch((err) => {
305
+ // An app installed before Pier could upload has every other scope, so
306
+ // this reads as a mysterious refusal in the chat. Name the fix instead:
307
+ // the manifest is only applied when an app is *created*.
308
+ if (!/missing_scope/.test(String(err)))
309
+ throw err;
310
+ throw new Error("the Slack app is missing the files:write scope โ€” add it under " +
311
+ "OAuth & Permissions and reinstall the app");
312
+ });
313
+ if (!slot.upload_url || !slot.file_id) {
314
+ throw new Error("slack files.getUploadURLExternal: no upload url");
315
+ }
316
+ const put = await fetch(slot.upload_url, {
317
+ method: "POST",
318
+ headers: { "content-type": "application/octet-stream" },
319
+ // Copied into a fresh view: a request body must be backed by an
320
+ // ArrayBuffer, and a Buffer read off disk is the wider ArrayBufferLike.
321
+ body: new Uint8Array(file.bytes),
322
+ signal: AbortSignal.timeout(120_000),
323
+ });
324
+ if (!put.ok)
325
+ throw new Error(`slack file upload: ${put.status}`);
326
+ await this.call("files.completeUploadExternal", {
327
+ files: [{ id: slot.file_id, title: file.name }],
328
+ channel_id: channel,
329
+ thread_ts: threadTs,
330
+ });
331
+ }
296
332
  }
@@ -5,6 +5,7 @@
5
5
  // limit, and what an empty turn still has to say. The adapter keeps the ๐Ÿ‘€
6
6
  // receipts, because those are about the turn ending, not about what was said.
7
7
  import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
8
+ import { sendAttachments, splitAttachments } from "./attach.js";
8
9
  import { isBlockRejection } from "./slack-api.js";
9
10
  import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
10
11
  /**
@@ -30,7 +31,10 @@ export class SlackOutbound {
30
31
  * and that is still something to show.
31
32
  */
32
33
  async reply(channel, threadTs, reply) {
33
- const text = reply.text.trim();
34
+ // A file the agent linked lives on Pier's machine, so the link is dead in
35
+ // Slack: the bytes are uploaded instead and the label stays in the text.
36
+ const { text: spoken, paths } = splitAttachments(reply.text);
37
+ const text = spoken.trim();
34
38
  const footer = reply.meta ? footerText(reply.meta) : "";
35
39
  const row = actions(reply.suggestions);
36
40
  // A turn that produced no text still posts its footer, and says which kind
@@ -41,7 +45,7 @@ export class SlackOutbound {
41
45
  const quiet = isSilentReply(reply)
42
46
  ? `_${quietLabel(reply.silence && escapeMrkdwn(reply.silence))}_`
43
47
  : "";
44
- if (!(text || row || footer || quiet))
48
+ if (!(text || row || footer || quiet || paths.length))
45
49
  return;
46
50
  const parts = text ? chunk(text, this.budget()) : [""];
47
51
  for (const [i, part] of parts.entries()) {
@@ -55,6 +59,12 @@ export class SlackOutbound {
55
59
  ...(last && row ? [row] : []),
56
60
  ]);
57
61
  }
62
+ // Attachments follow the words, so the message introducing them is above
63
+ // them; anything that could not be sent says so in the thread.
64
+ const lost = await sendAttachments(paths, (file) => this.api.uploadFile(channel, threadTs, file), this.log);
65
+ // Unescaped, like every other body: post() escapes on the path that needs it.
66
+ if (lost)
67
+ await this.post(channel, threadTs, lost, []);
58
68
  }
59
69
  /**
60
70
  * A system note: quoted, labelled with where it came from, and deliberately
@@ -41,11 +41,25 @@ export function toTs(value) {
41
41
  throw new Error(`not a time: ${value}`);
42
42
  return String(parsed / 1000);
43
43
  }
44
- export function slackToolSpec(execute) {
44
+ /**
45
+ * Whether a session opened now is given the tool at all: the same two switches
46
+ * `handleSlackTool` checks, asked before the description is paid for. The call
47
+ * keeps its own checks and its own two messages โ€” a session opened while Slack
48
+ * was configured outlives the operator switching it off, and that turn has to
49
+ * say so rather than find the tool quietly gone.
50
+ */
51
+ export function slackToolAvailable(store) {
52
+ const config = store.get("slack");
53
+ return config.enabled && !!config.token && config.agentTool;
54
+ }
55
+ export function slackToolSpec(execute, available) {
45
56
  return {
46
57
  name: "slack",
47
58
  label: "Slack",
48
- description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; delete removes the message at a ts, which Slack allows only for messages Pier itself posted; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls โ€” write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax โ€” read the pier-slack skill before posting.",
59
+ // One screen of contract; the paragraph this once was lives in the
60
+ // pier-slack skill, which the description sends the model to before it
61
+ // posts โ€” the part that goes wrong without instructions.
62
+ description: "Read and write Slack through Pier, which holds the bot token. Operations: context (which Slack conversation this session is in), read_channel (transcript for a time range), read_thread (one thread; only what is new since a message via after), read_message (the one at ts), post, edit/delete (Pier's own messages only), channels (what Pier can reach). Omit channel and thread_ts to act on the conversation you are in. since/until/after accept ISO 8601, epoch seconds or a ts. Every read fetches live; nothing is kept between calls. @mentions, #channels and links need Slack's own syntax โ€” read the pier-slack skill before posting.",
49
63
  parameters: Type.Object({
50
64
  // A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
51
65
  operation: Type.Unsafe({
@@ -56,6 +70,7 @@ export function slackToolSpec(execute) {
56
70
  "read_thread",
57
71
  "read_message",
58
72
  "post",
73
+ "edit",
59
74
  "delete",
60
75
  "channels",
61
76
  ],
@@ -69,12 +84,13 @@ export function slackToolSpec(execute) {
69
84
  until: Type.Optional(Type.String()),
70
85
  /** Strictly newer than this โ€” "what changed since I last looked". */
71
86
  after: Type.Optional(Type.String()),
72
- /** The one message `read_message` or `delete` is about. */
87
+ /** The one message `read_message`, `edit` or `delete` is about. */
73
88
  ts: Type.Optional(Type.String()),
74
89
  limit: Type.Optional(Type.Number()),
75
90
  thread_ts: Type.Optional(Type.String()),
76
91
  text: Type.Optional(Type.String()),
77
92
  }),
93
+ available,
78
94
  execute,
79
95
  };
80
96
  }
@@ -83,6 +99,17 @@ const required = (value, field) => {
83
99
  throw new Error(`${field} is required`);
84
100
  return value.trim();
85
101
  };
102
+ /**
103
+ * Slack rejects an oversized message outright, so the length is checked here:
104
+ * a refusal the agent can act on beats a post that silently never happened.
105
+ */
106
+ const messageText = (raw) => {
107
+ const text = required(raw, "text");
108
+ if (text.length > MARKDOWN_MAX) {
109
+ throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
110
+ }
111
+ return text;
112
+ };
86
113
  const record = (raw) => raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
87
114
  export async function handleSlackTool(deps, raw, callerSessionId = "") {
88
115
  const input = record(raw);
@@ -157,10 +184,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
157
184
  return readMessage(deps, client, channel, required(input.ts, "ts"), asked || undefined);
158
185
  }
159
186
  if (input.operation === "post") {
160
- const text = required(input.text, "text");
161
- if (text.length > MARKDOWN_MAX) {
162
- throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
163
- }
187
+ const text = messageText(input.text);
164
188
  // Defaults to the thread we are in; `thread_ts: "none"` is the explicit
165
189
  // way to start a new top-level message instead.
166
190
  const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
@@ -182,6 +206,20 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
182
206
  threadTs: threadTs ?? sent.ts,
183
207
  };
184
208
  }
209
+ if (input.operation === "edit") {
210
+ // Explicit `ts`, for delete's reason: an edit replaces the text outright,
211
+ // and Slack keeps no visible record of what it said before.
212
+ const ts = required(input.ts, "ts");
213
+ const text = messageText(input.text);
214
+ try {
215
+ await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
216
+ }
217
+ catch (err) {
218
+ throw new Error(explain(err));
219
+ }
220
+ deps.log(`slack tool edited ${ts} in ${channel}`);
221
+ return { channel, ts, edited: true };
222
+ }
185
223
  if (input.operation === "delete") {
186
224
  // Never defaulted from `here`: the thread's ts is the parent message, and
187
225
  // "delete" with an implied target is the one mistake with no undo.
@@ -305,10 +343,12 @@ function explain(err) {
305
343
  return {
306
344
  channel_not_found: "no such channel, or Pier's bot cannot see it โ€” check the channels operation",
307
345
  not_in_channel: "Pier's bot is not in that channel; someone has to invite it before it can read",
308
- missing_scope: "Pier's Slack app lacks the scope for this read; the operator must reinstall it",
309
- ratelimited: "Slack rate-limited this read; wait a minute or ask for a narrower range",
346
+ missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
347
+ ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
310
348
  thread_not_found: "no thread with that ts in this channel",
311
349
  cant_delete_message: "Slack only lets Pier delete what its own bot posted; a person's message has to be deleted by them",
350
+ cant_update_message: "Slack only lets Pier edit what its own bot posted; anyone else's message can only be replied to",
351
+ edit_window_closed: "Slack's edit window for that message has closed; post a correction instead of rewriting it",
312
352
  message_not_found: "no message with that ts in this channel โ€” a ts only means anything in the conversation it came from",
313
353
  }[code] ?? String(err);
314
354
  }
@@ -13,10 +13,14 @@ export class TelegramApi {
13
13
  this.token = token;
14
14
  }
15
15
  async call(method, payload, timeoutMs = 30_000, retry = true) {
16
+ // An upload is the one call that is not JSON: FormData carries the bytes,
17
+ // and fetch sets its own multipart boundary. It is re-sendable, so the
18
+ // flood retry below still works on it.
19
+ const multipart = payload instanceof FormData;
16
20
  const res = await fetch(`${BASE}/bot${this.token}/${method}`, {
17
21
  method: "POST",
18
- headers: { "content-type": "application/json" },
19
- body: JSON.stringify(payload),
22
+ headers: multipart ? undefined : { "content-type": "application/json" },
23
+ body: multipart ? payload : JSON.stringify(payload),
20
24
  signal: AbortSignal.timeout(timeoutMs),
21
25
  });
22
26
  const body = (await res.json());
@@ -41,6 +45,21 @@ export class TelegramApi {
41
45
  sendMessage(payload) {
42
46
  return this.call("sendMessage", payload);
43
47
  }
48
+ /** Bytes, not a file_id or a URL: the file is local to this machine, which
49
+ * is the whole reason the agent could not just link it. */
50
+ async sendFile({ chat_id, message_thread_id, file }) {
51
+ const form = new FormData();
52
+ form.set("chat_id", String(chat_id));
53
+ if (message_thread_id !== undefined)
54
+ form.set("message_thread_id", String(message_thread_id));
55
+ const field = file.image ? "photo" : "document";
56
+ // Copied into a fresh view: a Blob part must be backed by an ArrayBuffer,
57
+ // and a Buffer read off disk carries the wider ArrayBufferLike type.
58
+ form.set(field, new Blob([new Uint8Array(file.bytes)]), file.name);
59
+ // A long upload on a slow link is not a hung request; 30s is the budget
60
+ // for a JSON call, not for megabytes.
61
+ await this.call(file.image ? "sendPhoto" : "sendDocument", form, 120_000);
62
+ }
44
63
  async editMessage(payload) {
45
64
  await this.call("editMessageText", payload);
46
65
  }
@@ -14,6 +14,7 @@
14
14
  import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
15
15
  import { saveInboundAll } from "../core/inbox.js";
16
16
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
17
+ import { sendAttachments, splitAttachments } from "./attach.js";
17
18
  import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
18
19
  import { logger } from "../log.js";
19
20
  import { Chains } from "./chains.js";
@@ -413,7 +414,10 @@ export class TelegramChannel {
413
414
  */
414
415
  async send(conversation, reply) {
415
416
  const { chatId, topicId } = parseConversation(conversation);
416
- const text = reply.text.trim();
417
+ // A file the agent linked is local to this machine, so the link is dead in
418
+ // Telegram: the bytes are uploaded instead and the label stays in the text.
419
+ const { text: spoken, paths } = splitAttachments(reply.text);
420
+ const text = spoken.trim();
417
421
  // A turn that produced no text still posts its footer, and says which kind
418
422
  // of nothing it was: total silence is indistinguishable from a crash, and
419
423
  // the person waiting cannot tell. See AGENTS.md โ€” an empty turn is still an
@@ -430,17 +434,28 @@ export class TelegramChannel {
430
434
  // settleAfter: a ๐Ÿ‘€ left up because the reply failed would sit there until
431
435
  // the stale sweep, looking like the agent is still working.
432
436
  await this.receipts.settleAfter(conversation, async () => {
433
- if (!body.trim())
434
- return;
435
- const parts = chunk(body);
436
- for (const [i, part] of parts.entries()) {
437
+ if (body.trim()) {
438
+ const parts = chunk(body);
439
+ for (const [i, part] of parts.entries()) {
440
+ await this.api.sendMessage({
441
+ chat_id: chatId,
442
+ message_thread_id: topicId,
443
+ text: part,
444
+ parse_mode: "HTML",
445
+ // Next-step buttons ride the last chunk; a click sends the label.
446
+ reply_markup: i === parts.length - 1 ? buttons : undefined,
447
+ });
448
+ }
449
+ }
450
+ // Attachments follow the words, so the message that introduces them is
451
+ // above them; anything that could not be sent says so in the chat.
452
+ const lost = await sendAttachments(paths, (file) => this.api.sendFile({ chat_id: chatId, message_thread_id: topicId, file }), this.log);
453
+ if (lost) {
437
454
  await this.api.sendMessage({
438
455
  chat_id: chatId,
439
456
  message_thread_id: topicId,
440
- text: part,
457
+ text: escapeHtml(lost),
441
458
  parse_mode: "HTML",
442
- // Next-step buttons ride the last chunk; a click sends the label.
443
- reply_markup: i === parts.length - 1 ? buttons : undefined,
444
459
  });
445
460
  }
446
461
  });
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ Usage
21
21
  pier service status what systemd thinks of it
22
22
  pier update install the latest release and restart the service
23
23
  pier update --check only say whether one exists
24
+ pier tools sync install/update the managed CLI tools (rtk, โ€ฆ)
24
25
  pier restart finish running turns first, then restart the service
25
26
  pier reload re-read channel config and recycle idle sessions
26
27
  pier backup snapshot pier.db before a manual update
@@ -103,6 +104,9 @@ else if (command === "update") {
103
104
  allowOnly(["check"], "pier update");
104
105
  await update(values.check === true);
105
106
  }
107
+ else if (command === "tools") {
108
+ await tools(subcommand);
109
+ }
106
110
  else if (command === "restart" || command === "reload") {
107
111
  if (subcommand)
108
112
  fail(`unexpected argument "${subcommand}"`);
@@ -150,6 +154,36 @@ async function update(checkOnly) {
150
154
  say(`npm install -g @timqi/pier@${latest}`);
151
155
  say(`then restart Pier.`);
152
156
  }
157
+ /**
158
+ * What the daily task runs, and what an operator can type. The setting is the
159
+ * instruction; this converges on it and prints what happened, one line per
160
+ * tool. Non-zero when anything failed โ€” the task run is then a failed run with
161
+ * this text in it, which is the whole tools status surface.
162
+ */
163
+ async function tools(action = "") {
164
+ if (action !== "sync") {
165
+ process.stderr.write(`pier tools: unknown action "${action}"\n\n${HELP}`);
166
+ process.exit(2);
167
+ }
168
+ allowOnly([], "pier tools sync");
169
+ const [{ ManagedTools }, { SettingsStore }] = await Promise.all([
170
+ import("./tools.js"),
171
+ import("./settings.js"),
172
+ ]);
173
+ try {
174
+ // Read inside the sync's lock, not here: a sync that queued behind another
175
+ // one must converge on the set as it is when its turn comes.
176
+ const settings = new SettingsStore();
177
+ const report = await new ManagedTools().sync(() => settings.get());
178
+ say(report.summary);
179
+ if (report.failed)
180
+ process.exitCode = 1;
181
+ }
182
+ catch (err) {
183
+ process.stderr.write(`pier: tools sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
184
+ process.exitCode = 1;
185
+ }
186
+ }
153
187
  /** Both are signals to the running unit: SIGUSR2 drains then exits (systemd
154
188
  * starts the next process), SIGHUP reloads config in place (main.ts). */
155
189
  async function signalService(command) {
@@ -90,3 +90,21 @@ export function splitSpeaker(text) {
90
90
  text: text.slice(m[0].length),
91
91
  };
92
92
  }
93
+ /**
94
+ * A session titled by its first prompt inherits that prompt's header, and the
95
+ * header is for the model: anything a person reads โ€” a list row, a session
96
+ * header, a notification on a phone โ€” would say "operator: โ€ฆ" on every session
97
+ * the workbench ever opened. So the speaker comes off and what they said is
98
+ * the title. Here rather than in a UI module because the push notification
99
+ * needs the same answer and a second copy of this would drift (AGENTS.md ยง3).
100
+ */
101
+ export function readableTitle(title) {
102
+ if (!title)
103
+ return title;
104
+ const { text } = splitSpeaker(title);
105
+ // No header โ€” the title is what the person typed, and reflowing it would
106
+ // change what the sidebar's search is matching against for nothing.
107
+ if (text === title)
108
+ return title;
109
+ return text.replace(/\s+/g, " ").trim() || title;
110
+ }
@@ -41,7 +41,9 @@ export const fileMarker = (path) => `[${path.split("/").pop() ?? "file"}](file:/
41
41
  /**
42
42
  * The conversation-visible line for an attachment that never made it (5b: a
43
43
  * failed download must not look like no attachment). Plain text on purpose โ€”
44
- * not a link โ€” so every surface renders it as the words it is.
44
+ * not a link โ€” so every surface renders it as the words it is. Both
45
+ * directions: an inbound file Pier could not fetch and an outbound one it
46
+ * could not upload (channels/attach.ts) are the same fact to the reader.
45
47
  */
46
48
  export const lostMarker = (name, reason) => `[attachment lost: ${name} โ€” ${reason}]`;
47
49
  // A whole line that is one `[name](file:///โ€ฆ)` link โ€” what fileMarker emits.
@@ -52,7 +52,8 @@ new day โ€” so the last one still applies; a gap alone shows as time only, like
52
52
  export function surfacePrompt(instance) {
53
53
  const reach = instance.publicUrl
54
54
  ? `Address: ${instance.publicUrl} โ€” a board's link is that plus ` +
55
- "`/boards/<slug>/`, or `/p/<slug>/` once published."
55
+ "`/boards/<slug>/`, or `/p/<slug>-<token>/` once published, where `token` " +
56
+ "is the random field the manifest carries beside `public`."
56
57
  : "No public address is configured (the user sets one in Console โ†’ Settings), " +
57
58
  "so give paths and never guess a host.";
58
59
  return `${REPLY_SURFACE_PROMPT}
@@ -224,6 +224,15 @@ export class Router {
224
224
  });
225
225
  });
226
226
  }
227
+ // A queued message with no turn left to deliver it. `decide` reads the
228
+ // state once, so a steer chosen against a turn that ends before the call
229
+ // lands sits in Pi's queue until some *later* turn reads it โ€” on IM that
230
+ // is indistinguishable from the message never arriving (ยง5b). Pi drains
231
+ // its own queues up to the agent_end handler, so a non-empty queue on an
232
+ // idle session is exactly the message that missed that window.
233
+ if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
234
+ this.promoteQueued(session, key);
235
+ }
227
236
  // Every turn-end reaches the channel, empty text included: an adapter's
228
237
  // per-turn UI (Telegram's ๐Ÿ‘€ receipts) is retired here, and a turn that
229
238
  // settled with nothing to say still has to settle.
@@ -245,9 +254,69 @@ export class Router {
245
254
  unsubscribe,
246
255
  });
247
256
  }
257
+ /** Sessions whose queue is being promoted right now. `clearQueue` and the
258
+ * prompt that follows it both emit queue-state of their own, so without
259
+ * this the handler would re-enter on its own effects. */
260
+ promoting = new Set();
261
+ /**
262
+ * Turn a stranded queue into the turn it was waiting for. Not routed through
263
+ * `dispatch`: the text was prefixed when it was first dispatched
264
+ * (identity.ts), and sending it back through would head it a second time.
265
+ *
266
+ * Only ever reached from a queue-state event, never from a turn ending: Pi
267
+ * leaves the queue alone on `abort()`, so promoting on idle would make /stop
268
+ * start the very turn it was asked to stop. Recovering *those* messages stays
269
+ * the web's recall route, which hands them back to the composer.
270
+ */
271
+ promoteQueued(session, key) {
272
+ if (session.state !== "idle" || this.promoting.has(session.id))
273
+ return;
274
+ this.promoting.add(session.id);
275
+ void (async () => {
276
+ try {
277
+ // Re-read: a turn may have started since the event, and it will drain
278
+ // the queue itself โ€” clearing it here would take the messages out of it.
279
+ if (session.state !== "idle")
280
+ return;
281
+ const { steering, followUp } = await session.clearQueue();
282
+ const text = [...steering, ...followUp].join("\n").trim();
283
+ if (!text)
284
+ return;
285
+ // A drain is "no new turns", and this would be one. Told to the
286
+ // conversation rather than dropped, because the message is now out of
287
+ // the queue and nothing else would ever mention it (ยง5b).
288
+ if (this.draining) {
289
+ this.report(session.id, key, `queued message not taken โ€” Pier is restarting; send it again: ${truncate(text)}`);
290
+ return;
291
+ }
292
+ log.info(`promoting ${String(steering.length + followUp.length)} queued message(s) โ†’ session ${session.id}`);
293
+ await session.prompt(text);
294
+ }
295
+ catch (err) {
296
+ this.report(session.id, key, `delivering the queued messages failed: ${String(err)}`);
297
+ }
298
+ finally {
299
+ this.promoting.delete(session.id);
300
+ }
301
+ })();
302
+ }
248
303
  async abort(sessionId) {
249
304
  await this.bySession.get(sessionId)?.session.abort();
250
305
  }
306
+ /**
307
+ * Drop what this session was told about who is speaking, so the next message
308
+ * carries a full header again.
309
+ *
310
+ * For the surfaces that take a prefixed message back *out* of the context it
311
+ * was counted into โ€” a recalled queue, a rewound turn. The tracker's whole
312
+ * job is "the model has already been told" (identity.ts), and a header that
313
+ * never reached the model, or reached it and was then rewound away, makes
314
+ * every later message from that speaker unattributed in a group chat. One
315
+ * redundant header is the same price eviction already pays.
316
+ */
317
+ forgetSender(sessionId) {
318
+ this.senders.forget(sessionId);
319
+ }
251
320
  /** Refuse new work from every surface; in-flight turns keep running. */
252
321
  beginDrain() {
253
322
  this.draining = true;
@@ -384,6 +453,9 @@ export class Router {
384
453
  // Turn outcomes flow through the event stream; a rejected call surfaces
385
454
  // there too, never as a thrown exception across the seam.
386
455
  session[action](prompt).catch((err) => {
456
+ // The header was counted as delivered a line above; this message never
457
+ // arrived, so the next one from this speaker must carry it again.
458
+ this.senders.forget(session.id);
387
459
  this.report(session.id, msg.key, String(err));
388
460
  });
389
461
  return { sessionId: session.id };
package/dist/db.js CHANGED
@@ -175,6 +175,84 @@ const MIGRATIONS = [
175
175
  -- every one of its rows because a project is a cwd, not a table.
176
176
  ALTER TABLE session_state ADD COLUMN sort INTEGER;
177
177
  ALTER TABLE session_state ADD COLUMN project_sort INTEGER;
178
+ `,
179
+ // 7 โ€” the session listing, so a transcript is read once (agent/listing.ts).
180
+ `
181
+ -- One row per session file. (size, mtime) is what makes the row usable
182
+ -- without opening the file; parsed_bytes is where reading resumes when it
183
+ -- grew, and is always a line boundary. Derived from disk and disposable: a
184
+ -- deleted row costs one re-read, never a fact.
185
+ CREATE TABLE session_index (
186
+ path TEXT PRIMARY KEY,
187
+ id TEXT NOT NULL,
188
+ cwd TEXT NOT NULL,
189
+ created_at INTEGER NOT NULL,
190
+ name TEXT,
191
+ first_message TEXT,
192
+ size INTEGER NOT NULL,
193
+ mtime INTEGER NOT NULL,
194
+ parsed_bytes INTEGER NOT NULL
195
+ );
196
+ `,
197
+ // 8 โ€” Projects holds a working set: what is warm, plus what is kept.
198
+ `
199
+ -- Membership was permanent, so every throwaway session stayed in the rail
200
+ -- until someone removed it by hand. last_active is the lease: the end of a
201
+ -- turn renews it, and web/session-state.ts stops listing a row that ran out.
202
+ -- kept opts one row out of expiry entirely โ€” what the pin control now means.
203
+ ALTER TABLE session_state ADD COLUMN kept INTEGER NOT NULL DEFAULT 0;
204
+ ALTER TABLE session_state ADD COLUMN last_active INTEGER;
205
+ -- Left NULL on purpose: the honest value is when the transcript was last
206
+ -- written, which only a listing knows. web/server.ts pays one for a database
207
+ -- carrying rows without it, the same gate the pin backfill already uses, so
208
+ -- the first rail after an upgrade is dated by use and not by creation.
209
+ `,
210
+ // 9 โ€” the summary a transcript already carries is read, not mirrored.
211
+ `
212
+ -- Dropped rather than left unread: a column nobody writes still answers when
213
+ -- somebody selects it, and the next reader has no way to tell a stale title
214
+ -- from a current one. The pre-migration backup beside the database is the
215
+ -- way back, not a row of fossils. cwd stays โ€” it is the key a project's
216
+ -- manual place is stamped on, and it never changes for a session.
217
+ ALTER TABLE session_state DROP COLUMN title;
218
+ ALTER TABLE session_state DROP COLUMN created_at;
219
+ ALTER TABLE session_state DROP COLUMN last_active;
220
+ `,
221
+ // 10 โ€” taking a session into Projects is itself an act, and it is dated.
222
+ `
223
+ -- When a hand last put this session in Projects (pin, or a keep toggle).
224
+ -- Not the mirror migration 9 removed: last_active was a copy of a fact the
225
+ -- transcript owns, while this one exists nowhere else โ€” pinning a cold
226
+ -- session back is a statement that it is warm again, and without a record of
227
+ -- *when* it was made the row is dropped by the same read that drew it.
228
+ -- NULL for every row that predates this: never pinned within a lease.
229
+ ALTER TABLE session_state ADD COLUMN pinned_at INTEGER;
230
+ `,
231
+ // 11 โ€” Projects holds what a hand put there, for as long as the hand says.
232
+ `
233
+ -- The lease is gone, so both of its columns are. It expired nothing: a row
234
+ -- it dropped kept its transcript, its place and its ownership, and one more
235
+ -- turn brought it back โ€” so what it actually did was hide rows nobody asked
236
+ -- it to hide, and kept existed only to opt out of that. On the instance this
237
+ -- was decided on, the lease had never dropped a row: 20 pinned sessions,
238
+ -- none past seven days, one kept. Removing a row from Projects is the โœ“ on
239
+ -- the row, and it stays the only way out.
240
+ ALTER TABLE session_state DROP COLUMN kept;
241
+ ALTER TABLE session_state DROP COLUMN pinned_at;
242
+ `,
243
+ // 12 โ€” one row is how two processes take turns (src/tools.ts).
244
+ `
245
+ -- The tools sync, held across processes: the token says who holds it, the
246
+ -- heartbeat says they are still alive. Both processes already open this
247
+ -- database, and BEGIN IMMEDIATE is real mutual exclusion โ€” a lock file with
248
+ -- a pid in it is neither, which is what this replaces. One row, because
249
+ -- there is one thing to serialize; the second lock can bring its own table
250
+ -- and its own reason for existing.
251
+ CREATE TABLE tools_sync_lock (
252
+ id INTEGER PRIMARY KEY CHECK (id = 1),
253
+ token TEXT NOT NULL,
254
+ heartbeat_at INTEGER NOT NULL
255
+ );
178
256
  `,
179
257
  ];
180
258
  let shared;
@@ -22,11 +22,14 @@ export const BUNDLED = [
22
22
  factory: web,
23
23
  },
24
24
  ];
25
- /** The catalog a surface may show: no Pi types, nothing it cannot render. */
25
+ /** The catalog a surface may show: no Pi types, nothing it cannot render.
26
+ * Half of one list; src/tools.ts has the other. */
26
27
  export const bundledInfo = (enabled) => BUNDLED.map(({ name, summary, tools }) => ({
28
+ source: "bundled",
29
+ kind: "extension",
27
30
  name,
28
31
  summary,
29
- tools,
32
+ adds: tools,
30
33
  enabled: enabled.includes(name),
31
34
  }));
32
35
  /** The enabled ones as Pi inline extensions; unknown names are not ours. */
@@ -34,11 +34,16 @@ function displayUrl(url) {
34
34
  }
35
35
  return redacted.toString();
36
36
  }
37
+ const digest = (value) => createHash("sha256").update(value).digest("hex").slice(0, 12);
37
38
  export async function saveArtifact(url, text, retrievedAt) {
38
39
  await mkdir(ARTIFACT_DIR, { recursive: true, mode: 0o700 });
39
40
  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`);
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.
46
+ const path = join(ARTIFACT_DIR, `${host}-${digest(url.toString())}-${digest(text)}.md`);
42
47
  const temporary = `${path}.${randomUUID()}.tmp`;
43
48
  const header = [
44
49
  `Source: ${displayUrl(url)}`,