@timqi/pier 0.1.0 → 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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  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 +7 -23
  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 +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  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 +4 -12
  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 +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  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 +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  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-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  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-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  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-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  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-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  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 +30 -16
  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/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -1,13 +1,7 @@
1
- // Boards: a folder of static files an agent writes to present something at a
2
- // stable URL. The filesystem is the source of truth — boards are derived by
3
- // scanning $PIER_HOME/boards, never registered. See docs/design/05-boards.md.
4
- //
5
- // Only <board>/site is reachable over HTTP: sources, README and the manifest
6
- // itself stay off the wire, so a public board leaks nothing about how it was
7
- // made. `/boards/*` is authenticated; `/p/*` additionally requires the
8
- // manifest's `public` flag and runs as sandboxed active content — and is the
9
- // only password-free prefix, stylesheet included, so one firewall rule covers
10
- // everything a logged-out reader may fetch.
1
+ // Boards: static pages an agent writes, derived by scanning $PIER_HOME/boards,
2
+ // never registered (docs/design/05-boards.md). Only <board>/site is reachable
3
+ // over HTTP, so a public board leaks nothing about how it was made. `/p/*` is
4
+ // the only password-free prefix, stylesheet included, and runs sandboxed.
11
5
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
12
6
  import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
13
7
  import { extname, join, resolve, sep } from "node:path";
@@ -17,11 +11,8 @@ export const defaultBoardsDir = () => pierPath("boards");
17
11
  /** Deleted boards keep their bytes under `<slug>.deleted-<ts>`, which this
18
12
  * pattern excludes from every scan — one rename is the whole delete path. */
19
13
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
20
- /** A published board is addressed by `<slug>-<token>`: the slug alone is a
21
- * guessable word, so without the 32 bits after it `/p/` could be walked with
22
- * a dictionary. Minted the first time a manifest is seen public, by whichever
23
- * path published it — the Console's toggle or an agent editing board.json —
24
- * so a board cannot be public and enumerable at the same time. */
14
+ /** Without the 32 bits after the slug, `/p/` could be walked with a dictionary.
15
+ * Minted the first time a manifest is seen public, by whichever path did it. */
25
16
  const TOKEN = /^[a-f0-9]{8}$/;
26
17
  const mintToken = () => randomBytes(4).toString("hex");
27
18
  // A board ships fonts and images, so the list is wider than the attachment
@@ -43,10 +34,11 @@ const TYPES = {
43
34
  ".txt": "text/plain; charset=utf-8",
44
35
  ".csv": "text/plain; charset=utf-8",
45
36
  };
46
- // Board HTML is active content on the workbench origin. Scripts stay available
47
- // for self-contained pages, but the response sandbox removes forms, frames,
48
- // popups and network access. Public pages omit same-origin too, so their scripts
49
- // receive an opaque origin and cannot inherit an operator's authority.
37
+ // Active content on the workbench origin: the sandbox removes forms, frames,
38
+ // popups and subresource requests. A private board keeps `allow-same-origin`
39
+ // for the session cookie, so its script reads this origin's storage and
40
+ // top-level navigation remains a way out (docs/design/05-boards.md). Public
41
+ // pages omit same-origin, so their scripts inherit no authority at all.
50
42
  const CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " +
51
43
  "script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; " +
52
44
  "worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; " +
@@ -55,14 +47,9 @@ const PRIVATE_CSP = `sandbox allow-scripts allow-same-origin; ${CSP}`;
55
47
  const PUBLIC_CSP = `sandbox allow-scripts; ${CSP}`;
56
48
  /** Malformed boards are reported once, not on every scan. */
57
49
  const warned = new Set();
