@timqi/pier 0.0.5 → 0.0.7

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 (41) hide show
  1. package/README.md +1 -0
  2. package/dist/agent/events.js +6 -1
  3. package/dist/agent/pi.js +28 -3
  4. package/dist/channels/chunk.js +34 -0
  5. package/dist/channels/control.js +6 -12
  6. package/dist/channels/dedup.js +45 -0
  7. package/dist/channels/lark-api.js +233 -0
  8. package/dist/channels/lark-outbound.js +101 -0
  9. package/dist/channels/lark-panel.js +95 -0
  10. package/dist/channels/lark-render.js +107 -0
  11. package/dist/channels/lark.js +501 -0
  12. package/dist/channels/lines.js +19 -0
  13. package/dist/channels/panel.js +4 -0
  14. package/dist/channels/receipts.js +15 -0
  15. package/dist/channels/routes.js +0 -1
  16. package/dist/channels/runtime.js +5 -1
  17. package/dist/channels/slack-api.js +4 -2
  18. package/dist/channels/slack-panel.js +3 -6
  19. package/dist/channels/slack-render.js +3 -23
  20. package/dist/channels/slack.js +39 -72
  21. package/dist/channels/telegram-api.js +4 -2
  22. package/dist/channels/telegram-panel.js +3 -3
  23. package/dist/channels/telegram.js +55 -51
  24. package/dist/channels/types.js +9 -0
  25. package/dist/core/identity.js +21 -0
  26. package/dist/core/inbox.js +67 -1
  27. package/dist/core/types.js +4 -0
  28. package/dist/db.js +6 -0
  29. package/dist/main.js +5 -0
  30. package/dist/web/auth.js +36 -9
  31. package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  32. package/dist/web/public/assets/ghostty-web-BhZV0Vvv.js +13 -0
  33. package/dist/web/public/assets/{index-BAW9Nhaa.js → index-BbwoGR-O.js} +17 -17
  34. package/dist/web/public/assets/index-BlHvP59B.css +2 -0
  35. package/dist/web/public/index.html +16 -6
  36. package/dist/web/server.js +86 -19
  37. package/dist/web/session-state.js +59 -25
  38. package/dist/web/terminal.js +334 -0
  39. package/docs/deploy.md +5 -1
  40. package/package.json +10 -2
  41. package/dist/web/public/assets/index-CwBoxtXP.css +0 -2
package/README.md CHANGED
@@ -17,6 +17,7 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
17
17
  ## Requirements
18
18
 
19
19
  - Node 24 or newer (`node:sqlite` is used unflagged)
20
+ - Linux: Python 3, `make` and a C/C++ compiler for `node-pty`
20
21
  - A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
21
22
  login from Console → Settings → Providers after signing in
22
23
  - A user-writable global npm prefix if `pier update` should update a service
@@ -2,6 +2,7 @@
2
2
  // purpose: no @earendil-works/pi-* imports, so it stays unit-testable without Pi
3
3
  // and Pi types never leak past the seam. The golden-table test in
4
4
  // events.test.ts is the mapping's spec; extend types.ts before adding events.
