@timqi/pier 0.0.1 → 0.0.2

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 +76 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack.js +20 -9
  14. package/dist/channels/telegram-api.js +3 -4
  15. package/dist/channels/telegram.js +37 -28
  16. package/dist/cli.js +177 -29
  17. package/dist/core/hub.js +36 -5
  18. package/dist/core/identity.js +5 -0
  19. package/dist/core/inbound-file.js +70 -0
  20. package/dist/core/inbox.js +32 -0
  21. package/dist/core/queue.js +9 -3
  22. package/dist/core/reply.js +20 -5
  23. package/dist/core/router.js +186 -14
  24. package/dist/core/types.js +53 -0
  25. package/dist/db.js +54 -8
  26. package/dist/drain.js +145 -0
  27. package/dist/main.js +86 -18
  28. package/dist/secrets.js +10 -6
  29. package/dist/service.js +142 -18
  30. package/dist/settings.js +69 -8
  31. package/dist/tasks/agent.js +41 -5
  32. package/dist/tasks/callbacks.js +29 -89
  33. package/dist/tasks/definitions.js +2 -6
  34. package/dist/tasks/execution.js +10 -1
  35. package/dist/tasks/groups.js +20 -49
  36. package/dist/tasks/messages.js +106 -21
  37. package/dist/tasks/outbox.js +157 -0
  38. package/dist/tasks/routes.js +6 -4
  39. package/dist/tasks/service.js +79 -22
  40. package/dist/tasks/store.js +48 -55
  41. package/dist/tasks/tool.js +19 -4
  42. package/dist/tasks/types.js +7 -0
  43. package/dist/update.js +94 -0
  44. package/dist/web/auth.js +75 -22
  45. package/dist/web/explorer.js +146 -0
  46. package/dist/web/files.js +26 -11
  47. package/dist/web/instance.js +99 -0
  48. package/dist/web/provider-flows.js +249 -0
  49. package/dist/web/providers.js +129 -0
  50. package/dist/web/public/assets/index-BK64pHmP.js +90 -0
  51. package/dist/web/public/assets/index-De4GlOq4.css +2 -0
  52. package/dist/web/public/icon-192.png +0 -0
  53. package/dist/web/public/icon-32.png +0 -0
  54. package/dist/web/public/icon-512.png +0 -0
  55. package/dist/web/public/icon-maskable-512.png +0 -0
  56. package/dist/web/public/icon-touch-192.png +0 -0
  57. package/dist/web/public/icon.svg +29 -11
  58. package/dist/web/public/index.html +43 -28
  59. package/dist/web/server.js +47 -120
  60. package/docs/deploy.md +120 -64
  61. package/package.json +1 -1
  62. package/skills/pier-help/SKILL.md +110 -0
  63. package/skills/pier-slack/SKILL.md +3 -2
  64. package/skills/pier-tasks/SKILL.md +19 -12
  65. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  66. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  67. package/dist/web/public/sw.js +0 -21
@@ -11,7 +11,9 @@
11
11
  //
12
12
  // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
13
13
  // config.ts, platform-blind and shared with the adapters still to come.
14
- import { formatTurnMeta, originLabel } from "../core/reply.js";
14
+ import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
15
+ import { saveInbound } from "../core/inbox.js";
16
+ import { fileMarker, lostMarker, MAX_INBOUND_BYTES } from "../core/inbound-file.js";
15
17
  import { logger } from "../log.js";
16
18
  import { Chains } from "./chains.js";
17
19
  import { parseCommand } from "./commands.js";
