@timqi/pier 0.0.16 → 0.0.19

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 (79) hide show
  1. package/dist/agent/events.js +16 -4
  2. package/dist/agent/listing.js +12 -13
  3. package/dist/agent/pi.js +28 -2
  4. package/dist/boards/boards.js +7 -6
  5. package/dist/channels/attach.js +18 -4
  6. package/dist/channels/config.js +7 -2
  7. package/dist/channels/dedup.js +9 -1
  8. package/dist/channels/lark.js +2 -6
  9. package/dist/channels/receipts.js +21 -6
  10. package/dist/channels/slack-tool.js +86 -25
  11. package/dist/channels/slack.js +2 -10
  12. package/dist/channels/telegram.js +1 -0
  13. package/dist/core/hub.js +14 -4
  14. package/dist/core/inbox.js +1 -1
  15. package/dist/core/reply.js +64 -0
  16. package/dist/db.js +105 -0
  17. package/dist/main.js +1 -0
  18. package/dist/secrets.js +18 -6
  19. package/dist/settings.js +2 -11
  20. package/dist/tasks/agent.js +14 -1
  21. package/dist/tasks/definitions.js +3 -1
  22. package/dist/tasks/messages.js +1 -2
  23. package/dist/tasks/service.js +5 -0
  24. package/dist/tasks/store.js +44 -12
  25. package/dist/tools.js +42 -18
  26. package/dist/web/auth.js +212 -68
  27. package/dist/web/config.js +61 -0
  28. package/dist/web/explorer.js +20 -70
  29. package/dist/web/fs.js +159 -0
  30. package/dist/web/instance.js +11 -0
  31. package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
  32. package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
  33. package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
  34. package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
  35. package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
  36. package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
  37. package/dist/web/public/assets/code-DjWvqL94.js +1 -0
  38. package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
  39. package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
  40. package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
  41. package/dist/web/public/assets/{ghostty-web-C4ivXTBE.js → ghostty-web-D8CLFi_l.js} +1 -1
  42. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
  43. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
  44. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  45. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  46. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  47. package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
  48. package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
  49. package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
  50. package/dist/web/public/assets/{index-2E9_cwpg.css → index-yNocr797.css} +1 -1
  51. package/dist/web/public/assets/index-yNocr797.css.br +0 -0
  52. package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
  53. package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
  54. package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
  55. package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
  56. package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
  57. package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
  58. package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
  59. package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
  60. package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
  61. package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
  62. package/dist/web/public/icon.svg.br +0 -0
  63. package/dist/web/public/icon.svg.gz +0 -0
  64. package/dist/web/public/index.html +2 -2
  65. package/dist/web/public/index.html.br +0 -0
  66. package/dist/web/public/index.html.gz +0 -0
  67. package/dist/web/public/manifest.webmanifest.br +0 -0
  68. package/dist/web/public/manifest.webmanifest.gz +0 -0
  69. package/dist/web/public/sw.js.br +0 -0
  70. package/dist/web/public/sw.js.gz +0 -0
  71. package/dist/web/push.js +30 -7
  72. package/dist/web/route.js +17 -0
  73. package/dist/web/server.js +104 -23
  74. package/dist/web/terminal.js +21 -16
  75. package/docs/deploy.md +12 -3
  76. package/package.json +1 -1
  77. package/skills/pier-slack/SKILL.md +4 -16
  78. package/dist/web/files.js +0 -183
  79. package/dist/web/public/assets/index-DVUvzNK1.js +0 -93
