@timqi/pier 0.0.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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,27 @@
1
+ // IM channel configuration types — the wire contract shared by the store, the
2
+ // adapters and the Console view (which type-only imports it, so this file must
3
+ // stay free of node builtins, exactly like core/types.ts).
4
+ //
5
+ // Defaults are least-privilege: mention AND bind are required. The platform
6
+ // values are *seeds*, copied into a chat when the bot first sees it — not a
7
+ // fallback consulted at runtime. So every chat carries its own answer and a
8
+ // switch means what it says, instead of a three-state "inherit" nobody can
9
+ // read off the screen.
10
+ const PLATFORMS = ["telegram", "slack", "lark"];
11
+ /** Validate at the boundary: an unknown platform is a 404, not a new row. */
12
+ export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
13
+ export const defaultChannelConfig = () => ({
14
+ enabled: false,
15
+ token: "",
16
+ appToken: "",
17
+ agentTool: true,
18
+ requireMention: true,
19
+ requireBind: true,
20
+ topicMode: true,
21
+ cwd: "",
22
+ model: null,
23
+ thinking: null,
24
+ users: [],
25
+ chats: [],
26
+ bindCode: null,
27
+ });
package/dist/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // What `pier` does when typed. Dispatch only — no logic, and no imports of the
3
+ // server until a command that needs it: `pier service install` on a machine
4
+ // with no database should not open one.
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).
8
+ import { execFileSync } from "node:child_process";
9
+ import { createRequire } from "node:module";
10
+ import { parseArgs } from "node:util";
11
+ import { fileURLToPath } from "node:url";
12
+ const { version } = createRequire(import.meta.url)("../package.json");
13
+ const HELP = `pier ${version} — a self-hosted workspace for coding agents
14
+
15
+ Usage
16
+ pier run the workbench in this terminal
17
+ pier service install write and start a systemd user unit (Linux)
18
+ pier service uninstall stop it and remove the unit
19
+ pier service status what systemd thinks of it
20
+ pier --version | --help
21
+
22
+ Options for "service install"
23
+ --port <n> what to listen on (default 3141)
24
+ --host <addr> what to bind (default 127.0.0.1, loopback)
25
+ --pier-home <d> where state lives (default ~/.pier)
26
+ --force replace a unit already there
27
+
28
+ The workbench is behind a password generated on first run and printed once.
29
+ Under systemd that print lands in the journal: journalctl --user -u pier -e
30
+ `;
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;
44
+ if (values.help || command === "help") {
45
+ process.stdout.write(HELP);
46
+ }
47
+ else if (values.version || command === "version") {
48
+ process.stdout.write(`${version}\n`);
49
+ }
50
+ else if (!command) {
51
+ // The server starts on import; this file stays a dispatcher.
52
+ await import("./main.js");
53
+ }
54
+ else if (command === "service") {
55
+ await service(subcommand);
56
+ }
57
+ else {
58
+ process.stderr.write(`pier: unknown command "${command}"\n\n${HELP}`);
59
+ process.exit(2);
60
+ }
61
+ async function service(action = "status") {
62
+ 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") {
65
+ process.stderr.write(`pier service is systemd, so Linux only — this is ${process.platform}.\n` +
66
+ `Run "pier" in a terminal, or under whatever supervisor you already use;\n` +
67
+ `it needs no arguments and keeps its state in $PIER_HOME (~/.pier).\n`);
68
+ process.exit(2);
69
+ }
70
+ 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.
75
+ execPath: process.execPath,
76
+ 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,
80
+ force: values.force === true,
81
+ say,
82
+ });
83
+ return;
84
+ case "uninstall":
85
+ uninstall(undefined, say);
86
+ return;
87
+ case "status":
88
+ try {
89
+ // Inherited, not captured: systemctl's own output is the answer, and
90
+ // its exit code is nonzero for a service that is merely stopped.
91
+ execFileSync("systemctl", ["--user", "status", UNIT_NAME], { stdio: "inherit" });
92
+ }
93
+ catch {
94
+ process.exitCode = 1;
95
+ }
96
+ return;
97
+ default:
98
+ process.stderr.write(`pier service: unknown action "${action}"\n\n${HELP}`);
99
+ process.exit(2);
100
+ }
101
+ }
@@ -0,0 +1,53 @@
1
+ // Per-session event fan-out with a replay ring buffer. The single stamping
2
+ // point for seq/ts — nothing else in the system numbers events.
3
+ const RING_SIZE = 1000;
4
+ export class EventHub {
5
+ buses = new Map();
6
+ // Workspace bus: no seq, no replay — a client that missed events just
7
+ // re-lists on reconnect, so there is nothing to renumber.
8
+ workspace = new Set();
9
+ bus(sessionId) {
10
+ let b = this.buses.get(sessionId);
11
+ if (!b) {
12
+ b = { seq: 0, buffer: [], subscribers: new Set() };
13
+ this.buses.set(sessionId, b);
14
+ }
15
+ return b;
16
+ }
17
+ emit(sessionId, payload) {
18
+ const b = this.bus(sessionId);
19
+ const event = {
20
+ seq: ++b.seq,
21
+ ts: Date.now(),
22
+ sessionId,
23
+ ...payload,
24
+ };
25
+ b.buffer.push(event);
26
+ if (b.buffer.length > RING_SIZE)
27
+ b.buffer.shift();
28
+ for (const fn of b.subscribers)
29
+ fn(event);
30
+ return event;
31
+ }
32
+ subscribe(sessionId, fn) {
33
+ const b = this.bus(sessionId);
34
+ b.subscribers.add(fn);
35
+ return () => b.subscribers.delete(fn);
36
+ }
37
+ /** Events with seq > afterSeq still held in the ring buffer. */
38
+ replay(sessionId, afterSeq) {
39
+ return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
40
+ }
41
+ emitWorkspace(event) {
42
+ for (const fn of this.workspace)
43
+ fn(event);
44
+ }
45
+ subscribeWorkspace(fn) {
46
+ this.workspace.add(fn);
47
+ return () => this.workspace.delete(fn);
48
+ }
49
+ /** Highest seq stamped so far (0 if none). */
50
+ lastSeq(sessionId) {
51
+ return this.buses.get(sessionId)?.seq ?? 0;
52
+ }
53
+ }
@@ -0,0 +1,66 @@
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.
13
+ /** A gap this long makes the timestamp worth its tokens. */
14
+ 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
+ */
20
+ export function sanitizeIdentity(value) {
21
+ const token = (value || "")
22
+ .replace(/[\r\n]+/g, " ")
23
+ .replace(/[[\]<>]/g, "")
24
+ .trim();
25
+ return token.slice(0, 60) || "unknown";
26
+ }
27
+ const two = (n) => String(n).padStart(2, "0");
28
+ const hhmm = (d) => `${two(d.getHours())}:${two(d.getMinutes())}`;
29
+ 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
+ */
35
+ export class SenderPrefix {
36
+ 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
+ */
41
+ next(sessionId, sender, at = Date.now()) {
42
+ if (!sender?.id)
43
+ return "";
44
+ const last = this.seen.get(sessionId);
45
+ this.seen.set(sessionId, { senderId: sender.id, at });
46
+ const now = new Date(at);
47
+ const newSpeaker = last?.senderId !== sender.id;
48
+ const gap = !last || at - last.at >= GAP_MS;
49
+ const newDay = !last || day(new Date(last.at)) !== day(now);
50
+ if (!newSpeaker && !gap && !newDay)
51
+ 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.
57
+ const id = sanitizeIdentity(sender.id);
58
+ const label = sanitizeIdentity(sender.name);
59
+ const who = newSpeaker ? (label === id ? `<${id}>` : `${label}<${id}>`) : "";
60
+ // The date only when it changed; inside one conversation-day it is noise.
61
+ const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
62
+ return `[${[who, when].filter(Boolean).join(" ")}]`;
63
+ }
64
+ }
65
+ /** Put the prefix above the message, or hand the message back untouched. */
66
+ export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
@@ -0,0 +1,11 @@
1
+ // The whole queue policy. Fixed by docs/architecture.md — do not add options.
2
+ export function decide(msg, state) {
3
+ const steerPrefixed = msg.text.startsWith("!");
4
+ const text = steerPrefixed ? msg.text.slice(1).trimStart() : msg.text;
5
+ if (state === "idle")
6
+ return { action: "prompt", text };
7
+ if (msg.mode === "steer" || msg.mode === "followUp") {
8
+ return { action: msg.mode, text };
9
+ }
10
+ return { action: steerPrefixed ? "steer" : "followUp", text };
11
+ }
@@ -0,0 +1,202 @@
1
+ // Assistant-reply presentation, computed once for every surface: the next-step
2
+ // block below, and a turn's completion stats. Both are rendered by the web
3
+ // chat and by every IM adapter, so the wording and the units live here rather
4
+ // than being re-derived per surface (they drifted once already).
5
+ //
6
+ // An agent may
7
+ // end a turn with a "next step" block — a `---` rule followed by a row of
8
+ // `[label]` tokens — and each surface renders those labels as buttons whose
9
+ // click sends the label back as an ordinary user message. The syntax lives
10
+ // here so web, IM adapters and Show pages never re-implement it.
11
+ /**
12
+ * The surface contract handed to every agent Pier launches (main.ts wires it
13
+ * into the factory). Both halves of the feature live in this file: the syntax
14
+ * the agent is told to emit, and the parser that reads it back.
15
+ */
16
+ export const REPLY_SURFACE_PROMPT = `## Pier chat surface
17
+
18
+ Your replies render in a chat UI (web and IM). Three optional markdown
19
+ conventions:
20
+
21
+ - **Next-step buttons** — a last line of \`---\`, then up to 5 \`[label]\` tokens
22
+ separated by \`|\`: \`---\` / \`[Run it] | [Show the diff]\`. A click sends that
23
+ label as the user's next message. Only for short, obvious next moves, never
24
+ for anything destructive.
25
+ - **Attachments** — link a file you produced by absolute \`file://\` URL:
26
+ \`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
27
+ other files as a download card; only files inside the session's working
28
+ directory are readable.
29
+ - **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
30
+ remains no message is sent. In a group chat you are handed every message,
31
+ including humans talking to each other: stay silent rather than acknowledge
32
+ what was not addressed to you.
33
+
34
+ A message may start with \`[name<id> time]\` — the sender, added by Pier, not
35
+ typed by them. It appears only when the speaker or the day changes, so the last
36
+ one still applies. Use that \`id\` to mention someone; never ask for their own.
37
+ `;
38
+ /**
39
+ * The contract above plus the two facts about *this* deployment that an agent
40
+ * cannot work out for itself.
41
+ *
42
+ * Told, not discovered. Pier knows both — the home is its own startup argument
43
+ * and the address is a file it wrote — while an agent can only guess at a
44
+ * path and shell out to look. The guess is wrong on every instance that moves
45
+ * `PIER_HOME`, and it fails as "nothing is configured": indistinguishable from
46
+ * the truth, and reported to the user as such. Which is what happened.
47
+ */
48
+ export function surfacePrompt(instance) {
49
+ const reach = instance.publicUrl
50
+ ? `Address: ${instance.publicUrl} — a board's link is that plus ` +
51
+ "`/boards/<slug>/`, or `/p/<slug>/` once published."
52
+ : "No public address is configured (the user sets one in Console → Settings), " +
53
+ "so give paths and never guess a host.";
54
+ return `${REPLY_SURFACE_PROMPT}
55
+ ## This Pier instance
56
+
57
+ Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
58
+ `;
59
+ }
60
+ /**
61
+ * Where a system input came from, in the fewest words that still say it.
62
+ *
63
+ * Lives here with `formatTurnMeta` for the same reason: it is *wording*, and
64
+ * every surface must spell it the same way. It was copied verbatim into the
65
+ * second adapter before landing here.
66
+ */
67
+ export function originLabel(origin) {
68
+ if (origin.kind === "error")
69
+ return "\u26a0 failed";
70
+ if (origin.kind !== "task-message") {
71
+ return origin.kind === "task-delegation" ? "\u25b6 delegated task" : "\u21a9 task callback";
72
+ }
73
+ const kinds = {
74
+ steer: "\u270e steer",
75
+ follow_up: "\uff0b follow-up",
76
+ progress: "\u25c7 progress",
77
+ decision: "\u2753 decision needed",
78
+ reply: "\u21a9 reply",
79
+ };
80
+ return `from a subagent \u00b7 ${kinds[origin.messageKind] ?? origin.messageKind}`;
81
+ }
82
+ /**
83
+ * Quote and bracket characters that may be lifted out of a `**strong**` run.
84
+ * Moving them changes nothing a reader can see — the punctuation is simply no
85
+ * longer bold — while taking the delimiter off a character the parser refuses
86
+ * to close on.
87
+ */
88
+ const LIFTABLE = /[\u201c\u201d"\u2018\u2019'()\uff08\uff09\u300c\u300d\u300e\u300f\u3010\u3011\u300a\u300b\u3008\u3009[\]]/;
89
+ /**
90
+ * Repair emphasis that CommonMark refuses to close.
91
+ *
92
+ * A closing `**` must be *right-flanking*: preceded by non-whitespace, and
93
+ * either not preceded by punctuation or else followed by whitespace or
94
+ * punctuation. `**\u201c\u600e\u4e48\u505a\u201d**\uff1a` fails both halves \u2014 preceded by a quote,
95
+ * followed by a CJK colon the rule does not count \u2014 so the run never closes and
96
+ * the reader sees literal asterisks. `**\u95f2\u804a**\uff1a` is fine, because a letter
97
+ * precedes the delimiter. This is a known ten-year-old hole in the spec around
98
+ * CJK, not a Slack bug, and it shows up constantly in model output.
99
+ *
100
+ * The repair is to move the punctuation outside the delimiters, so the `**`
101
+ * lands against a letter: `\u201c**\u600e\u4e48\u505a**\u201d\uff1a`. Code is protected first \u2014 asterisks
102
+ * inside a fence or a code span are content, not markup.
103
+ */
104
+ export function cjkFriendly(markdown) {
105
+ const stash = [];
106
+ const keep = (text) => `\uE010${stash.push(text) - 1}\uE011`;
107
+ let out = markdown
108
+ .replace(/```[\s\S]*?```/g, (m) => keep(m))
109
+ .replace(/`[^`\n]+`/g, (m) => keep(m));
110
+ out = out.replace(/\*\*(\S|\S[\s\S]*?\S)\*\*/g, (whole, inner) => {
111
+ let lead = "";
112
+ let trail = "";
113
+ let body = inner;
114
+ while (body.length > 1 && LIFTABLE.test(body[0])) {
115
+ lead += body[0];
116
+ body = body.slice(1);
117
+ }
118
+ while (body.length > 1 && LIFTABLE.test(body.at(-1))) {
119
+ trail = body.at(-1) + trail;
120
+ body = body.slice(0, -1);
121
+ }
122
+ // Nothing was on the edges, or the run is only punctuation: leave it be.
123
+ return lead || trail ? `${lead}**${body}**${trail}` : whole;
124
+ });
125
+ return out.replace(/\uE010(\d+)\uE011/g, (_m, i) => stash[Number(i)] ?? "");
126
+ }
127
+ /** How a reasoning level is spelled wherever a human reads it. */
128
+ export const thinkingLabel = (level) => level === "xhigh" ? "Extra high" : level[0].toUpperCase() + level.slice(1);
129
+ /** 1200 → "1.2K", 12_000 → "12K" — absolute token counts read badly inline. */
130
+ export const compact = (n) => {
131
+ if (n < 1000)
132
+ return String(n);
133
+ const k = n / 1000;
134
+ // Precision comes from the *rounded* value, so 9_990 reads "10K" instead of
135
+ // a "10.0K" that claims a decimal it does not have.
136
+ return k >= 9.95 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
137
+ };
138
+ /** "45s" / "1m14s". Floored at one second: sub-second precision is noise. */
139
+ function formatDuration(ms) {
140
+ const secs = Math.max(1, Math.round(ms / 1000));
141
+ return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`;
142
+ }
143
+ /**
144
+ * A turn's cost, in the one wording every surface uses: how long it took and
145
+ * how big the context is now. `tokens` is the context size at completion, not
146
+ * a per-turn sum (see TurnMeta) — the label stays "tok" because that is what
147
+ * the number is counted in.
148
+ */
149
+ export const formatTurnMeta = (meta) => `${formatDuration(meta.durationMs)} · ${compact(meta.tokens)} tok`;
150
+ /**
151
+ * Trailing `---` line + a row of bracket tokens, optionally `|`-separated.
152
+ * A markdown link (`[label](url)`) leaves `(url)` unmatched, so reference-link
153
+ * blocks stay message content instead of turning into buttons.
154
+ *
155
+ * `(?:^|\n)` because a turn is allowed to be nothing but its options: anchoring
156
+ * on a preceding newline left that whole block sitting in the message as raw
157
+ * text, with no buttons anywhere.
158
+ */
159
+ const BLOCK = /(?:^|\n)[ \t]*-{3,}[ \t]*\r?\n((?:[ \t]*\[[^\]\r\n]+\][ \t]*(?:[||][ \t]*)?)+)\s*$/;
160
+ const TOKEN = /\[([^\]\r\n]+)\]/g;
161
+ const MAX_SUGGESTIONS = 5;
162
+ /** Split an assistant turn's markdown into renderable text + next-step labels. */
163
+ /**
164
+ * A deliberate non-answer. In a group thread the agent is handed every message,
165
+ * and most of them are two humans talking; a bot that replies to each one is
166
+ * unusable. Stripping the block here means an adapter needs no new concept: a
167
+ * turn whose text is empty already posts nothing and still retires its per-turn
168
+ * UI, which is exactly "listened, said nothing".
169
+ *
170
+ * The reason inside stays in the Pi transcript, so the choice is auditable
171
+ * without being broadcast to the chat.
172
+ */
173
+ const SILENT = /<silent>([\s\S]*?)<\/silent>/gi;
174
+ /**
175
+ * Why the agent stayed quiet. Stripped from anything sent to a chat, but the
176
+ * workbench is the operator's own view of the run — "it said nothing and here
177
+ * is why" is observability, and hiding it there would just make a silent turn
178
+ * look like a broken one.
179
+ */
180
+ export function silentReason(markdown) {
181
+ const reasons = [...markdown.matchAll(SILENT)]
182
+ .map((m) => (m[1] ?? "").trim())
183
+ .filter(Boolean);
184
+ return reasons.length ? reasons.join(" · ") : undefined;
185
+ }
186
+ export function splitReply(rawMarkdown, meta) {
187
+ // Every surface that renders this goes through here, and every CommonMark
188
+ // parser has some version of the CJK emphasis hole — so the repair belongs
189
+ // once, at the seam, not per adapter.
190
+ const markdown = cjkFriendly(rawMarkdown.replace(SILENT, "").trim());
191
+ const silence = silentReason(rawMarkdown);
192
+ const m = BLOCK.exec(markdown);
193
+ if (!m?.[1])
194
+ return { text: markdown, suggestions: [], meta, silence };
195
+ const suggestions = [...m[1].matchAll(TOKEN)]
196
+ .map((t) => (t[1] ?? "").trim())
197
+ .filter(Boolean)
198
+ .slice(0, MAX_SUGGESTIONS);
199
+ if (!suggestions.length)
200
+ return { text: markdown, suggestions: [], meta, silence };
201
+ return { text: markdown.slice(0, m.index).trimEnd(), suggestions, meta, silence };
202
+ }
@@ -0,0 +1,189 @@
1
+ // Conversation → session routing plus event wiring. In-memory for v1;
2
+ // persistence arrives with task storage (docs/plans/bootstrap.md step 4).
3
+ import { logger } from "../log.js";
4
+ import { EventHub } from "./hub.js";
5
+ import { SenderPrefix, withPrefix } from "./identity.js";
6
+ import { decide } from "./queue.js";
7
+ import { splitReply } from "./reply.js";
8
+ const log = logger("core");
9
+ /** An error goes into a chat window, so it is trimmed to something readable. */
10
+ const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
11
+ function keyOf(key) {
12
+ return `${key.channelId}:${key.conversationId}`;
13
+ }
14
+ export class Router {
15
+ hub;
16
+ resolve;
17
+ byKey = new Map();
18
+ bySession = new Map();
19
+ channels = new Map();
20
+ /** Who each session last heard from, so a header costs tokens only on news. */
21
+ senders = new SenderPrefix();
22
+ constructor(hub,
23
+ /** Create or resume the session owning a conversation (wired in main.ts). */
24
+ resolve) {
25
+ this.hub = hub;
26
+ this.resolve = resolve;
27
+ }
28
+ registerChannel(channel) {
29
+ this.channels.set(channel.id, channel);
30
+ }
31
+ /**
32
+ * Tell the conversation something went wrong, then the event stream.
33
+ *
34
+ * Applies to every channel, because the failure mode is the same everywhere:
35
+ * an IM user watching the eyes come off with no reply cannot tell a crash
36
+ * from a deliberate silence, and the operator cannot debug what they never
37
+ * saw. The web reads errors off the hub already, so only a registered channel
38
+ * gets a note; `notify` is used rather than `send` so it is never mistaken
39
+ * for an assistant turn.
40
+ */
41
+ report(sessionId, key, message) {
42
+ // Three surfaces, one failure: the chat that is waiting, the web timeline,
43
+ // and the log the operator greps once it is reported to them.
44
+ log.error(`${keyOf(key)} session ${sessionId}: ${message}`);
45
+ this.hub.emit(sessionId, { type: "error", message });
46
+ const channel = this.channels.get(key.channelId);
47
+ // Best-effort and never recursive: if telling the chat also fails, the hub
48
+ // already has the original.
49
+ channel?.notify(key.conversationId, { text: truncate(message), origin: { kind: "error" } })
50
+ .catch((err) => {
51
+ log.error(`could not report the failure to ${key.channelId}`, err);
52
+ this.hub.emit(sessionId, {
53
+ type: "error",
54
+ message: `could not report the failure to ${key.channelId}: ${String(err)}`,
55
+ });
56
+ });
57
+ }
58
+ stateOf(sessionId) {
59
+ return this.bySession.get(sessionId)?.session.state;
60
+ }
61
+ stateSinceOf(sessionId) {
62
+ return this.bySession.get(sessionId)?.stateSince;
63
+ }
64
+ /** Current in-memory model of a live session (undefined when not attached). */
65
+ modelOf(sessionId) {
66
+ return this.bySession.get(sessionId)?.session.model;
67
+ }
68
+ /**
69
+ * Which conversation a session is answering, if any. The inverse of
70
+ * `sessionOf`, and what lets a tool act on "here" — an agent reached through
71
+ * a Slack thread otherwise has no way to name the thread it is replying in.
72
+ * A task or subagent session is attached to nothing and answers undefined.
73
+ */
74
+ conversationOf(sessionId) {
75
+ return this.bySession.get(sessionId)?.key;
76
+ }
77
+ /** Attach an existing session to a conversation and wire its events. */
78
+ attach(key, session) {
79
+ this.byKey.set(keyOf(key), session);
80
+ const existing = this.bySession.get(session.id);
81
+ if (existing?.session === session)
82
+ return;
83
+ this.bySession.set(session.id, { session, key, stateSince: Date.now() });
84
+ log.info(`attached ${keyOf(key)} → session ${session.id}`);
85
+ session.subscribe((payload) => {
86
+ this.hub.emit(session.id, payload);
87
+ // Run state is workspace-visible: every client's session list shows it.
88
+ if (payload.type === "state") {
89
+ const attached = this.bySession.get(session.id);
90
+ if (attached)
91
+ attached.stateSince = Date.now();
92
+ this.hub.emitWorkspace({
93
+ type: "session-state",
94
+ sessionId: session.id,
95
+ state: payload.state,
96
+ });
97
+ }
98
+ // An error the session itself reported (a tool that threw, a model
99
+ // refusal, a lost connection). Without this it lands only in the web
100
+ // timeline and the IM side goes quiet for no visible reason.
101
+ if (payload.type === "error") {
102
+ log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
103
+ const channel = this.channels.get(key.channelId);
104
+ channel?.notify(key.conversationId, {
105
+ text: truncate(payload.message),
106
+ origin: { kind: "error" },
107
+ }).catch((err) => log.error(`notify ${key.channelId} failed`, err));
108
+ }
109
+ // A system input is context the chat did not see being typed. It goes
110
+ // out before the turn it triggers, so the answer has a visible cause.
111
+ if (payload.type === "system-input") {
112
+ const channel = this.channels.get(key.channelId);
113
+ channel?.notify(key.conversationId, { text: payload.text, origin: payload.origin })
114
+ .catch((err) => {
115
+ log.error(`notify ${key.channelId} failed`, err);
116
+ this.hub.emit(session.id, {
117
+ type: "error",
118
+ message: `notify ${key.channelId} failed: ${String(err)}`,
119
+ });
120
+ });
121
+ }
122
+ // Every turn-end reaches the channel, empty text included: an adapter's
123
+ // per-turn UI (Telegram's 👀 receipts) is retired here, and a turn that
124
+ // settled with nothing to say still has to settle.
125
+ if (payload.type === "turn-end") {
126
+ log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
127
+ const channel = this.channels.get(key.channelId);
128
+ if (channel) {
129
+ channel.send(key.conversationId, splitReply(payload.text, payload.meta)).catch((err) => {
130
+ this.report(session.id, key, `outbound to ${key.channelId} failed: ${String(err)}`);
131
+ });
132
+ }
133
+ }
134
+ });
135
+ }
136
+ async abort(sessionId) {
137
+ await this.bySession.get(sessionId)?.session.abort();
138
+ }
139
+ /**
140
+ * The session already attached to a conversation, if any. Never creates one:
141
+ * a channel's stop or settings command must not be what opens a session.
142
+ */
143
+ sessionOf(key) {
144
+ return this.byKey.get(keyOf(key));
145
+ }
146
+ async abortConversation(key) {
147
+ await this.sessionOf(key)?.abort();
148
+ }
149
+ /** Session owning a conversation, resolving and attaching it on first use. */
150
+ async ensure(key) {
151
+ let session = this.byKey.get(keyOf(key));
152
+ // Web and task conversation ids are session ids. Reuse an attached
153
+ // instance so two surfaces never open the same Pi transcript twice.
154
+ if (!session && (key.channelId === "web" || key.channelId === "task")) {
155
+ session = this.bySession.get(key.conversationId)?.session;
156
+ if (session)
157
+ this.byKey.set(keyOf(key), session);
158
+ }
159
+ if (!session) {
160
+ try {
161
+ session = await this.resolve(key);
162
+ }
163
+ catch (err) {
164
+ // The one failure with nowhere to report itself: there is no session id
165
+ // to emit under yet, and every caller only sees a rejected promise.
166
+ log.error(`could not open a session for ${keyOf(key)}`, err);
167
+ throw err;
168
+ }
169
+ this.attach(key, session);
170
+ }
171
+ return session;
172
+ }
173
+ async dispatch(msg) {
174
+ const session = await this.ensure(msg.key);
175
+ const { action, text } = decide(msg, session.state);
176
+ // A group chat is many people talking into one session; without a speaker
177
+ // line the agent cannot tell them apart or mention anyone back. Emitted
178
+ // only when the speaker or the clock says something new.
179
+ const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
180
+ log.debug(`${action} ${keyOf(msg.key)} → session ${session.id}` +
181
+ ` (${String(prompt.length)} chars, ${String(msg.images?.length ?? 0)} images)`);
182
+ // Turn outcomes flow through the event stream; a rejected call surfaces
183
+ // there too, never as a thrown exception across the seam.
184
+ session[action](prompt, msg.images).catch((err) => {
185
+ this.report(session.id, msg.key, String(err));
186
+ });
187
+ return { sessionId: session.id };
188
+ }
189
+ }
@@ -0,0 +1,7 @@
1
+ // Normative seam types — THIS FILE is the system contract (docs/architecture.md
2
+ // documents the rules around it). Changing a seam is a design decision, not a
3
+ // refactor; keep it implementable over RPC (no Pi types may appear here).
4
+ /** Every level Pi accepts, in order. The union is derived so the two cannot
5
+ * drift, and boundary validators use isThinkingLevel instead of their own copy. */
6
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
7
+ export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);