@timqi/pier 0.1.14 → 0.3.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 (130) 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/listing.js +14 -10
  5. package/dist/agent/packages.js +6 -10
  6. package/dist/agent/pi.js +113 -70
  7. package/dist/agent/roles.js +104 -0
  8. package/dist/agent/types.js +78 -0
  9. package/dist/boards/boards.js +42 -32
  10. package/dist/channels/chains.js +0 -4
  11. package/dist/channels/commands.js +7 -12
  12. package/dist/channels/config.js +27 -1
  13. package/dist/channels/control.js +5 -16
  14. package/dist/channels/conversations.js +4 -9
  15. package/dist/channels/lark-api.js +20 -26
  16. package/dist/channels/lark-outbound.js +21 -29
  17. package/dist/channels/lark-panel.js +1 -1
  18. package/dist/channels/lark.js +81 -39
  19. package/dist/channels/lines.js +33 -1
  20. package/dist/channels/panel.js +17 -107
  21. package/dist/channels/routes.js +11 -21
  22. package/dist/channels/runtime.js +79 -25
  23. package/dist/channels/slack-outbound.js +12 -14
  24. package/dist/channels/slack-panel.js +1 -1
  25. package/dist/channels/slack-render.js +1 -1
  26. package/dist/channels/slack-thread.js +65 -0
  27. package/dist/channels/slack.js +85 -124
  28. package/dist/channels/types.js +1 -1
  29. package/dist/cli.js +7 -2
  30. package/dist/config-sync.js +42 -37
  31. package/dist/core/chain.js +221 -0
  32. package/dist/core/identity.js +36 -13
  33. package/dist/core/inbound-file.js +11 -4
  34. package/dist/core/reply.js +111 -81
  35. package/dist/core/router.js +111 -118
  36. package/dist/core/search.js +65 -0
  37. package/dist/core/types.js +38 -77
  38. package/dist/db.js +41 -2
  39. package/dist/drain.js +1 -1
  40. package/dist/main.js +65 -40
  41. package/dist/settings.js +26 -19
  42. package/dist/socket.js +1 -0
  43. package/dist/tasks/agent.js +11 -5
  44. package/dist/tasks/callbacks.js +55 -5
  45. package/dist/tasks/cli.js +22 -11
  46. package/dist/tasks/definitions.js +30 -17
  47. package/dist/tasks/execution.js +11 -5
  48. package/dist/tasks/groups.js +5 -2
  49. package/dist/tasks/messages.js +7 -7
  50. package/dist/tasks/open-items.js +88 -0
  51. package/dist/tasks/operations.js +76 -39
  52. package/dist/tasks/outbox.js +19 -6
  53. package/dist/tasks/routes.js +19 -249
  54. package/dist/tasks/service.js +166 -34
  55. package/dist/tasks/store.js +123 -57
  56. package/dist/tasks/types.js +3 -0
  57. package/dist/tools.js +12 -5
  58. package/dist/web/auth.js +2 -2
  59. package/dist/web/config-sync.js +4 -5
  60. package/dist/web/config.js +9 -2
  61. package/dist/web/explorer.js +2 -2
  62. package/dist/web/fs.js +16 -1
  63. package/dist/web/instance.js +8 -5
  64. package/dist/web/packages.js +1 -1
  65. package/dist/web/passkeys.js +3 -3
  66. package/dist/web/providers.js +3 -3
  67. package/dist/web/public/assets/code-CWT27KH1.js +2 -0
  68. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  69. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
  71. package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
  72. package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
  73. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  74. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  75. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  76. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  77. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  78. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  79. package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
  80. package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
  81. package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
  82. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  83. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  84. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  85. package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
  86. package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
  87. package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
  88. package/dist/web/public/index.html +39 -109
  89. package/dist/web/public/index.html.br +0 -0
  90. package/dist/web/public/index.html.gz +0 -0
  91. package/dist/web/public/manifest.webmanifest +0 -2
  92. package/dist/web/push.js +3 -3
  93. package/dist/web/server.js +89 -116
  94. package/dist/web/session-state.js +4 -40
  95. package/docs/deploy.md +5 -3
  96. package/package.json +2 -1
  97. package/skills/pier-boards/SKILL.md +14 -4
  98. package/skills/pier-help/SKILL.md +62 -23
  99. package/skills/pier-search/SKILL.md +27 -0
  100. package/skills/pier-tasks/SKILL.md +68 -18
  101. package/dist/channels/handoff.js +0 -94
  102. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  103. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  104. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  105. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  106. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  107. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  108. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  109. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  110. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  111. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  112. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  113. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  114. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  115. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  116. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  117. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  118. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  119. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  120. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  121. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  122. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  123. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  124. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  125. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  126. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  129. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  130. 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,16 @@ 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
