@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,18 +1,8 @@
1
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
- */
2
+ // block syntax, silence, completion stats and the emphasis repair. Web chat and
3
+ // every IM adapter render these, so the wording and units live here.
4
+ /** The surface contract handed to every agent Pier launches (main.ts); the
5
+ * syntax the agent is told to emit sits beside the parser that reads it back. */
16
6
  const REPLY_SURFACE_PROMPT = `## Pier chat surface
17
7
 
18
8
  Your replies render in a chat UI (web and IM). Three optional markdown
@@ -39,22 +29,9 @@ typed by them. It appears only on a change — new speaker, a ~10-minute gap, a
39
29
  new day — so the last one still applies; a gap alone shows as time only, like
40
30
  \`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
41
31
  `;
42
- /**
43
- * The contract above plus the facts about *this* deployment that an agent
44
- * cannot work out for itself.
45
- *
46
- * Told, not discovered. Pier knows them — the home is its own startup argument
47
- * and the address is a file it wrote — while an agent can only guess at a
48
- * path and shell out to look. The guess is wrong on every instance that moves
49
- * `PIER_HOME`, and it fails as "nothing is configured": indistinguishable from
50
- * the truth, and reported to the user as such. Which is what happened.
51
- *
52
- * The editing line is the same shape of fact, learned the same way: GPT models
53
- * carry `apply_patch` from their own post-training, and not finding it in the
54
- * tool list they go looking in the shell — one failed call, then a filesystem
55
- * hunt, and in one session a stale Codex binary that worked, which put every
56
- * later edit outside Pi's tooling.
57
- */
32
+ /** Deployment facts an agent cannot discover: a guessed path is wrong wherever
33
+ * `PIER_HOME` moved and fails as "nothing is configured"; GPT models carry
34
+ * `apply_patch` from post-training and go hunting for it in the shell. */
58
35
  export function surfacePrompt(instance) {
59
36
  const reach = instance.publicUrl
60
37
  ? `Address: ${instance.publicUrl} — a board's link is that plus ` +
@@ -72,13 +49,7 @@ Editing: files change through the \`edit\` tool (exact text replacement) or
72
49
  so do not call one or go looking for one in the shell.
73
50
  `;
74
51
  }
75
- /**
76
- * Where a system input came from, in the fewest words that still say it.
77
- *
78
- * Lives here with `formatTurnMeta` for the same reason: it is *wording*, and
79
- * every surface must spell it the same way. It was copied verbatim into the
80
- * second adapter before landing here.
81
- */
52
+ /** Where a system input came from; wording every surface must spell the same. */
82
53
  export function originLabel(origin) {
83
54
  if (origin.kind === "error")
84
55
  return "\u26a0 failed";
@@ -94,40 +65,18 @@ export function originLabel(origin) {
94
65
  };
95
66
  return `from a subagent \u00b7 ${kinds[origin.messageKind] ?? origin.messageKind}`;
96
67
  }
97
- /**
98
- * Is a turn coming, once this note is posted?
99
- *
100
- * A system input is context for the turn it triggers, so on IM the note is the
101
- * only message that turn has to wear the 👀 — nobody typed one. An error note
102
- * is the opposite: it reports a turn that already ended or never began, and a
103
- * receipt marked on it would hang until the stale sweep.
104
- *
105
- * Beside `originLabel` for the same reason it is: three adapters ask this, and
106
- * three copies of the answer are three chances to drift.
107
- */
68
+ /** Is a turn coming once this note is posted? On IM the note is the only
69
+ * message that turn has to wear the 👀; an error note reports a turn that
70
+ * already ended, and a receipt on it would hang until the stale sweep. */
108
71
  export const awaitsTurn = (origin) => origin.kind !== "error";