@@ -184,13 +184,25 @@ export function toSessionEvents(e) {
184
184
  case "agent_start":
185
185
  return [{ type: "state", state: "streaming" }, { type: "turn-start" }];
186
186
  case "agent_end": {
187
+ // Pi retries a retryable provider error itself and emits one agent_end
188
+ // per attempt. Only the last one ends the turn: translating the others
189
+ // posts a reply and an error per attempt for a failure Pi is still
190
+ // recovering from, and an `idle` the session is not in.
191
+ if (e.willRetry)
192
+ return [];
187
193
  const final = lastAssistant(e.messages);
194
+ // A turn can end without the model ever answering. Carried twice on
195
+ // purpose: on turn-end because it is *how this turn ended*, which is what
196
+ // a task run settles on (tasks/agent.ts), and as the error event that is
197
+ // already every chat surface's failure path (core/router.ts).
198
+ const failure = final?.stopReason === "error"
199
+ ? final.errorMessage || "unknown agent error"
200
+ : undefined;
188
201
  const out = [
189
- { type: "turn-end", text: textOf(final?.content) },
202
+ { type: "turn-end", text: textOf(final?.content), ...(failure ? { error: failure } : {}) },
190
203
  ];
191
- if (final?.stopReason === "error") {
192
- out.push({ type: "error", message: final.errorMessage ?? "unknown agent error" });
193
- }
204
+ if (failure)
205
+ out.push({ type: "error", message: failure });
194
206
  out.push({ type: "state", state: "idle" });
195
207
  return out;
196
208
  }
@@ -16,7 +16,7 @@
16
16
  // incremental.
17
17
  import { createReadStream, promises as fs } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { pierDb } from "../db.js";
19
+ import { pierDb, statements, transact } from "../db.js";
20
20
  import { SESSION_TITLE_MAX } from "../limits.js";
21
21
  import { logger } from "../log.js";
22
22
  import { defaultAgentDir } from "./config.js";
@@ -75,6 +75,7 @@ function fold(acc, line) {
75
75
  export class IndexedListing {
76
76
  dir;
77
77
  #db;
78
+ #statements;
78
79
  constructor(dir = join(defaultAgentDir(), "sessions"), db) {
79
80
  this.dir = dir;
80
81
  this.#db = db;
@@ -85,9 +86,12 @@ export class IndexedListing {
85
86
  #store() {
86
87
  return (this.#db ??= pierDb());
87
88
  }
89
+ /** The scan's three statements, compiled on the first scan and not again. */
90
+ #sql() {
91
+ return (this.#statements ??= statements(this.#store()));
92
+ }
88
93
  async scan() {
89
- const db = this.#store();
90
- const known = new Map(db.prepare("SELECT * FROM session_index").all().map((row) => [row.path, row]));
94
+ const known = new Map(this.#sql()("SELECT * FROM session_index").all().map((row) => [row.path, row]));
91
95
  const records = [];
92
96
  const write = [];
93
97
  for (const file of await this.#files()) {
@@ -228,26 +232,21 @@ export class IndexedListing {
228
232
  if (!rows.length && !gone.length)
229
233
  return;
230
234
  const db = this.#store();
231
- const upsert = db.prepare(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
235
+ const sql = this.#sql();
236
+ const upsert = sql(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
232
237
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
233
238
  ON CONFLICT(path) DO UPDATE SET
234
239
  id = excluded.id, cwd = excluded.cwd, created_at = excluded.created_at,
235
240
  name = excluded.name, first_message = excluded.first_message,
236
241
  size = excluded.size, mtime = excluded.mtime,
237
242
  parsed_bytes = excluded.parsed_bytes`);
238
- const drop = db.prepare("DELETE FROM session_index WHERE path = ?");
239
- db.exec("BEGIN");
240
- try {
243
+ const drop = sql("DELETE FROM session_index WHERE path = ?");
244
+ transact(db, () => {
241
245
  for (const r of rows) {
242
246
  upsert.run(r.path, r.id, r.cwd, r.created_at, r.name, r.first_message, r.size, r.mtime, r.parsed_bytes);
243
247
  }
244
248
  for (const path of gone)
245
249
  drop.run(path);
246
- db.exec("COMMIT");
247
- }
248
- catch (err) {
249
- db.exec("ROLLBACK");
250
- throw err;
251
- }
250
+ });
252
251
  }
253
252
  }
package/dist/agent/pi.js CHANGED
@@ -90,6 +90,17 @@ export const standDownShadowed = (base) => {
90
90
  }),
91
91
  };
92
92
  };
93
+ /**
94
+ * A bundled skill stands down with the tool it documents. A skill's
95
+ * description is resident in every prompt, so one pointing at a tool this
96
+ * session was not given is both a cost and a route the agent cannot take.
97
+ */
98
+ export const standDownUndocumented = (tools, skills) => {
99
+ const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
100
+ if (!gone.size)
101
+ return skills;
102
+ return skills.filter((skill) => !gone.has(skill.name));
103
+ };
93
104
  export class PiSession {
94
105
  pi;
95
106
  pinned;
@@ -285,8 +296,19 @@ export class PiSession {
285
296
  return this.pi.abort();
286
297
  }
287
298
  subscribe(fn) {
299
+ let retryPending = false;
288
300
  return this.pi.subscribe((event) => {
289
- for (const payload of toSessionEvents(event)) {
301
+ const piEvent = event;
302
+ if (piEvent.type === "agent_end")
303
+ retryPending = piEvent.willRetry === true;
304
+ if (piEvent.type === "agent_settled" && retryPending) {
305
+ // Aborting Pi during retry backoff produces no final agent_end.
306
+ retryPending = false;
307
+ fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
308
+ fn({ type: "state", state: "idle" });
309
+ return;
310
+ }
311
+ for (const payload of toSessionEvents(piEvent)) {
290
312
  fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
291
313
  }
292
314
  });
@@ -553,6 +575,10 @@ export class PiAgentFactory {
553
575
  // Pi's generic default, preserving the user's later instruction layer.
554
576
  systemPromptOverride: pierSystemPrompt,
555
577
  additionalSkillPaths: this.skillPaths,
578
+ skillsOverride: (base) => ({
579
+ ...base,
580
+ skills: standDownUndocumented(this.extraTools, base.skills),
581
+ }),
556
582
  extensionFactories: [
557
583
  { name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
558
584
  ...inlineExtensions(this.enabledExtensions()),
@@ -688,7 +714,7 @@ export class PiAgentFactory {
688
714
  * session this factory opened. The miss is what earns a fresh scan, because
689
715
  * callers read it as permission to start a replacement session
690
716
  * (channels/conversations.ts), which costs a conversation its history, or as
691
- * a session that no longer exists (tasks/, web/files.ts). `reused` is how we
717
+ * a session that no longer exists (tasks/, web/server.ts). `reused` is how we
692
718
  * know a scan is owed: same entry back, same disk state. */
693
719
  async locate(sessionId) {
694
720
  const find = (infos) => infos.find((s) => s.id === sessionId);
@@ -122,15 +122,16 @@ export async function listBoards(dir) {
122
122
  catch {
123
123
  return []; // no boards yet
124
124
  }
125
- const boards = [];
126
- for (const slug of entries.sort()) {
125
+ // One board's manifest says nothing about the next one's, so the scan waits
126
+ // once for all of them rather than once per board.
127
+ const boards = await Promise.all(entries.sort().map(async (slug) => {
127
128
  const manifest = await readManifest(dir, slug);
128
129
  if (!manifest)
129
- continue;
130
+ return null;
130
131
  const { title, description, sessions, public: isPublic, token } = manifest;
131
- boards.push({ slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) });
132
- }
133
- return boards;
132
+ return { slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) };
133
+ }));
134
+ return boards.filter((board) => board !== null);
134
135
  }
135
136
  /** Containment, not normalization: the resolved realpath must sit inside the
136
137
  * board's own site dir or nothing is served. */
@@ -65,7 +65,14 @@ export async function sendAttachments(paths, upload, log) {
65
65
  log(`attachment ${path} not sent: ${reason}`);
66
66
  lost.push(lostMarker(nameOf(path), reason));
67
67
  };
68
- for (const path of paths.slice(0, MAX_ATTACHMENTS)) {
68
+ const taken = paths.slice(0, MAX_ATTACHMENTS);
69
+ // Five files that know nothing about each other cost one round trip, not
70
+ // five. Two phases so the platform still receives them in the order the
71
+ // turn linked them, and each failure is kept in its own slot — `lost` reads
72
+ // in link order whichever upload finished first.
73
+ const reasons = [];
74
+ const reasonOf = (err) => (err instanceof Error ? err.message : String(err));
75
+ const files = await Promise.all(taken.map(async (path, i) => {
69
76
  try {
70
77
  const info = await stat(path);
71
78
  if (!info.isFile())
@@ -74,12 +81,19 @@ export async function sendAttachments(paths, upload, log) {
74
81
  throw new Error(`too large (>${MAX_ATTACH_BYTES} bytes)`);
75
82
  const name = nameOf(path);
76
83
  const ext = extname(name).slice(1).toLowerCase();
77
- await upload({ name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) });
84
+ return { name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) };
78
85
  }
79
86
  catch (err) {
80
- fail(path, err instanceof Error ? err.message : String(err));
87
+ reasons[i] = reasonOf(err);
88
+ return undefined;
81
89
  }
82
- }
90
+ }));
91
+ await Promise.all(files.map((file, i) => file ? upload(file).catch((err) => void (reasons[i] = reasonOf(err))) : undefined));
92
+ taken.forEach((path, i) => {
93
+ const reason = reasons[i];
94
+ if (reason !== undefined)
95
+ fail(path, reason);
96
+ });
83
97
  for (const path of paths.slice(MAX_ATTACHMENTS)) {
84
98
  fail(path, `more than ${MAX_ATTACHMENTS} files in one turn`);
85
99
  }
@@ -73,13 +73,18 @@ export class ChannelStore {
73
73
  * discovery is passive. A new chat copies the platform defaults and owns
74
74
  * them from then on — the mention and bind gates are what keep it harmless
75
75
  * until an operator configures it.
76
+ *
77
+ * Telegram gets a chat's name free with every update, so it discovers on
78
+ * every message: the "already known, unchanged" answer is read off the
79
+ * cached document and costs no clone. Only a real change pays for one.
76
80
  */
77
81
  discoverChat(platform, chat) {
82
+ const cached = this.cached(platform).chats.find((c) => c.id === chat.id);
83
+ if (cached && cached.name === chat.name && cached.kind === chat.kind)
84
+ return;
78
85
  const config = this.get(platform);
79
86
  const known = config.chats.find((c) => c.id === chat.id);
80
87
  if (known) {
81
- if (known.name === chat.name && known.kind === chat.kind)
82
- return;
83
88
  known.name = chat.name || known.name;
84
89
  known.kind = chat.kind;
85
90
  }
@@ -4,6 +4,9 @@
4
4
  // the second copy had already appeared and the map has an invariant that is
5
5
  // easy to lose on a rewrite: it is fed by every message in every chat the bot
6
6
  // is in, so it must be bounded and time-limited, never grow-only.
7
+ /** Fraction of `max` a full map is cut back to, so the eviction walk is paid
8
+ * once per that many messages instead of once per message. */
9
+ const KEEP = 0.9;
7
10
  export class Dedup {
8
11
  log;
9
12
  ttlMs;
@@ -28,8 +31,13 @@ export class Dedup {
28
31
  // is a forgotten id under extreme load (a redelivery slips through,
29
32
  // which downstream handling tolerates); unbounded memory is worse.
30
33
  // Map iterates in insertion order, so the front is the oldest.
34
+ //
35
+ // Evicting down to `KEEP` rather than to exactly `max` is what makes
36
+ // the walk amortized: at the bound, freeing one slot per message meant
37
+ // re-walking the whole map on every message from then on.
38
+ const keep = Math.floor(this.max * KEEP);
31
39
  for (const [id] of this.seen) {
32
- if (this.seen.size < this.max)
40
+ if (this.seen.size <= keep)
33
41
  break;
34
42
  this.seen.delete(id);
35
43
  }
@@ -44,7 +44,6 @@ const DRAIN_TIMEOUT_MS = 5000;
44
44
  /** How long a delivered event id is remembered, against redelivery. */
45
45
  const DEDUP_TTL_MS = 5 * 60_000;
46
46
  const DEDUP_MAX = 2000;
47
- const SWEEP_EVERY_MS = 60_000;
48
47
  /**
49
48
  * A Lark conversation is always `<chatId>/<rootMessageId>` — the thread is
50
49
  * the session. This pair is the only definition of the format; control.ts
@@ -87,7 +86,6 @@ export class LarkChannel {
87
86
  out;
88
87
  socket;
89
88
  running = false;
90
- sweptAt = 0;
91
89
  constructor(deps) {
92
90
  this.deps = deps;
93
91
  const config = deps.store.get("lark");
@@ -143,10 +141,8 @@ export class LarkChannel {
143
141
  onEvent(event, onMessage) {
144
142
  if (!this.running)
145
143
  return;
146
- if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
147
- this.sweptAt = Date.now();
148
- void this.receipts.sweep();
149
- }
144
+ // Asked on every event, throttled inside receipts.ts.
145
+ void this.receipts.sweep();
150
146
  // Our own echo or another app's message.
151
147
  if (event.senderType === "app")
152
148
  return;
@@ -8,6 +8,14 @@
8
8
  // it finds at startup (nothing in memory can be its own yet), and sweeps its
9
9
  // own stragglers on a timer.
10
10
  import { pierDb } from "../db.js";
11
+ /**
12
+ * How often the straggler sweep may really run. Adapters ask on the inbound
13
+ * path — per envelope, or per `getUpdates` round trip — and the books only
14
+ * change on the scale of `staleMs`, so a busy chat would otherwise run this
15
+ * query hundreds of times a minute. Throttled here rather than in each
16
+ * adapter, which is where the same timestamp had been copied twice already.
17
+ */
18
+ const SWEEP_EVERY_MS = 60_000;
11
19
  const toReceipt = (row) => ({
12
20
  conversationId: row.conversation_id,
13
21
  chatId: row.chat_id,
@@ -68,6 +76,7 @@ export class Receipts {
68
76
  staleMs;
69
77
  /** In-flight `setReaction` per marked message. Only this process's own. */
70
78
  applying = new Map();
79
+ sweptAt = 0;
71
80
  constructor(api, ledger, log, emoji,
72
81
  /** After this, a receipt's turn is assumed never to settle. */
73
82
  staleMs) {
@@ -106,14 +115,20 @@ export class Receipts {
106
115
  * ours yet — and past `staleMs` a receipt's turn is never going to settle.
107
116
  */
108
117
  sweep(all = false) {
118
+ const now = Date.now();
119
+ // `all` is the startup sweep: it takes everything, so it is never skipped.
120
+ if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
121
+ return Promise.resolve();
122
+ this.sweptAt = now;
109
123
  return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
110
124
  }
111
125
  async clear(receipts) {
112
- for (const { chatId, messageId } of receipts) {
113
- const key = `${chatId}:${messageId}`;
114
- await this.applying.get(key);
115
- this.applying.delete(key);
116
- await this.api.setReaction(chatId, messageId, null).catch(() => { });
117
- }
126
+ // Wait together, then launch clears in booking order: if one apply is slow,
127
+ // it must not let a later receipt clear first.
128
+ await Promise.all(receipts.map(({ chatId, messageId }) => this.applying.get(`${chatId}:${messageId}`)));
129
+ for (const { chatId, messageId } of receipts)
130
+ this.applying.delete(`${chatId}:${messageId}`);
131
+ await Promise.all(receipts.map(({ chatId, messageId }) => this.api.setReaction(chatId, messageId, null)
132
+ .catch((err) => this.log(`reaction clear failed: ${String(err)}`))));
118
133
  }
119
134
  }
@@ -27,19 +27,25 @@ const tsToNumber = (ts) => Number(ts);
27
27
  const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
28
28
  /** Minute precision in the transcript: the exact time is in the `ts` beside it. */
29
29
  const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
30
+ /** Epoch seconds at the year 2100: past this, the caller meant milliseconds. */
31
+ const MAX_SECONDS = 4_102_444_800;
30
32
  /** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
31
33
  export function toTs(value) {
32
34
  if (value === undefined || value === "")
33
35
  return undefined;
34
- if (typeof value === "number")
35
- return String(value);
36
- const trimmed = value.trim();
37
- if (/^\d+(\.\d+)?$/.test(trimmed))
38
- return trimmed;
39
- const parsed = Date.parse(trimmed);
40
- if (Number.isNaN(parsed))
36
+ const raw = typeof value === "number" ? String(value) : value.trim();
37
+ // A ts is passed through untouched — it is an id, not a number to reformat.
38
+ const numeric = /^\d+(\.\d+)?$/.test(raw);
39
+ const seconds = numeric ? Number(raw) : Date.parse(raw) / 1000;
40
+ if (Number.isNaN(seconds))
41
41
  throw new Error(`not a time: ${value}`);
42
- return String(parsed / 1000);
42
+ // Milliseconds are the mistake worth naming: Slack takes the window without
43
+ // complaint, finds nothing that far in the future, and answers with an empty
44
+ // read that is indistinguishable from a channel where nobody spoke.
45
+ if (seconds > MAX_SECONDS) {
46
+ throw new Error(`${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
47
+ }
48
+ return numeric ? raw : String(seconds);
43
49
  }
44
50
  /**
45
51
  * Whether a session opened now is given the tool at all: the same two switches
@@ -91,6 +97,8 @@ export function slackToolSpec(execute, available) {
91
97
  text: Type.Optional(Type.String()),
92
98
  }),
93
99
  available,
100
+ // Stands down with the tool: without Slack, its manual is a route to nowhere.
101
+ skill: "pier-slack",
94
102
  execute,
95
103
  };
96
104
  }
@@ -165,10 +173,12 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
165
173
  // own bounds are inclusive-ish, so the boundary message is dropped here
166
174
  // rather than trusted to the API.
167
175
  const after = toTs(input.after);
176
+ // Both reads take it, so it is read once: a cap that worked on a channel and
177
+ // was ignored on a thread would be the more expensive kind of surprise.
178
+ const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
168
179
  if (input.operation === "read_channel") {
169
180
  const since = after ?? toTs(input.since);
170
181
  const until = toTs(input.until);
171
- const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
172
182
  return readChannel(deps, client, channel, since, until, after, limit);
173
183
  }
174
184
  if (input.operation === "read_thread") {
@@ -177,7 +187,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
177
187
  : at?.threadTs;
178
188
  if (!threadTs)
179
189
  throw new Error("thread_ts is required outside a Slack thread");
180
- return readThread(deps, client, channel, threadTs, after);
190
+ return readThread(deps, client, channel, threadTs, after, limit);
181
191
  }
182
192
  if (input.operation === "read_message") {
183
193
  const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
@@ -204,6 +214,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
204
214
  at: sent.ts ? tsToIso(sent.ts) : null,
205
215
  // Returned so a follow-up can reply under what was just posted.
206
216
  threadTs: threadTs ?? sent.ts,
217
+ ...inertMention(text),
207
218
  };
208
219
  }
209
220
  if (input.operation === "edit") {
@@ -211,14 +222,23 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
211
222
  // and Slack keeps no visible record of what it said before.
212
223
  const ts = required(input.ts, "ts");
213
224
  const text = messageText(input.text);
225
+ // Defaulted from `here` as `post` is: a correction is nearly always to a
226
+ // reply the agent made in this thread, and `conversations.history` cannot
227
+ // see inside a thread — without this the read below always comes up empty.
228
+ const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
229
+ const inThread = asked || (channel === at?.channel ? at?.threadTs : undefined);
230
+ // Read before the write, because after it the old wording exists nowhere:
231
+ // Slack keeps no version, so this log is the only record of what was
232
+ // replaced. Best-effort — a failed read must not block the correction.
233
+ const was = await previousText(client, channel, ts, inThread);
214
234
  try {
215
235
  await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
216
236
  }
217
237
  catch (err) {
218
238
  throw new Error(explain(err));
219
239
  }
220
- deps.log(`slack tool edited ${ts} in ${channel}`);
221
- return { channel, ts, edited: true };
240
+ deps.log(`slack tool edited ${ts} in ${channel}; was: ${was ?? "(not captured)"}`);
241
+ return { channel, ts, edited: true, ...inertMention(text) };
222
242
  }
223
243
  if (input.operation === "delete") {
224
244
  // Never defaulted from `here`: the thread's ts is the parent message, and
@@ -237,6 +257,50 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
237
257
  }
238
258
  throw new Error(`unknown slack operation: ${String(input.operation)}`);
239
259
  }
260
+ /**
261
+ * The one message at `ts`. A `ts` is unique only within its conversation, and
262
+ * `conversations.history` never returns what was posted inside a thread — so a
263
+ * reply has to be asked for through its thread, and only the caller knows.
264
+ */
265
+ async function oneMessage(client, channel, ts, threadTs) {
266
+ const page = threadTs
267
+ ? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
268
+ : await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
269
+ return page.messages.find((msg) => msg.ts === ts);
270
+ }
271
+ /** One line of what a message said, for the log an edit leaves behind. */
272
+ async function previousText(client, channel, ts, threadTs) {
273
+ try {
274
+ const text = (await oneMessage(client, channel, ts, threadTs))?.text;
275
+ return text === undefined ? undefined : text.slice(0, 200).replace(/\s+/g, " ");
276
+ }
277
+ catch {
278
+ // Not swallowed: the caller logs that the old wording was not captured,
279
+ // which is the fact that matters. Refusing the edit over it would be worse.
280
+ return undefined;
281
+ }
282
+ }
283
+ /**
284
+ * A plain `@alice` is the one Slack mistake that looks like it worked: the
285
+ * message goes up, renders as text, and notifies nobody. Reported after the
286
+ * fact rather than refused — a name in prose is legitimate, an unping is not
287
+ * worth losing the message over.
288
+ */
289
+ function inertMention(text) {
290
+ const prose = text
291
+ .replace(/```[\s\S]*?```|`[^`]*`/g, "") // code says @ and # for other reasons
292
+ .replace(/<[^>]*>/g, ""); // already Slack syntax
293
+ const hit = /(?:^|\s)([@#][A-Za-z][\w.-]*)/.exec(prose)?.[1];
294
+ if (!hit)
295
+ return {};
296
+ const as = hit.startsWith("@")
297
+ ? /^@(here|channel|everyone)$/.test(hit) ? `<!${hit.slice(1)}>` : "<@U…>"
298
+ : "<#C…>";
299
+ return {
300
+ hint: `${hit} is plain text and notified nobody — Slack needs ${as}. ` +
301
+ `Edit this ts if it was meant to reach someone.`,
302
+ };
303
+ }
240
304
  /** Accept a `#name` or a bare name as well as an id — models prefer names. */
241
305
  function resolveChannel(deps, given) {
242
306
  if (/^[CDG][A-Z0-9]+$/.test(given))
@@ -265,30 +329,27 @@ async function readChannel(deps, client, channel, since, until, after, limit) {
265
329
  messages: await lines(deps, client, window),
266
330
  };
267
331
  }
268
- async function readThread(deps, client, channel, threadTs, after) {
332
+ async function readThread(deps, client, channel, threadTs, after, limit) {
269
333
  const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
270
- const messages = newerThan(transcript(fetched.messages), after);
334
+ const all = newerThan(transcript(fetched.messages), after);
335
+ // Oldest first, as in a channel read: a thread cut at its newest end still
336
+ // reads as a thread, and the cut has to be said either way — a long thread
337
+ // that answers as if it were complete is the read nobody double-checks.
338
+ const messages = all.slice(0, limit);
271
339
  return {
272
340
  channel,
273
341
  // Hoisted: every line in a thread carries the same one.
274
342
  threadTs,
275
343
  count: messages.length,
344
+ ...(fetched.truncated || all.length > messages.length ? { truncated: true } : {}),
276
345
  ...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
277
346
  format: LINE_FORMAT,
278
347
  messages: await lines(deps, client, messages),
279
348
  };
280
349
  }
281
- /**
282
- * One message, because that is sometimes the whole question. A `ts` is unique
283
- * only within its conversation, and `conversations.history` never returns what
284
- * was posted inside a thread — so a reply has to be asked for through its
285
- * thread, and saying which one is the caller's job.
286
- */
350
+ /** One message, because that is sometimes the whole question. */
287
351
  async function readMessage(deps, client, channel, ts, threadTs) {
288
- const page = threadTs
289
- ? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
290
- : await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
291
- const found = page.messages.find((msg) => msg.ts === ts);
352
+ const found = await oneMessage(client, channel, ts, threadTs);
292
353
  if (!found) {
293
354
  throw new Error(threadTs
294
355
  ? `no message ${ts} in thread ${threadTs}`
@@ -342,7 +403,7 @@ function explain(err) {
342
403
  const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
343
404
  return {
344
405
  channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
345
- not_in_channel: "Pier's bot is not in that channel; someone has to invite it before it can read",
406
+ not_in_channel: "Pier's bot is not in that channel; someone has to run `/invite @Pier` there before it can read",
346
407
  missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
347
408
  ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
348
409
  thread_not_found: "no thread with that ts in this channel",
@@ -52,8 +52,6 @@ const DRAIN_TIMEOUT_MS = 5000;
52
52
  */
53
53
  const DEDUP_TTL_MS = 5 * 60_000;
54
54
  const DEDUP_MAX = 2000;
55
- /** How often the straggler sweep may run; see onEnvelope. */
56
- const SWEEP_EVERY_MS = 60_000;
57
55
  /**
58
56
  * Commands that may appear as a bare word, and exactly how many arguments each
59
57
  * takes. Both halves are load-bearing, because Slack gives us no leading `/` to
@@ -132,7 +130,6 @@ export class SlackChannel {
132
130
  out;
133
131
  socket;
134
132
  running = false;
135
- sweptAt = 0;
136
133
  constructor(deps) {
137
134
  this.deps = deps;
138
135
  const config = deps.store.get("slack");
@@ -195,13 +192,8 @@ export class SlackChannel {
195
192
  onEnvelope(env, onMessage) {
196
193
  if (!this.running)
197
194
  return;
198
- // Envelopes arrive pushed, not in polled batches, so the sweep is timed
199
- // rather than per-envelope: a busy channel would otherwise run this query
200
- // hundreds of times a minute for something that changes every 30.
201
- if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
202
- this.sweptAt = Date.now();
203
- void this.receipts.sweep();
204
- }
195
+ // Asked on every envelope, throttled inside receipts.ts.
196
+ void this.receipts.sweep();
205
197
  if (env.type === "events_api") {
206
198
  const payload = env.payload;
207
199
  const event = payload?.event;
@@ -124,6 +124,7 @@ export class TelegramChannel {
124
124
  const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
125
125
  if (!this.running)
126
126
  return;
127
+ // Asked on every round trip, throttled inside receipts.ts.
127
128
  void this.receipts.sweep();
128
129
  if (!updates.length && Date.now() - startedAt < 1000) {
129
130
  await new Promise((r) => setTimeout(r, 1000));
package/dist/core/hub.js CHANGED
@@ -36,9 +36,18 @@ export class EventHub {
36
36
  sessionId,
37
37
  ...payload,
38
38
  };
39
- b.buffer.push(event);
40
- if (b.buffer.length > RING_SIZE)
41
- b.buffer.shift();
39
+ // Text deltas fan out live but never enter the ring: one long reply emits
40
+ // thousands of them, so a ring that held them would hold *only* them and
41
+ // would have evicted the turn-start, tool and turn-end events a
42
+ // reconnecting client replays for. The text is not lost — `turn-end`
43
+ // carries the full reply (web/ui/chat.ts treats it as authoritative).
44
+ // Thinking stays replayable because a native EventSource reconnect does
45
+ // not reload the transcript snapshot that would otherwise restore it.
46
+ if (payload.type !== "text-delta") {
47
+ b.buffer.push(event);
48
+ if (b.buffer.length > RING_SIZE)
49
+ b.buffer.shift();
50
+ }
42
51
  fanOut(b.subscribers, event);
43
52
  }
44
53
  subscribe(sessionId, fn) {
@@ -46,7 +55,8 @@ export class EventHub {
46
55
  b.subscribers.add(fn);
47
56
  return () => b.subscribers.delete(fn);
48
57
  }
49
- /** Events with seq > afterSeq still held in the ring buffer. */
58
+ /** Events with seq > afterSeq still held in the ring buffer — oldest first,
59
+ * and without text deltas, which are live-only. */
50
60
  replay(sessionId, afterSeq) {
51
61
  return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
52
62
  }
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
11
11
  import { basename, join } from "node:path";
12
12
  import { pierPath } from "../paths.js";
13
13
  import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
14
- /** Where every inbound file lives; web/files.ts allowlists this root. */
14
+ /** Where every inbound file lives; the attachment route allowlists this root. */
15
15
  export const INBOX_DIR = pierPath("inbox");
16
16
  /**
17
17
  * Write one inbound file and return its absolute path. The timestamp-random