58
- /** The one choke point where a slug becomes a path, so the slug is validated
59
- * here and nowhere else: an unvalidated `../../etc` would read outside the
60
- * boards dir, and a NUL byte would throw instead of 404. Unknown fields get
61
- * defaults, a broken file is skipped whole, and extra keys are the agent's
62
- * business — they survive a write. The one manifest this writes back is a
63
- * public board that arrived without a token: minting is the same decision as
64
- * reading `public`, and doing it anywhere else leaves the agent's own publish
65
- * path — editing `board.json` — with no URL. */
50
+ /** The one place a slug becomes a path, so it is validated here (`../../etc`,
51
+ * NUL). Extra keys survive a write. The one write-back is minting a token for
52
+ * a public board that arrived without one, so the agent's own publish path has a URL. */
66
53
  async function readManifest(dir, slug) {
67
54
  if (!SLUG.test(slug))
68
55
  return null;
@@ -119,8 +106,11 @@ export async function listBoards(dir) {
119
106
  .filter((e) => e.isDirectory() && SLUG.test(e.name))
120
107
  .map((e) => e.name);
121
108
  }
122
- catch {
123
- return []; // no boards yet
109
+ catch (err) {
110
+ // No directory is no boards yet; anything else hides every board at once.
111
+ if (err.code !== "ENOENT")
112
+ logger("boards").warn(`cannot scan ${dir}`, err);
113
+ return [];
124
114
  }
125
115
  // One board's manifest says nothing about the next one's, so the scan waits
126
116
  // once for all of them rather than once per board.
@@ -1,43 +1,21 @@
1
- // Outbound attachments: which links in a turn are files the platform has to
2
- // carry, and the bytes behind them.
3
- //
4
- // The agent links a file it produced by absolute `file://` URL — the
5
- // convention core/reply.ts hands it, spelled the same way inbound
6
- // (core/inbound-file.ts). The web chat renders that link as a card because the
7
- // browser can fetch the bytes back over an authenticated route; an IM client
8
- // cannot, and a `file:///…` link in Slack is a dead path on someone else's
9
- // machine. So an adapter uploads the file to the platform instead, and the
10
- // link's label stays behind as the words around it.
11
- //
12
- // The upload itself is per-platform and stays in each `*-api.ts`; what is
13
- // shared — the grammar, the caps, and the line a failed attachment still owes
14
- // the conversation — lives here so three adapters do not each have a copy.
1
+ // Outbound attachments: a `file://` link in a turn is a dead path on someone
2
+ // else's machine, so an IM adapter uploads the bytes and keeps the label. The
3
+ // upload is per-platform; the grammar, the caps and the line a failed
4
+ // attachment still owes the conversation are shared here.
15
5
  import { readFile, stat } from "node:fs/promises";
16
6
  import { basename, extname } from "node:path";
17
7
  import { lostMarker, replaceOutsideCode } from "../core/inbound-file.js";
18
- /**
19
- * One cap for every platform: Telegram refuses a photo past 10 MB, which is
20
- * the smallest of the three, and a turn that lands on one chat and not on
21
- * another is worse than a turn that is honest everywhere.
22
- */
8
+ /** Telegram refuses a photo past 10 MB, the smallest of the three; one cap so
9
+ * a turn does not land on one chat and not another. */
23
10
  export const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
24
- /** Per turn. Linking a directory's worth of files is a mistake, not a plan. */
25
11
  const MAX_ATTACHMENTS = 5;
26
- /** Extensions the platforms show inline. Everything else goes as a document —
27
- * svg included, deliberately: it is markup, and it renders as a file. */
12
+ /** svg excluded deliberately: it is markup, and renders as a file. */
28
13
  const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
29
- /** `[label](file:///abs/path)`, inline or on a line of its own. The optional
30
- * `!` is an image embed, which is the same request with a different sigil. */
14
+ /** `[label](file:///abs/path)`; a leading `!` is the same request as an image embed. */
31
15
  const LINK = /!?\[([^\]\n]*)\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)/g;
32
- /** A trailing slash or a bare root would otherwise leave an unnamed file. */
33
16
  const nameOf = (path) => basename(path) || "file";
34
- /**
35
- * Split a turn's markdown into the text an IM chat should show and the files
36
- * it linked. Each link collapses to its label — or to the file's name when the
37
- * agent wrote none — so the sentence it sat in still reads, and the turn never
38
- * becomes empty just because its only content was an attachment. A link inside
39
- * code is an example of the convention, not a use of it: it is left alone.
40
- */
17
+ /** Each link collapses to its label (or the file's name), so the sentence it
18
+ * sat in still reads. A link inside code is an example, left alone. */
41
19
  export function splitAttachments(markdown) {
42
20
  const paths = [];
43
21
  const text = replaceOutsideCode(markdown, LINK, (match) => {
@@ -56,12 +34,8 @@ export function splitAttachments(markdown) {
56
34
  });
57
35
  return { text, paths };
58
36
  }
59
- /**
60
- * Upload every file a turn linked, and return the line the conversation still
61
- * owes: an attachment that never arrived must not look like an attachment that
62
- * was never mentioned (AGENTS.md 5b), so each failure is named in the chat as
63
- * well as in the log. Empty string when everything landed.
64
- */
37
+ /** Returns the line the conversation still owes: an attachment that never
38
+ * arrived must not look like one never mentioned (§5). "" when all landed. */
65
39
  export async function sendAttachments(paths, upload, log) {
66
40
  const lost = [];
67
41
  const fail = (path, reason) => {
@@ -69,10 +43,8 @@ export async function sendAttachments(paths, upload, log) {
69
43
  lost.push(lostMarker(nameOf(path), reason));
70
44
  };
71
45
  const taken = paths.slice(0, MAX_ATTACHMENTS);
72
- // Five files that know nothing about each other cost one round trip, not
73
- // five. Two phases so the platform still receives them in the order the
74
- // turn linked them, and each failure is kept in its own slot — `lost` reads
75
- // in link order whichever upload finished first.
46
+ // Two phases: reads in parallel, then uploads, so the platform receives
47
+ // files in link order and `lost` reads in link order too.
76
48
  const reasons = [];
77
49
  const reasonOf = (err) => (err instanceof Error ? err.message : String(err));
78
50
  const files = await Promise.all(taken.map(async (path, i) => {
@@ -1,28 +1,13 @@
1
- // Ordering: one promise chain per conversation.
2
- //
3
- // Messages from different chats are handled concurrently — a slow download must
4
- // not stall another group — but messages within one chat strictly in arrival
5
- // order, because a steer overtaking the message it interrupts reorders the
6
- // conversation.
7
- //
8
- // Two rules live here because both adapters got to write them once and neither
9
- // should write them twice:
10
- //
11
- // - Every link needs its own `catch`. One rejected handler otherwise poisons
12
- // the chain and silences that chat for the life of the process — an ordering
13
- // mechanism that fails closed, permanently.
14
- // - `drain()` is bounded. `runtime.reload()` runs on the Console's save, so a
15
- // stuck handler must not hold that request open.
1
+ // One promise chain per conversation: chats run concurrently, but within one
2
+ // chat strictly in arrival order, or a steer overtakes the message it interrupts.
16
3
  export class Chains {
17
4
  log;
18
5
  maxActive;
19
6
  active = new Map();
7
+ held = 0;
8
+ waiting = [];
20
9
  constructor(log,
21
- /**
22
- * How many conversations may be in flight before a new one queues behind an
23
- * existing chain. Bounds concurrency (open sockets, downloads); a transport
24
- * that can also slow its source down should do that separately.
25
- */
10
+ /** Bounds concurrency (sockets, downloads), not the source's backlog. */
26
11
  maxActive = Infinity) {
27
12
  this.log = log;
28
13
  this.maxActive = maxActive;
@@ -30,34 +15,45 @@ export class Chains {
30
15
  get size() {
31
16
  return this.active.size;
32
17
  }
33
- /** Append to a conversation's chain, dropping the entry once it drains. */
34
18
  run(key, task) {
19
+ // A conversation owns one slot for the life of its chain, so a follow-up
20
+ // message queues behind its own handler instead of claiming a second.
35
21
  const mine = this.active.get(key);
36
- // A conversation with a chain already waits on itself; only a new one has
37
- // to wait for a slot. `active` is non-empty whenever the cap is hit, so the
38
- // race always settles.
39
- const start = mine ??
40
- (this.active.size >= this.maxActive
41
- ? Promise.race(this.active.values()).catch(() => { })
42
- : Promise.resolve());
43
- const next = start
22
+ const next = (mine ?? this.acquire())
44
23
  .then(task)
24
+ // Every link catches: one rejection would otherwise silence the chat for good.
45
25
  .catch((err) => this.log(`handler failed in ${key}: ${String(err)}`));
46
26
  this.active.set(key, next);
47
27
  void next.then(() => {
48
- if (this.active.get(key) === next)
49
- this.active.delete(key);
28
+ if (this.active.get(key) !== next)
29
+ return;
30
+ this.active.delete(key);
31
+ this.release();
50
32
  });
51
33
  }
52
- /** Wait for the oldest in-flight chain — the backpressure primitive. */
34
+ /** Reserved synchronously, so a burst of `run` calls cannot all pass the cap. */
35
+ acquire() {
36
+ if (this.held < this.maxActive) {
37
+ this.held += 1;
38
+ return Promise.resolve();
39
+ }
40
+ return new Promise((resolve) => this.waiting.push(resolve));
41
+ }
42
+ release() {
43
+ // The slot moves to one waiter rather than being counted back, which is
44
+ // what keeps a release from waking the whole queue.
45
+ const next = this.waiting.shift();
46
+ if (next)
47
+ next();
48
+ else
49
+ this.held -= 1;
50
+ }
51
+ /** The backpressure primitive. */
53
52
  oldest() {
54
53
  return Promise.race(this.active.values()).catch(() => { });
55
54
  }
56
- /**
57
- * Let in-flight work finish before a replacement adapter starts, but never
58
- * wait forever: two adapters handling one message would prompt twice, and a
59
- * hung handler holding up a config save is worse than either.
60
- */
55
+ /** Two adapters handling one message would prompt twice; a hung handler
56
+ * holding up the Console's save is worse than either, hence the bound. */
61
57
  async drain(timeoutMs) {
62
58
  await Promise.race([
63
59
  Promise.allSettled(this.active.values()),
@@ -1,15 +1,7 @@
1
- // Splitting a long turn into sendable messages.
2
- //
3
- // Every platform caps a message, and the index arithmetic for "cut at the last
4
- // blank line that fits, else the last newline, else mid-text" is fiddly enough
5
- // that having it twice is having it wrong once. The cap and any per-platform
6
- // repair (Slack re-balances code fences across the cut) stay with the renderer.
7
- /**
8
- * Cut `text` into pieces of at most `max` characters, preferring a blank line,
9
- * then a newline, then the hard limit. A cut lands mid-text only when there is
10
- * no break in the second half of the window — better a blunt split than a
11
- * dropped turn.
12
- */
1
+ // Splitting a long turn into sendable messages; the cap and any per-platform
2
+ // repair stay with the renderer.
3
+ /** Prefers a blank line, then a newline, then the hard limit; a cut lands
4
+ * mid-text only when there is no break in the second half of the window. */
13
5
  export function chunkText(text, max) {
14
6
  if (text.length <= max)
15
7
  return [text];
@@ -26,27 +18,15 @@ export function chunkText(text, max) {
26
18
  parts.push(rest);
27
19
  return parts;
28
20
  }
29
- /**
30
- * Close a fence a chunk left open, and reopen it on the next one. Telegram can
31
- * be cut mid-`<pre>` and shrug — its parser closes the tag itself — but Slack
32
- * and Lark both swallow the rest of a message after an unterminated ```, and
33
- * the next chunk starts *outside* a fence, so the tail of a long code block
34
- * renders as prose.
35
- *
36
- * Fences are tracked by line-leading runs with their *length*, per CommonMark:
37
- * a ```` fence (used to quote a ``` block) only closes on a run at least as
38
- * long, so counting bare ``` occurrences would see the inner block close the
39
- * outer one and mangle both halves of the cut.
40
- */
21
+ /** Slack and Lark swallow the rest of a message after an unterminated ```, so a
22
+ * fence left open is closed and reopened on the next chunk. Runs are tracked
23
+ * by length, per CommonMark: a ```` fence only closes on a run at least as long. */
41
24
  export function balanceFences(parts) {
42
- /** Backticks of the fence currently open across the boundary; 0 = closed. */
43
25
  let open = 0;
44
26
  const fence = (n) => "`".repeat(n);
45
27
  return parts.map((part) => {
46
28
  const reopened = open ? `${fence(open)}\n${part}` : part;
47
- // The prepended fence counts too — the scan restarts from "closed" and
48
- // reads it as the opener, so a chunk that closes the block it inherited
49
- // comes out even.
29
+ // The scan restarts from "closed" and reads the prepended fence as the opener.
50
30
  open = 0;
51
31
  for (const line of reopened.split("\n")) {
52
32
  const run = /^\s*(`{3,})/.exec(line)?.[1]?.length ?? 0;
@@ -1,21 +1,10 @@
1
- // Slash commands in IM text, parsed once for every platform.
2
- //
3
- // Every chat platform types commands the same way and mangles them slightly
4
- // differently: Telegram appends `@botname` when several bots share a group,
5
- // clients add stray whitespace, and users capitalise. One parser so an adapter
6
- // never re-derives "is this a command" and the answer never drifts between
7
- // platforms.
8
- /**
9
- * Parse a command out of one inbound message. Whitespace on both ends is
10
- * dropped first, then a leading `/` is required: anything else is ordinary
11
- * text and must reach the agent untouched.
12
- */
1
+ // Slash commands in IM text, parsed once for every platform: Telegram appends
2
+ // `@botname` when several bots share a group, clients add whitespace, users capitalise.
13
3
  export function parseCommand(text) {
14
4
  const trimmed = text.trim();
15
5
  if (!trimmed.startsWith("/"))
16
6
  return null;
17
- // Args are taken verbatim, not re-joined from split words: a path or a
18
- // sentence must survive with its own spacing intact.
7
+ // Args verbatim: a path or a sentence must keep its own spacing.
19
8
  const match = /^\/(\S+)[ \t]*([\s\S]*)$/.exec(trimmed);
20
9
  const [name = "", target] = (match?.[1] ?? "").split("@");
21
10
  if (!name)
@@ -1,31 +1,25 @@
1
- // Channel config persistence and the permission gate every adapter shares.
2
- // One JSON document per platform holds credentials, defaults, bound users and
3
- // the discovered chats — token, groups and permissions in one place, so a
4
- // surface configuring a platform reads and writes exactly one row.
5
- // The shapes live in types.ts; this file is the store and the policy.
1
+ // Channel config persistence and the permission gate every adapter shares: one
2
+ // JSON document per platform, so a surface configuring one reads and writes one row.
3
+ import { randomInt } from "node:crypto";
6
4
  import { pierDb } from "../db.js";
5
+ import { isSealed } from "../secrets.js";
7
6
  import { defaultChannelConfig, } from "./types.js";
8
7
  const BIND_CODE_TTL_MS = 10 * 60_000;
9
- /** Envelope from secrets.ts. A token that matches was sealed by us; anything
10
- * else is legacy plaintext, still honored and re-sealed on the next save. */
11
- const SEALED = /^v1:[0-9a-f]{8}:/;
8
+ /** Six symbols out of 36 is 2 billion, but a caller who may retry forever only
9
+ * needs the TTL; five wrong tries void the code instead. */
10
+ const BIND_CODE_TRIES = 5;
11
+ const BIND_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
12
12
  export class ChannelStore {
13
13
  secrets;
14
14
  db;
15
15
  cache = new Map();
16
- /** Without `secrets`, tokens persist as given — tests and one-off tools.
17
- * With it, tokens are sealed at rest; a locked store throws on both paths
16
+ /** Without `secrets` (tests), tokens persist as given. A locked store throws
18
17
  * rather than serving a token it cannot read. */
19
18
  constructor(db = pierDb(), secrets) {
20
19
  this.secrets = secrets;
21
20
  this.db = db;
22
21
  }
23
- /**
24
- * The live cached document. Private on purpose: handing it out let a caller
25
- * mutate config without saving, so memory and disk could disagree with no
26
- * way to tell which was right. Internal readers use this; everyone outside
27
- * gets a copy from get().
28
- */
22
+ /** Private: handed out, a caller could mutate config without saving. */
29
23
  cached(platform) {
30
24
  const hit = this.cache.get(platform);
31
25
  if (hit)
@@ -36,23 +30,20 @@ export class ChannelStore {
36
30
  : defaultChannelConfig();
37
31
  if (this.secrets) {
38
32
  for (const key of ["token", "appToken"]) {
39
- if (SEALED.test(config[key]))
33
+ if (isSealed(config[key]))
40
34
  config[key] = this.secrets.decrypt(config[key]);
41
35
  }
42
36
  }
43
37
  this.cache.set(platform, config);
44
38
  return config;
45
39
  }
46
- /** A detached copy: edit it freely, then hand it back to save(). */
47
40
  get(platform) {
48
41
  return structuredClone(this.cached(platform));
49
42
  }
50
43
  save(platform, config) {
51
- // Clone on the way in too, so the caller keeping its object and mutating
52
- // it later cannot reach into the cache behind save()'s back.
44
+ // Cloned, so the caller's object cannot reach into the cache later.
53
45
  this.cache.set(platform, structuredClone(config));
54
- // The cache holds plaintext (it is what adapters connect with); only the
55
- // row is sealed.
46
+ // The cache holds plaintext; only the row is sealed.
56
47
  const stored = this.secrets ? structuredClone(config) : config;
57
48
  if (this.secrets) {
58
49
  for (const key of ["token", "appToken"]) {
@@ -68,16 +59,9 @@ export class ChannelStore {
68
59
  chat(platform, chatId) {
69
60
  return this.get(platform).chats.find((c) => c.id === chatId);
70
61
  }
71
- /**
72
- * Record a chat the bot just met. Telegram has no "list my chats" API, so
73
- * discovery is passive. A new chat copies the platform defaults and owns
74
- * them from then on — the mention and bind gates are what keep it harmless
75
- * until an operator configures it.
76
- *
77
- * Telegram gets a chat's name free with every update, so it discovers on
78
- * every message: the "already known, unchanged" answer is read off the
79
- * cached document and costs no clone. Only a real change pays for one.
80
- */
62
+ /** Telegram has no "list my chats" API, so discovery is passive and happens
63
+ * on every message: the unchanged case must cost no clone. A new chat copies
64
+ * the platform defaults and owns them from then on. */
81
65
  discoverChat(platform, chat) {
82
66
  const cached = this.cached(platform).chats.find((c) => c.id === chat.id);
83
67
  if (cached && cached.name === chat.name && cached.kind === chat.kind)
@@ -98,9 +82,7 @@ export class ChannelStore {
98
82
  }
99
83
  this.save(platform, config);
100
84
  }
101
- // Read-only and on the per-message path: no clone, nothing here escapes.
102
- // An undiscovered chat falls back to the platform seed; in practice the
103
- // adapter discovers before it asks.
85
+ // On the per-message path: no clone, nothing here escapes.
104
86
  policy(platform, chatId) {
105
87
  const config = this.cached(platform);
106
88
  const chat = config.chats.find((c) => c.id === chatId);
@@ -119,10 +101,11 @@ export class ChannelStore {
119
101
  isBound(platform, userId) {
120
102
  return this.cached(platform).users.some((u) => u.id === userId);
121
103
  }
122
- /** Single-use, short-lived code an operator reads off the Console. */
104
+ /** `randomInt` rejection-samples, and this code is the whole distance
105
+ * between a stranger and an agent with a shell. */
123
106
  issueBindCode(platform) {
124
107
  const config = this.get(platform);
125
- const code = Math.random().toString(36).slice(2, 8).toUpperCase();
108
+ const code = Array.from({ length: 6 }, () => BIND_CODE_ALPHABET[randomInt(BIND_CODE_ALPHABET.length)]).join("");
126
109
  config.bindCode = { code, expiresAt: Date.now() + BIND_CODE_TTL_MS };
127
110
  this.save(platform, config);
128
111
  return config.bindCode;
@@ -131,15 +114,20 @@ export class ChannelStore {
131
114
  const config = this.get(platform);
132
115
  const pending = config.bindCode;
133
116
  if (!pending || pending.expiresAt < Date.now())
134
- return false;
135
- if (pending.code !== code.trim().toUpperCase())
136
- return false;
117
+ return "invalid";
118
+ if (pending.code !== code.trim().toUpperCase()) {
119
+ const tries = (pending.tries ?? 0) + 1;
120
+ const voided = tries >= BIND_CODE_TRIES;
121
+ config.bindCode = voided ? null : { ...pending, tries };
122
+ this.save(platform, config);
123
+ return voided ? "voided" : "invalid";
124
+ }
137
125
  config.bindCode = null;
138
126
  if (!config.users.some((u) => u.id === user.id)) {
139
127
  config.users.push({ id: user.id, name: user.name, boundAt: Date.now() });
140
128
  }
141
129
  this.save(platform, config);
142
- return true;
130
+ return "bound";
143
131
  }
144
132
  unbind(platform, userId) {
145
133
  const config = this.get(platform);
@@ -147,20 +135,13 @@ export class ChannelStore {
147
135
  this.save(platform, config);
148
136
  }
149
137
  }
150
- /**
151
- * The whole inbound permission policy, platform-blind and total.
152
- *
153
- * A group denial is silent by contract: a group where the bot answers "you are
154
- * not allowed" to every passing message is worse than one that stays quiet.
155
- * A DM is the exception — two parties, so silence is just confusing — and the
156
- * adapter answers `not-bound` there.
157
- */
138
+ /** The whole inbound permission policy. A group denial is silent by contract;
139
+ * a DM is the exception, and the adapter answers `not-bound` there. */
158
140
  export function gate({ policy, isDm, addressed, bound, bindRequest }) {
159
141
  if (!policy.enabled)
160
142
  return "chat-disabled";
161
- // A DM has exactly two parties: mention is meaningless, and bind is not
162
- // optional there — it is the only thing between a stranger and an agent with
163
- // a shell. `requireMention`/`requireBind` are group settings by construction.
143
+ // In a DM bind is not optional: it is the only thing between a stranger and
144
+ // an agent with a shell. `requireMention`/`requireBind` are group settings.
164
145
  if (isDm)
165
146
  return bound || bindRequest ? "allow" : "not-bound";
166
147
  if (policy.requireMention && !addressed)
@@ -1,16 +1,9 @@
1
- // What an adapter is allowed to do to a session, beyond handing it a prompt.
2
- //
3
- // `Channel` has exactly one inbound path (`onMessage`) and keeps it: an in-chat
4
- // settings panel needs to read a session's model and change it, which is not a
5
- // prompt and must not become a second seam. So the channel layer — which owns
6
- // the router already — hands adapters this narrow, platform-blind interface.
7
- // Everything here is a thin wrapper over core; no policy lives in it.
1
+ // What an adapter may do to a session beyond handing it a prompt. Not part of
2
+ // the `Channel` seam: the channel layer owns the router and hands adapters this
3
+ // narrow interface instead. Thin wrappers over core; no policy.
8
4
  import { chatOf, isChannelPlatform } from "./types.js";
9
5
  export function createControl({ router, factory, conversations, store }) {
10
6
  const launchFor = (key) => {
11
- // Decoding a conversation id back to a chat id is the channel layer's
12
- // business, never core's; the chat half of every platform's id has one
13
- // decoder (chatOf), so no adapter import is needed here.
14
7
  if (!isChannelPlatform(key.channelId))
15
8
  return {};
16
9
  const policy = store.policy(key.channelId, chatOf(key.conversationId));
@@ -28,7 +21,6 @@ export function createControl({ router, factory, conversations, store }) {
28
21
  const session = router.sessionOf(key);
29
22
  if (!session)
30
23
  return null;
31
- // AgentSession has no cwd; the factory's listing is where it lives.
32
24
  const summary = await factory.find(session.id);
33
25
  const usage = session.contextUsage;
34
26
  return {
@@ -55,8 +47,7 @@ export function createControl({ router, factory, conversations, store }) {
55
47
  ...launch,
56
48
  cwd: cwd || launch.cwd || process.cwd(),
57
49
  });
58
- // Persist before attaching: a crash in between must not leave the chat
59
- // pointing at a session nobody recorded.
50
+ // Persist before attaching: a crash between must not leave an unrecorded session.
60
51
  conversations.set(key, session.id);
61
52
  router.attach(key, session);
62
53
  return session.id;
@@ -1,14 +1,6 @@
1
- // Durable conversation → session routing for IM channels.
2
- //
3
- // core/router.ts keeps its map in memory, which is enough for the surfaces
4
- // whose conversation id already IS a session id (web) or that persist their
5
- // target themselves (tasks). An IM conversation id is a chat or a topic, so
6
- // without this table a restart would silently hand every group a brand-new
7
- // session: the chat history stays on screen while the agent forgets all of
8
- // it, and the old transcript becomes unreachable.
9
- //
10
- // Owned by channels/ rather than core/ so core stays storage-agnostic — the
11
- // same split tasks/ already uses for its target session ids.
1
+ // Durable conversation → session routing for IM channels: an IM conversation id
2
+ // is a chat, not a session id, so without this table a restart would hand every
3
+ // group a brand-new session. In channels/ so core stays storage-agnostic.
12
4
  import { pierDb } from "../db.js";
13
5
  export class ConversationStore {
14
6
  db;
@@ -29,30 +21,22 @@ export class ConversationStore {
29
21
  session_id = excluded.session_id, updated_at = excluded.updated_at
30
22
  `).run(key.channelId, key.conversationId, sessionId, Date.now());
31
23
  }
32
- /** Which channel owns this session, durably — the router's own answer is
33
- * in-memory and becomes undefined the moment an idle session is evicted, so
34
- * a surface asking "was this turn already delivered to a chat?" long after
35
- * the turn cannot use it. Sessions with no row are nobody's conversation. */
24
+ /** Durable, unlike the router's answer, which is gone once an idle session
25
+ * is evicted. No row: nobody's conversation. */
36
26
  channelOf(sessionId) {
37
27
  const row = this.db.prepare(`
38
28
  SELECT channel_id FROM conversations WHERE session_id = ? LIMIT 1
39
29
  `).get(sessionId);
40
30
  return row?.channel_id;
41
31
  }
42
- /** Drop a mapping whose session Pi no longer has, so the next message
43
- * starts a fresh one instead of failing forever. */
44
32
  forget(key) {
45
33
  this.db.prepare(`
46
34
  DELETE FROM conversations WHERE channel_id = ? AND conversation_id = ?
47
35
  `).run(key.channelId, key.conversationId);
48
36
  }
49
37
  }
50
- /**
51
- * The IM half of the router's session factory: reuse this conversation's
52
- * session across restarts, and only create when there is nothing to resume.
53
- * Wired in main.ts, so neither core nor an adapter learns where the mapping
54
- * lives.
55
- */
38
+ /** The IM half of the router's session factory, wired in main.ts so neither
39
+ * core nor an adapter learns where the mapping lives. */
56
40
  export function resolveConversation(store, factory, launchFor, onStale) {
57
41
  return async (key) => {
58
42
  const known = store.get(key);
@@ -61,8 +45,7 @@ export function resolveConversation(store, factory, launchFor, onStale) {
61
45
  return await factory.resume(known);
62
46
  }
63
47
  catch (err) {
64
- // Pi never persisted it (a first turn that never landed) or the
65
- // transcript was deleted. Re-route rather than fail every message.
48
+ // Never persisted, or deleted: re-route rather than fail every message.
66
49
  onStale?.(`${key.channelId}:${key.conversationId} lost session ${known}: ${String(err)}`);
67
50
  store.forget(key);
68
51
  }