109
- /**
110
- * Quote and bracket characters that may be lifted out of a `**strong**` run.
111
- * Moving them changes nothing a reader can see — the punctuation is simply no
112
- * longer bold — while taking the delimiter off a character the parser refuses
113
- * to close on.
114
- */
72
+ /** Punctuation that may be lifted out of a `**strong**` run: nothing a reader
73
+ * can see changes, and the delimiter comes off a character the parser refuses
74
+ * to close on. */
115
75
  const LIFTABLE = /[\u201c\u201d"\u2018\u2019'()\uff08\uff09\u300c\u300d\u300e\u300f\u3010\u3011\u300a\u300b\u3008\u3009[\]]/;
116
- /**
117
- * Repair emphasis that CommonMark refuses to close.
118
- *
119
- * A closing `**` must be *right-flanking*: preceded by non-whitespace, and
120
- * either not preceded by punctuation or else followed by whitespace or
121
- * punctuation. `**\u201c\u600e\u4e48\u505a\u201d**\uff1a` fails both halves \u2014 preceded by a quote,
122
- * followed by a CJK colon the rule does not count \u2014 so the run never closes and
123
- * the reader sees literal asterisks. `**\u95f2\u804a**\uff1a` is fine, because a letter
124
- * precedes the delimiter. This is a known ten-year-old hole in the spec around
125
- * CJK, not a Slack bug, and it shows up constantly in model output.
126
- *
127
- * The repair is to move the punctuation outside the delimiters, so the `**`
128
- * lands against a letter: `\u201c**\u600e\u4e48\u505a**\u201d\uff1a`. Code is protected first \u2014 asterisks
129
- * inside a fence or a code span are content, not markup.
130
- */
76
+ /** CommonMark's right-flanking rule does not count CJK punctuation, so
77
+ * `**“怎么做”**:` never closes (a spec hole, not a platform bug, and constant
78
+ * in model output). Moving the punctuation outside — `“**怎么做**”:` — lands
79
+ * the `**` against a letter. Fences and code spans are content, protected first. */
131
80
  export function cjkFriendly(markdown) {
132
81
  const stash = [];
133
82
  const keep = (text) => `\uE010${stash.push(text) - 1}\uE011`;
@@ -146,21 +95,14 @@ export function cjkFriendly(markdown) {
146
95
  trail = body.at(-1) + trail;
147
96
  body = body.slice(0, -1);
148
97
  }
149
- // Nothing was on the edges, or the run is only punctuation: leave it be.
150
98
  return lead || trail ? `${lead}**${body}**${trail}` : whole;
151
99
  });
152
100
  return out.replace(/\uE010(\d+)\uE011/g, (_m, i) => stash[Number(i)] ?? "");
153
101
  }
154
- /**
155
- * The one wording for a turn that said nothing, wherever it is shown — the
156
- * chat surfaces and the task result path. The reason arrives pre-escaped
157
- * because each surface escapes for its own markup; the label itself contains
158
- * nothing any of them escape.
159
- */
102
+ /** The reason arrives pre-escaped: each surface escapes for its own markup,
103
+ * and the label itself contains nothing any of them escape. */
160
104
  export const quietLabel = (silence) => silence ? `stayed silent — ${silence}` : "no reply";
161
- /** One policy for "did this turn actually reply": options count as a reply —
162
- * the buttons are the answer, so a turn that is only its options is not
163
- * "nothing". Every surface decides through here. */
105
+ /** Options count as a reply: a turn that is only its buttons is not "nothing". */
164
106
  export const isSilentReply = (reply) => !reply.text.trim() && reply.suggestions.length === 0;
165
107
  /** How a reasoning level is spelled wherever a human reads it. */
166
108
  export const thinkingLabel = (level) => level === "xhigh" ? "Extra high" : level[0].toUpperCase() + level.slice(1);
@@ -169,8 +111,7 @@ export const compact = (n) => {
169
111
  if (n < 1000)
170
112
  return String(n);
171
113
  const k = n / 1000;
172
- // Precision comes from the *rounded* value, so 9_990 reads "10K" instead of
173
- // a "10.0K" that claims a decimal it does not have.
114
+ // Precision from the rounded value: 9_990 reads "10K", not "10.0K".
174
115
  return k >= 9.95 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
175
116
  };
176
117
  /** "45s" / "1m14s". Floored at one second: sub-second precision is noise. */