@@ -158,14 +160,14 @@ export class TelegramChannel {
158
160
  if (!msg.from || msg.from.id === this.me?.id)
159
161
  return; // own echo, or malformed
160
162
  const raw = (msg.text ?? msg.caption ?? "").trim();
161
- if (!raw && !msg.photo?.length)
163
+ if (!raw && !msg.photo?.length && !msg.document)
162
164
  return;
163
165
  const chatId = String(msg.chat.id);
164
166
  const isDm = msg.chat.type === "private";
165
167
  const kind = isDm ? "dm" : msg.chat.is_forum ? "forum" : "group";
166
168
  const name = msg.chat.title ?? [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
167
169
  this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
168
- const text = this.stripMention(raw, msg);
170
+ const text = this.stripMention(raw);
169
171
  const command = parseCommand(text);
170
172
  // A command aimed at another bot in the same group is not ours to answer.
171
173
  const mine = !command?.target || command.target.toLowerCase() === this.me?.username.toLowerCase();
@@ -194,12 +196,12 @@ export class TelegramChannel {
194
196
  return;
195
197
  // `@bot` on its own (text is empty once the mention is stripped) and
196
198
  // `/settings` are the same request: show me this conversation's settings.
197
- if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length))) {
199
+ if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length && !msg.document))) {
198
200
  return this.panel.open(here, chatId, msg.message_thread_id);
199
201
  }
200
202
  // Downloading only past the gate: an unauthorized sender must not be able
201
203
  // to make the bot pull bytes on their behalf.
202
- const images = await this.photos(msg);
204
+ const markers = await this.saveAttachments(msg);
203
205
  const topicId = await this.routeTopic(msg, text);
204
206
  const key = { channelId: this.id, conversationId: conversationId(chatId, topicId) };
205
207
  this.receipts.mark(key.conversationId, chatId, String(msg.message_id));
@@ -211,8 +213,7 @@ export class TelegramChannel {
211
213
  // A group is many people talking into one session; the update already
212
214
  // carries the name, so no lookup is needed here.
213
215
  sender: { id: String(msg.from.id), name: senderName(msg.from) },
214
- text,
215
- images,
216
+ text: [text, ...markers].filter(Boolean).join("\n"),
216
217
  mode: "steer",
217
218
  });
218
219
  }
@@ -354,17 +355,32 @@ export class TelegramChannel {
354
355
  return msg.message_thread_id;
355
356
  }
356
357
  }
