@timqi/pier 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-DOr8dWeX.js} +1 -1
  91. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  92. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
  94. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  95. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
  97. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  98. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  99. package/dist/web/public/assets/index-DbFu15NN.js +85 -0
  100. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  101. package/dist/web/public/assets/index-DbFu15NN.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-Cv0A-e08.js} +1 -1
  106. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
  109. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  110. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
  112. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
  115. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BffPVgXg.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,11 +1,8 @@
1
- // At-least-once delivery, deduplicated: both push transports (Slack Socket
2
- // Mode, Lark's long connection) redeliver an event the platform did not see
3
- // acknowledged, so the same id can arrive twice. One implementation, because
4
- // the second copy had already appeared and the map has an invariant that is
5
- // easy to lose on a rewrite: it is fed by every message in every chat the bot
6
- // is in, so it must be bounded and time-limited, never grow-only.
7
- /** Fraction of `max` a full map is cut back to, so the eviction walk is paid
8
- * once per that many messages instead of once per message. */
1
+ // At-least-once delivery, deduplicated: Slack and Lark redeliver an event they
2
+ // did not see acknowledged. The map is fed by every message in every chat, so
3
+ // it must be bounded and time-limited, never grow-only.
4
+ /** A full map is cut back to this fraction of `max`, so the eviction walk is
5
+ * amortized instead of paid on every message at the bound. */
9
6
  const KEEP = 0.9;
