@timqi/pier 0.0.29 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -1,11 +1,93 @@
1
1
  // One serialized configuration subscriber, shared by Settings and the hourly
2
2
  // task. Only a successfully applied response advances its persistent ETag.
3
+ // The source is fetched over HTTPS only, redirects followed while they stay
4
+ // HTTPS, JSON capped at 1 MiB so a hostile or broken source cannot exhaust memory.
3
5
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
- import { configSourceUrl, CONFIG_SYNC_BYTES, downloadConfig } from "./config-sync-fetch.js";
5
6
  import { transact } from "./db.js";
6
7
  import { logger } from "./log.js";
7
8
  import { normalizeModelMenu } from "./settings.js";
8
9
  const log = logger("config-sync");
10
+ export const CONFIG_SYNC_BYTES = 1024 * 1024;
11
+ const MAX_REDIRECTS = 5;
12
+ export function configSourceUrl(raw) {
13
+ let url;
14
+ try {
15
+ url = new URL(raw.trim());
16
+ }
17
+ catch {
18
+ throw new Error("A valid HTTPS source URL is required");
19
+ }
20
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || raw.length > 4096) {
21
+ throw new Error("Source must be HTTPS, without credentials or a fragment");
22
+ }
23
+ return url;
24
+ }
25
+ export async function downloadConfig(raw, etag, signal) {
26
+ let url = configSourceUrl(raw);
27
+ for (let hop = 0;; hop++) {
28
+ signal.throwIfAborted();
29
+ let res;
30
+ try {
31
+ res = await fetch(url, {
32
+ method: "GET", redirect: "manual", signal,
33
+ headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
34
+ });
35
+ }
36
+ catch {
37
+ throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source");
38
+ }
39
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
40
+ if (location) {
41
+ await res.body?.cancel().catch(() => { });
42
+ if (hop >= MAX_REDIRECTS)
43
+ throw new Error("Source redirected too many times");
44
+ try {
45
+ url = configSourceUrl(new URL(location, url).href);
46
+ }
47
+ catch {
48
+ throw new Error("Source redirected to a location that is not HTTPS");
49
+ }
50
+ continue;
51
+ }
52
+ if (res.status !== 200 && res.status !== 304) {
53
+ await res.body?.cancel().catch(() => { });
54
+ throw new Error(res.status === 404 || res.status === 410
55
+ ? "Source link was revoked or does not exist"
56
+ : `Source returned HTTP ${String(res.status)}`);
57
+ }
58
+ const status = res.status === 304 ? 304 : 200;
59
+ if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
60
+ await res.body?.cancel().catch(() => { });
61
+ throw new Error("Source did not return JSON");
62
+ }
63
+ return { status, etag: res.headers.get("etag"), body: await read(res, signal) };
64
+ }
65
+ }
66
+ async function read(res, signal) {
67
+ const reader = res.body?.getReader();
68
+ if (!reader)
69
+ return "";
70
+ const chunks = [];
71
+ let size = 0;
72
+ try {
73
+ for (;;) {
74
+ const { done, value } = await reader.read();
75
+ if (done)
76
+ break;
77
+ size += value.length;
78
+ if (size > CONFIG_SYNC_BYTES)
79
+ throw new Error("Configuration exceeds 1 MiB");
80
+ chunks.push(Buffer.from(value));
81
+ }
82
+ }
83
+ catch (err) {
84
+ await reader.cancel().catch(() => { });
85
+ if (err instanceof Error && err.message === "Configuration exceeds 1 MiB")
86
+ throw err;
87
+ throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Configuration download failed");
88
+ }
89
+ return Buffer.concat(chunks).toString("utf8");
90
+ }
9
91
  const KEY = "configSync";
10
92
  // Object order is not a change; array order (model menu priority) is.