357
- async photos(msg) {
358
- const largest = msg.photo?.at(-1);
359
- if (!largest)
360
- return [];
361
- try {
362
- return [await this.api.downloadPhoto(largest.file_id)];
363
- }
364
- catch (err) {
365
- this.log(`photo download failed: ${String(err)}`);
366
- return [];
358
+ /** Save the message's attachments to the inbox; each becomes a prompt
359
+ * line a failed one becomes a lost-marker line, never silence (5b). */
360
+ async saveAttachments(msg) {
361
+ // Telegram sends a photo as a size ladder — the last entry is the largest.
362
+ const photo = msg.photo?.at(-1);
363
+ const doc = msg.document;
364
+ const wanted = [
365
+ ...(photo ? [{ id: photo.file_id, name: undefined, label: "photo", mime: "image/jpeg", size: photo.file_size }] : []),
366
+ ...(doc ? [{ id: doc.file_id, name: doc.file_name, label: doc.file_name ?? "file", mime: doc.mime_type ?? "application/octet-stream", size: doc.file_size }] : []),
367
+ ];
368
+ const markers = [];
369
+ for (const { id, name, label, mime, size } of wanted) {
370
+ if (size !== undefined && size > MAX_INBOUND_BYTES) {
371
+ markers.push(lostMarker(label, "too large"));
372
+ continue;
373
+ }
374
+ try {
375
+ const file = await this.api.downloadFile(id);
376
+ markers.push(fileMarker(await saveInbound(this.id, name ?? file.name, mime, file.bytes)));
377
+ }
378
+ catch (err) {
379
+ this.log(`attachment download failed: ${String(err)}`);
380
+ markers.push(lostMarker(label, "download failed"));
381
+ }
367
382
  }
383
+ return markers;
368
384
  }
369
385
  // --- addressing ------------------------------------------------------------
370
386
  /** Mentioned, replying to the bot, or a slash command aimed at this bot. */
@@ -379,14 +395,10 @@ export class TelegramChannel {
379
395
  return !!this.me?.username && text.toLowerCase().includes(handle);
380
396
  }
381
397
  /** A leading @bot is addressing, not content — the agent should not see it. */
382
- stripMention(text, msg) {
398
+ stripMention(text) {
383
399
  const handle = `@${this.me?.username ?? ""}`;
384
400
  if (!this.me?.username)
385
401
  return text;
386
- const mention = msg.entities?.find((e) => e.type === "mention" && e.offset === 0);
387
- if (mention && text.slice(0, mention.length).toLowerCase() === handle.toLowerCase()) {
388
- return text.slice(mention.length).replace(/^[\s,:-]+/, "");
389
- }
390
402
  return text.toLowerCase().startsWith(handle.toLowerCase())
391
403
  ? text.slice(handle.length).replace(/^[\s,:-]+/, "")
392
404
  : text;
@@ -404,12 +416,9 @@ export class TelegramChannel {
404
416
  // the person waiting cannot tell. See AGENTS.md — an empty turn is still an
405
417
  // event, and an event nobody can see is not observable.
406
418
  const buttons = keyboard(reply.suggestions);
407
- // Options count as a reply: the buttons are the answer.
408
- const quiet = text || buttons
409
- ? ""
410
- : reply.silence
411
- ? `<i>stayed silent \u2014 ${escapeHtml(reply.silence)}</i>`
412
- : "<i>no reply</i>";
419
+ const quiet = isSilentReply(reply)
420
+ ? `<i>${quietLabel(reply.silence && escapeHtml(reply.silence))}</i>`
421
+ : "";
413
422
  const body = (text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta);
414
423
  try {
415
424
  if (body.trim()) {
package/dist/cli.js CHANGED
@@ -3,13 +3,15 @@
3
3
  // server until a command that needs it: `pier service install` on a machine
4
4
  // with no database should not open one.
5
5
  //
6
- // Hand-rolled against node:util's parseArgs rather than a CLI framework: three
7
- // commands and four flags do not earn a dependency (AGENTS.md 8).
6
+ // Hand-rolled against node:util's parseArgs rather than a CLI framework: this
7
+ // small command set does not earn a dependency (AGENTS.md 8).
8
8
  import { execFileSync } from "node:child_process";
9
- import { createRequire } from "node:module";
9
+ import { accessSync, constants, realpathSync } from "node:fs";
10
+ import { delimiter, join, resolve } from "node:path";
10
11
  import { parseArgs } from "node:util";
11
12
  import { fileURLToPath } from "node:url";
12
- const { version } = createRequire(import.meta.url)("../package.json");
13
+ import { currentVersion, UpdateCheck } from "./update.js";
14
+ const version = currentVersion();
13
15
  const HELP = `pier ${version} — a self-hosted workspace for coding agents
14
16
 
15
17
  Usage
@@ -17,6 +19,11 @@ Usage
17
19
  pier service install write and start a systemd user unit (Linux)
18
20
  pier service uninstall stop it and remove the unit
19
21
  pier service status what systemd thinks of it
22
+ pier update install the latest release and restart the service
23
+ pier update --check only say whether one exists
24
+ pier restart finish running turns first, then restart the service
25
+ pier reload re-read channel config and recycle idle sessions
26
+ pier backup snapshot pier.db before a manual update
20
27
  pier --version | --help
21
28
 
22
29
  Options for "service install"
@@ -28,19 +35,40 @@ Options for "service install"
28
35
  The workbench is behind a password generated on first run and printed once.
29
36
  Under systemd that print lands in the journal: journalctl --user -u pier -e
30
37
  `;
31
- const { values, positionals } = parseArgs({
32
- allowPositionals: true,
33
- strict: false,
34
- options: {
35
- help: { type: "boolean", short: "h" },
36
- version: { type: "boolean", short: "v" },
37
- force: { type: "boolean" },
38
- port: { type: "string" },
39
- host: { type: "string" },
40
- "pier-home": { type: "string" },
41
- },
42
- });
43
- const [command, subcommand] = positionals;
38
+ const say = (message) => void process.stdout.write(`${message}\n`);
39
+ const fail = (message) => {
40
+ process.stderr.write(`pier: ${message}\n`);
41
+ process.exit(2);
42
+ };
43
+ const parsed = (() => {
44
+ try {
45
+ return parseArgs({
46
+ allowPositionals: true,
47
+ strict: true,
48
+ options: {
49
+ help: { type: "boolean", short: "h" },
50
+ version: { type: "boolean", short: "v" },
51
+ check: { type: "boolean" },
52
+ force: { type: "boolean" },
53
+ port: { type: "string" },
54
+ host: { type: "string" },
55
+ "pier-home": { type: "string" },
56
+ },
57
+ });
58
+ }
59
+ catch (err) {
60
+ return fail(err instanceof Error ? err.message : String(err));
61
+ }
62
+ })();
63
+ const { values, positionals } = parsed;
64
+ const [command, subcommand, ...extra] = positionals;
65
+ if (extra.length)
66
+ fail(`unexpected argument "${extra[0]}"`);
67
+ const allowOnly = (allowed, usage) => {
68
+ const invalid = Object.keys(values).find((key) => !allowed.includes(key));
69
+ if (invalid)
70
+ fail(`--${invalid} is not valid for ${usage}`);
71
+ };
44
72
  if (values.help || command === "help") {
45
73
  process.stdout.write(HELP);
46
74
  }
@@ -48,49 +76,169 @@ else if (values.version || command === "version") {
48
76
  process.stdout.write(`${version}\n`);
49
77
  }
50
78
  else if (!command) {
79
+ allowOnly([], "pier");
51
80
  // The server starts on import; this file stays a dispatcher.
52
81
  await import("./main.js");
53
82
  }
54
83
  else if (command === "service") {
55
84
  await service(subcommand);
56
85
  }
86
+ else if (command === "backup") {
87
+ if (subcommand)
88
+ fail(`unexpected argument "${subcommand}"`);
89
+ allowOnly([], "pier backup");
90
+ await backup();
91
+ }
92
+ else if (command === "update") {
93
+ if (subcommand)
94
+ fail(`unexpected argument "${subcommand}"`);
95
+ allowOnly(["check"], "pier update");
96
+ await update(values.check === true);
97
+ }
98
+ else if (command === "restart" || command === "reload") {
99
+ if (subcommand)
100
+ fail(`unexpected argument "${subcommand}"`);
101
+ allowOnly([], `pier ${command}`);
102
+ await signalService(command);
103
+ }
57
104
  else {
58
105
  process.stderr.write(`pier: unknown command "${command}"\n\n${HELP}`);
59
106
  process.exit(2);
60
107
  }
108
+ /**
109
+ * Checking is Pier's own code; applying it is npm's. Under systemd the work is
110
+ * handed to a second unit — this process is about to be restarted, and a child
111
+ * of the service being restarted dies with it.
112
+ */
113
+ async function update(checkOnly) {
114
+ const check = new UpdateCheck(version);
115
+ await check.refresh();
116
+ const { current, latest, available } = check.status();
117
+ if (latest === null) {
118
+ process.stderr.write(`could not reach the registry — running ${current}.\n`);
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+ if (!available) {
123
+ process.stdout.write(`${current} is the latest.\n`);
124
+ return;
125
+ }
126
+ process.stdout.write(`${latest} is out (running ${current}).\n`);
127
+ if (checkOnly)
128
+ return;
129
+ const { startUpdate } = await import("./service.js");
130
+ if (process.platform === "linux") {
131
+ const started = startUpdate({ say });
132
+ if (started === "started")
133
+ return;
134
+ if (started === "failed") {
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ }
139
+ // No service manager owns this process, so do not mutate its rollback point
140
+ // until the operator is ready to run all three steps.
141
+ say(`pier backup`);
142
+ say(`npm install -g @timqi/pier@${latest}`);
143
+ say(`then restart Pier.`);
144
+ }
145
+ /** Both are signals to the running unit: SIGUSR2 drains then exits (systemd
146
+ * starts the next process), SIGHUP reloads config in place (main.ts). */
147
+ async function signalService(command) {
148
+ if (process.platform !== "linux") {
149
+ return fail(`only under the systemd service — send ${command === "restart" ? "SIGUSR2" : "SIGHUP"} to the pier process yourself`);
150
+ }
151
+ const { UNIT_NAME } = await import("./service.js");
152
+ const signal = command === "restart" ? "SIGUSR2" : "SIGHUP";
153
+ try {
154
+ // `--kill-who`, not the newer `--kill-whom`: the old spelling is the one
155
+ // every systemd still parses (systemd/systemd#29793).
156
+ execFileSync("systemctl", ["--user", "kill", "-s", signal, "--kill-who=main", UNIT_NAME], { stdio: "inherit" });
157
+ }
158
+ catch (err) {
159
+ // A failed kill already printed why on the inherited stderr; a missing
160
+ // systemctl printed nothing, so name it (same shape as `service status`).
161
+ if (err.code === "ENOENT") {
162
+ process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
163
+ }
164
+ process.exitCode = 1;
165
+ return;
166
+ }
167
+ say(command === "restart"
168
+ ? "draining — running turns finish first (up to 5 minutes), then Pier restarts."
169
+ : "reloading — adapters re-read their config; idle sessions re-open with the current one.");
170
+ }
171
+ async function backup() {
172
+ const [{ backupDb }, { PIER_DB }] = await Promise.all([import("./db.js"), import("./paths.js")]);
173
+ const path = backupDb(PIER_DB);
174
+ process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
175
+ }
176
+ function commandPath(name) {
177
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
178
+ const path = join(dir || ".", name);
179
+ try {
180
+ accessSync(path, constants.X_OK);
181
+ return realpathSync(path);
182
+ }
183
+ catch {
184
+ // Keep looking: version managers commonly put several prefixes on PATH.
185
+ }
186
+ }
187
+ return fail(`${name} is not executable on PATH`);
188
+ }
61
189
  async function service(action = "status") {
62
190
  const { install, uninstall, UNIT_NAME } = await import("./service.js");
63
- const say = (message) => void process.stdout.write(`${message}\n`);
64
- if (action !== "status" && process.platform !== "linux") {
191
+ const systemdAction = action === "install" || action === "uninstall";
192
+ if (systemdAction && process.platform !== "linux") {
65
193
  process.stderr.write(`pier service is systemd, so Linux only — this is ${process.platform}.\n` +
66
194
  `Run "pier" in a terminal, or under whatever supervisor you already use;\n` +
67
195
  `it needs no arguments and keeps its state in $PIER_HOME (~/.pier).\n`);
68
196
  process.exit(2);
69
197
  }
70
198
  switch (action) {
71
- case "install":
72
- install({
73
- // The node running this command, not whatever systemd would find on a
74
- // minimal PATH and the entry beside this file, not a checkout.
199
+ case "install": { // The installed unit owns this Node prefix until replaced.
200
+ allowOnly(["force", "port", "host", "pier-home"], "pier service install");
201
+ const port = typeof values.port === "string" ? Number(values.port) : 3141;
202
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
203
+ fail("--port must be an integer from 1 to 65535");
204
+ const host = typeof values.host === "string" ? values.host : "127.0.0.1";
205
+ if (!host || /\s|[\0\r\n]/.test(host))
206
+ fail("--host must be one address with no whitespace");
207
+ const rawHome = values["pier-home"];
208
+ if (rawHome === "")
209
+ fail("--pier-home must not be empty");
210
+ const pierHome = typeof rawHome === "string" ? resolve(rawHome) : undefined;
211
+ if (!install({
75
212
  execPath: process.execPath,
213
+ npmPath: commandPath("npm"),
76
214
  entry: fileURLToPath(new URL("./main.js", import.meta.url)),
77
- host: typeof values.host === "string" ? values.host : "127.0.0.1",
78
- port: typeof values.port === "string" ? Number(values.port) : 3141,
79
- pierHome: typeof values["pier-home"] === "string" ? values["pier-home"] : undefined,
215
+ host,
216
+ port,
217
+ pierHome,
80
218
  force: values.force === true,
81
219
  say,
82
- });
220
+ }))
221
+ process.exitCode = 1;
83
222
  return;
223
+ }
84
224
  case "uninstall":
85
- uninstall(undefined, say);
225
+ allowOnly([], "pier service uninstall");
226
+ if (!uninstall(undefined, say))
227
+ process.exitCode = 1;
86
228
  return;
87
229
  case "status":
230
+ allowOnly([], "pier service status");
88
231
  try {
89
232
  // Inherited, not captured: systemctl's own output is the answer, and
90
233
  // its exit code is nonzero for a service that is merely stopped.
91
234
  execFileSync("systemctl", ["--user", "status", UNIT_NAME], { stdio: "inherit" });
92
235
  }
93
- catch {
236
+ catch (err) {
237
+ // A stopped service already printed its status; a missing systemctl
238
+ // printed nothing, so name it.
239
+ if (err.code === "ENOENT") {
240
+ process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
241
+ }
94
242
  process.exitCode = 1;
95
243
  }
96
244
  return;
package/dist/core/hub.js CHANGED
@@ -1,6 +1,20 @@
1
1
  // Per-session event fan-out with a replay ring buffer. The single stamping
2
2
  // point for seq/ts — nothing else in the system numbers events.
3
+ import { logger } from "../log.js";
4
+ const log = logger("core");
3
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. */
8
+ function fanOut(subscribers, event) {
9
+ for (const fn of subscribers) {
10
+ try {
11
+ fn(event);
12
+ }
13
+ catch (err) {
14
+ log.warn(`event subscriber threw: ${String(err)}`);
15
+ }
16
+ }
17
+ }
4
18
  export class EventHub {
5
19
  buses = new Map();
6
20
  // Workspace bus: no seq, no replay — a client that missed events just
@@ -25,9 +39,7 @@ export class EventHub {
25
39
  b.buffer.push(event);
26
40
  if (b.buffer.length > RING_SIZE)
27
41
  b.buffer.shift();
28
- for (const fn of b.subscribers)
29
- fn(event);
30
- return event;
42
+ fanOut(b.subscribers, event);
31
43
  }
32
44
  subscribe(sessionId, fn) {
33
45
  const b = this.bus(sessionId);
@@ -39,8 +51,7 @@ export class EventHub {
39
51
  return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
40
52
  }
41
53
  emitWorkspace(event) {
42
- for (const fn of this.workspace)
43
- fn(event);
54
+ fanOut(this.workspace, event);
44
55
  }
45
56
  subscribeWorkspace(fn) {
46
57
  this.workspace.add(fn);
@@ -50,4 +61,24 @@ export class EventHub {
50
61
  lastSeq(sessionId) {
51
62
  return this.buses.get(sessionId)?.seq ?? 0;
52
63
  }
64
+ /** Whether anyone is still watching this session (an open SSE stream). */
65
+ hasSubscribers(sessionId) {
66
+ return (this.buses.get(sessionId)?.subscribers.size ?? 0) > 0;
67
+ }
68
+ /**
69
+ * Release the ring of a session nobody is watching — the memory an evicted
70
+ * session leaves behind (1000 events of text, per session, forever).
71
+ *
72
+ * The bus itself stays, holding its seq: a client that reconnects with a
73
+ * Last-Event-ID drops anything numbered at or below what it saw, so a
74
+ * counter restarting at 1 would make every later event invisible to it.
75
+ * What is left is a number and an empty set.
76
+ */
77
+ dropReplay(sessionId) {
78
+ if (this.hasSubscribers(sessionId))
79
+ return;
80
+ const b = this.buses.get(sessionId);
81
+ if (b)
82
+ b.buffer = [];
83
+ }
53
84
  }
@@ -61,6 +61,11 @@ export class SenderPrefix {
61
61
  const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
62
62
  return `[${[who, when].filter(Boolean).join(" ")}]`;
63
63
  }
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
+ forget(sessionId) {
67
+ this.seen.delete(sessionId);
68
+ }
64
69
  }
65
70
  /** Put the prefix above the message, or hand the message back untouched. */
66
71
  export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
@@ -0,0 +1,70 @@
1
+ // The inbound-file 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, and the size cap both ends enforce.
4
+ // Producers are node code (channels/, web/server.ts) but the web composer
5
+ // builds markers and the web chat parses them in the browser, so the grammar
6
+ // lives in a module with no node imports that either side can load. The
7
+ // filesystem half (saving the bytes) is core/inbox.ts.
8
+ /** One cap for every inbound path: composer, upload route, Slack metadata. */
9
+ export const MAX_INBOUND_BYTES = 32 * 1024 * 1024;
10
+ /** Extension for a name-less upload (a pasted screenshot has no filename). */
11
+ const MIME_EXT = {
12
+ "image/png": ".png",
13
+ "image/jpeg": ".jpg",
14
+ "image/gif": ".gif",
15
+ "image/webp": ".webp",
16
+ "application/pdf": ".pdf",
17
+ "text/plain": ".txt",
18
+ };
19
+ /**
20
+ * A filename that is safe as a path segment and inside a markdown link:
21
+ * basename only (no traversal), whitespace and link-breaking characters
22
+ * folded to `-`, length capped.
23
+ */
24
+ export function safeName(name, mimeType) {
25
+ const base = (name ?? "").split("/").pop().replace(/[\s\\()[\]<>%#?]/g, "-");
26
+ if (!base || base === "." || base === "..")
27
+ return `file${MIME_EXT[mimeType] ?? ""}`;
28
+ if (base.length <= 64)
29
+ return base;
30
+ const dot = base.lastIndexOf(".");
31
+ const ext = dot > 0 ? base.slice(dot, dot + 16) : "";
32
+ return base.slice(0, 64 - ext.length) + ext;
33
+ }
34
+ /**
35
+ * The prompt line for a saved file — the attachment convention, inbound. The
36
+ * path is percent-encoded (parentheses included, which encodeURI leaves
37
+ * alone) so the link survives markdown and the marker regex even when
38
+ * `PIER_HOME` contains spaces or parens; splitInboundFiles decodes.
39
+ */
40
+ export const fileMarker = (path) => `[${path.split("/").pop() ?? "file"}](file://${encodeURI(path).replace(/\(/g, "%28").replace(/\)/g, "%29")})`;
41
+ /**
42
+ * The conversation-visible line for an attachment that never made it (5b: a
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.
45
+ */
46
+ export const lostMarker = (name, reason) => `[attachment lost: ${name} — ${reason}]`;
47
+ // A whole line that is one `[name](file:///…)` link — what fileMarker emits.
48
+ const MARKER_RE = /^\[[^\]\n]*\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)$/;
49
+ /**
50
+ * Split a user message into its typed text and the attached files' paths.
51
+ * Only the contiguous *trailing* block of marker lines is an attachment —
52
+ * that is where every producer puts them — so a `file://` link the user
53
+ * wrote mid-message stays message text.
54
+ */
55
+ export function splitInboundFiles(raw) {
56
+ const lines = raw.split("\n");
57
+ let start = lines.length;
58
+ while (start > 0 && MARKER_RE.test(lines[start - 1].trim()))
59
+ start--;
60
+ const paths = lines.slice(start).map((line) => {
61
+ const path = MARKER_RE.exec(line.trim())[1];
62
+ try {
63
+ return decodeURIComponent(path);
64
+ }
65
+ catch {
66
+ return path; // not percent-encoded — take the path as written
67
+ }
68
+ });
69
+ return { text: lines.slice(0, start).join("\n").trimEnd(), paths };
70
+ }
@@ -0,0 +1,32 @@
1
+ // Inbound user files: bytes land on disk exactly once.
2
+ //
3
+ // A photo pasted on the web, dropped in Telegram or uploaded to Slack used to
4
+ // travel as base64 through the seam into the transcript, where it was re-sent
5
+ // with every provider request until compaction. Now the adapter (or the web
6
+ // upload route) saves the bytes under `$PIER_HOME/inbox/<channel>/` and the
7
+ // prompt carries only a marker line (core/inbound-file.ts owns that grammar),
8
+ // so the agent reads a file only when it decides the file is worth looking at.
9
+ import { mkdir, writeFile } from "node:fs/promises";
10
+ import { randomBytes } from "node:crypto";
11
+ import { basename, join } from "node:path";
12
+ import { pierPath } from "../paths.js";
13
+ import { safeName } from "./inbound-file.js";
14
+ /** Where every inbound file lives; web/files.ts allowlists this root. */
15
+ export const INBOX_DIR = pierPath("inbox");
16
+ /**
17
+ * Write one inbound file and return its absolute path. The timestamp-random
18
+ * prefix keeps concurrent saves collision-free (`wx` turns the impossible
19
+ * collision into an error instead of an overwrite) and makes `ls` read as a
20
+ * timeline. Owner-only modes: uploads are private conversation content on a
21
+ * possibly shared machine. Nothing is ever deleted here — pruning the inbox
22
+ * is the operator's call (docs/deploy.md).
23
+ */
24
+ export async function saveInbound(channelId, name, mimeType, bytes) {
25
+ // The channel id is ours ("web" | "telegram" | "slack"), not user input,
26
+ // but basename() keeps a future id honest.
27
+ const dir = join(INBOX_DIR, basename(channelId));
28
+ await mkdir(dir, { recursive: true, mode: 0o700 });
29
+ const path = join(dir, `${String(Date.now())}-${randomBytes(3).toString("hex")}-${safeName(name, mimeType)}`);
30
+ await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
31
+ return path;
32
+ }
@@ -1,11 +1,17 @@
1
1
  // The whole queue policy. Fixed by docs/architecture.md — do not add options.
2
2
  export function decide(msg, state) {
3
+ // An explicit mode takes the text verbatim: IM sends steer for every
4
+ // message, so a leading "!" there is content, not a control prefix —
5
+ // consuming it silently rewrote what the person typed.
6
+ if (msg.mode === "steer" || msg.mode === "followUp") {
7
+ return { action: state === "idle" ? "prompt" : msg.mode, text: msg.text };
8
+ }
9
+ // On auto the "!" is a control prefix and always consumed — whether the
10
+ // turn happened to end first must not decide if it was content. Idle just
11
+ // means there is nothing to steer, so it degenerates to a prompt.
3
12
  const steerPrefixed = msg.text.startsWith("!");
4
13
  const text = steerPrefixed ? msg.text.slice(1).trimStart() : msg.text;
5
14
  if (state === "idle")
6
15
  return { action: "prompt", text };
7
- if (msg.mode === "steer" || msg.mode === "followUp") {
8
- return { action: msg.mode, text };
9
- }
10
16
  return { action: steerPrefixed ? "steer" : "followUp", text };
11
17
  }