@@ -178,42 +119,20 @@ function formatDuration(ms) {
178
119
  const secs = Math.max(1, Math.round(ms / 1000));
179
120
  return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`;
180
121
  }
181
- /**
182
- * A turn's cost, in the one wording every surface uses: how long it took and
183
- * how big the context is now. `tokens` is the context size at completion, not
184
- * a per-turn sum (see TurnMeta) — the label stays "tok" because that is what
185
- * the number is counted in.
186
- */
122
+ /** `tokens` is the context size at completion, not a per-turn sum (TurnMeta). */
187
123
  export const formatTurnMeta = (meta) => `${formatDuration(meta.durationMs)} · ${compact(meta.tokens)} tok`;
188
- /**
189
- * Trailing `---` line + a row of bracket tokens, optionally `|`-separated.
190
- * A markdown link (`[label](url)`) leaves `(url)` unmatched, so reference-link
191
- * blocks stay message content instead of turning into buttons.
192
- *
193
- * `(?:^|\n)` because a turn is allowed to be nothing but its options: anchoring
194
- * on a preceding newline left that whole block sitting in the message as raw
195
- * text, with no buttons anywhere.
196
- */
124
+ /** Trailing `---` line + a row of bracket tokens, optionally `|`-separated. A
125
+ * markdown link leaves `(url)` unmatched, so reference-link blocks stay
126
+ * content. `(?:^|\n)`: a turn may be nothing but its options. */
197
127
  const BLOCK = /(?:^|\n)[ \t]*-{3,}[ \t]*\r?\n((?:[ \t]*\[[^\]\r\n]+\][ \t]*(?:[||][ \t]*)?)+)\s*$/;
198
128
  const TOKEN = /\[([^\]\r\n]+)\]/g;
199
129
  const MAX_SUGGESTIONS = 5;
200
- /**
201
- * A deliberate non-answer. In a group thread the agent is handed every message,
202
- * and most of them are two humans talking; a bot that replies to each one is
203
- * unusable. Stripping the block here means an adapter needs no new concept: a
204
- * turn whose text is empty already posts nothing and still retires its per-turn
205
- * UI, which is exactly "listened, said nothing".
206
- *
207
- * The reason inside stays in the Pi transcript, so the choice is auditable
208
- * without being broadcast to the chat.
209
- */
130
+ /** A deliberate non-answer. Stripped here so an adapter needs no new concept:
131
+ * an empty turn already posts nothing and retires its per-turn UI. The reason
132
+ * stays in the transcript, auditable without being broadcast. */
210
133
  const SILENT = /<silent>([\s\S]*?)<\/silent>/gi;
211
- /**
212
- * Why the agent stayed quiet. Stripped from anything sent to a chat, but the
213
- * workbench is the operator's own view of the run — "it said nothing and here
214
- * is why" is observability, and hiding it there would just make a silent turn
215
- * look like a broken one.
216
- */
134
+ /** Why the agent stayed quiet; hidden from the chat, shown on the workbench,
135
+ * where a silent turn must not look like a broken one. */
217
136
  export function silentReason(markdown) {
218
137
  const reasons = [...markdown.matchAll(SILENT)]
219
138
  .map((m) => (m[1] ?? "").trim())
@@ -222,9 +141,6 @@ export function silentReason(markdown) {
222
141
  }
223
142
  /** Split an assistant turn's markdown into renderable text + next-step labels. */
224
143
  export function splitReply(rawMarkdown, meta) {
225
- // Every surface that renders this goes through here, and every CommonMark
226
- // parser has some version of the CJK emphasis hole — so the repair belongs
227
- // once, at the seam, not per adapter.
228
144
  const markdown = streamBody(rawMarkdown);
229
145
  const silence = silentReason(rawMarkdown);
230
146
  const m = BLOCK.exec(markdown);
@@ -238,36 +154,19 @@ export function splitReply(rawMarkdown, meta) {
238
154
  return { text: markdown, suggestions: [], meta, silence };
239
155
  return { text: markdown.slice(0, m.index).trimEnd(), suggestions, meta, silence };
240
156
  }
241
- /**
242
- * The body of a block that is still being written, for a surface rendering it
243
- * before the turn ends: everything `splitReply` repairs, minus the next-step
244
- * block. That block is only a next-step block at the very *end* of a turn, so
245
- * text a later paragraph has already closed behind keeps it as body — which is
246
- * what the final render will do with it too.
247
- */
157
+ /** For rendering mid-turn: everything `splitReply` repairs, minus the
158
+ * next-step block, which is only one at the very end of a turn. */
248
159
  export const streamBody = (markdown) => cjkFriendly(markdown.replace(SILENT, "").trim());
249
- /** Lines a blank line does not necessarily separate: a list item, a quote and
250
- * an indented continuation all survive one — a loose list is still one list.
251
- * Deliberately over-matching (`*` and `-` also catch emphasis and a rule):
252
- * claiming one boundary too few costs nothing but a repaint. */
160
+ /** Lines a blank line does not necessarily separate (a loose list is still one
161
+ * list). Over-matching is fine: one boundary too few costs only a repaint. */
253
162
  const CONTINUES = /^(?:\s|[-*+>]|\d+[.)])/;
254
- /**
255
- * How much of `markdown` after `from` can no longer change as more text
256
- * arrives: the offset just past the last blank line that closes a block, or
257
- * `from` if there is none.
258
- *
259
- * Streaming markdown is the caller. Re-parsing the whole reply on every
260
- * repaint costs O(N²) over a turn, so the web chat renders each closed prefix
261
- * once and keeps its DOM. A boundary is therefore only claimed where parsing
262
- * the two sides separately renders the same as parsing them together: never
263
- * inside a backtick or tilde fence, inside a block stripped on completion, or
264
- * between two lines a blank line leaves in the same list or quote. Fence runs
265
- * keep their length as in `channels/chunk.ts`: a ```` fence is not closed by
266
- * the ``` it quotes.
267
- */
163
+ /** Offset past the last blank line after `from` that closes a block, so a
164
+ * streaming renderer can parse each closed prefix once (whole-reply reparse is
165
+ * O(N²) over a turn). A boundary is claimed only where the two sides render the
166
+ * same apart as together: never inside a fence, a `<silent>` block, or a loose
167
+ * list/quote. A ```` fence is not closed by the ``` it quotes. */
268
168
  export function stableBlockEnd(markdown, from = 0) {
269
- // The last line is still growing, so it decides nothing — not the fence
270
- // state, and not whether the blank line above it ended a block.
169
+ // The last line is still growing, so it decides nothing.
271
170
  const end = markdown.lastIndexOf("\n") + 1;
272
171
  /** Marker run of the fence currently open; empty = closed. */
273
172
  let open = "";