5
+ import { MAX_STEP_OUTPUT } from "../core/types.js";
5
6
  export function textOf(content) {
6
7
  if (typeof content === "string")
7
8
  return content;
@@ -101,8 +102,12 @@ export function toChatTurns(messages) {
101
102
  if (m.role === "toolResult") {
102
103
  const step = pendingTools.get(m.toolCallId ?? "");
103
104
  if (step) {
104
- step.output = textOf(m.content);
105
+ // Capped where the transcript is rebuilt, not where it is rendered: a
106
+ // long session's tool results are megabytes nobody ever sees.
107
+ const output = textOf(m.content);
108
+ step.output = output.length > MAX_STEP_OUTPUT ? output.slice(0, MAX_STEP_OUTPUT) + "…" : output;
105
109
  step.isError = m.isError ?? false;
110
+ step.done = true;
106
111
  pendingTools.delete(m.toolCallId ?? "");
107
112
  }
108
113
  continue;
package/dist/agent/pi.js CHANGED
@@ -203,6 +203,11 @@ export class PiAgentFactory {
203
203
  }
204
204
  /** One runtime for the whole process; catalogs are global, not per session. */
205
205
  catalog;
206
+ /** Where each listed session lives. `listAll` reads the head of every
207
+ * session file on disk (~250ms at 200 sessions, and it only grows), which
208
+ * `resume` paid on every cold open — web selection, an IM message, a task
209
+ * run. The sidebar's own listing keeps this warm; a miss still lists. */
210
+ located = new Map();
206
211
  refreshQueue = Promise.resolve();
207
212
  builtinProviderIds;
208
213
  /** Structural fit: CredentialStore mirrors pi-ai's interface of the same
@@ -414,7 +419,7 @@ export class PiAgentFactory {
414
419
  return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
415
420
  }
416
421
  async fork(sourceSessionId, opts) {
417
- const infos = await SessionManager.listAll();
422
+ const infos = await this.listed();
418
423
  const source = infos.find((session) => session.id === sourceSessionId);
419
424
  if (!source)
420
425
  throw new Error(`unknown session: ${sourceSessionId}`);
@@ -433,14 +438,34 @@ export class PiAgentFactory {
433
438
  return this.open(opts.cwd, manager, opts);
434
439
  }
435
440
  async resume(sessionId) {
436
- const infos = await SessionManager.listAll();
441
+ const known = this.located.get(sessionId);
442
+ if (known) {
443
+ try {
444
+ return await this.open(known.cwd, SessionManager.open(known.path));
445
+ }
446
+ catch (err) {
447
+ // The file moved or went away under us: the cache was the only thing
448
+ // that claimed otherwise, so drop it and take the slow, true path.
449
+ log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
450
+ this.located.delete(sessionId);
451
+ }
452
+ }
453
+ const infos = await this.listed();
437
454
  const info = infos.find((s) => s.id === sessionId);
438
455
  if (!info)
439
456
  throw new Error(`unknown session: ${sessionId}`);
440
457
  return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
441
458
  }
442
- async list() {
459
+ /** Every listing goes through here, so it also refreshes `located`. */
460
+ async listed() {
443
461
  const infos = await SessionManager.listAll();
462
+ for (const s of infos) {
463
+ this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
464
+ }
465
+ return infos;
466
+ }
467
+ async list() {
468
+ const infos = await this.listed();
444
469
  return infos.map((s) => ({
445
470
  id: s.id,
446
471
  cwd: s.cwd,
@@ -26,3 +26,37 @@ export function chunkText(text, max) {
26
26
  parts.push(rest);
27
27
  return parts;
28
28
  }
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
+ */
41
+ export function balanceFences(parts) {
42
+ /** Backticks of the fence currently open across the boundary; 0 = closed. */
43
+ let open = 0;
44
+ const fence = (n) => "`".repeat(n);
45
+ return parts.map((part) => {
46
+ 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.
50
+ open = 0;
51
+ for (const line of reopened.split("\n")) {
52
+ const run = /^\s*(`{3,})/.exec(line)?.[1]?.length ?? 0;
53
+ if (!run)
54
+ continue;
55
+ if (!open)
56
+ open = run;
57
+ else if (run >= open)
58
+ open = 0; // a closing run must match the opener
59
+ }
60
+ return open ? `${reopened}\n${fence(open)}` : reopened;
61
+ });
62
+ }
@@ -5,21 +5,15 @@
5
5
  // prompt and must not become a second seam. So the channel layer — which owns
6
6
  // the router already — hands adapters this narrow, platform-blind interface.
7
7
  // Everything here is a thin wrapper over core; no policy lives in it.
8
- import { parseConversation as parseSlack } from "./slack.js";
9
- import { parseConversation as parseTelegram } from "./telegram.js";
8
+ import { chatOf, isChannelPlatform } from "./types.js";
10
9
  export function createControl({ router, factory, conversations, store }) {
11
10
  const launchFor = (key) => {
12
- const platform = key.channelId;
13
- // Decoding a conversation id back to a chat id is the adapter layer's
14
- // business, never core's — so each platform's own parser is used here.
15
- const chatId = platform === "telegram"
16
- ? parseTelegram(key.conversationId).chatId
17
- : platform === "slack"
18
- ? parseSlack(key.conversationId).channel
19
- : undefined;
20
- if (chatId === undefined)
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
+ if (!isChannelPlatform(key.channelId))
21
15
  return {};
22
- const policy = store.policy(platform, chatId);
16
+ const policy = store.policy(key.channelId, chatOf(key.conversationId));
23
17
  return {
24
18
  cwd: policy.cwd || undefined,
25
19
  model: policy.model ?? undefined,
@@ -0,0 +1,45 @@
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
+ export class Dedup {
8
+ log;
9
+ ttlMs;
10
+ max;
11
+ seen = new Map();
12
+ constructor(log, ttlMs, max) {
13
+ this.log = log;
14
+ this.ttlMs = ttlMs;
15
+ this.max = max;
16
+ }
17
+ /** True when this id was already delivered inside the TTL. */
18
+ duplicate(eventId, now = Date.now()) {
19
+ if (!eventId)
20
+ return false;
21
+ if (this.seen.size >= this.max) {
22
+ for (const [id, at] of this.seen) {
23
+ if (now - at > this.ttlMs)
24
+ this.seen.delete(id);
25
+ }
26
+ // Pruning expired entries is not enough under a burst of live ones —
27
+ // `max` must be a real bound, so the oldest entries go next. The cost
28
+ // is a forgotten id under extreme load (a redelivery slips through,
29
+ // which downstream handling tolerates); unbounded memory is worse.
30
+ // Map iterates in insertion order, so the front is the oldest.
31
+ for (const [id] of this.seen) {
32
+ if (this.seen.size < this.max)
33
+ break;
34
+ this.seen.delete(id);
35
+ }
36
+ }
37
+ const at = this.seen.get(eventId);
38
+ if (at !== undefined && now - at <= this.ttlMs) {
39
+ this.log(`duplicate event ${eventId} ignored`);
40
+ return true;
41
+ }
42
+ this.seen.set(eventId, now);
43
+ return false;
44
+ }
45
+ }
@@ -0,0 +1,233 @@
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).
21
+ import * as Lark from "@larksuiteoapi/node-sdk";
22
+ /** Lark answers HTTP 200 with a business code; non-zero is the real error. */
23
+ function ok(what, res) {
24
+ if (res.code !== 0)
25
+ throw new Error(`lark ${what}: ${res.code} ${res.msg ?? ""}`.trim());
26
+ return res;
27
+ }
28
+ export class LarkApi {
29
+ log;
30
+ client;
31
+ /** Our own open_id, remembered from botOpenId() — reaction removal must
32
+ * only ever touch a reaction *this* app made. */
33
+ me = "";
34
+ constructor(appId, appSecret, log = () => { }) {
35
+ this.log = log;
36
+ this.client = new Lark.Client({
37
+ appId,
38
+ appSecret,
39
+ domain: Lark.Domain.Feishu,
40
+ loggerLevel: Lark.LoggerLevel.error,
41
+ });
42
+ this.appId = appId;
43
+ this.appSecret = appSecret;
44
+ }
45
+ appId;
46
+ appSecret;
47
+ async botOpenId() {
48
+ const res = await this.client.request({
49
+ url: "/open-apis/bot/v3/info",
50
+ method: "GET",
51
+ });
52
+ this.me = ok("bot info", res).bot?.open_id ?? "";
53
+ return this.me;
54
+ }
55
+ // --- 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
+ */
63
+ connect(handlers) {
64
+ const dispatcher = new Lark.EventDispatcher({
65
+ loggerLevel: Lark.LoggerLevel.error,
66
+ }).register({
67
+ "im.message.receive_v1": (data) => {
68
+ handlers.onMessage({
69
+ eventId: data.event_id,
70
+ senderId: data.sender?.sender_id?.open_id,
71
+ senderType: data.sender?.sender_type,
72
+ message: {
73
+ messageId: data.message.message_id,
74
+ rootId: data.message.root_id,
75
+ chatId: data.message.chat_id,
76
+ chatType: data.message.chat_type,
77
+ messageType: data.message.message_type,
78
+ content: data.message.content,
79
+ mentions: data.message.mentions,
80
+ },
81
+ });
82
+ // Resolve now: the SDK answers the frame only after this returns, and
83
+ // a turn outlives Lark's redelivery deadline.
84
+ return Promise.resolve();
85
+ },
86
+ "card.action.trigger": (data) => {
87
+ handlers.onCardAction({
88
+ eventId: data.event_id,
89
+ messageId: data.context?.open_message_id ?? data.open_message_id ?? "",
90
+ chatId: data.context?.open_chat_id ?? data.open_chat_id ?? "",
91
+ operatorId: data.operator?.open_id ?? "",
92
+ value: data.action?.value,
93
+ name: data.action?.name,
94
+ formValue: data.action?.form_value,
95
+ });
96
+ return Promise.resolve();
97
+ },
98
+ });
99
+ const ws = new Lark.WSClient({
100
+ appId: this.appId,
101
+ appSecret: this.appSecret,
102
+ domain: Lark.Domain.Feishu,
103
+ loggerLevel: Lark.LoggerLevel.error,
104
+ });
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.
111
+ void ws.start({ eventDispatcher: dispatcher })
112
+ .catch((err) => this.log(`lark long connection failed: ${String(err)}`));
113
+ return Promise.resolve({
114
+ close: () => {
115
+ try {
116
+ ws.close();
117
+ }
118
+ catch (err) {
119
+ this.log(`lark socket close failed: ${String(err)}`);
120
+ }
121
+ return Promise.resolve();
122
+ },
123
+ });
124
+ }
125
+ // --- messages ------------------------------------------------------------------
126
+ async replyCard(messageId, card) {
127
+ const res = await this.client.im.v1.message.reply({
128
+ path: { message_id: messageId },
129
+ data: {
130
+ msg_type: "interactive",
131
+ 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.
134
+ reply_in_thread: true,
135
+ },
136
+ });
137
+ return { messageId: ok("message.reply", res).data?.message_id ?? "" };
138
+ }
139
+ async patchCard(messageId, card) {
140
+ ok("message.patch", await this.client.im.v1.message.patch({
141
+ path: { message_id: messageId },
142
+ data: { content: JSON.stringify(card) },
143
+ }));
144
+ }
145
+ async deleteMessage(messageId) {
146
+ ok("message.delete", await this.client.im.v1.message.delete({ path: { message_id: messageId } }));
147
+ }
148
+ // --- reactions -------------------------------------------------------------------
149
+ async addReaction(messageId, emojiType) {
150
+ ok("reaction.create", await this.client.im.v1.messageReaction.create({
151
+ path: { message_id: messageId },
152
+ data: { reaction_type: { emoji_type: emojiType } },
153
+ }));
154
+ }
155
+ /**
156
+ * Lark deletes reactions by `reaction_id`, and several parties may have
157
+ * used the same emoji — so list, keep only the entry *this app* owns
158
+ * (`operator_type` alone is not ownership: another bot's 👀 is an app
159
+ * reaction too, and deleting it would strand our own), delete that.
160
+ */
161
+ async removeReaction(messageId, emojiType) {
162
+ let pageToken;
163
+ do {
164
+ const res = ok("reaction.list", await this.client.im.v1.messageReaction.list({
165
+ path: { message_id: messageId },
166
+ params: { reaction_type: emojiType, page_size: 50, page_token: pageToken },
167
+ }));
168
+ for (const item of res.data?.items ?? []) {
169
+ const op = item.operator;
170
+ if (op?.operator_type !== "app")
171
+ continue;
172
+ // The list reports an app operator by open_id or app_id depending on
173
+ // surface; accept either of ours, never a blank (avibe's rule).
174
+ const id = (op.operator_id ?? "").trim();
175
+ if (!id || (id !== this.me && id !== this.appId))
176
+ continue;
177
+ if (item.reaction_id) {
178
+ ok("reaction.delete", await this.client.im.v1.messageReaction.delete({
179
+ path: { message_id: messageId, reaction_id: item.reaction_id },
180
+ }));
181
+ return;
182
+ }
183
+ }
184
+ pageToken = res.data?.has_more ? res.data?.page_token : undefined;
185
+ } while (pageToken);
186
+ }
187
+ // --- lookups ---------------------------------------------------------------------
188
+ async chatName(chatId) {
189
+ const res = ok("chat.get", await this.client.im.v1.chat.get({ path: { chat_id: chatId } }));
190
+ return res.data?.name || undefined;
191
+ }
192
+ async userName(openId) {
193
+ // Needs contact:user.base:readonly; without it the id is the honest label.
194
+ try {
195
+ const res = ok("user.get", await this.client.contact.v3.user.get({
196
+ path: { user_id: openId },
197
+ params: { user_id_type: "open_id" },
198
+ }));
199
+ return res.data?.user?.name || openId;
200
+ }
201
+ catch (err) {
202
+ this.log(`lark user lookup failed for ${openId}: ${String(err)}`);
203
+ return openId;
204
+ }
205
+ }
206
+ // --- files ----------------------------------------------------------------------
207
+ /**
208
+ * `maxBytes` is enforced *while streaming*: the receive event often omits
209
+ * `file_size`, so the metadata check upstream cannot be the only cap, and
210
+ * buffering an unbounded stream whole into memory is the exact failure the
211
+ * cap exists for. The error message carries "too large" — the adapter's
212
+ * lost-marker wording keys on it.
213
+ */
214
+ async download(messageId, fileKey, type, maxBytes) {
215
+ const res = await this.client.im.v1.messageResource.get({
216
+ path: { message_id: messageId, file_key: fileKey },
217
+ params: { type },
218
+ });
219
+ const stream = res.getReadableStream();
220
+ const parts = [];
221
+ let size = 0;
222
+ for await (const part of stream) {
223
+ const buf = Buffer.isBuffer(part) ? part : Buffer.from(part);
224
+ size += buf.length;
225
+ if (size > maxBytes) {
226
+ stream.destroy?.();
227
+ throw new Error(`lark resource ${fileKey}: too large (>${maxBytes} bytes)`);
228
+ }
229
+ parts.push(buf);
230
+ }
231
+ return { bytes: new Uint8Array(Buffer.concat(parts)) };
232
+ }
233
+ }
@@ -0,0 +1,101 @@
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.
8
+ import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
9
+ import { button, buttonRow, card, chunk, footer, LARK_MAX, markdown, OFFER_PREFIX, withFooter, withoutButtons, } from "./lark-render.js";
10
+ /** How many sent cards the retire cache remembers (avibe keeps 200). */
11
+ const SENT_CACHE = 200;
12
+ export class LarkOutbound {
13
+ api;
14
+ log;
15
+ /**
16
+ * The cards this process sent with buttons on them, for retiring the row
17
+ * once an option is taken — a 2.0 card cannot be read back from the
18
+ * platform (see LarkActionValue), so what we sent is the only copy.
19
+ * In-memory and bounded, copied from avibe: purely cosmetic state. A click
20
+ * after a restart still *works* (the label rides in the value); the buttons
21
+ * merely stay up, and the skip is logged.
22
+ */
23
+ sent = new Map();
24
+ constructor(api, log) {
25
+ this.api = api;
26
+ this.log = log;
27
+ }
28
+ /**
29
+ * Post one turn as replies into the conversation's thread, empty text
30
+ * included: a turn that produced nothing still posts its footer and says
31
+ * which kind of nothing it was — total silence is indistinguishable from a
32
+ * crash, and the person watching the 👀 come off has no way to tell.
33
+ *
34
+ * The footer folds into the last body chunk's own markdown element (a
35
+ * second element renders a blank gap); only a bodiless turn gets it as the
36
+ * standalone muted element. Buttons ride the last chunk, like every other
37
+ * platform — the card is remembered so retire() can rebuild it without them.
38
+ */
39
+ async reply(root, reply) {
40
+ const text = reply.text.trim();
41
+ const meta = reply.meta ? formatTurnMeta(reply.meta) : "";
42
+ const quiet = isSilentReply(reply) ? quietLabel(reply.silence) : "";
43
+ const note = [quiet, meta].filter(Boolean).join(" · ");
44
+ if (!(text || reply.suggestions.length || note))
45
+ return;
46
+ const row = reply.suggestions.length
47
+ ? buttonRow(reply.suggestions.map((label, index) => button(label, { key: `${OFFER_PREFIX}${index}`, root, label })))
48
+ : undefined;
49
+ const parts = text ? chunk(text, LARK_MAX) : [""];
50
+ for (const [i, part] of parts.entries()) {
51
+ const last = i === parts.length - 1;
52
+ const elements = [];
53
+ if (part)
54
+ elements.push(last && note ? withFooter(part, note) : markdown(part));
55
+ else if (last && note)
56
+ elements.push(footer(note));
57
+ if (last && row)
58
+ elements.push(row);
59
+ if (!elements.length)
60
+ continue;
61
+ const { messageId } = await this.api.replyCard(root, card(elements));
62
+ if (last && row && messageId)
63
+ this.remember(messageId, card(elements));
64
+ }
65
+ }
66
+ /**
67
+ * Take the buttons off a card one option was just taken from — the rest
68
+ * answer a question the conversation has moved past. Best-effort by design:
69
+ * an unremembered card (sent before a restart) keeps its row, logged.
70
+ */
71
+ async retire(messageId) {
72
+ const known = this.sent.get(messageId);
73
+ if (!known) {
74
+ this.log(`options on ${messageId} not retired: sent before this process`);
75
+ return;
76
+ }
77
+ this.sent.delete(messageId);
78
+ const kept = withoutButtons(known);
79
+ await this.api.patchCard(messageId, kept.body.elements.length ? kept : card([footer("Option taken.")])).catch((err) => this.log(`retiring options failed: ${String(err)}`));
80
+ }
81
+ remember(messageId, sent) {
82
+ this.sent.set(messageId, sent);
83
+ while (this.sent.size > SENT_CACHE) {
84
+ const oldest = this.sent.keys().next().value;
85
+ if (oldest === undefined)
86
+ break;
87
+ this.sent.delete(oldest);
88
+ }
89
+ }
90
+ /**
91
+ * A system note: quoted, labelled with where it came from, and deliberately
92
+ * plain — no buttons and no turn footer, because the turn this input
93
+ * triggers has not ended yet.
94
+ */
95
+ async note(root, note) {
96
+ const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
97
+ for (const part of chunk(`*${originLabel(note.origin)}*\n${body}`, LARK_MAX)) {
98
+ await this.api.replyCard(root, card([markdown(part)]));
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,95 @@
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.
10
+ import { button as cardButton, buttonRow, card, footer, formInput, markdown, } from "./lark-render.js";
11
+ import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
12
+ /** A form-submit button name: `cwdgo:<thread root>`. */
13
+ export const CWD_SUBMIT_PREFIX = "cwdgo:";
14
+ /** The form input's field name, the key `form_value` answers under. */
15
+ export const CWD_FIELD = "cwd";
16
+ export class LarkPanel extends ChatPanel {
17
+ deps;
18
+ platform = "lark";
19
+ fence = ["`", "`"];
20
+ constructor(deps) {
21
+ super(deps);
22
+ this.deps = deps;
23
+ }
24
+ /** Lark's markdown treats what it cannot parse as literal text; there is no
25
+ * escape syntax to apply (see lark-render.ts). */
26
+ esc(text) {
27
+ return text;
28
+ }
29
+ // --- rendering -------------------------------------------------------------
30
+ btn(b, root) {
31
+ return cardButton(b.label, { key: `${PANEL_PREFIX}${b.action}`, root });
32
+ }
33
+ render(view, root, note) {
34
+ const elements = [
35
+ ...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
+ ...(view.picks?.length ? [buttonRow(view.picks.map((p) => this.btn(p, root)))] : []),
38
+ ...view.rows.filter((r) => r.length).map((row) => buttonRow(row.map((b) => this.btn(b, root)))),
39
+ ];
40
+ if (note)
41
+ elements.push(footer(note));
42
+ return card(elements);
43
+ }
44
+ /** Open a fresh panel, replacing whichever one this conversation had. */
45
+ async open(key, chatId, root) {
46
+ const sent = await this.deps.api.replyCard(root, this.render(await this.view(key, chatId), root));
47
+ this.remember(key, { chatId, root, messageId: sent.messageId, models: [] });
48
+ }
49
+ async draw(state, view, note) {
50
+ await this.deps.api.patchCard(state.messageId, this.render(view, state.root, note))
51
+ .catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
52
+ }
53
+ async erase(state) {
54
+ await this.deps.api.deleteMessage(state.messageId)
55
+ .catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
56
+ }
57
+ // --- 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
+ */
62
+ async onAction(action, key, payload, root) {
63
+ return this.dispatch(key, payload, action, () => this.open(key, action.chatId, root));
64
+ }
65
+ // --- working directory (one typed answer, in a form card) -------------------
66
+ async promptCwd(key, state, _action) {
67
+ const form = {
68
+ tag: "form",
69
+ name: "cwd_form",
70
+ elements: [
71
+ formInput(CWD_FIELD, "Working directory", CWD_PLACEHOLDER),
72
+ markdown(`An absolute path. ${CWD_TAIL}`),
73
+ {
74
+ tag: "button",
75
+ text: { tag: "plain_text", content: "Start" },
76
+ type: "primary",
77
+ action_type: "form_submit",
78
+ name: `${CWD_SUBMIT_PREFIX}${state.root}`,
79
+ },
80
+ ],
81
+ };
82
+ 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
+ }
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
+ */
89
+ async onCwdSubmit(key, action, root) {
90
+ if (!this.state(key)) {
91
+ this.remember(key, { chatId: action.chatId, root, messageId: action.messageId, models: [] });
92
+ }
93
+ await this.startSessionIn(key, String(action.formValue?.[CWD_FIELD] ?? "").trim());
94
+ }
95
+ }