10
7
  export class Dedup {
11
8
  log;
@@ -26,15 +23,9 @@ export class Dedup {
26
23
  if (now - at > this.ttlMs)
27
24
  this.seen.delete(id);
28
25
  }
29
- // Pruning expired entries is not enough under a burst of live ones —
30
- // `max` must be a real bound, so the oldest entries go next. The cost
31
- // is a forgotten id under extreme load (a redelivery slips through,
32
- // which downstream handling tolerates); unbounded memory is worse.
33
- // Map iterates in insertion order, so the front is the oldest.
34
- //
35
- // Evicting down to `KEEP` rather than to exactly `max` is what makes
36
- // the walk amortized: at the bound, freeing one slot per message meant
37
- // re-walking the whole map on every message from then on.
26
+ // `max` must be a real bound, so under a burst of live entries the
27
+ // oldest go too (Map iterates in insertion order); a redelivery slipping
28
+ // through under extreme load is tolerated, unbounded memory is not.
38
29
  const keep = Math.floor(this.max * KEEP);
39
30
  for (const [id] of this.seen) {
40
31
  if (this.seen.size <= keep)
@@ -1,20 +1,13 @@
1
- // The two inbound decisions every adapter makes identically: may this message
2
- // through, and may this stranger be told how to bind.
3
- //
4
- // Both were written twice before landing here, and both have a rule that is
5
- // easy to get subtly wrong on the second copy — a drop must always name its
6
- // verdict, and the throttle map must prune rather than grow. Keeping them in
7
- // one place is what makes "log every drop" and "bound every map fed by
8
- // strangers" true of every platform instead of one.
1
+ // The inbound decisions every adapter makes identically: may this message
2
+ // through, may this chat be remembered, and may this stranger be told how to
3
+ // bind.
9
4
  import { gate } from "./config.js";
10
- /** How often one unbound DM sender may be told how to bind. */
11
5
  const BIND_HINT_EVERY_MS = 10 * 60_000;
12
6
  export class Gatekeeper {
13
7
  store;
14
8
  platform;
15
9
  log;
16
10
  noun;
17
- /** Last time each unbound DM sender was told how to bind. */
18
11
  hints = new Map();
19
12
  constructor(store, platform, log,
20
13
  /** What the platform calls a conversation, for the drop log. */
@@ -24,11 +17,8 @@ export class Gatekeeper {
24
17
  this.log = log;
25
18
  this.noun = noun;
26
19
  }
27
- /**
28
- * The whole inbound permission decision, plus the log line every drop owes.
29
- * A silently skipped branch is indistinguishable from a bug, so the verdict
30
- * is always named.
31
- */
20
+ /** Every drop names its verdict: a silently skipped branch is
21
+ * indistinguishable from a bug. */
32
22
  admit(what, chatId, req) {
33
23
  const verdict = gate({
34
24
  policy: this.store.policy(this.platform, chatId),
@@ -42,14 +32,14 @@ export class Gatekeeper {
42
32
  this.log(`dropped ${what} in ${this.noun} ${chatId}: ${verdict}`);
43
33
  return false;
44
34
  }
45
- /**
46
- * May this sender be told how to bind? Groups stay silent by contract, but a
47
- * DM that swallows every message looks broken rather than locked — and a bot
48
- * that answers every one is an echo amplifier.
49
- *
50
- * Anyone can DM a bot, so this map is fed by strangers: expired entries are
51
- * dropped on the way past instead of keeping one per sender forever.
52
- */
35
+ /** A group the bot was added to is something an operator must see, but a DM
36
+ * from a stranger may not write a row (or make the bot look its sender up):
37
+ * anyone can start one. */
38
+ mayDiscover(req) {
39
+ return !req.isDm || this.store.isBound(this.platform, req.userId);
40
+ }
41
+ /** A bot that answers every stranger is an echo amplifier. The map is fed by
42
+ * strangers, so expired entries are pruned on the way past. */
53
43
  mayHint(userId, now = Date.now()) {
54
44
  if (now - (this.hints.get(userId) ?? 0) < BIND_HINT_EVERY_MS)
55
45
  return false;
@@ -1,23 +1,10 @@
1
- // Thin Lark (Feishu) client: API shapes and the WebSocket transport, no policy.
2
- // The one file in channels/ that talks to open.feishu.cn, so the adapter stays
3
- // testable against `LarkClient`.
4
- //
5
- // Unlike Slack's Socket Mode — JSON frames a while loop can own — Lark's long
6
- // connection is a protobuf-framed proprietary protocol with server-pushed
7
- // reconnect/ping config, so the official SDK carries the transport (and its
8
- // tenant-token refresh). It is confined to this file; nothing SDK-shaped leaks
9
- // past `LarkClient`. Domain is fixed to Feishu (open.feishu.cn) on purpose:
10
- // this instance's operator uses Feishu, and a Lark-international switch is a
11
- // config field we would carry for nobody.
12
- //
13
- // Two credentials, like Slack but for a different reason: every call and the
14
- // socket itself authenticate as `app_id` + `app_secret`, so ChannelConfig's
15
- // `token` carries the App ID and `appToken` the App Secret.
16
- //
17
- // One transport fact that shaped the adapter: the SDK sends the WS response
18
- // frame only *after* the registered handler resolves, and Lark redelivers what
19
- // it never saw answered — so handlers here must return once the event is
20
- // queued, never once it is handled ("ack is not handling", paid for on Slack).
1
+ // Thin Lark (Feishu) client: API shapes and the WebSocket transport, no policy;
2
+ // the one file that talks to open.feishu.cn. The long connection is a
3
+ // protobuf-framed proprietary protocol, so the official SDK carries the
4
+ // transport, confined to this file. Domain is fixed to Feishu. ChannelConfig's
5
+ // `token` is the App ID and `appToken` the App Secret. The SDK acks a frame only
6
+ // after the handler resolves and Lark redelivers what it never saw answered, so
7
+ // handlers return once the event is queued.
21
8
  import * as Lark from "@larksuiteoapi/node-sdk";
22
9
  /** Lark answers HTTP 200 with a business code; non-zero is the real error. */
23
10
  function ok(what, res) {
@@ -28,8 +15,7 @@ function ok(what, res) {
28
15
  export class LarkApi {
29
16
  log;
30
17
  client;
31
- /** Our own open_id, remembered from botOpenId() — reaction removal must
32
- * only ever touch a reaction *this* app made. */
18
+ /** Reaction removal must only touch a reaction this app made. */
33
19
  me = "";
34
20
  constructor(appId, appSecret, log = () => { }) {
35
21
  this.log = log;
@@ -53,13 +39,8 @@ export class LarkApi {
53
39
  return this.me;
54
40
  }
55
41
  // --- long connection ---------------------------------------------------------
56
- /**
57
- * The SDK owns the loop: endpoint discovery, protobuf frames, ping/pong and
58
- * the reconnect pacing the server itself pushes down. `card.action.trigger`
59
- * is registered through `register`'s generic because IHandles types events
60
- * only, not callbacks; its payload shape is pinned by the adapter's golden
61
- * tests instead.
62
- */
42
+ /** `card.action.trigger` goes through `register`'s generic because IHandles
43
+ * types events only, not callbacks. */
63
44
  connect(handlers) {
64
45
  const dispatcher = new Lark.EventDispatcher({
65
46
  loggerLevel: Lark.LoggerLevel.error,
@@ -79,8 +60,7 @@ export class LarkApi {
79
60
  mentions: data.message.mentions,
80
61
  },
81
62
  });
82
- // Resolve now: the SDK answers the frame only after this returns, and
83
- // a turn outlives Lark's redelivery deadline.
63
+ // The SDK acks only after this returns; a turn outlives the redelivery deadline.
84
64
  return Promise.resolve();
85
65
  },
86
66
  "card.action.trigger": (data) => {
@@ -102,12 +82,9 @@ export class LarkApi {
102
82
  domain: Lark.Domain.Feishu,
103
83
  loggerLevel: Lark.LoggerLevel.error,
104
84
  });
105
- // Fire and forget, deliberately: start() settles on the SDK's schedule —
106
- // it retries a busy endpoint and can sit in that loop for a long time —
107
- // and ChannelRuntime serializes reloads, so awaiting here would let one
108
- // unreachable network block every later Console save. Credentials were
109
- // already proven by botOpenId() before connect() is called; a transport
110
- // failure after that is the reconnect loop's job, and is logged.
85
+ // Not awaited: start() can sit in the SDK's retry loop for a long time,
86
+ // and ChannelRuntime serializes reloads, so one unreachable network would
87
+ // block every later Console save. botOpenId() already proved the credentials.
111
88
  void ws.start({ eventDispatcher: dispatcher })
112
89
  .catch((err) => this.log(`lark long connection failed: ${String(err)}`));
113
90
  return Promise.resolve({
@@ -129,23 +106,15 @@ export class LarkApi {
129
106
  data: {
130
107
  msg_type: "interactive",
131
108
  content: JSON.stringify(card),
132
- // What makes the reply land in the message's own topic instead of the
133
- // chat's main flow — Lark's equivalent of posting to a thread_ts.
109
+ // Lark's equivalent of posting to a thread_ts.
134
110
  reply_in_thread: true,
135
111
  },
136
112
  });
137
113
  return { messageId: ok("message.reply", res).data?.message_id ?? "" };
138
114
  }
139
- /**
140
- * Two calls: the bytes go to the platform first and come back as a key,
141
- * then the key is posted as a message. Images take the image endpoint so
142
- * they render inline; everything else is a `stream` file, which is Lark's
143
- * name for "a file whose type I am not claiming to know".
144
- *
145
- * The SDK unwraps an upload response to its `data`, so a business failure
146
- * arrives as a missing key rather than as a code — hence the explicit throw
147
- * instead of `ok()`.
148
- */
115
+ /** Images take the image endpoint so they render inline; `stream` is Lark's
116
+ * "type unknown". The SDK unwraps an upload response to its `data`, so a
117
+ * failure arrives as a missing key, not a code. */
149
118
  async uploadFile(rootId, file) {
150
119
  const bytes = Buffer.from(file.bytes);
151
120
  let content;
@@ -190,12 +159,8 @@ export class LarkApi {
190
159
  data: { reaction_type: { emoji_type: emojiType } },
191
160
  }));
192
161
  }
193
- /**
194
- * Lark deletes reactions by `reaction_id`, and several parties may have
195
- * used the same emoji — so list, keep only the entry *this app* owns
196
- * (`operator_type` alone is not ownership: another bot's 👀 is an app
197
- * reaction too, and deleting it would strand our own), delete that.
198
- */
162
+ /** `operator_type` alone is not ownership: another bot's 👀 is an app
163
+ * reaction too, and deleting it would strand our own. */
199
164
  async removeReaction(messageId, emojiType) {
200
165
  let pageToken;
201
166
  do {
@@ -208,7 +173,7 @@ export class LarkApi {
208
173
  if (op?.operator_type !== "app")
209
174
  continue;
210
175
  // The list reports an app operator by open_id or app_id depending on
211
- // surface; accept either of ours, never a blank (avibe's rule).
176
+ // surface; accept either of ours, never a blank.
212
177
  const id = (op.operator_id ?? "").trim();
213
178
  if (!id || (id !== this.me && id !== this.appId))
214
179
  continue;
@@ -242,13 +207,8 @@ export class LarkApi {
242
207
  }
243
208
  }
244
209
  // --- files ----------------------------------------------------------------------
245
- /**
246
- * `maxBytes` is enforced *while streaming*: the receive event often omits
247
- * `file_size`, so the metadata check upstream cannot be the only cap, and
248
- * buffering an unbounded stream whole into memory is the exact failure the
249
- * cap exists for. The error message carries "too large" — the adapter's
250
- * lost-marker wording keys on it.
251
- */
210
+ /** Enforced while streaming: the receive event often omits `file_size`. The
211
+ * error carries "too large", which the lost-marker wording keys on. */
252
212
  async download(messageId, fileKey, type, maxBytes) {
253
213
  const res = await this.client.im.v1.messageResource.get({
254
214
  path: { message_id: messageId, file_key: fileKey },
@@ -1,45 +1,25 @@
1
- // How a turn becomes cards in a Lark thread.
2
- //
3
- // Split from the adapter the way slack-outbound.ts is: what a turn renders as
4
- // — one card per chunk, footer and buttons on the last, and what an empty turn
5
- // still has to say — is a different decision from routing inbound traffic. The
6
- // adapter keeps the 👀 receipts, because those are about the turn ending, not
7
- // about what was said.
1
+ // How a turn becomes cards in a Lark thread: one card per chunk, footer and
2
+ // buttons on the last, and what an empty turn still has to say.
8
3
  import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
9
4
  import { sendAttachments, splitAttachments } from "./attach.js";
10
5
  import { button, buttonRow, card, chunk, footer, LARK_MAX, markdown, OFFER_PREFIX, withFooter, withoutButtons, } from "./lark-render.js";
11
- /** How many sent cards the retire cache remembers (avibe keeps 200). */
12
6
  const SENT_CACHE = 200;
13
7
  export class LarkOutbound {
14
8
  api;
15
9
  log;
16
- /**
17
- * The cards this process sent with buttons on them, for retiring the row
18
- * once an option is taken — a 2.0 card cannot be read back from the
19
- * platform (see LarkActionValue), so what we sent is the only copy.
20
- * In-memory and bounded, copied from avibe: purely cosmetic state. A click
21
- * after a restart still *works* (the label rides in the value); the buttons
22
- * merely stay up, and the skip is logged.
23
- */
10
+ /** A 2.0 card cannot be read back (LarkActionValue), so what we sent is the
11
+ * only copy to retire buttons from. Cosmetic: a click after a restart still
12
+ * works, the buttons merely stay up. */
24
13
  sent = new Map();
25
14
  constructor(api, log) {
26
15
  this.api = api;
27
16
  this.log = log;
28
17
  }
29
- /**
30
- * Post one turn as replies into the conversation's thread, empty text
31
- * included: a turn that produced nothing still posts its footer and says
32
- * which kind of nothing it was — total silence is indistinguishable from a
33
- * crash, and the person watching the 👀 come off has no way to tell.
34
- *
35
- * The footer folds into the last body chunk's own markdown element (a
36
- * second element renders a blank gap); only a bodiless turn gets it as the
37
- * standalone muted element. Buttons ride the last chunk, like every other
38
- * platform — the card is remembered so retire() can rebuild it without them.
39
- */
18
+ /** An empty turn still posts its footer and says which kind of nothing (§5).
19
+ * The footer folds into the last chunk's element (a second element renders
20
+ * a blank gap); only a bodiless turn gets the standalone one. */
40
21
  async reply(root, reply) {
41
- // A file the agent linked lives on Pier's machine, so the link is dead in
42
- // Lark: the bytes are uploaded instead and the label stays in the text.
22
+ // A local file link is dead in Lark: the bytes are uploaded instead.
43
23
  const { text: spoken, paths } = splitAttachments(reply.text);
44
24
  const text = spoken.trim();
45
25
  const meta = reply.meta ? formatTurnMeta(reply.meta) : "";
@@ -66,17 +46,11 @@ export class LarkOutbound {
66
46
  if (last && row && messageId)
67
47
  this.remember(messageId, card(elements));
68
48
  }
69
- // Attachments follow the words, so the card introducing them is above
70
- // them; anything that could not be sent says so in the thread.
71
49
  const lost = await sendAttachments(paths, (file) => this.api.uploadFile(root, file), this.log);
72
50
  if (lost)
73
51
  await this.api.replyCard(root, card([markdown(lost)]));
74
52
  }
75
- /**
76
- * Take the buttons off a card one option was just taken from — the rest
77
- * answer a question the conversation has moved past. Best-effort by design:
78
- * an unremembered card (sent before a restart) keeps its row, logged.
79
- */
53
+ /** Best-effort: a card sent before a restart keeps its row, logged. */
80
54
  async retire(messageId) {
81
55
  const known = this.sent.get(messageId);
82
56
  if (!known) {
@@ -96,14 +70,8 @@ export class LarkOutbound {
96
70
  this.sent.delete(oldest);
97
71
  }
98
72
  }
99
- /**
100
- * A system note: quoted, labelled with where it came from, and deliberately
101
- * plain — no buttons and no turn footer, because the turn this input
102
- * triggers has not ended yet.
103
- *
104
- * Answers with the id of the last card it posted — where the caller puts the
105
- * 👀 for that turn, at the foot of the topic the reply will land in.
106
- */
73
+ /** No footer: the turn this input triggers has not ended. Answers with the
74
+ * id of the last card posted, where the caller puts the 👀. */
107
75
  async note(root, note) {
108
76
  const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
109
77
  let messageId;
@@ -1,17 +1,10 @@
1
- // Lark's half of the settings panel: card markup, flow button rows, and a form
2
- // card for the one typed answer. The panel itself lives in `panel.ts`.
3
- //
4
- // Lark has no modal a WebSocket app can open — a "modal" here is the panel
5
- // message patched into a form card (an input plus a submit button), which is
6
- // avibe's verified pattern. The submit button's `name` carries the thread root
7
- // the same way every other panel button's callback value does, so a submission
8
- // needs no adapter-side state to find its conversation — the map below only
9
- // remembers where the panel message itself lives.
1
+ // Lark's half of the settings panel (panel.ts has the rest). Lark has no modal
2
+ // a WebSocket app can open, so the typed answer is the panel patched into a
3
+ // form card; the submit button's `name` carries the thread root.
10
4
  import { button as cardButton, buttonRow, card, footer, formInput, markdown, } from "./lark-render.js";
11
5
  import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
12
6
  /** A form-submit button name: `cwdgo:<thread root>`. */
13
7
  export const CWD_SUBMIT_PREFIX = "cwdgo:";
14
- /** The form input's field name, the key `form_value` answers under. */
15
8
  const CWD_FIELD = "cwd";
16
9
  export class LarkPanel extends ChatPanel {
17
10
  deps;
@@ -21,8 +14,7 @@ export class LarkPanel extends ChatPanel {
21
14
  super(deps);
22
15
  this.deps = deps;
23
16
  }
24
- /** Lark's markdown treats what it cannot parse as literal text; there is no
25
- * escape syntax to apply (see lark-render.ts). */
17
+ /** Lark's markdown has no escape syntax (lark-render.ts). */
26
18
  esc(text) {
27
19
  return text;
28
20
  }
@@ -33,7 +25,6 @@ export class LarkPanel extends ChatPanel {
33
25
  render(view, root, note) {
34
26
  const elements = [
35
27
  ...view.groups.map((g) => markdown([`**${g.title}**${g.suffix ?? ""}`, ...g.lines].join("\n"))),
36
- // A flow row wraps, so a page of models lays out like Slack's one row.
37
28
  ...(view.picks?.length ? [buttonRow(view.picks.map((p) => this.btn(p, root)))] : []),
38
29
  ...view.rows.filter((r) => r.length).map((row) => buttonRow(row.map((b) => this.btn(b, root)))),
39
30
  ];
@@ -41,7 +32,6 @@ export class LarkPanel extends ChatPanel {
41
32
  elements.push(footer(note));
42
33
  return card(elements);
43
34
  }
44
- /** Open a fresh panel, replacing whichever one this conversation had. */
45
35
  async open(key, chatId, root) {
46
36
  const sent = await this.deps.api.replyCard(root, this.render(await this.view(key, chatId), root));
47
37
  this.remember(key, { chatId, root, messageId: sent.messageId, models: [] });
@@ -55,10 +45,7 @@ export class LarkPanel extends ChatPanel {
55
45
  .catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
56
46
  }
57
47
  // --- actions ---------------------------------------------------------------
58
- /**
59
- * Handle a `cfg:` click. Returns false when the action is not ours, so the
60
- * caller can treat it as one of the agent's next-step labels instead.
61
- */
48
+ /** Returns false when the action is not ours. */
62
49
  async onAction(action, key, payload, root) {
63
50
  return this.dispatch(key, payload, action, () => this.open(key, action.chatId, root));
64
51
  }
@@ -81,11 +68,8 @@ export class LarkPanel extends ChatPanel {
81
68
  };
82
69
  await this.deps.api.patchCard(state.messageId, card([form, buttonRow([this.btn({ label: "Cancel", action: "panel" }, state.root)])])).catch((err) => this.deps.log(`cwd form failed: ${String(err)}`));
83
70
  }
84
- /**
85
- * Consume a form submission. The adapter routes any `cwdgo:` submit here;
86
- * a panel that outlived its process is re-remembered from the event itself,
87
- * so the outcome still lands on the card the user is looking at.
88
- */
71
+ /** A panel that outlived its process is re-remembered from the event, so
72
+ * the outcome lands on the card the user is looking at. */
89
73
  async onCwdSubmit(key, action, root) {
90
74
  if (!this.state(key)) {
91
75
  this.remember(key, { chatId: action.chatId, root, messageId: action.messageId, models: [] });
@@ -1,62 +1,29 @@
1
1
  // How a reply looks on Lark: an interactive card whose body is a markdown
2
- // element, buttons as flow columns, and the turn footer as notation-sized grey
3
- // text.
4
- //
5
- // Everything is a card, never a plain `text` message, because three checklist
6
- // features live or die on it: buttons (next-step suggestions, the settings
7
- // panel), edit-in-place (the panel's one-message contract, via message.patch),
8
- // and the muted footer. The costs are known and accepted: the chat list
9
- // previews a card as「卡片」rather than its first line, and card interactions
10
- // expire after 30 days — fine for buttons that answer the turn they rode on.
11
- //
12
- // The markdown element takes the agent's markdown near-unmodified — Lark's
13
- // dialect covers emphasis, fences, lists, links and quotes — so unlike
14
- // Telegram (HTML) and Slack's mrkdwn fallback there is no translation layer,
15
- // and no escaping either: Lark treats what it cannot parse as literal text
16
- // rather than rejecting the message (avibe ships the same identity escape).
2
+ // element. Always a card, never plain `text`: buttons, edit-in-place and the
3
+ // muted footer all need one. Costs accepted: the chat list previews a card as
4
+ // 「卡片」, and card interactions expire after 30 days. Lark's markdown dialect
5
+ // takes the agent's markdown near-unmodified and treats what it cannot parse as
6
+ // literal text, so there is no translation and no escaping.
17
7
  import { balanceFences, chunkText } from "./chunk.js";
18
- /**
19
- * Chunk budget in characters. The binding limit is the card's 30KB request
20
- * cap in *bytes*; a CJK character spends three, plus JSON string overhead, so
21
- * 7000 chars keeps the worst all-CJK turn near 21KB with room for the card
22
- * scaffolding around it.
23
- */
8
+ /** The binding limit is the card's 30KB request cap in bytes; a CJK character
9
+ * spends three, so 7000 chars keeps an all-CJK turn near 21KB plus scaffolding. */
24
10
  export const LARK_MAX = 7000;
25
11
  // Lark truncates a button label around this, mid-word.
26
12
  const BUTTON_MAX = 60;
27
- /**
28
- * Cut at the last break that fits, then re-balance code fences across the cut:
29
- * Lark, like Slack, lets an unterminated ``` swallow the rest of the message.
30
- */
13
+ /** Fences re-balanced across the cut: an unterminated ``` swallows the rest. */
31
14
  export const chunk = (text, max) => balanceFences(chunkText(text, max));
32
- /** The body of a turn, rendered by Lark's own markdown dialect. */
33
15
  export const markdown = (content) => ({ tag: "markdown", content });
34
- /**
35
- * Small muted text. Card schema 2.0 removed the `note` component; its
36
- * replacement is a notation-sized markdown element, and the grey must come
37
- * from the inline font tag because 2.0's markdown element rejects a
38
- * `text_color` property. Both verified against the live API (by avibe).
39
- *
40
- * Standalone cards only (a quiet turn, an options row): beside a body it
41
- * would render a blank gap — elements space themselves apart and no spacing
42
- * knob is documented — so there the footer folds into the body's own element
43
- * (`withFooter`), where a newline is just a newline.
44
- */
16
+ /** Schema 2.0 has no `note`, and its markdown element rejects `text_color`, so
17
+ * the grey is an inline font tag. Standalone cards only: beside a body it
18
+ * renders a blank gap, so there the footer folds into the body (`withFooter`). */
45
19
  export const footer = (content) => ({
46
20
  tag: "markdown",
47
21
  content: `<font color='grey'>${content}</font>`,
48
22
  text_size: "notation",
49
23
  });
50
- /**
51
- * Markdown constructs a following line *continues* instead of leaving: a list
52
- * item, a blockquote, a table row. After one of these a single newline is
53
- * lazy continuation — the footer rendered glued onto "5. 麻辣烫:…" in the
54
- * field — so the footer needs the blank line that ends the construct. After a
55
- * plain paragraph the single newline stays, because that is the tight spacing
56
- * this helper exists for.
57
- */
24
+ /** After a list item, a quote or a table row a single newline is lazy
25
+ * continuation and the footer glues onto the line; those need a blank line. */
58
26
  const LAZY_LINE = /^\s*(?:[-*+]\s|\d+[.)]\s|>|\|)/;
59
- /** A body and its footer in one markdown element — grey, gapless. */
60
27
  export const withFooter = (body, note) => {
61
28
  const last = body.trimEnd().split("\n").at(-1) ?? "";
62
29
  const brk = LAZY_LINE.test(last) ? "\n\n" : "\n";
@@ -69,11 +36,7 @@ export const button = (label, value) => ({
69
36
  type: "default",
70
37
  behaviors: [{ type: "callback", value }],
71
38
  });
72
- /**
73
- * One row of buttons as a `flow` column set: each column sizes to its button
74
- * and the row wraps on narrow screens, so a long label beside a short one
75
- * costs nothing (the same property Slack's actions row has natively).
76
- */
39
+ /** A `flow` column set sizes each column to its button and wraps on narrow screens. */
77
40
  export const buttonRow = (buttons) => ({
78
41
  tag: "column_set",
79
42
  flex_mode: "flow",
@@ -84,7 +47,7 @@ export const card = (elements) => ({
84
47
  schema: "2.0",
85
48
  body: { direction: "vertical", elements },
86
49
  });
87
- /** A typed answer inside a card — Lark's stand-in for a modal. */
50
+ /** Lark's stand-in for a modal. */
88
51
  export const formInput = (name, label, placeholder) => ({
89
52
  tag: "input",
90
53
  name,
@@ -93,15 +56,8 @@ export const formInput = (name, label, placeholder) => ({
93
56
  placeholder: { tag: "plain_text", content: placeholder },
94
57
  });
95
58
  // --- next-step buttons -----------------------------------------------------------
96
- /**
97
- * The callback value carries the key (`sg:0`) *and* the label. On Slack the
98
- * label is read back off the message the platform echoes with the click; Lark
99
- * echoes only the value, and `message.get` cannot return a 2.0 card at all
100
- * (see LarkActionValue), so the value is this platform's "read it back off
101
- * the message" — still platform state, never adapter memory, so a button
102
- * survives a restart and the Console's reload the same way (avibe ships the
103
- * label in the value too: `quick_reply:<label>`).
104
- */
59
+ /** The value carries the key and the label: Lark echoes only the value, and
60
+ * `message.get` cannot return a 2.0 card (LarkActionValue), so this is the one
61
+ * place a button's meaning survives a restart. */
105
62
  export const OFFER_PREFIX = "sg:";
106
- /** The card minus its button rows — how taken options are retired. */
107
63
  export const withoutButtons = (cardIn) => card(cardIn.body.elements.filter((el) => el.tag !== "column_set" && el.tag !== "button"));