+ // The home chat is the head's, in a topic or not, under one key — except a
164
+ // child's topic (docs/design/11 §Child threads), which is that session's.
165
+ const home = this.isHead(msg.chatId, msg.rootId);
166
+ const here = { channelId: this.id, conversationId: home ? msg.chatId : conversationId(msg.chatId, root) };
166
167
  const bindRequest = command?.name === "bind" && isDm;
167
168
  const admitted = this.gate.admit("message", msg.chatId, {
168
169
  isDm,
169
170
  // Mentioned, or continuing a topic Pier already owns — durable, so it
170
171
  // holds after a restart.
171
- addressed: mentioned || (!!msg.rootId && !!this.deps.control?.knows(here)),
172
+ addressed: mentioned || (!home && !!msg.rootId && !!this.deps.control?.knows(here)),
172
173
  userId: senderId,
173
174
  bindRequest,
174
175
  });
@@ -179,22 +180,29 @@ export class LarkChannel {
179
180
  }
180
181
  if (bindRequest)
181
182
  return this.bind(senderId, msg.messageId, command?.args ?? "");
182
- if (command?.name === "stop")
183
+ // The head has no panel and takes `/stop` and `/skills` as chat commands (core/chain.ts).
184
+ if (!text && !attachments.length)
185
+ return this.log(`empty message in ${here.conversationId}, dropped`);
186
+ if (!home && command?.name === "stop")
183
187
  return this.abortTurn(here, msg.messageId);
188
+ if (!home && command?.name === "skills")
189
+ return this.listSkills(here);
184
190
  // Downloading only past the gate: an unauthorized sender must not make the
185
191
  // bot pull bytes on their behalf.
186
192
  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.
193
+ // `/s <text>` drafts a session, so only where this message would start one:
194
+ // outside any topic. The held question carries its markers, so Start sends what the user sent.
190
195
  const question = msg.rootId ? undefined : settingsDraft(text);