11
93
  export function configJson(value) {
@@ -162,8 +244,8 @@ export class ConfigSync {
162
244
  try {
163
245
  await this.deps.reload();
164
246
  }
165
- catch {
166
- throw new Error("Configuration saved, but reload failed; retry synchronization");
247
+ catch (cause) {
248
+ throw new Error("Configuration saved, but reload failed; retry synchronization", { cause });
167
249
  }
168
250
  this.#save({ needsReload: false });
169
251
  }
@@ -171,7 +253,8 @@ export class ConfigSync {
171
253
  }
172
254
  catch (err) {
173
255
  const error = err instanceof Error ? err.message : "Configuration sync failed";
174
- log.error(error);
256
+ // The message is the operator's; the cause, when there is one, is the log's.
257
+ log.error(error, err instanceof Error ? err.cause : err);
175
258
  this.#save({ lastChecked: Date.now(), error });
176
259
  throw new Error(error);
177
260
  }
package/dist/core/hub.js CHANGED
@@ -3,8 +3,7 @@
3
3
  import { logger } from "../log.js";
4
4
  const log = logger("core");
5
5
  const RING_SIZE = 1000;
6
- /** A throwing subscriber costs only itself — emit() runs on the emitter's
7
- * stack (Pi's dispatch path, for session events), which must not unwind. */
6
+ /** emit() runs on the emitter's stack (Pi's dispatch path), which must not unwind. */
8
7
  function fanOut(subscribers, event) {
9
8
  for (const fn of subscribers) {
10
9
  try {
@@ -17,8 +16,7 @@ function fanOut(subscribers, event) {
17
16
  }
18
17
  export class EventHub {
19
18
  buses = new Map();
20
- // Workspace bus: no seq, no replay — a client that missed events just
21
- // re-lists on reconnect, so there is nothing to renumber.
19
+ // No seq, no replay: a client that missed events re-lists on reconnect.
22
20
  workspace = new Set();
23
21
  bus(sessionId) {
24
22
  let b = this.buses.get(sessionId);
@@ -36,13 +34,9 @@ export class EventHub {
36
34
  sessionId,
37
35
  ...payload,
38
36
  };
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.
37
+ // One long reply emits thousands of text deltas; a ring holding them would
38
+ // hold only them. `turn-end` carries the full text. Thinking stays
39
+ // replayable: an EventSource reconnect does not reload the transcript.
46
40
  if (payload.type !== "text-delta") {
47
41
  b.buffer.push(event);
48
42
  if (b.buffer.length > RING_SIZE)
@@ -80,15 +74,8 @@ export class EventHub {
80
74
  hasSubscribers(sessionId) {
81
75
  return (this.buses.get(sessionId)?.subscribers.size ?? 0) > 0;
82
76
  }
83
- /**
84
- * Release the ring of a session nobody is watching — the memory an evicted
85
- * session leaves behind (1000 events of text, per session, forever).
86
- *
87
- * The bus itself stays, holding its seq: a client that reconnects with a
88
- * Last-Event-ID drops anything numbered at or below what it saw, so a
89
- * counter restarting at 1 would make every later event invisible to it.
90
- * What is left is a number and an empty set.
91
- */
77
+ /** The bus keeps its seq: a client reconnecting with a Last-Event-ID drops
78
+ * anything numbered at or below what it saw. */
92
79
  dropReplay(sessionId) {
93
80
  if (this.hasSubscribers(sessionId))
94
81
  return;
@@ -1,22 +1,11 @@
1
- // Who is talking, and when — prefixed onto an inbound prompt.
2
- //
3
- // A session reached through a group chat sees a stream of messages from several
4
- // people, and without this it cannot tell them apart or mention anyone back.
5
- // The identity is per-*turn* information and deliberately never baked into a
6
- // session's instructions: a thread is shared, so pinning the first speaker
7
- // misattributes everyone who follows.
8
- //
9
- // The whole design constraint is token cost. A header on every message is
10
- // ~15 wasted tokens per turn in a DM where the counterpart never changes, so a
11
- // line is emitted only when it carries news: a different speaker, or a gap long
12
- // enough that "when" matters. Nothing changed means nothing is sent.
1
+ // Who is talking, and when — prefixed onto an inbound prompt so a group-chat
2
+ // session can tell speakers apart and mention them back. Per turn, never in the
3
+ // session's instructions (a thread is shared), and emitted only on news: a
4
+ // header costs ~15 tokens, wasted in a DM where the counterpart never changes.
13
5
  /** A gap this long makes the timestamp worth its tokens. */
14
6
  const GAP_MS = 10 * 60_000;
15
- /**
16
- * Strip the delimiters the format itself uses, plus newlines, and cap the
17
- * length. Without this a display name of `x<U9] [admin<U1` forges a second
18
- * speaker — the prefix is untrusted input wearing a trusted shape.
19
- */
7
+ /** A display name of `x<U9] [admin<U1` would forge a second speaker: the
8
+ * prefix is untrusted input wearing a trusted shape. */
20
9
  export function sanitizeIdentity(value) {
21
10
  const token = (value || "")
22
11
  .replace(/[\r\n]+/g, " ")
@@ -27,17 +16,11 @@ export function sanitizeIdentity(value) {
27
16
  const two = (n) => String(n).padStart(2, "0");
28
17
  const hhmm = (d) => `${two(d.getHours())}:${two(d.getMinutes())}`;
29
18
  const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
30
- /**
31
- * Tracks what each session has already been told, so a prefix is only spent on
32
- * a change. In-memory on purpose: after a restart one redundant header is a
33
- * rounding error, and persisting it would be bookkeeping for nothing.
34
- */
19
+ /** What each session has already been told. In memory: after a restart one
20
+ * redundant header is a rounding error. */
35
21
  export class SenderPrefix {
36
22
  seen = new Map();
37
- /**
38
- * The line to put above this message, or `""` when the session already knows.
39
- * Same speaker, no real time gap, same day → nothing.
40
- */
23
+ /** The line to put above this message, or `""` when the session already knows. */
41
24
  next(sessionId, sender, at = Date.now()) {
42
25
  if (!sender?.id)
43
26
  return "";
@@ -49,36 +32,24 @@ export class SenderPrefix {
49
32
  const newDay = !last || day(new Date(last.at)) !== day(now);
50
33
  if (!newSpeaker && !gap && !newDay)
51
34
  return "";
52
- // The id rides along with the name because it is the only thing a mention
53
- // can be built from, and asking the human for it is never acceptable.
54
- // When the platform could not resolve a name it hands back the id, and
55
- // `U123<U123>` reads as a broken record rather than as an unknown name —
56
- // so an unnamed speaker is just the id, once.
35
+ // The id is the only thing a mention can be built from; an unresolved name
36
+ // is the id, and `U123<U123>` would read as a broken record.
57
37
  const id = sanitizeIdentity(sender.id);
58
38
  const label = sanitizeIdentity(sender.name);
59
39
  const who = newSpeaker ? (label === id ? `<${id}>` : `${label}<${id}>`) : "";
60
- // The date only when it changed; inside one conversation-day it is noise.
61
40
  const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
62
41
  return `[${[who, when].filter(Boolean).join(" ")}]`;
63
42
  }
64
- /** Forget a session being evicted. Costs one redundant header if it comes
65
- * back, which is the same price a restart already pays. */
66
43
  forget(sessionId) {
67
44
  this.seen.delete(sessionId);
68
45
  }
69
46
  }
70
- /** Put the prefix above the message, or hand the message back untouched. */
71
47
  export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
72
- // Only the shapes `next()` emits: `who`, `when`, or both, and — because
73
- // `withPrefix` always joins with one — a newline after them. Anything else, a
74
- // markdown link opening the message or a human typing `[14:23] on my way`, is
75
- // body text and must come back untouched.
48
+ // Only the shapes `next()` emits, newline included: a human typing
49
+ // `[14:23] on my way` is body text and must come back untouched.
76
50
  const HEADER = /^\[(?:([^\n[\]<>]*)<([^\n[\]<>]+)>)? ?((?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})?\]\n/;
77
- /**
78
- * Read back a header this module wrote. The prefix is a token-saving device for
79
- * the model, not something a human should have to read: a surface showing a
80
- * stored message can render the speaker as it likes and the body without it.
81
- */
51
+ /** Read back a header this module wrote: the prefix is for the model, and a
52
+ * surface showing a stored message renders the speaker its own way. */
82
53
  export function splitSpeaker(text) {
83
54
  const m = HEADER.exec(text);
84
55
  if (!m?.[2] && !m?.[3])
@@ -96,25 +67,15 @@ const ATTACHMENT_LINES = /(?:\n\[[^\]\n]*(?:\]\(\s*<?file:\/\/[^\n]*|$))+$/;
96
67
  /** A long code span that opens the message, when something else follows it.
97
68
  * Long, because `npm test` before "fails" is the subject, not a paste. */
98
69
  const LEADING_CODE_SPAN = /^\s*`[^`\n]{40,}`\s+(\S[\s\S]*)$/;
99
- /**
100
- * A session titled by its first prompt inherits that prompt's header, and the
101
- * header is for the model: anything a person reads — a list row, a session
102
- * header, a notification on a phone — would say "operator: …" on every session
103
- * the workbench ever opened. So the speaker comes off and what they said is
104
- * the title. Here rather than in a UI module because the push notification
105
- * needs the same answer and a second copy of this would drift (AGENTS.md §3).
106
- *
107
- * Two more things a first prompt carries that a title should not: the
108
- * attachment lines a channel appended (`[name](file:///…)`, often cut mid-name
109
- * by the title clip), and a pasted log line in backticks ahead of the actual
110
- * question — dropped only when a question follows; a paste alone stays.
111
- */
70
+ /** A title derived from a first prompt drops what was for the model, not the
71
+ * reader: the speaker header, the attachment lines a channel appended, and a
72
+ * pasted code span ahead of the actual question. Shared by list rows and push
73
+ * notifications, hence core. */
112
74
  export function readableTitle(title) {
113
75
  if (!title)
114
76
  return title;
115
77
  const { text } = splitSpeaker(title);
116
- // No header — the title is what the person typed, and reflowing it would
117
- // change what the sidebar's search is matching against for nothing.
78
+ // No header: reflowing what the person typed would change what search matches.
118
79
  if (text === title)
119
80
  return title;
120
81
  const said = text
@@ -1,11 +1,5 @@
1
- // The file-link convention, browser-safe: what a user's attachment may be
2
- // called, the marker line that tells the agent about it, the parser that
3
- // splits it back out of a message, the size cap both ends enforce, and where a
4
- // `file://` link does not count as one at all (inside code).
5
- // Producers are node code (channels/, web/server.ts) but the web composer
6
- // builds markers and the web chat parses them in the browser, so the grammar
7
- // lives in a module with no node imports that either side can load. The
8
- // filesystem half (saving the bytes) is core/inbox.ts.
1
+ // The inbound file-link grammar. No node imports: the browser composer builds
2
+ // markers and the web chat parses them. Saving the bytes is core/inbox.ts.
9
3
  /** One cap for every inbound path: composer, upload route, Slack metadata. */
10
4
  export const MAX_INBOUND_BYTES = 32 * 1024 * 1024;
11
5
  /** Extension for a name-less upload (a pasted screenshot has no filename). */
@@ -17,11 +11,7 @@ const MIME_EXT = {
17
11
  "application/pdf": ".pdf",
18
12
  "text/plain": ".txt",
19
13
  };
20
- /**
21
- * A filename that is safe as a path segment and inside a markdown link:
22
- * basename only (no traversal), whitespace and link-breaking characters
23
- * folded to `-`, length capped.
24
- */
14
+ /** Safe as a path segment (no traversal) and inside a markdown link. */
25
15
  export function safeName(name, mimeType) {
26
16
  const base = (name ?? "").split("/").pop().replace(/[\s\\()[\]<>%#?]/g, "-");
27
17
  if (!base || base === "." || base === "..")
@@ -32,29 +22,16 @@ export function safeName(name, mimeType) {
32
22
  const ext = dot > 0 ? base.slice(dot, dot + 16) : "";
33
23
  return base.slice(0, 64 - ext.length) + ext;
34
24
  }
35
- /**
36
- * The prompt line for a saved file — the attachment convention, inbound. The
37
- * path is percent-encoded (parentheses included, which encodeURI leaves
38
- * alone) so the link survives markdown and the marker regex even when
39
- * `PIER_HOME` contains spaces or parens; splitInboundFiles decodes.
40
- */
25
+ /** Percent-encoded, parentheses included (encodeURI leaves them), so the link
26
+ * survives markdown when `PIER_HOME` contains spaces or parens. */
41
27
  export const fileMarker = (path) => `[${path.split("/").pop() ?? "file"}](file://${encodeURI(path).replace(/\(/g, "%28").replace(/\)/g, "%29")})`;
42
- /**
43
- * The conversation-visible line for an attachment that never made it (5b: a
44
- * failed download must not look like no attachment). Plain text on purpose —
45
- * not a link — so every surface renders it as the words it is. Both
46
- * directions: an inbound file Pier could not fetch and an outbound one it
47
- * could not upload (channels/attach.ts) are the same fact to the reader.
48
- */
28
+ /** A failed download must not look like no attachment (§5). Plain text, not a
29
+ * link, so every surface renders the words; used in both directions. */
49
30
  export const lostMarker = (name, reason) => `[attachment lost: ${name} — ${reason}]`;
50
31
  /** A fenced block's opening or closing line. */
51
32
  const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
52
- /**
53
- * The character ranges of `text` that are code: fenced blocks (fence lines
54
- * included) and inline spans. Inline spans are paired within a line — one that
55
- * wraps across a newline is legal markdown and not how anyone writes an
56
- * example link, and per-line pairing keeps this a scan instead of a parser.
57
- */
33
+ /** Code ranges: fenced blocks and inline spans. Spans are paired within a line,
34
+ * which keeps this a scan instead of a parser. */
58
35
  function codeRanges(text) {
59
36
  const ranges = [];
60
37
  let fence;
@@ -71,8 +48,8 @@ function codeRanges(text) {
71
48
  ranges.push([at, at + line.length]);
72
49
  }
73
50
  else {
74
- // A span closes on the next backtick run of the same length; runs in
75
- // between are content, so an unpaired opener leaves the rest as prose.
51
+ // A span closes on the next run of the same length; an unpaired opener
52
+ // leaves the rest as prose.
76
53
  const runs = [...line.matchAll(/`+/g)];
77
54
  for (let i = 0; i < runs.length; i++) {
78
55
  const open = runs[i];
@@ -89,13 +66,8 @@ function codeRanges(text) {
89
66
  }
90
67
  return ranges;
91
68
  }
92
- /**
93
- * `text.replace(pattern, …)` for every match that is not inside code. An agent
94
- * that documents this convention writes an example link in backticks, and a
95
- * scanner that cannot tell an example from a link turned that example into a
96
- * real attachment — a dead card in the chat, a lost-attachment line in Slack.
97
- * Matches inside code are left byte-identical: the reader asked to see them.
98
- */
69
+ /** `text.replace(pattern, …)` outside code only: an example link in backticks
70
+ * is not an attachment, and the reader asked to see it byte-identical. */
99
71
  export function replaceOutsideCode(text, pattern, replace) {
100
72
  const skip = codeRanges(text);
101
73
  let out = "";
@@ -112,14 +84,8 @@ export function replaceOutsideCode(text, pattern, replace) {
112
84
  }
113
85
  // A whole line that is one `[name](file:///…)` link — what fileMarker emits.
114
86
  const MARKER_RE = /^\[[^\]\n]*\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)$/;
115
- /**
116
- * Split a user message into its typed text and the attached files' paths.
117
- * Only the contiguous *trailing* block of marker lines is an attachment —
118
- * that is where every producer puts them — so a `file://` link the user
119
- * wrote mid-message stays message text. No code scan needed for the same
120
- * reason: an example in a fence sits under its closing line, which is not a
121
- * marker line, so the walk stops there before ever reaching it.
122
- */
87
+ /** Only the contiguous trailing block of marker lines is an attachment, so a
88
+ * `file://` link mid-message stays text and a fenced example is never reached. */
123
89
  export function splitInboundFiles(raw) {
124
90
  const lines = raw.split("\n");
125
91
  let start = lines.length;
@@ -1,42 +1,27 @@
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.
1
+ // Inbound user files: bytes land on disk once under `$PIER_HOME/inbox/<channel>/`
2
+ // and the prompt carries only a marker line, so a file is never re-sent with
3
+ // every provider request and the agent reads it only when it chooses to.
9
4
  import { mkdir, writeFile } from "node:fs/promises";
10
5
  import { randomBytes } from "node:crypto";
11
6
  import { basename, join } from "node:path";
12
7
  import { pierPath } from "../paths.js";
13
8
  import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
14
9
  /** Where every inbound file lives; the attachment route 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
- */
10
+ const INBOX_DIR = pierPath("inbox");
11
+ /** `wx` turns a prefix collision into an error, not an overwrite. Owner-only
12
+ * modes: uploads are private content on a possibly shared machine. Nothing is
13
+ * deleted here; pruning is the operator's call (docs/deploy.md). */
24
14
  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.
15
+ // The channel id is ours, not user input; basename() keeps a future id honest.
27
16
  const dir = join(INBOX_DIR, basename(channelId));
28
17
  await mkdir(dir, { recursive: true, mode: 0o700 });
29
18
  const path = join(dir, `${String(Date.now())}-${randomBytes(3).toString("hex")}-${safeName(name, mimeType)}`);
30
19
  await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
31
20
  return path;
32
21
  }
33
- /**
34
- * Collect a fetch response's body, refusing past `maxBytes` mid-stream. The
35
- * metadata size gate in saveInboundAll is only as honest as the platform's
36
- * metadata — absent or wrong, `arrayBuffer()` buffers whatever arrives — so
37
- * the read itself is bounded too. Throws with "too large" in the message,
38
- * which the loop below translates into the honest lost-marker reason.
39
- */
22
+ /** The metadata size gate is only as honest as the platform's metadata, so the
23
+ * read itself is bounded too. Throws with "too large" in the message, which
24
+ * saveInboundAll translates into the lost-marker reason. */
40
25
  export async function readCapped(body, maxBytes) {
41
26
  if (!body)
42
27
  return new Uint8Array(0);
@@ -67,14 +52,8 @@ export async function readCapped(body, maxBytes) {
67
52
  }
68
53
  return bytes;
69
54
  }
70
- /**
71
- * Save a message's attachments; each becomes a marker line for the prompt —
72
- * and a failed or oversized one becomes a lost-marker line, never silence
73
- * (5b). Written three times, once per adapter, before landing here: the
74
- * size gate before the fetch (an unauthorized sender is already filtered by
75
- * then, but a movie must not be buffered whole either) and the never-silent
76
- * failure path are invariants, and invariants drift when copied.
77
- */
55
+ /** Each attachment becomes a marker line; a failed or oversized one becomes a
56
+ * lost-marker line, never silence (§5). The size gate runs before the fetch. */
78
57
  export async function saveInboundAll(channelId, files, log) {
79
58
  const markers = [];
80
59
  for (const file of files) {
@@ -89,7 +68,6 @@ export async function saveInboundAll(channelId, files, log) {
89
68
  }
90
69
  catch (err) {
91
70
  log(`attachment download failed: ${String(err)}`);
92
- // A fetch that refused mid-stream names its reason; keep it honest.
93
71
  const why = String(err).includes("too large") ? "too large" : "download failed";
94
72
  markers.push(lostMarker(file.label, why));
95
73
  }
@@ -1,14 +1,12 @@
1
1
  // The whole queue policy. Fixed by docs/architecture.md — do not add options.
2
2
  export function decide(msg, state) {
3
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.
4
+ // message, so a leading "!" there is content, not a control prefix.
6
5
  if (msg.mode === "steer" || msg.mode === "followUp") {
7
6
  return { action: state === "idle" ? "prompt" : msg.mode, text: msg.text };
8
7
  }
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.
8
+ // On auto the "!" is always consumed: whether the turn happened to end
9
+ // first must not decide if it was content.
12
10
  const steerPrefixed = msg.text.startsWith("!");
13
11
  const text = steerPrefixed ? msg.text.slice(1).trimStart() : msg.text;
14
12
  if (state === "idle")