@timqi/pier 0.1.14 → 0.2.0

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 (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -1,13 +1,16 @@
1
1
  // Lark (Feishu) adapter: normalize long-connection events, render outbound
2
- // turns as cards. As on Slack, Pier never posts into a chat's main flow: a
3
- // conversation is `<chatId>/<rootMessageId>` (`reply_in_thread`; DMs thread
4
- // too). Lark-specific: `content` is a double-encoded JSON string and a mention
2
+ // turns as cards. As on Slack, Pier never posts into a chat's main flow but the
3
+ // home chat's (docs/design/11-im-conversation.md), which is one conversation
4
+ // keyed `<chatId>`: any other is `<chatId>/<rootMessageId>` (`reply_in_thread`;
5
+ // DMs thread too). Lark-specific: `content` is a double-encoded JSON string and a mention
5
6
  // is a `@_user_N` placeholder resolved through `mentions[]`; 👀 is the reaction
6
7
  // key `OnIt`, removed by reaction_id; a card callback does not say which thread
7
8
  // its message lives in, so every button value carries the root; delivery is
8
9
  // at-least-once and acked when the handler returns, so handlers queue and return.
10
+ import { isChatCommand } from "../core/types.js";
9
11
  import { saveInboundAll } from "../core/inbox.js";
10
12
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
13
+ import { skillsText } from "../core/chain.js";
11
14
  import { awaitsTurn } from "../core/reply.js";
12
15
  import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
13
16
  import { logger } from "../log.js";
@@ -31,7 +34,7 @@ const DEDUP_TTL_MS = 5 * 60_000;
31
34
  const DEDUP_MAX = 2000;
32
35
  /** The only definition of the conversation id format; control.ts decodes with it. */
33
36
  const conversationId = (chatId, root) => `${chatId}/${root}`;
34
- export const parseConversation = (id) => {
37
+ const parseConversation = (id) => {
35
38
  const at = id.indexOf("/");
36
39
  return at < 0
37
40
  ? { chatId: id, root: "" }
@@ -75,13 +78,8 @@ export class LarkChannel {
75
78
  ? this.api.addReaction(messageId, emoji)
76
79
  : this.api.removeReaction(messageId, WORKING),
77
80
  }, deps.receipts ?? new ReceiptLedger("lark"), this.log, WORKING, RECEIPT_STALE_MS, (conversationId) => deps.control?.working({ channelId: this.id, conversationId }) ?? false);
78
- if (deps.control && deps.handoff) {
79
- this.panel = new LarkPanel({
80
- api: this.api,
81
- control: deps.control,
82
- handoff: deps.handoff,
83
- log: this.log,
84
- });
81
+ if (deps.control) {
82
+ this.panel = new LarkPanel({ api: this.api, control: deps.control, log: this.log });
85
83
  }
86
84
  }
87
85
  async start(onMessage) {
@@ -162,13 +160,15 @@ export class LarkChannel {
162
160
  const text = raw.trim();
163
161
  const command = parseCommand(text);
164
162
  const root = threadOf(msg);
165
- const here = { channelId: this.id, conversationId: conversationId(msg.chatId, root) };
163
+ // Every message in the home chat, in a topic or not, is the head's, under one key.
164
+ const home = this.isHome(msg.chatId);
165
+ const here = { channelId: this.id, conversationId: home ? msg.chatId : conversationId(msg.chatId, root) };
166
166
  const bindRequest = command?.name === "bind" && isDm;
167
167
  const admitted = this.gate.admit("message", msg.chatId, {
168
168
  isDm,
169
169
  // Mentioned, or continuing a topic Pier already owns — durable, so it
170
170
  // holds after a restart.
171
- addressed: mentioned || (!!msg.rootId && !!this.deps.control?.knows(here)),
171
+ addressed: mentioned || (!home && !!msg.rootId && !!this.deps.control?.knows(here)),
172
172
  userId: senderId,
173
173
  bindRequest,
174
174
  });
@@ -179,22 +179,29 @@ export class LarkChannel {
179
179
  }
180
180
  if (bindRequest)
181
181
  return this.bind(senderId, msg.messageId, command?.args ?? "");
182
- if (command?.name === "stop")
182
+ // The head has no panel and takes `/stop` and `/skills` as chat commands (core/chain.ts).
183
+ if (!text && !attachments.length)
184
+ return this.log(`empty message in ${here.conversationId}, dropped`);
185
+ if (!home && command?.name === "stop")
183
186
  return this.abortTurn(here, msg.messageId);
187
+ if (!home && command?.name === "skills")
188
+ return this.listSkills(here);
184
189
  // Downloading only past the gate: an unauthorized sender must not make the
185
190
  // bot pull bytes on their behalf.
186
191
  const markers = await this.saveAttachments(msg.messageId, attachments);
187
- // A bare `@bot` and `/settings` are the same request; `s <text>` drafts a
188
- // session, so only where this message would start one: outside any topic.
189
- // The held question carries its markers, so Start sends what the user sent.
192
+ // `/s <text>` drafts a session, so only where this message would start one:
193
+ // outside any topic. The held question carries its markers, so Start sends what the user sent.
190
194
  const question = msg.rootId ? undefined : settingsDraft(text);
191
- if (this.panel && (question || command?.name === "settings" || (!text && !attachments.length && mentioned))) {
195
+ if (this.panel && !home && (question || command?.name === "settings")) {
192
196
  return this.panel.open(here, root, question && [question, ...markers].join("\n"));
193
197
  }
194
198
  // Resolved before the mark: any await between mark() and dispatch is a
195
199
  // window in which a previous turn can settle and take this receipt with it.
196
200
  const sender = { id: senderId, name: await this.userName(senderId) };
197
- this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
201
+ // The head answers a chat command with a note, not a turn: nothing would take a 👀 off it.
202
+ const answered = home && command && !command.args && isChatCommand(command.name);
203
+ if (!answered)
204
+ this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
198
205
  // Steer: a follow-up is the wrong default when the human is watching a 👀.
199
206
  onMessage({
200
207
  key: here,
@@ -301,14 +308,18 @@ export class LarkChannel {
301
308
  ? action.name.slice(CWD_SUBMIT_PREFIX.length)
302
309
  : "";
303
310
  const root = action.value?.root ?? formRoot;
304
- if (!root) {
311
+ // Any tap in the home chat is the head's, echoed where the card was;
312
+ // anywhere else only a topic's card is Pier's.
313
+ const home = this.isHome(action.chatId);
314
+ if (!root && !(home && payload.startsWith(OFFER_PREFIX))) {
305
315
  this.log(`card action without a thread root in ${action.chatId}, dropped`);
306
316
  return;
307
317
  }
308
318
  const key = {
309
319
  channelId: this.id,
310
- conversationId: conversationId(action.chatId, root),
320
+ conversationId: home ? action.chatId : conversationId(action.chatId, root),
311
321
  };
322
+ const to = root ? { root } : { chatId: action.chatId };
312
323
  const admitted = this.gate.admit("action", action.chatId, {
313
324
  isDm: this.deps.store.chat("lark", action.chatId)?.kind === "dm",
314
325
  addressed: true, // clicking the bot's own button is addressing it
@@ -316,11 +327,12 @@ export class LarkChannel {
316
327
  });
317
328
  if (!admitted)
318
329
  return;
319
- if (formRoot && action.formValue) {
330
+ // The head has no panel: a card left in the chat before it became the home is stale.
331
+ if (!home && formRoot && action.formValue) {
320
332
  await this.panel?.onCwdSubmit(key, action, root);
321
333
  return;
322
334
  }
323
- if (payload.startsWith(PANEL_PREFIX)) {
335
+ if (!home && payload.startsWith(PANEL_PREFIX)) {
324
336
  // Start's question: the card is the message the tap was on, so it carries the 👀.
325
337
  const run = (text) => this.deliver(key, action, action.messageId, text, onMessage);
326
338
  if (!(await this.panel?.onAction(action, key, payload, root, run))) {
@@ -336,14 +348,14 @@ export class LarkChannel {
336
348
  : undefined;
337
349
  if (label === undefined) {
338
350
  this.log(`unknown or stale action ${payload} in chat ${action.chatId}`);
339
- await this.api.replyCard(root, card([markdown(STALE_OPTION)]))
351
+ await this.out.post(to, card([markdown(STALE_OPTION)]))
340
352
  .catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
341
353
  return;
342
354
  }
343
355
  // A bot cannot post as the user, so the pick is echoed: otherwise the
344
356
  // topic shows an answer to a request nobody can see, with nothing to carry the eyes.
345
357
  await this.out.retire(action.messageId);
346
- const echo = await this.api.replyCard(root, card([markdown(picked(label))]))
358
+ const echo = await this.out.post(to, card([markdown(picked(label))]))
347
359
  .catch((err) => {
348
360
  this.log(`option echo failed: ${String(err)}`);
349
361
  return undefined;
@@ -363,6 +375,11 @@ export class LarkChannel {
363
375
  await this.deps.control?.abort(key);
364
376
  await this.api.replyCard(messageId, card([markdown(STOPPED)]));
365
377
  }
378
+ /** The head's `/skills` is MainChain's; a thread has no chain, so it is answered here. */
379
+ async listSkills(key) {
380
+ const text = skillsText((await this.deps.control?.skills(key)) ?? []);
381
+ await this.notify(key.conversationId, { text, origin: { kind: "chat-command", command: "skills" } });
382
+ }
366
383
  // --- bind ------------------------------------------------------------------
367
384
  /** Groups stay silent, but a DM that swallows every message looks broken
368
385
  * rather than locked. */
@@ -378,6 +395,16 @@ export class LarkChannel {
378
395
  await this.api.replyCard(messageId, card([markdown(bindResult(outcome, name))]));
379
396
  }
380
397
  // --- lookups ---------------------------------------------------------------
398
+ isHome(chatId) {
399
+ return this.deps.control?.isHome({ channelId: this.id, conversationId: chatId }) ?? false;
400
+ }
401
+ /** A topic, or the home chat's main flow; undefined for any other chat's. */
402
+ target(conversation) {
403
+ const { chatId, root } = parseConversation(conversation);
404
+ if (root)
405
+ return { root };
406
+ return this.isHome(chatId) ? { chatId } : undefined;
407
+ }
381
408
  async userName(openId) {
382
409
  const hit = this.names.get(openId);
383
410
  if (hit)
@@ -400,33 +427,28 @@ export class LarkChannel {
400
427
  }
401
428
  // --- outbound --------------------------------------------------------------
402
429
  async send(conversation, reply) {
403
- const { root } = parseConversation(conversation);
404
- // No root is a foreign id; posting it would put a turn in the chat's main
405
- // flow. Refused loudly, receipts still cleared.
406
- if (!root) {
430
+ const to = this.target(conversation);
431
+ // No root outside the home chat is a foreign id; posting it would put a
432
+ // turn in the chat's main flow. Refused loudly, receipts still cleared.
433
+ if (!to) {
407
434
  this.log(`refusing to answer ${conversation}: no thread root in the conversation id`);
408
435
  await this.receipts.settle(conversation);
409
436
  return;
410
437
  }
411
438
  // The turn ended either way; a 👀 left up by a failed send looks like work.
412
- await this.receipts.settleAfter(conversation, () => this.out.reply(root, reply), reply.meta);
413
- }
414
- /** A web session's topic: the root is the one card Pier posts into a
415
- * chat's main flow (channels/handoff.ts). */
416
- async openThread(chatId, note) {
417
- return conversationId(chatId, await this.out.open(chatId, note));
439
+ await this.receipts.settleAfter(conversation, () => this.out.reply(to, reply), reply.meta);
418
440
  }
419
441
  /** The 👀 goes on the note itself: the turn it triggers has no message of
420
442
  * the user's to carry them. */
421
443
  async notify(conversation, note) {
422
- const { chatId, root } = parseConversation(conversation);
423
- if (!root) {
444
+ const to = this.target(conversation);
445
+ if (!to) {
424
446
  this.log(`refusing to post a system note to ${conversation}: no thread root in the conversation id`);
425
447
  return;
426
448
  }
427
- const messageId = await this.out.note(root, note);
449
+ const messageId = await this.out.note(to, note);
428
450
  if (messageId && awaitsTurn(note.origin)) {
429
- this.receipts.mark(conversation, chatId, messageId, note.at);
451
+ this.receipts.mark(conversation, parseConversation(conversation).chatId, messageId, note.at);
430
452
  }
431
453
  }
432
454
  }
@@ -1,5 +1,7 @@
1
1
  // What the shared control moments say — one spelling for both platforms. The
2
- // only legitimate variation is the spelling of the bind command, so it is the parameter.
2
+ // only legitimate variations are platform spellings (the bind command, a label's
3
+ // emphasis), so they are the parameters.
4
+ import { originLabel } from "../core/reply.js";
3
5
  export const bindHint = (command) => `You are not bound yet. Ask the operator for a bind code, then send ${command}.`;
4
6
  /** The name arrives escaped by the caller. */
5
7
  export const bindResult = (outcome, name) => {
@@ -15,3 +17,33 @@ export const STOPPED = "\u23f9 Stopped.";
15
17
  export const picked = (label) => `\u25b8 ${label}`;
16
18
  /** Said in the chat: the person clicked and would otherwise see nothing happen (§5). */
17
19
  export const STALE_OPTION = "⚠ That option is no longer available — please type the choice instead.";
20
+ /** What a chat window gets of a system input; a task callback carries up to
21
+ * 8000 characters of result text (tasks/callbacks.ts). */
22
+ const NOTE_CHARS = 200;
23
+ const NOTE_LINES = 4;
24
+ function digest(text) {
25
+ const body = text.trimEnd();
26
+ let head = body.split("\n").slice(0, NOTE_LINES).join("\n");
27
+ if (head.length > NOTE_CHARS) {
28
+ const capped = head.slice(0, NOTE_CHARS);
29
+ // A boundary before the midpoint loses more than the ragged edge costs.
30
+ const boundary = Math.max(capped.lastIndexOf("\n"), capped.lastIndexOf(" "));
31
+ head = capped.slice(0, boundary > NOTE_CHARS / 2 ? boundary : NOTE_CHARS);
32
+ }
33
+ const rest = body.slice(head.length).trim();
34
+ if (!rest)
35
+ return body;
36
+ const dropped = rest.split("\n").length;
37
+ return `${head.trimEnd()}\n… +${String(dropped)} more line${dropped === 1 ? "" : "s"}`;
38
+ }
39
+ /** The origin label in the platform's `emphasis` over the quoted body. A system
40
+ * input is context for the turn it precedes: pasted whole, a run result buries
41
+ * a chat that cannot collapse it, so it is digested (the hub keeps it all). An
42
+ * error is quoted whole, core already capped it; so is a chat command's
43
+ * answer, bounded and the reason it was asked. */
44
+ export function noteBody(note, emphasis) {
45
+ const whole = note.origin.kind === "error" || note.origin.kind === "chat-command";
46
+ const text = whole ? note.text : digest(note.text);
47
+ const body = text.split("\n").map((line) => `> ${line}`).join("\n");
48
+ return `${emphasis}${originLabel(note.origin)}${emphasis}\n${body}`;
49
+ }
@@ -4,54 +4,26 @@
4
4
  // without a session shows a draft — cwd, model, reasoning, a pending question —
5
5
  // that rides every button's value, so the card is the store and a restart
6
6
  // loses nothing.
7
- import { sessionLabel, splitSpeaker } from "../core/identity.js";
8
7
  import { splitInboundFiles } from "../core/inbound-file.js";
9
- import { compact, cut, splitReply, thinkingLabel } from "../core/reply.js";
8
+ import { compact, cut, thinkingLabel } from "../core/reply.js";
10
9
  import { isThinkingLevel, } from "../core/types.js";
11
10
  import { HAS_SESSION, NO_SESSION } from "./control.js";
12
- import { HandoffError } from "./handoff.js";
13
11
  export const PANEL_PREFIX = "cfg:";
14
12
  const PER_PAGE = 8;
15
- /** The picker's reach: five pages of the newest unbound sessions. */
16
- const SESSIONS_LISTED = 40;
17
- const TITLE_CHARS = 40;
18
13
  const QUESTION_CHARS = 80;
19
- /** Exchanges excerpted under the session, and the width of one line: four of
20
- * these stay far inside Slack's 3000-character section. */
21
- const RECENT_EXCHANGES = 2;
22
- const EXCERPT_CHARS = 150;
23
14
  /** Characters of the serialized draft — what a button actually carries. Slack
24
15
  * caps a value at 2000 and Lark a card at 30 KB; the rest of the 2000 is room
25
16
  * for the cwd, model and reasoning a pick adds after the question. */
26
17
  const DRAFT_CHARS = 1700;
27
18
  /** Pages whose every pick Pi's fixed-at-creation cwd or the row would refuse:
28
19
  * reachable only by tapping a card that gained a session since it was drawn. */
29
- const DRAFT_ONLY = new Set(["cwd", "cwdtype", "sessions", "session"]);
20
+ const DRAFT_ONLY = new Set(["cwd", "cwdtype"]);
30
21
  export const CWD_DRAFT_TAIL = "Start creates the session there.";
31
22
  export const CWD_PLACEHOLDER = "/path/to/project";
32
23
  export const QUESTION_TOO_LONG = "Your question is too long to hold — send it again after Start.";
33
24
  const btn = (label, action) => ({ label, action });
34
25
  /** A button label is the last segment; the numbered line above has the whole path. */
35
26
  const shortDir = (path) => path.split("/").filter(Boolean).at(-1) ?? path;
36
- /** One transcript line as a reader sees it: what was said, minus what was
37
- * written for the model (the speaker header, attachment markers, the
38
- * next-step block, a silent turn's reason). */
39
- const excerpt = (text, role) => {
40
- const said = role === "user" ? splitInboundFiles(splitSpeaker(text).text).text : splitReply(text).text;
41
- return cut(said.replace(/\s+/g, " ").trim(), EXCERPT_CHARS);
42
- };
43
- const shortTitle = (s) => cut(sessionLabel(s), TITLE_CHARS);
44
- /** Compact age, as the web sidebar spells it ("now", "12m", "3h", "2d"). */
45
- const age = (ts, now) => {
46
- const mins = Math.round((now - ts) / 60_000);
47
- if (mins < 1)
48
- return "now";
49
- if (mins < 60)
50
- return `${String(mins)}m`;
51
- if (mins < 1440)
52
- return `${String(Math.round(mins / 60))}h`;
53
- return `${String(Math.round(mins / 1440))}d`;
54
- };
55
27
  /** Page `page` of `items`, clamped: a stale Next past the end lands on the last page. */
56
28
  const paged = (items, page) => {
57
29
  const pages = Math.max(1, Math.ceil(items.length / PER_PAGE));
@@ -64,9 +36,9 @@ const pager = (action, at, pages) => [
64
36
  ...(at < pages - 1 ? [btn("Next ›", `${action}:${String(at + 1)}`)] : []),
65
37
  btn("‹ Back", "panel"),
66
38
  ];
67
- /** A refusal's sentence (a control refusal, a HandoffError) is the whole
68
- * answer; any other failure names what was attempted. */
69
- const failed = (what, err) => err instanceof HandoffError || (err instanceof Error && (err.message === NO_SESSION || err.message === HAS_SESSION))
39
+ /** A refusal's sentence (a control refusal) is the whole answer; any other
40
+ * failure names what was attempted. */
41
+ const failed = (what, err) => err instanceof Error && (err.message === NO_SESSION || err.message === HAS_SESSION)
70
42
  ? err.message
71
43
  : `${what}: ${String(err)}`;
72
44
  /** `undefined` when there is nothing to carry, so a with-session panel's buttons stay bare. */
@@ -124,8 +96,7 @@ export class ChatPanel {
124
96
  const status = await this.deps.control.status(key);
125
97
  if (!status)
126
98
  return this.draftView(key, state.draft);
127
- // The conversation is right above the card: no excerpt, and no way to a
128
- // second session — that is a new thread.
99
+ // The conversation is right above the card; a second session is a new thread.
129
100
  return {
130
101
  groups: [{ title: "Session", lines: this.sessionLines(status) }],
131
102
  rows: [[
@@ -148,7 +119,7 @@ export class ChatPanel {
148
119
  ],
149
120
  }],
150
121
  rows: [
151
- [btn("Model & reasoning", "pins:0"), btn("Directory…", "cwd"), btn("Continue web session…", "sessions:0")],
122
+ [btn("Model & reasoning", "pins:0"), btn("Directory…", "cwd")],
152
123
  [btn("Start", "start"), btn("Close", "close")],
153
124
  ],
154
125
  };
@@ -163,8 +134,8 @@ export class ChatPanel {
163
134
  };
164
135
  }
165
136
  sessionLines(status) {
166
- const usage = status.tokens !== null && status.contextWindow
167
- ? `${compact(status.tokens)}/${compact(status.contextWindow)} tok`
137
+ const usage = status.tokens !== null && status.compactAt
138
+ ? `${compact(status.tokens)}/${compact(status.compactAt)} tok`
168
139
  : "not measured yet";
169
140
  const fresh = status.empty && status.state === "idle";
170
141
  return [
@@ -186,21 +157,6 @@ export class ChatPanel {
186
157
  return { items: [], unavailable };
187
158
  }
188
159
  }
189
- /** An excerpt, not a summary: the last exchanges are what makes a session
190
- * one has been away from recognisable on a phone. */
191
- async recentGroups(key) {
192
- const { items: exchanges, unavailable } = await this.listed(this.deps.control.recent(key, RECENT_EXCHANGES), "Could not read the transcript");
193
- if (unavailable)
194
- return [{ title: "Recent", lines: [unavailable] }];
195
- const lines = exchanges.flatMap(({ user, assistant }) => {
196
- const reply = assistant === undefined ? "" : excerpt(assistant, "assistant");
197
- return [
198
- `▸ ${this.esc(excerpt(user, "user"))}`,
199
- ...(reply ? [`◂ ${this.esc(reply)}`] : []),
200
- ];
201
- });
202
- return lines.length ? [{ title: "Recent", lines }] : [];
203
- }
204
160
  async refresh(key, note) {
205
161
  const state = this.state(key);
206
162
  if (!state)
@@ -209,17 +165,12 @@ export class ChatPanel {
209
165
  }
210
166
  /** The card's last draw, once an operation completed: the session as it now
211
167
  * is, no button. The panel is released with it, so nothing can tap it again
212
- * and the card stays as a record; another @bot opens a fresh one. `recent`
213
- * excerpts the transcript — what tells a phone reader which conversation
214
- * was continued. */
215
- async settle(key, state, note, recent) {
168
+ * and the card stays as a record; another `/settings` opens a fresh one. */
169
+ async settle(key, state, note) {
216
170
  const status = await this.deps.control.status(key);
217
171
  if (!status)
218
172
  return this.refresh(key, note);
219
- await this.draw(state, {
220
- groups: [{ title: "Session", lines: this.sessionLines(status) }, ...recent ? await this.recentGroups(key) : []],
221
- rows: [],
222
- }, note);
173
+ await this.draw(state, { groups: [{ title: "Session", lines: this.sessionLines(status) }], rows: [] }, note);
223
174
  this.panels.delete(key.conversationId);
224
175
  }
225
176
  // --- actions -----------------------------------------------------------------
@@ -254,12 +205,6 @@ export class ChatPanel {
254
205
  case "pin":
255
206
  await this.pickPin(key, Number(arg));
256
207
  return true;
257
- case "sessions":
258
- await this.showSessions(key, Number(arg) || 0);
259
- return true;
260
- case "session":
261
- await this.pickSession(key, Number(arg));
262
- return true;
263
208
  case "cwd":
264
209
  if (arg)
265
210
  await this.pickDir(key, Number(arg));
@@ -274,7 +219,7 @@ export class ChatPanel {
274
219
  return true;
275
220
  case "stop":
276
221
  await this.deps.control.abort(key);
277
- await this.settle(key, state, "Stop requested.", false);
222
+ await this.settle(key, state, "Stop requested.");
278
223
  return true;
279
224
  default:
280
225
  this.deps.log(`unknown panel action: ${action}`);
@@ -299,7 +244,7 @@ export class ChatPanel {
299
244
  ? slice.map((pin, i) => {
300
245
  const ticked = current.model?.provider === pin.provider && current.model.id === pin.id
301
246
  && current.thinking === pin.thinking;
302
- return `${String(from + i + 1)}. ${ticked ? "✓ " : ""}${this.esc(pin.id)} · ${thinkingLabel(pin.thinking)}${pin.note ? ` — ${this.esc(pin.note)}` : ""}`;
247
+ return `${String(from + i + 1)}. ${ticked ? "✓ " : ""}${this.esc(pin.id)} · ${thinkingLabel(pin.thinking)}`;
303
248
  })
304
249
  : ["No pinned models — Settings → Models → Model menu."],
305
250
  }],
@@ -308,41 +253,6 @@ export class ChatPanel {
308
253
  rows: [pager("pins", at, pages)],
309
254
  });
310
255
  }
311
- async showSessions(key, page) {
312
- const state = this.state(key);
313
- if (!state)
314
- return;
315
- const { items, unavailable } = await this.listed(this.deps.handoff.unbound(SESSIONS_LISTED), "Could not list sessions");
316
- state.sessions = items;
317
- const { at, pages, slice, from } = paged(state.sessions, page);
318
- const now = Date.now();
319
- await this.draw(state, {
320
- groups: [{
321
- title: "Continue web session",
322
- suffix: ` · page ${at + 1}/${pages}`,
323
- lines: slice.length
324
- ? slice.map((s, i) => `${String(from + i + 1)}. ${this.esc(shortTitle(s))} · ${this.code(shortDir(s.cwd))} · ${age(s.modified ?? s.createdAt, now)}`)
325
- : [unavailable ?? "No unbound sessions."],
326
- }],
327
- picks: slice.map((s, i) => btn(`${String(from + i + 1)} ${shortTitle(s)}`, `session:${String(from + i)}`)),
328
- rows: [pager("sessions", at, pages)],
329
- });
330
- }
331
- async pickSession(key, index) {
332
- const state = this.state(key);
333
- const session = state?.sessions[index];
334
- if (!state || !session)
335
- return this.refresh(key, "That session is no longer listed.");
336
- try {
337
- await this.deps.handoff.continueHere(key, session.id);
338
- // The bound session has its own settings; the draft is spent.
339
- state.draft = {};
340
- await this.settle(key, state, `Continuing session ${session.id.slice(0, 8)} — reply in this thread.`, true);
341
- }
342
- catch (err) {
343
- await this.refresh(key, failed("Could not continue that session", err));
344
- }
345
- }
346
256
  async pickPin(key, index) {
347
257
  const state = this.state(key);
348
258
  const pin = this.deps.control.pins()[index];
@@ -356,7 +266,7 @@ export class ChatPanel {
356
266
  try {
357
267
  await this.deps.control.setModel(key, model);
358
268
  await this.deps.control.setThinking(key, pin.thinking);
359
- await this.settle(key, state, `Model set to ${pin.id} · ${thinkingLabel(pin.thinking)}.`, false);
269
+ await this.settle(key, state, `Model set to ${pin.id} · ${thinkingLabel(pin.thinking)}.`);
360
270
  }
361
271
  catch (err) {
362
272
  await this.refresh(key, failed("Could not set that model", err));
@@ -415,9 +325,9 @@ export class ChatPanel {
415
325
  state.draft = {};
416
326
  if (q) {
417
327
  await run(q);
418
- return this.settle(key, state, `Started ${id.slice(0, 8)} — running your question.`, false);
328
+ return this.settle(key, state, `Started ${id.slice(0, 8)} — running your question.`);
419
329
  }
420
330
  const cwd = (await this.deps.control.status(key))?.cwd ?? launch.cwd ?? "?";
421
- await this.settle(key, state, `Started ${id.slice(0, 8)} in ${cwd}.`, false);
331
+ await this.settle(key, state, `Started ${id.slice(0, 8)} in ${cwd}.`);
422
332
  }
423
333
  }
@@ -1,7 +1,5 @@
1
- // Settings → Channels HTTP surface: one document per platform, and the
2
- // web → IM handoff beside it.
1
+ // Settings → Channels HTTP surface: one document per platform.
3
2
  import { isThinkingLevel } from "../core/types.js";
4
- import { HandoffError } from "./handoff.js";
5
3
  import { defaultChannelConfig, isChannelPlatform, } from "./types.js";
6
4
  /** Stable mask: recomputable at write time, so "unchanged" is detectable. */
7
5
  const maskToken = (token) => token ? `${"•".repeat(8)}${token.slice(-4)}` : "";
@@ -34,6 +32,7 @@ function parseChats(raw, known) {
34
32
  return base;
35
33
  return {
36
34
  ...base,
35
+ home: edit.home === true && base.kind === "dm" ? true : undefined,
37
36
  enabled: asBool(edit.enabled),
38
37
  requireMention: asBool(edit.requireMention),
39
38
  requireBind: asBool(edit.requireBind),
@@ -43,7 +42,7 @@ function parseChats(raw, known) {
43
42
  };
44
43
  });
45
44
  }
46
- export function registerChannelRoutes(app, store, runtime, handoff, conversations) {
45
+ export function registerChannelRoutes(app, store, runtime, conversations) {
47
46
  app.get("/api/channels/:platform", (c) => {
48
47
  const platform = c.req.param("platform");
49
48
  if (!isChannelPlatform(platform))
@@ -83,7 +82,15 @@ export function registerChannelRoutes(app, store, runtime, handoff, conversation
83
82
  // next start read as the first one and skip the swapped bot's cleanup.
84
83
  botId: current.botId,
85
84
  };
85
+ const before = store.home();
86
86
  store.save(platform, next);
87
+ const home = next.chats.find((chat) => chat.home);
88
+ if (home) {
89
+ store.setHome(platform, home.id);
90
+ // A thread session opened here before is unreachable once the chat is the head's.
91
+ if (before?.platform !== platform || before.chatId !== home.id)
92
+ conversations.forgetChat(platform, home.id);
93
+ }
87
94
  await runtime.reload();
88
95
  return c.json({ ok: true });
89
96
  });
@@ -112,21 +119,4 @@ export function registerChannelRoutes(app, store, runtime, handoff, conversation
112
119
  store.unbind(platform, c.req.param("id"));
113
120
  return c.json({ ok: true });
114
121
  });
115
- app.get("/api/handoff/targets", (c) => c.json({ targets: handoff.targets() }));
116
- app.post("/api/handoff", async (c) => {
117
- const body = (await c.req.json().catch(() => null));
118
- const sessionId = asString(body?.sessionId);
119
- const chatId = asString(body?.chatId);
120
- const platform = body?.platform;
121
- if (!sessionId || !chatId || !isChannelPlatform(platform))
122
- return c.json({ error: "invalid body" }, 400);
123
- try {
124
- return c.json(await handoff.continueIn({ sessionId, platform, chatId }), 201);
125
- }
126
- catch (err) {
127
- if (err instanceof HandoffError)
128
- return c.json({ error: err.message }, err.status);
129
- throw err;
130
- }
131
- });
132
122
  }