191
- if (this.panel && (question || command?.name === "settings" || (!text && !attachments.length && mentioned))) {
196
+ if (this.panel && !home && (question || command?.name === "settings")) {
192
197
  return this.panel.open(here, root, question && [question, ...markers].join("\n"));
193
198
  }
194
199
  // Resolved before the mark: any await between mark() and dispatch is a
195
200
  // window in which a previous turn can settle and take this receipt with it.
196
201
  const sender = { id: senderId, name: await this.userName(senderId) };
197
- this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
202
+ // The head answers a chat command with a note, not a turn: nothing would take a 👀 off it.
203
+ const answered = home && command && !command.args && isChatCommand(command.name);
204
+ if (!answered)
205
+ this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
198
206
  // Steer: a follow-up is the wrong default when the human is watching a 👀.
199
207
  onMessage({
200
208
  key: here,
@@ -301,14 +309,18 @@ export class LarkChannel {
301
309
  ? action.name.slice(CWD_SUBMIT_PREFIX.length)
302
310
  : "";
303
311
  const root = action.value?.root ?? formRoot;
304
- if (!root) {
312
+ // Any tap in the home chat is the head's, echoed where the card was,
313
+ // unless a child's topic; anywhere else only a topic's card is Pier's.
314
+ const home = this.isHead(action.chatId, root);
315
+ if (!root && !(home && payload.startsWith(OFFER_PREFIX))) {
305
316
  this.log(`card action without a thread root in ${action.chatId}, dropped`);
306
317
  return;
307
318
  }
308
319
  const key = {
309
320
  channelId: this.id,
310
- conversationId: conversationId(action.chatId, root),
321
+ conversationId: home ? action.chatId : conversationId(action.chatId, root),
311
322
  };
323
+ const to = root ? { root } : { chatId: action.chatId };
312
324
  const admitted = this.gate.admit("action", action.chatId, {
313
325
  isDm: this.deps.store.chat("lark", action.chatId)?.kind === "dm",
314
326
  addressed: true, // clicking the bot's own button is addressing it
@@ -316,11 +328,12 @@ export class LarkChannel {
316
328
  });
317
329
  if (!admitted)
318
330
  return;
319
- if (formRoot && action.formValue) {
331
+ // The head has no panel: a card left in the chat before it became the home is stale.
332
+ if (!home && formRoot && action.formValue) {
320
333
  await this.panel?.onCwdSubmit(key, action, root);
321
334
  return;
322
335
  }
323
- if (payload.startsWith(PANEL_PREFIX)) {
336
+ if (!home && payload.startsWith(PANEL_PREFIX)) {
324
337
  // Start's question: the card is the message the tap was on, so it carries the 👀.
325
338
  const run = (text) => this.deliver(key, action, action.messageId, text, onMessage);
326
339
  if (!(await this.panel?.onAction(action, key, payload, root, run))) {
@@ -336,14 +349,14 @@ export class LarkChannel {
336
349
  : undefined;
337
350
  if (label === undefined) {
338
351
  this.log(`unknown or stale action ${payload} in chat ${action.chatId}`);
339
- await this.api.replyCard(root, card([markdown(STALE_OPTION)]))
352
+ await this.out.post(to, card([markdown(STALE_OPTION)]))
340
353
  .catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
341
354
  return;
342
355
  }
343
356
  // A bot cannot post as the user, so the pick is echoed: otherwise the
344
357
  // topic shows an answer to a request nobody can see, with nothing to carry the eyes.
345
358
  await this.out.retire(action.messageId);
346
- const echo = await this.api.replyCard(root, card([markdown(picked(label))]))
359
+ const echo = await this.out.post(to, card([markdown(picked(label))]))
347
360
  .catch((err) => {
348
361
  this.log(`option echo failed: ${String(err)}`);
349
362
  return undefined;
@@ -363,6 +376,11 @@ export class LarkChannel {
363
376
  await this.deps.control?.abort(key);
364
377
  await this.api.replyCard(messageId, card([markdown(STOPPED)]));
365
378
  }
379
+ /** The head's `/skills` is MainChain's; a thread has no chain, so it is answered here. */
380
+ async listSkills(key) {
381
+ const text = skillsText((await this.deps.control?.skills(key)) ?? []);
382
+ await this.notify(key.conversationId, { text, origin: { kind: "chat-command", command: "skills" } });
383
+ }
366
384
  // --- bind ------------------------------------------------------------------
367
385
  /** Groups stay silent, but a DM that swallows every message looks broken
368
386
  * rather than locked. */
@@ -378,6 +396,21 @@ export class LarkChannel {
378
396
  await this.api.replyCard(messageId, card([markdown(bindResult(outcome, name))]));
379
397
  }
380
398
  // --- lookups ---------------------------------------------------------------
399
+ isHome(chatId) {
400
+ return this.deps.control?.isHome({ channelId: this.id, conversationId: chatId }) ?? false;
401
+ }
402
+ /** The head's: the home chat, outside any topic a session of its own is bound to. */
403
+ isHead(chatId, root) {
404
+ return this.isHome(chatId) &&
405
+ !(root && this.deps.control?.knows({ channelId: this.id, conversationId: conversationId(chatId, root) }));
406
+ }
407
+ /** A topic, or the home chat's main flow; undefined for any other chat's. */
408
+ target(conversation) {
409
+ const { chatId, root } = parseConversation(conversation);
410
+ if (root)
411
+ return { root };
412
+ return this.isHome(chatId) ? { chatId } : undefined;
413
+ }
381
414
  async userName(openId) {
382
415
  const hit = this.names.get(openId);
383
416
  if (hit)
@@ -400,33 +433,42 @@ export class LarkChannel {
400
433
  }
401
434
  // --- outbound --------------------------------------------------------------
402
435
  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) {
436
+ const to = this.target(conversation);
437
+ // No root outside the home chat is a foreign id; posting it would put a
438
+ // turn in the chat's main flow. Refused loudly, receipts still cleared.
439
+ if (!to) {
407
440
  this.log(`refusing to answer ${conversation}: no thread root in the conversation id`);
408
441
  await this.receipts.settle(conversation);
409
442
  return;
410
443
  }
411
444
  // 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));
445
+ await this.receipts.settleAfter(conversation, () => this.out.reply(to, reply), reply.meta);
418
446
  }
419
447
  /** The 👀 goes on the note itself: the turn it triggers has no message of
420
448
  * the user's to carry them. */
421
449
  async notify(conversation, note) {
422
- const { chatId, root } = parseConversation(conversation);
423
- if (!root) {
450
+ const to = this.target(conversation);
451
+ if (!to) {
424
452
  this.log(`refusing to post a system note to ${conversation}: no thread root in the conversation id`);
425
453
  return;
426
454
  }
427
- const messageId = await this.out.note(root, note);
455
+ const messageId = await this.out.note(to, note);
428
456
  if (messageId && awaitsTurn(note.origin)) {
429
- this.receipts.mark(conversation, chatId, messageId, note.at);
457
+ this.receipts.mark(conversation, parseConversation(conversation).chatId, messageId, note.at);
430
458
  }
431
459
  }
460
+ async openThread(chatId, note) {
461
+ if (!this.isHome(chatId))
462
+ throw new Error(`refusing to open a topic in ${chatId}: not the home chat`);
463
+ const messageId = await this.out.note({ chatId }, note);
464
+ if (!messageId)
465
+ throw new Error(`Lark returned no message id for the root in ${chatId}`);
466
+ return conversationId(chatId, messageId);
467
+ }
468
+ async editRoot(conversation, note) {
469
+ const { root } = parseConversation(conversation);
470
+ if (!root)
471
+ throw new Error(`refusing to edit ${conversation}: no thread root in the conversation id`);
472
+ await this.out.edit(root, note);
473
+ }
432
474
  }
@@ -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
  }