@primusclaw/personal-agent 0.4.0 → 0.6.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.
package/dist/index.js CHANGED
@@ -3800,7 +3800,14 @@ var approvalText = defineCatalog({
3800
3800
  questionYours: "Your answer",
3801
3801
  questionOnly: (p) => `Only ${p.who} can answer.`,
3802
3802
  questionAsker: "the person who asked",
3803
- send: "Send answer"
3803
+ send: "Send answer",
3804
+ /** Under a question card's questions: a client that does not show the card can still answer. */
3805
+ questionReply: "You can also just reply in the chat with your choice.",
3806
+ /** The same in a group chat, where only a message that @mentions the bot reaches it. */
3807
+ questionReplyGroup: "You can also just reply in the chat with your choice, @mentioning me.",
3808
+ /** A question card once a message typed in the chat answered it. */
3809
+ questionReplied: (p) => `Answered: ${p.answer}`,
3810
+ questionRepliedAt: (p) => `Replied in the chat, ${p.at}`
3804
3811
  },
3805
3812
  zh: {
3806
3813
  title: "\u9700\u8981\u4F60\u6279\u51C6",
@@ -3823,7 +3830,11 @@ var approvalText = defineCatalog({
3823
3830
  questionYours: "\u4F60\u7684\u56DE\u7B54",
3824
3831
  questionOnly: (p) => `\u53EA\u6709${p.who}\u53EF\u4EE5\u56DE\u7B54\u3002`,
3825
3832
  questionAsker: "\u53D1\u8D77\u4EBA",
3826
- send: "\u53D1\u9001\u56DE\u7B54"
3833
+ send: "\u53D1\u9001\u56DE\u7B54",
3834
+ questionReply: "\u4E5F\u53EF\u4EE5\u76F4\u63A5\u5728\u804A\u5929\u91CC\u56DE\u590D\u9009\u9879\u3002",
3835
+ questionReplyGroup: "\u4E5F\u53EF\u4EE5\u5728\u804A\u5929\u91CC @\u6211 \u76F4\u63A5\u56DE\u590D\u9009\u9879\u3002",
3836
+ questionReplied: (p) => `\u5DF2\u56DE\u7B54\uFF1A${p.answer}`,
3837
+ questionRepliedAt: (p) => `\u5728\u804A\u5929\u91CC\u56DE\u590D\uFF0C${p.at}`
3827
3838
  }
3828
3839
  });
3829
3840
 
@@ -4018,7 +4029,18 @@ var assistantCardText = defineCatalog({
4018
4029
  minutesAgo: (p) => `${String(p.n)} min ago`,
4019
4030
  hoursAgo: (p) => `${String(p.n)} h ago`,
4020
4031
  daysAgo: (p) => `${String(p.n)} d ago`,
4021
- askedTitle: (p) => `Asked of others \xB7 ${String(p.n)}`
4032
+ askedTitle: (p) => `Asked of others \xB7 ${String(p.n)}`,
4033
+ choiceTitle: "Pick one",
4034
+ choiceTitleMany: "Pick any that apply",
4035
+ choiceSend: "Send",
4036
+ choiceTyped: "You can also just reply in the chat.",
4037
+ choiceNone: "Pick an option first, then press Send.",
4038
+ choiceSent: (p) => `You picked: ${p.answer}`,
4039
+ choiceGone: "This choice is no longer open. Reply in the chat instead.",
4040
+ choiceSending: (p) => `Sending: ${p.answer}. If nothing comes back, reply in the chat instead.`,
4041
+ choiceFailed: "That did not go through. Press Send again.",
4042
+ /** How the options picked are joined into the person's message. */
4043
+ choiceJoin: ", "
4022
4044
  },
4023
4045
  zh: {
4024
4046
  ...english2,
@@ -4055,7 +4077,17 @@ var assistantCardText = defineCatalog({
4055
4077
  minutesAgo: (p) => `${String(p.n)} \u5206\u949F\u524D`,
4056
4078
  hoursAgo: (p) => `${String(p.n)} \u5C0F\u65F6\u524D`,
4057
4079
  daysAgo: (p) => `${String(p.n)} \u5929\u524D`,
4058
- askedTitle: (p) => `\u8BF7\u522B\u4EBA\u505A\u7684\u4EE3\u5BA1 \xB7 ${String(p.n)}`
4080
+ askedTitle: (p) => `\u8BF7\u522B\u4EBA\u505A\u7684\u4EE3\u5BA1 \xB7 ${String(p.n)}`,
4081
+ choiceTitle: "\u8BF7\u9009\u62E9\u4E00\u9879",
4082
+ choiceTitleMany: "\u53EF\u591A\u9009",
4083
+ choiceSend: "\u53D1\u9001",
4084
+ choiceTyped: "\u4E5F\u53EF\u4EE5\u76F4\u63A5\u5728\u804A\u5929\u91CC\u56DE\u590D\u3002",
4085
+ choiceNone: "\u8BF7\u5148\u9009\u4E00\u9879\uFF0C\u518D\u70B9\u53D1\u9001\u3002",
4086
+ choiceSent: (p) => `\u4F60\u9009\u4E86\uFF1A${p.answer}`,
4087
+ choiceGone: "\u8FD9\u4E2A\u9009\u62E9\u5DF2\u7ECF\u5931\u6548\uFF0C\u8BF7\u76F4\u63A5\u5728\u804A\u5929\u91CC\u56DE\u590D\u3002",
4088
+ choiceSending: (p) => `\u6B63\u5728\u53D1\u9001\uFF1A${p.answer}\u3002\u5982\u679C\u4E00\u76F4\u6CA1\u6709\u56DE\u5E94\uFF0C\u8BF7\u76F4\u63A5\u5728\u804A\u5929\u91CC\u56DE\u590D\u3002`,
4089
+ choiceFailed: "\u6CA1\u6709\u53D1\u51FA\u53BB\uFF0C\u8BF7\u518D\u70B9\u4E00\u6B21\u53D1\u9001\u3002",
4090
+ choiceJoin: "\u3001"
4059
4091
  }
4060
4092
  });
4061
4093
  var ASKED_VERDICT = {
@@ -5746,6 +5778,8 @@ var turnText = defineCatalog({
5746
5778
  thinking: "Thinking\u2026",
5747
5779
  /** A turn still running when its stream has to close: the answer follows as a message of its own. */
5748
5780
  stillWorking: "Still working on it; the result will follow as a new message.",
5781
+ /** A turn whose stream is closed because a card now waits for the person's choice. */
5782
+ awaitingChoice: "Waiting for your choice in the card; I will continue once you pick.",
5749
5783
  interrupted: "Interrupted.",
5750
5784
  /** What a turn the person stopped ends with. */
5751
5785
  stopped: "Stopped.",
@@ -5767,6 +5801,7 @@ var turnText = defineCatalog({
5767
5801
  planMore: (p) => `\u2026\u2026\u53E6\u6709 ${String(p.n)} \u9879\uFF08${String(p.done)} \u9879\u5DF2\u5B8C\u6210\uFF0C${String(p.todo)} \u9879\u5F85\u529E\uFF09`,
5768
5802
  thinking: "\u601D\u8003\u4E2D\u2026",
5769
5803
  stillWorking: "\u4ECD\u5728\u5904\u7406\uFF0C\u5B8C\u6210\u540E\u53E6\u53D1\u7ED3\u679C\u3002",
5804
+ awaitingChoice: "\u8BF7\u5728\u5361\u7247\u91CC\u9009\u62E9\uFF0C\u9009\u597D\u540E\u6211\u63A5\u7740\u505A\u3002",
5770
5805
  interrupted: "\u5DF2\u4E2D\u65AD\u3002",
5771
5806
  stopped: "\u5DF2\u505C\u6B62\u3002",
5772
5807
  doneShort: "\u5B8C\u6210\u3002",
@@ -6062,6 +6097,19 @@ function deliveryLine(delivery, url) {
6062
6097
  return turnEnglish.deliveryLine(plain(delivery.name), sizeText(delivery.size), caption, url);
6063
6098
  }
6064
6099
 
6100
+ // packages/core/src/language/chosen.ts
6101
+ function chosenLang(prefs) {
6102
+ if (!prefs.langSet) return void 0;
6103
+ return prefs.lang === "zh" || prefs.lang === "en" ? prefs.lang : void 0;
6104
+ }
6105
+ function replyLanguageContext(prefs) {
6106
+ const lang = chosenLang(prefs);
6107
+ return lang === void 0 ? {} : { reply_language: lang };
6108
+ }
6109
+ function replyLanguageLine(lang) {
6110
+ return `[Reply language: ${lang === "zh" ? "Chinese" : "English"}]`;
6111
+ }
6112
+
6065
6113
  // packages/core/src/language/detect.ts
6066
6114
  var CJK2 = /[㐀-䶿一-鿿豈-﫿]/u;
6067
6115
  function hasCjk2(text8) {
@@ -6195,9 +6243,13 @@ ${text8}` : quote || text8;
6195
6243
  function messageOf(ctx) {
6196
6244
  const said2 = withQuote(ctx.request.sent || ctx.request.text, ctx.request.quote ?? "");
6197
6245
  const note = ctx.record.filesNote;
6198
- return said2 && note ? `${said2}
6246
+ const message = said2 && note ? `${said2}
6199
6247
 
6200
6248
  ${note}` : said2 || note;
6249
+ const chosen = ctx.request.replyLang;
6250
+ return message && chosen !== void 0 ? `${message}
6251
+
6252
+ ${replyLanguageLine(chosen)}` : message;
6201
6253
  }
6202
6254
 
6203
6255
  // packages/core/src/locales/claw.ts
@@ -6949,6 +7001,8 @@ var StatusMessage = class {
6949
7001
  opening;
6950
7002
  expiry;
6951
7003
  again;
7004
+ /** The stream being closed because a card waits for the person (`awaitingChoice`). */
7005
+ choice;
6952
7006
  preparingTimer;
6953
7007
  lastText = "";
6954
7008
  lastAt = 0;
@@ -7027,20 +7081,61 @@ ${header}` : header;
7027
7081
  const live = this.live;
7028
7082
  if (!live) return;
7029
7083
  await sleepSeconds(this.clock, Math.max(STREAM_CLOSE_SECONDS - live.age(), 0), signal);
7030
- if (this.closed || !live.closable) return;
7084
+ await this.closeEarly(live, turnText.in(this.lang).stillWorking, "before the chat's limit", signal);
7085
+ }
7086
+ /**
7087
+ * Close the open stream with `line` (after the agent's words in the bubble) while the turn goes on; the answer, whenever it comes, is
7088
+ * a message of its own. Nothing more is sent to the stream once this has begun.
7089
+ */
7090
+ async closeEarly(live, line2, why, signal) {
7091
+ if (this.closed || !live.closable || this.streamOver || this.expiring) return;
7031
7092
  this.expiring = true;
7032
7093
  if (this.stream && this.flusher && !this.flusher.done) await unlessAborted(this.flusher.settled(), signal);
7033
7094
  this.streamOver = true;
7034
- const line2 = turnText.in(this.lang).stillWorking;
7035
- const closed = await live.final(plainMessage(live.streamed ? `${live.streamed}
7095
+ const words3 = this.streamable() || live.streamed;
7096
+ const closed = await live.final(plainMessage(words3 ? `${words3}
7036
7097
 
7037
7098
  ${line2}` : line2));
7038
- if (!closed.ok)
7039
- this.log.warn("the turn's stream could not be closed before the chat's limit; the answer follows as a message");
7099
+ if (!closed.ok) this.log.warn(`the turn's stream could not be closed ${why}; the answer follows as a message`);
7040
7100
  this.log.info("the turn's stream was closed; the answer follows as a message of its own", {
7101
+ why,
7041
7102
  age: Math.round(live.age())
7042
7103
  });
7043
7104
  }
7105
+ /**
7106
+ * A card now waits for the person (a question of the agent): it is their turn, so the floating status must not go on
7107
+ * saying "Thinking…" until the chat's limit. An open stream is closed at once with AWAITING_CHOICE, and the answer comes
7108
+ * as a message of its own, as after the limit. Without a stream (the progress message) nothing changes: it has no
7109
+ * spinner. Never fails the turn.
7110
+ */
7111
+ awaitingChoice() {
7112
+ const live = this.live;
7113
+ if (this.closed || !live || this.streamOver || this.expiring || this.choice) return;
7114
+ this.choice = detach(async (signal) => {
7115
+ if (this.opening && !this.opening.done) await unlessAborted(this.opening.settled(), signal);
7116
+ if (!this.expiring) this.expiry?.cancel();
7117
+ await this.closeEarly(live, turnText.in(this.lang).awaitingChoice, "while a card waits for the person", signal);
7118
+ });
7119
+ }
7120
+ /**
7121
+ * Claw queued the message behind a turn it is still running: `notice` closes the stream at once, so it does not say
7122
+ * "Thinking…" for the whole wait. The answer comes as a message of its own, as after the limit. When the stream did not open
7123
+ * after all, `notice` is said as a message. False when there is no stream: the caller says it. Never fails the turn.
7124
+ */
7125
+ queued(notice) {
7126
+ const live = this.live;
7127
+ if (this.closed || !live || this.streamOver || this.expiring || this.choice) return false;
7128
+ this.choice = detach(async (signal) => {
7129
+ if (this.opening && !this.opening.done) await unlessAborted(this.opening.settled(), signal);
7130
+ if (this.closed || !live.closable || this.streamOver || this.expiring) {
7131
+ await this.surface.send(plainMessage(notice));
7132
+ return;
7133
+ }
7134
+ this.expiry?.cancel();
7135
+ await this.closeEarly(live, notice, "while the message waits behind another turn", signal);
7136
+ });
7137
+ return true;
7138
+ }
7044
7139
  /** The chat's id of the posted message; undefined when the post failed or its id never came (nothing is edited then). */
7045
7140
  async messageId(signal) {
7046
7141
  if (!this.posted) return void 0;
@@ -7185,8 +7280,7 @@ ${line2}` : line2));
7185
7280
  this.hard = this.hard || hard;
7186
7281
  this.stopPreparingTimer();
7187
7282
  if (this.flusher && !this.flusher.done && (hard || !this.sending)) this.flusher.cancel();
7188
- if (hard) this.again?.cancel();
7189
- if (hard) this.expiry?.cancel();
7283
+ if (hard) for (const task of [this.again, this.expiry, this.choice]) task?.cancel();
7190
7284
  if (hard && this.live?.closable && !this.streamOver) {
7191
7285
  this.streamOver = true;
7192
7286
  const live = this.live;
@@ -7220,7 +7314,7 @@ ${line2}` : line2));
7220
7314
  async quiesce() {
7221
7315
  this.close();
7222
7316
  if (this.expiry && !this.expiry.done && !this.expiring) this.expiry.cancel();
7223
- for (const task of [this.opening, this.flusher, this.expiring ? this.expiry : void 0]) {
7317
+ for (const task of [this.opening, this.flusher, this.expiring ? this.expiry : void 0, this.choice]) {
7224
7318
  if (task) await task.settled();
7225
7319
  }
7226
7320
  }
@@ -7992,15 +8086,17 @@ async function ask2(ctx, sessionId, step) {
7992
8086
  const asked3 = isObject3(step.approval) && truthy(step.approval) ? approvalOf(ctx, sessionId, step.approval) : questionOf2(ctx, sessionId, step.question ?? {});
7993
8087
  const { tool: tool2, toolUseId, request, envelope } = asked3;
7994
8088
  const hooks = ctx.hooks;
7995
- if (toolUseId && hooks.onApproval?.(sessionId, toolUseId, tool2, waitSeconds(request["timeout_ms"]), envelope) === false)
8089
+ const questions = tool2 === QUESTION_TOOL ? request["questions"] : void 0;
8090
+ if (toolUseId && hooks.onApproval?.(sessionId, toolUseId, tool2, waitSeconds(request["timeout_ms"]), envelope, questions) === false)
7996
8091
  return;
8092
+ let cardId;
7997
8093
  try {
7998
- await ctx.deps.surface.deliver(envelope);
8094
+ cardId = await ctx.deps.surface.deliver(envelope);
7999
8095
  } catch (err2) {
8000
8096
  if (toolUseId) hooks.onApprovalUnsent?.(sessionId, toolUseId);
8001
8097
  throw err2;
8002
8098
  }
8003
- if (toolUseId) hooks.onApprovalSent?.(sessionId, toolUseId);
8099
+ if (toolUseId) hooks.onApprovalSent?.(sessionId, toolUseId, cardId);
8004
8100
  }
8005
8101
  async function readOnce(ctx, args, cursor, told) {
8006
8102
  const { sessionId, state, status, signal } = args;
@@ -8032,6 +8128,7 @@ async function take(ctx, args, step) {
8032
8128
  args.status.progress(step);
8033
8129
  if (truthy(step.approval) || truthy(step.question)) {
8034
8130
  await ask2(ctx, args.sessionId, step);
8131
+ if (!truthy(step.approval)) args.status.awaitingChoice();
8035
8132
  return void 0;
8036
8133
  }
8037
8134
  if (!step.finished) return void 0;
@@ -8418,8 +8515,9 @@ var TurnRun = class {
8418
8515
  state.writes = ctx.record.wrote;
8419
8516
  this.state = state;
8420
8517
  noteRunning(ctx, state, this.running);
8421
- if (messageId === void 0 && !ctx.request.attach) {
8422
- await ctx.deps.surface.send(plainMessage(turnText.in(langOf(ctx)).queuedNotice));
8518
+ if (queuedByClaw(messageId) && !ctx.request.attach) {
8519
+ const notice = turnText.in(langOf(ctx)).queuedNotice;
8520
+ if (!this.status.queued(notice)) await ctx.deps.surface.send(plainMessage(notice));
8423
8521
  }
8424
8522
  return consume(ctx, { sessionId, state, status: this.status, running: this.running, signal });
8425
8523
  }
@@ -8573,6 +8671,10 @@ var TurnRun = class {
8573
8671
  await offerReport(ctx);
8574
8672
  }
8575
8673
  };
8674
+ var QUEUED_MESSAGE_PREFIX = "claw-pending-";
8675
+ function queuedByClaw(messageId) {
8676
+ return messageId === void 0 || messageId.startsWith(QUEUED_MESSAGE_PREFIX);
8677
+ }
8576
8678
  async function runTurn(ctx, controls) {
8577
8679
  const replyTo = streamReplyTo(ctx);
8578
8680
  if (!replyTo) detach(() => typing(ctx));
@@ -8773,6 +8875,7 @@ var TurnEngine = class {
8773
8875
  quote: run.quote,
8774
8876
  sent: run.sent,
8775
8877
  lang: run.lang,
8878
+ ...run.replyLang === void 0 ? {} : { replyLang: run.replyLang },
8776
8879
  files: run.files,
8777
8880
  ...review ? reviewRequest(review) : {}
8778
8881
  };
@@ -8782,9 +8885,9 @@ var TurnEngine = class {
8782
8885
  const review = run.review?.turn;
8783
8886
  return {
8784
8887
  ...run.provider ? { keyProvider: run.provider } : {},
8785
- onApproval: (sid, toolUseId, tool2, expiresIn, envelope) => approvals.record(run.userId, sid, toolUseId, tool2, expiresIn, envelope),
8786
- onApprovalSent: (sid, toolUseId) => {
8787
- approvals.sent(sid, toolUseId);
8888
+ onApproval: (sid, toolUseId, tool2, expiresIn, envelope, questions) => approvals.record(run.userId, sid, toolUseId, tool2, expiresIn, envelope, questions),
8889
+ onApprovalSent: (sid, toolUseId, cardId) => {
8890
+ approvals.sent(sid, toolUseId, cardId);
8788
8891
  },
8789
8892
  onApprovalUnsent: (sid, toolUseId) => {
8790
8893
  approvals.unsent(sid, toolUseId);
@@ -9697,6 +9800,14 @@ var CredentialUnavailable = class extends Error {
9697
9800
  };
9698
9801
  var IDENTITY_CARD_INTERVAL_SECONDS = 24 * 3600;
9699
9802
  var IDENTITY_CARD_NOTICE = "identity_card";
9803
+ function chatCredentialOf(links2, exchange) {
9804
+ return async (sender) => {
9805
+ const key = links2.getKey(sender.id) ?? void 0;
9806
+ if (exchange === void 0 || !exchange.enabled) return key;
9807
+ const token = await exchange.tokenFor(sender.id);
9808
+ return token.ok ? token.value : key;
9809
+ };
9810
+ }
9700
9811
  var Credentials = class {
9701
9812
  constructor(deps, views) {
9702
9813
  this.deps = deps;
@@ -10203,7 +10314,7 @@ var CHAT_ENTRIES = [
10203
10314
  category: "chat",
10204
10315
  zh: "\u8981\u4F60\u62CD\u677F\u65F6\u6211\u53D1\u5361\u7247\uFF1A\u70B9\u6309\u94AE\u56DE\u7B54\u3001\u6279\u51C6\u6216\u62D2\u7EDD",
10205
10316
  en: "When I need you, I send a card: tap to answer, approve or deny",
10206
- covers: ["card:decision", "card:answer", "card:assistant_action"],
10317
+ covers: ["card:decision", "card:answer", "card:assistant_action", "card:assistant_choice"],
10207
10318
  where: "both"
10208
10319
  }),
10209
10320
  capability({
@@ -13134,11 +13245,12 @@ var PersonalRouter = class {
13134
13245
  const { assistant, remote } = this.ctx.deps;
13135
13246
  const userId = chat.sender.id;
13136
13247
  const understood = assistant.understands(userId);
13137
- const switching = understood && remote.enabled && switchCommand(message.text) !== null;
13138
- const forAssistant = understood && !exactCommand(message.text, { menuCommand, isKnownCommand }) && !switching;
13139
- const text8 = understood ? menuCommand(message.text) : this.text.textOf(message);
13248
+ const choice = message.choice === true;
13249
+ const switching = understood && !choice && remote.enabled && switchCommand(message.text) !== null;
13250
+ const forAssistant = understood && (choice || !exactCommand(message.text, { menuCommand, isKnownCommand })) && !switching;
13251
+ const text8 = understood && !choice ? menuCommand(message.text) : this.text.textOf(message);
13140
13252
  learnLang(this.ctx.deps.store, userId, message.text);
13141
- if (await this.beforeCredential(message, chat, text8, forAssistant)) return;
13253
+ if (!choice && await this.beforeCredential(message, chat, text8, forAssistant)) return;
13142
13254
  const key = await this.ctx.credentials.credential(chat, true);
13143
13255
  if (key === void 0) return;
13144
13256
  if (forAssistant && !this.text.talksToAgent(message)) await this.text.understand(message, chat, key);
@@ -13234,7 +13346,7 @@ var GroupRemote = class {
13234
13346
  const { remote } = this.ctx.deps;
13235
13347
  const bound = this.chatOf(chat);
13236
13348
  const attached = remote.attached(bound.id);
13237
- const args = this.agentArgs(words3.text, attached);
13349
+ const args = message.choice === true ? null : this.agentArgs(words3.text, attached);
13238
13350
  const provider = this.ctx.credentials.provider(chat.sender.id, key);
13239
13351
  if (args !== null) {
13240
13352
  const parsed = parseAgentCommand(args);
@@ -13255,11 +13367,12 @@ var GroupRemote = class {
13255
13367
  const { remote } = this.ctx.deps;
13256
13368
  const bound = this.chatOf(chat);
13257
13369
  const provider = this.ctx.credentials.provider(chat.sender.id, key);
13258
- if (naturalCommand(words3.text) === "/stop" || parseCommand(words3.text) === "stop") {
13370
+ const choice = message.choice === true;
13371
+ if (!choice && (naturalCommand(words3.text) === "/stop" || parseCommand(words3.text) === "stop")) {
13259
13372
  await remote.stop(bound, key, chat.conversation);
13260
13373
  return true;
13261
13374
  }
13262
- if (parseCommand(words3.text) !== null) return false;
13375
+ if (!choice && parseCommand(words3.text) !== null) return false;
13263
13376
  let quote = words3.quote;
13264
13377
  if (remote.windowMode(bound.id) === "card") {
13265
13378
  if (!remote.quotesWindow(bound.id, message)) return false;
@@ -13273,160 +13386,687 @@ var GroupRemote = class {
13273
13386
  }
13274
13387
  };
13275
13388
 
13276
- // packages/core/src/routing/personal-text.ts
13277
- var ELSEWHERE = "Files are not passed on from this chat while it talks to an agent; only your words were sent.";
13278
- var PersonalText = class {
13279
- constructor(ctx, commands, starter) {
13280
- this.ctx = ctx;
13281
- this.commands = commands;
13282
- this.starter = starter;
13283
- }
13284
- ctx;
13285
- commands;
13286
- starter;
13287
- /**
13288
- * What the person wrote, with a word of the commands menu read as its command and a sentence help teaches read as the
13289
- * command it means; a sentence for what this chat cannot do right now stays words.
13290
- */
13291
- textOf(message, group = false) {
13292
- const { github, assistant, remote, config } = this.ctx.deps;
13293
- const userId = group ? void 0 : message.sender.id;
13294
- const on = (test) => userId !== void 0 && test(userId);
13295
- const attached = on((id) => remote.attached(id));
13296
- const off = /* @__PURE__ */ new Set();
13297
- const rules = [
13298
- ["github", github !== void 0],
13299
- ["board", on((id) => assistant.on(id))],
13300
- ["brief", config.briefEnabled],
13301
- ["brief now", on((id) => assistant.on(id))],
13302
- ["agents", remote.enabled],
13303
- ["agent", remote.enabled],
13304
- ["agent detach", attached],
13305
- ["stop it", attached],
13306
- // ("停掉它" stops the attached agent; with the assistant it is the assistant's to read)
13307
- ["new", !(attached && on((id) => assistant.on(id)))]
13308
- // (in an attached chat: a new agent session, the assistant's)
13309
- ];
13310
- for (const [word2, enabled] of rules) if (!enabled) off.add(word2);
13311
- if (remote.enabled && on((id) => assistant.on(id)) && menuCommand(message.text, group) === "/agent sessions" && !/agent/iu.test(message.text))
13312
- return "/sessions";
13313
- return naturalCommand(menuCommand(message.text, group), group, off);
13314
- }
13315
- async onText(message, chat, key) {
13316
- const text8 = this.textOf(message);
13317
- if (parseCommand(text8) === "unlink" && !message.text.startsWith("/")) {
13318
- await chat.say(routingText.in(chat.lang()).unlinkAsk);
13319
- return;
13320
- }
13321
- if (await this.commands.run(text8, chat, key, message)) return;
13322
- if (!this.talksToAgent(message))
13323
- await this.offerGithub(message, chat, key, text8, (m, fresh) => this.sentOn(m, chat, fresh));
13324
- await this.sentOn(message, chat, key);
13325
- }
13326
- /**
13327
- * Whether this 1:1 message goes to the remote session the chat is attached to: only words the person typed, and not
13328
- * those that start by naming the bot. A chat whose session card is the window keeps its chat box for the bot: only a
13329
- * reply that quotes the card goes to the session.
13330
- */
13331
- talksToAgent(message) {
13332
- const { remote } = this.ctx.deps;
13333
- const userId = message.sender.id;
13334
- if (!remote.attached(userId) || message.provenance !== "typed" || message.namesBotFirst) return false;
13335
- if (remote.windowMode(userId) === "card") return remote.quotesWindow(userId, message);
13336
- return true;
13337
- }
13338
- /** The files that go to Claw with a message; the person is told about the ones that do not, and they are counted. */
13339
- async attachments(message, chat, elsewhere = false) {
13340
- const { messageText, metrics } = this.ctx.deps;
13341
- const unreadable = messageText.unreadable(message);
13342
- if (unreadable !== void 0) {
13343
- metrics.filesDropped("unreadable", unreadable.count);
13344
- await chat.say(unreadable.line);
13345
- }
13346
- if (message.files.length > 0 && elsewhere) {
13347
- metrics.filesDropped("remote_agent", message.files.length);
13348
- await chat.say(ELSEWHERE);
13349
- return [];
13350
- }
13351
- return message.files;
13352
- }
13353
- /** What the chat is attached to, for the assistant (only where remote agents are on). */
13354
- async attached(userId, key) {
13355
- const { remote } = this.ctx.deps;
13356
- return remote.enabled ? { attached: await remote.chatContext(userId, key) } : {};
13357
- }
13358
- /** A 1:1 message that is no command, as it goes on to the attached agent, the assistant or Claw. */
13359
- async sentOn(message, chat, key) {
13360
- const { messageText, assistant } = this.ctx.deps;
13361
- const userId = chat.sender.id;
13362
- const text8 = this.textOf(message);
13363
- const toAgent = this.talksToAgent(message);
13364
- const quote = toAgent && this.ctx.deps.remote.quotesWindow(userId, message) ? "" : messageText.quoteBlock(message, false);
13365
- const sent = messageText.withLinks(text8, message);
13366
- const prompt = messageText.withQuote(sent, quote);
13367
- const files = await this.attachments(message, chat, toAgent);
13368
- const control = this.sessionControl(userId, prompt, toAgent, quote, files, text8);
13369
- if (control && assistant.understands(userId)) {
13370
- await this.understand(message, chat, key);
13371
- } else if (prompt && toAgent && !control) {
13372
- await this.remotePrompt(message, chat, key, prompt);
13373
- } else if (prompt || files.length > 0) {
13374
- await this.toAssistantOrTurn(message, chat, key, { text: text8, quote, sent, prompt, files });
13375
- }
13389
+ // packages/core/src/approvals/questions.ts
13390
+ import { z as z4 } from "zod";
13391
+
13392
+ // packages/core/src/text/plain.ts
13393
+ function clip3(text8, limit) {
13394
+ return clipLine(squashSpaces(text8), limit);
13395
+ }
13396
+ function pyJson(value, options = {}) {
13397
+ return options.compact ? compact(value) : pyJsonDumps(value);
13398
+ }
13399
+ function compact(value) {
13400
+ if (Array.isArray(value)) return `[${value.map(compact).join(",")}]`;
13401
+ if (value !== null && typeof value === "object") {
13402
+ const entries = Object.entries(value);
13403
+ return `{${entries.map(([k, v]) => `${pyJsonDumps(k)}:${compact(v)}`).join(",")}}`;
13376
13404
  }
13377
- /**
13378
- * A message to an attached chat that is about the sessions themselves (switch, open a new one), with the assistant on to
13379
- * read it: the words alone (a quote or a file goes to the agent with them).
13380
- */
13381
- sessionControl(userId, prompt, toAgent, quote, files, text8) {
13382
- if (!prompt || !toAgent || quote || files.length > 0) return false;
13383
- return this.ctx.deps.assistant.on(userId) && sessionControl(text8);
13405
+ return pyJsonDumps(value);
13406
+ }
13407
+
13408
+ // packages/core/src/approvals/questions.ts
13409
+ var QUESTION_TOOL2 = "ask_user_question";
13410
+ var QUESTIONS_MAX = 5;
13411
+ var OPTIONS_MAX = 10;
13412
+ var QUESTION_MAX = 500;
13413
+ var OPTION_MAX = 120;
13414
+ var ANSWER_MAX = 1e3;
13415
+ function scalarText(value) {
13416
+ if (value === null || value === void 0 || value === false || value === 0 || value === "") return "";
13417
+ if (typeof value === "string") return value;
13418
+ if (typeof value === "number") return String(value);
13419
+ if (value === true) return "True";
13420
+ return "";
13421
+ }
13422
+ var rawOption = z4.object({ id: z4.unknown(), label: z4.unknown() }).partial();
13423
+ var rawQuestion = z4.object({ id: z4.unknown(), question: z4.unknown(), options: z4.unknown(), allow_multiple: z4.unknown() }).partial();
13424
+ function optionsOf(raw) {
13425
+ const out = [];
13426
+ for (const item of Array.isArray(raw) ? raw : []) {
13427
+ const option = rawOption.safeParse(item);
13428
+ if (!option.success || typeof item !== "object" || Array.isArray(item)) continue;
13429
+ const id = scalarText(option.data.id);
13430
+ if (!pyStrip(id)) continue;
13431
+ out.push({ id, label: clip3(scalarText(option.data.label) || id, OPTION_MAX) });
13384
13432
  }
13385
- /** A message the assistant takes when it is on, else a turn of Claw's. */
13386
- async toAssistantOrTurn(message, chat, key, said2) {
13387
- const { assistant } = this.ctx.deps;
13388
- const userId = chat.sender.id;
13389
- if (assistant.on(userId) && await this.ctx.admission.assistantOverLimit(chat, message.id, key)) return;
13390
- const routed = await assistant.route({
13391
- userId,
13392
- credential: key,
13393
- prompt: said2.prompt || assistant.filesOnly(chat.lang()),
13394
- conversation: chat.conversation,
13395
- files: said2.files,
13396
- ...await this.attached(userId, key)
13433
+ return out.slice(0, OPTIONS_MAX);
13434
+ }
13435
+ function cardQuestions(raw) {
13436
+ const out = [];
13437
+ for (const item of Array.isArray(raw) ? raw : []) {
13438
+ const question = rawQuestion.safeParse(item);
13439
+ if (!question.success || typeof item !== "object" || Array.isArray(item)) continue;
13440
+ const id = scalarText(question.data.id);
13441
+ const text8 = scalarText(question.data.question);
13442
+ if (!pyStrip(id) || !pyStrip(text8)) continue;
13443
+ out.push({
13444
+ id,
13445
+ question: clip3(text8, QUESTION_MAX),
13446
+ options: optionsOf(question.data.options),
13447
+ allowMultiple: question.data.allow_multiple === true
13397
13448
  });
13398
- if (routed) return;
13399
- this.ctx.admission.dropLastHandoff(userId);
13400
- await this.starter.start({
13401
- chat,
13402
- credential: key,
13403
- text: said2.text,
13404
- messageId: message.id,
13405
- clientPlatform: message.clientPlatform,
13406
- quote: said2.quote,
13407
- sent: said2.sent,
13408
- files: said2.files
13449
+ }
13450
+ return out.slice(0, QUESTIONS_MAX);
13451
+ }
13452
+ function questionWire(question) {
13453
+ return {
13454
+ id: question.id,
13455
+ question: question.question,
13456
+ options: question.options.map((o) => ({ id: o.id, label: o.label })),
13457
+ allow_multiple: question.allowMultiple
13458
+ };
13459
+ }
13460
+ function answersOf(data) {
13461
+ const answers = /* @__PURE__ */ new Map();
13462
+ const skipped = [];
13463
+ cardQuestions(data["questions"]).forEach((q, i) => {
13464
+ const labels = new Map(q.options.map((o) => [o.id, o.label]));
13465
+ const chosen = scalarText(own(data, `choice_${String(i)}`)).split(",").flatMap((c) => {
13466
+ const label = labels.get(c);
13467
+ return label === void 0 ? [] : [label];
13409
13468
  });
13469
+ const typed2 = cpHead(pyStrip(scalarText(own(data, `text_${String(i)}`))), ANSWER_MAX);
13470
+ const answer2 = [chosen.join(", "), typed2].filter(Boolean).join(". ");
13471
+ if (answer2) answers.set(q.id, answer2);
13472
+ else skipped.push(q.id);
13473
+ });
13474
+ return { answers, skipped };
13475
+ }
13476
+ function optionNamed(options, token) {
13477
+ const wanted = token.toLowerCase();
13478
+ const byLabel = options.find((o) => o.label.toLowerCase() === wanted || o.id.toLowerCase() === wanted);
13479
+ if (byLabel !== void 0 || !/^\d{1,2}$/.test(token)) return byLabel;
13480
+ return options[Number(token) - 1];
13481
+ }
13482
+ function typedAnswers(questions, typed2) {
13483
+ const reply = cpHead(pyStrip(typed2), ANSWER_MAX);
13484
+ const answers = /* @__PURE__ */ new Map();
13485
+ const only2 = questions.length === 1 ? questions[0] : void 0;
13486
+ if (only2 !== void 0) {
13487
+ const tokens2 = only2.allowMultiple ? reply.split(/\s*[,,、;;]\s*/u).filter(Boolean) : [reply];
13488
+ const named2 = tokens2.flatMap((t4) => optionNamed(only2.options, t4) ?? []);
13489
+ const labels = named2.length > 0 && named2.length === tokens2.length ? named2.map((o) => o.label) : [];
13490
+ answers.set(only2.id, labels.length ? labels.join(", ") : reply);
13491
+ } else {
13492
+ for (const q of questions) answers.set(q.id, reply);
13410
13493
  }
13411
- /** A prompt for the attached agent, within the task limit (the slot is held until the remote turn counts as running). */
13412
- async remotePrompt(message, chat, key, prompt) {
13413
- const { remote } = this.ctx.deps;
13414
- const request = {
13415
- message,
13416
- chat: chat.sender,
13417
- credential: key,
13418
- prompt,
13419
- conversation: chat.conversation,
13420
- provider: this.ctx.credentials.provider(chat.sender.id, key)
13421
- };
13422
- if (remote.running(chat.sender.id)) {
13423
- await remote.prompt(request);
13424
- return;
13425
- }
13426
- if (!await this.ctx.admission.admitRemote(chat, message.id, key)) return;
13427
- try {
13428
- await remote.prompt(request);
13429
- } finally {
13494
+ return { answers, skipped: [] };
13495
+ }
13496
+
13497
+ // packages/core/src/approvals/click.ts
13498
+ function viewMessage2(view7, fallbackText = "") {
13499
+ return { body: { type: "view", view: view7, fallbackText } };
13500
+ }
13501
+
13502
+ // packages/core/src/proactive/quiet.ts
13503
+ var APPROVAL_DEFAULT_SECONDS = 600;
13504
+ var PERMISSION_DEFAULT_SECONDS = 600;
13505
+ var PERMISSION_MAX_SECONDS = 3600;
13506
+ var formatters = /* @__PURE__ */ new Map();
13507
+ function formatter(zone) {
13508
+ const known = formatters.get(zone);
13509
+ if (known) return known;
13510
+ try {
13511
+ const made = new Intl.DateTimeFormat("en-US", {
13512
+ timeZone: zone,
13513
+ hour: "2-digit",
13514
+ minute: "2-digit",
13515
+ hourCycle: "h23"
13516
+ });
13517
+ formatters.set(zone, made);
13518
+ return made;
13519
+ } catch {
13520
+ return void 0;
13521
+ }
13522
+ }
13523
+ function zoneOf3(prefs) {
13524
+ return prefs.timezone && formatter(prefs.timezone) ? prefs.timezone : DEFAULT_TIMEZONE;
13525
+ }
13526
+ function localMinutes(epochSeconds2, zone) {
13527
+ const parts2 = (formatter(zone) ?? formatter(DEFAULT_TIMEZONE))?.formatToParts(new Date(epochSeconds2 * 1e3)) ?? [];
13528
+ const value = (type) => Number(parts2.find((p) => p.type === type)?.value ?? "0");
13529
+ return value("hour") * 60 + value("minute");
13530
+ }
13531
+ function minutesOf(clock2) {
13532
+ const [hour = "0", minute = "0"] = clock2.split(":");
13533
+ return Number(hour) * 60 + Number(minute);
13534
+ }
13535
+ function inQuiet(now, zone, start, end) {
13536
+ const minute = localMinutes(now, zone);
13537
+ const s = minutesOf(start);
13538
+ const e = minutesOf(end);
13539
+ if (s === e) return false;
13540
+ return s < e ? s <= minute && minute < e : minute >= s || minute < e;
13541
+ }
13542
+ function muted(prefs, now) {
13543
+ if (prefs.pausedUntil !== null && now < prefs.pausedUntil) return "paused";
13544
+ if (prefs.quietEnabled && inQuiet(now, zoneOf3(prefs), prefs.quietStart, prefs.quietEnd)) return "quiet";
13545
+ return void 0;
13546
+ }
13547
+ function isoSeconds2(raw) {
13548
+ if (typeof raw !== "string") return void 0;
13549
+ const match = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2}(?::?\d{2})?)$/.exec(
13550
+ raw.trim()
13551
+ );
13552
+ if (!match) return void 0;
13553
+ const at = Date.parse(raw.trim().replace(" ", "T"));
13554
+ return Number.isNaN(at) ? void 0 : at / 1e3;
13555
+ }
13556
+ function approvalExpiry(event, now) {
13557
+ const approval = event["approval"];
13558
+ const raw = typeof approval === "object" && approval !== null ? approval["expires_at"] : void 0;
13559
+ return isoSeconds2(raw) ?? now + APPROVAL_DEFAULT_SECONDS;
13560
+ }
13561
+ function remoteAskExpiry(ask3, now) {
13562
+ const at = isoSeconds2(ask3["expires_at"]);
13563
+ return at === void 0 ? now + PERMISSION_DEFAULT_SECONDS : Math.min(at, now + PERMISSION_MAX_SECONDS);
13564
+ }
13565
+
13566
+ // packages/core/src/routing/question-reply.ts
13567
+ var TYPED_ANSWER_MARGIN_SECONDS = 15;
13568
+ async function answerWaitingQuestion(ctx, reply) {
13569
+ const { store, claw, logger } = ctx.deps;
13570
+ const { chat } = reply;
13571
+ const waiting = waitingQuestion(ctx, reply);
13572
+ if (waiting === void 0) return false;
13573
+ const { pending, questions } = waiting;
13574
+ const { sessionId, toolUseId } = pending;
13575
+ const key = await stillAsked(ctx, reply, pending);
13576
+ if (key === void 0) return false;
13577
+ if (!store.approvals.closeApproval(sessionId, toolUseId, "sending")) return false;
13578
+ const answers = typedAnswers(questions, reply.text);
13579
+ try {
13580
+ await claw.sessions.answer(key, sessionId, toolUseId, Object.fromEntries(answers.answers), answers.skipped);
13581
+ } catch (error) {
13582
+ if (!isClawOrNetwork(error)) throw error;
13583
+ const stale = error instanceof ClawError && [404, 409, 410].includes(error.status);
13584
+ store.approvals.closeApproval(sessionId, toolUseId, stale ? "expired" : "pending", { onlyIfPending: false });
13585
+ logger.warn("a typed answer to a question card was not taken; the message goes on as a new one", {
13586
+ error: statusOrKind(error)
13587
+ });
13588
+ return false;
13589
+ }
13590
+ store.approvals.closeApproval(sessionId, toolUseId, "decided", { onlyIfPending: false });
13591
+ logger.info("a typed message answered the question card waiting in the chat", {
13592
+ chat: chat.scope ? "group" : "1:1",
13593
+ questions: questions.length,
13594
+ card: pending.cardId ? "edited" : "not editable"
13595
+ });
13596
+ const lang = answerLang(store.prefs.getPrefs(chat.sender.id).lang, chat.locale, questions[0]?.question, reply.text);
13597
+ await settleCard(ctx, chat, pending.cardId, [...answers.answers], lang);
13598
+ return true;
13599
+ }
13600
+ function waitingQuestion(ctx, reply) {
13601
+ const { store } = ctx.deps;
13602
+ const { chat } = reply;
13603
+ if (!pyStrip(reply.text)) return void 0;
13604
+ const sessionId = store.sessions.getSession(chat.sender.id, chat.scope);
13605
+ if (sessionId === null || store.reviewDelegation.isActingSession(sessionId)) return void 0;
13606
+ const pending = store.approvals.pendingQuestion(sessionId, chat.sender.id);
13607
+ if (pending === null || pending.expiresAt - store.now() < TYPED_ANSWER_MARGIN_SECONDS) return void 0;
13608
+ const questions = cardQuestions(pending.questions);
13609
+ return questions.length === 0 ? void 0 : { pending, questions };
13610
+ }
13611
+ async function stillAsked(ctx, reply, pending) {
13612
+ const { store, claw, logger } = ctx.deps;
13613
+ try {
13614
+ const key = await reply.credential();
13615
+ if ((await claw.sessions.turn(key, pending.sessionId)).running) return key;
13616
+ store.approvals.closeApproval(pending.sessionId, pending.toolUseId, "expired");
13617
+ return void 0;
13618
+ } catch (error) {
13619
+ if (!isClawOrNetwork(error)) throw error;
13620
+ logger.warn("a typed answer to a question card was not sent: the session's turn could not be read", {
13621
+ error: statusOrKind(error)
13622
+ });
13623
+ return void 0;
13624
+ }
13625
+ }
13626
+ async function settleCard(ctx, chat, cardId, answers, lang) {
13627
+ const { store, logger } = ctx.deps;
13628
+ const zone = zoneOf3(store.prefs.getPrefs(chat.sender.id));
13629
+ const answer2 = [...new Set(answers.map(([, a]) => a))].join(" / ");
13630
+ const words3 = approvalText.in(lang).questionReplied({ answer: answer2 });
13631
+ try {
13632
+ if (cardId) {
13633
+ const view7 = { name: "questionAnswered", model: { answers, when: store.now(), zone, replied: lang } };
13634
+ await chat.conversation.update(cardId, viewMessage2(view7, words3));
13635
+ } else {
13636
+ await chat.say(words3);
13637
+ }
13638
+ } catch (error) {
13639
+ logger.warn("the question card could not be marked answered", { error: errorName(error) });
13640
+ }
13641
+ }
13642
+
13643
+ // packages/core/src/routing/turn-start.ts
13644
+ var MOBILE_PLATFORMS = ["ios", "ipados", "android"];
13645
+ function isMobile(clientPlatform) {
13646
+ return MOBILE_PLATFORMS.includes(clientPlatform.toLowerCase());
13647
+ }
13648
+ function logTurnStarted(logger, clientPlatform, group, route) {
13649
+ logger.info("turn started from Teams client", {
13650
+ platform: clientPlatform || "unknown",
13651
+ chat: group ? "group" : "1:1",
13652
+ route
13653
+ });
13654
+ }
13655
+ var TurnStarter = class {
13656
+ constructor(ctx) {
13657
+ this.ctx = ctx;
13658
+ }
13659
+ ctx;
13660
+ running = /* @__PURE__ */ new Set();
13661
+ /** The turns that run now (a test waits for them). */
13662
+ settled() {
13663
+ return Promise.all([...this.running]).then(() => void 0);
13664
+ }
13665
+ /** The turn that runs (its end is `done`), or undefined when none was started (the person was told why). */
13666
+ async start(started) {
13667
+ const request = { ...started, ...this.replyLang(started) };
13668
+ const { chat, review } = request;
13669
+ if (await this.answersQuestion(request)) return void 0;
13670
+ const key = review ? reviewTurnKey(chat.sender.id, chat.scope, review.itemId, review.pull) : chatTurnKey(chat.sender.id, chat.scope);
13671
+ const turn = new ActiveTurn(review !== void 0, review?.itemId ?? "");
13672
+ const admitted = review === void 0 && await this.admit(request, key, turn);
13673
+ if (admitted === void 0) return void 0;
13674
+ if (!admitted) {
13675
+ const queued = await this.queueBehind(key, turn, chat);
13676
+ if (queued === "refused") return void 0;
13677
+ if (queued === "waiting") return this.track(this.afterFront(request, key, turn));
13678
+ }
13679
+ return this.launch(request, key, turn);
13680
+ }
13681
+ /** A typed message the waiting question card of the chat's session took as its answer (`question-reply.ts`). */
13682
+ async answersQuestion(request) {
13683
+ if (!request.typed || request.review !== void 0 || (request.files?.length ?? 0) > 0) return false;
13684
+ const provider = this.providerOf(request);
13685
+ return answerWaitingQuestion(this.ctx, {
13686
+ chat: request.chat,
13687
+ text: request.text,
13688
+ credential: provider ?? (() => Promise.resolve(request.credential))
13689
+ });
13690
+ }
13691
+ track(task) {
13692
+ this.running.add(task);
13693
+ void task.finally(() => this.running.delete(task));
13694
+ return { done: task };
13695
+ }
13696
+ /** The turn goes to Claw now; its end is `done`. */
13697
+ async launch(request, key, turn) {
13698
+ turn.launched = true;
13699
+ let run;
13700
+ try {
13701
+ run = await this.runOf(request);
13702
+ } catch (error) {
13703
+ this.ctx.turns.drop(key, turn);
13704
+ throw error;
13705
+ }
13706
+ return this.track(this.guarded(request, run, key, turn));
13707
+ }
13708
+ /**
13709
+ * A queued turn: it goes once every turn in front of it is over, or stopped (Claw took its interrupt: Claw is free for the next
13710
+ * message, whether or not that turn ever reports its end); the caller does not wait for it. Not at all when it was given up
13711
+ * meanwhile (`/stop`, said there).
13712
+ */
13713
+ async afterFront(request, key, turn) {
13714
+ for (; ; ) {
13715
+ if (turn.over) return;
13716
+ const turns = this.ctx.turns.of(key);
13717
+ const at = turns.indexOf(turn);
13718
+ if (at < 0) return;
13719
+ const blocking = turns.slice(0, at).find((front) => !front.stopped.isSet);
13720
+ if (blocking === void 0) break;
13721
+ await Promise.race([blocking.ended.whenSet(), blocking.stopped.whenSet(), turn.ended.whenSet()]);
13722
+ }
13723
+ let started;
13724
+ try {
13725
+ started = await this.launch(request, key, turn);
13726
+ } catch (error) {
13727
+ const ref = this.ctx.deps.random.hexId().slice(0, 8);
13728
+ this.ctx.deps.logger.error("a queued turn could not start", { ref, error: errorName(error) });
13729
+ await this.tell(request, `Something went wrong on my side (ref ${ref}). Please try again.`);
13730
+ return;
13731
+ }
13732
+ await started.done;
13733
+ }
13734
+ /** True when admitted as the chat's first turn; false when the chat has one (queue behind it); undefined: refused. */
13735
+ async admit(request, key, turn) {
13736
+ if (this.ctx.turns.has(key)) return false;
13737
+ const { chat } = request;
13738
+ return this.ctx.admission.lock.run(chat.sender.id, async () => {
13739
+ if (this.ctx.turns.has(key)) return false;
13740
+ if (await this.ctx.admission.overLimit(chat, request.messageId, "claw_turn", "", request.credential))
13741
+ return void 0;
13742
+ this.ctx.turns.claim(key, turn);
13743
+ return true;
13744
+ });
13745
+ }
13746
+ /**
13747
+ * Registers `turn` behind the chat's running one: "front" when the chat has none after all (it runs now), "waiting" when it
13748
+ * is queued (the person is told its place), "refused" (and told why) when it may not wait.
13749
+ */
13750
+ async queueBehind(key, turn, chat) {
13751
+ const queued = this.ctx.turns.enqueue(key, turn);
13752
+ const said2 = routingText.in(chat.lang());
13753
+ if (queued === "still_working" || queued === "queue_full") {
13754
+ await chat.say(queued === "still_working" ? said2.stillWorking : said2.queueFull({ n: QUEUED_TURNS_MAX }));
13755
+ return "refused";
13756
+ }
13757
+ if (queued.front === turn) return "front";
13758
+ const stoppedInFront = this.ctx.turns.of(key).every((front) => front === turn || front.stopped.isSet);
13759
+ if (stoppedInFront) return "waiting";
13760
+ try {
13761
+ await chat.say(said2.queuedBehind({ n: queued.waiting }));
13762
+ } catch (error) {
13763
+ this.ctx.turns.drop(key, turn);
13764
+ throw error;
13765
+ }
13766
+ return "waiting";
13767
+ }
13768
+ async sessionOf(request) {
13769
+ const { chat, review } = request;
13770
+ const { store, config } = this.ctx.deps;
13771
+ if (review !== void 0) return review.itemSessionId;
13772
+ const sessionId = store.sessions.getSession(chat.sender.id, chat.scope);
13773
+ if (sessionId === null) return null;
13774
+ if (Boolean(store.sessions.getSessionPlugin(chat.sender.id, chat.scope)) !== Boolean(config.pluginId)) {
13775
+ await chat.say("Starting a new session so I can use the Primus platform tools.");
13776
+ return null;
13777
+ }
13778
+ if (chat.scope && !store.sessions.getSessionShared(chat.sender.id, chat.scope)) return null;
13779
+ return sessionId;
13780
+ }
13781
+ providerOf(request) {
13782
+ const usesExchange = request.review !== void 0 || this.ctx.credentials.onExchange;
13783
+ if (!this.ctx.deps.exchange.enabled || !usesExchange) return void 0;
13784
+ return () => this.ctx.credentials.fresh(request.chat.sender.id, request.credential);
13785
+ }
13786
+ ownThumbs(request) {
13787
+ return isMobile(request.clientPlatform) && request.review === void 0 && this.ctx.deps.config.ownThumbsMobile;
13788
+ }
13789
+ /** What the run reads of the chat's session: its id, the model it has, the repository a new one is bound to. */
13790
+ async sessionPart(request) {
13791
+ const { chat, review } = request;
13792
+ const { store } = this.ctx.deps;
13793
+ const sessionId = await this.sessionOf(request);
13794
+ const chosen = sessionId === null && !chat.scope ? store.repoHistory.getPendingRepo(chat.sender.id) : null;
13795
+ return {
13796
+ sessionId,
13797
+ sessionModel: sessionId === null || review ? "" : store.sessions.getSessionModel(chat.sender.id, chat.scope),
13798
+ repo: chosen === null ? null : { url: `${REPO_HOST}/${chosen.repo}`, branch: chosen.branch }
13799
+ };
13800
+ }
13801
+ async runOf(request) {
13802
+ const { chat, review } = request;
13803
+ const { store, config, logger } = this.ctx.deps;
13804
+ const session = await this.sessionPart(request);
13805
+ const group = chat.scope !== "";
13806
+ logTurnStarted(logger, request.clientPlatform, group, review ? "review" : "legacy");
13807
+ return {
13808
+ ...session,
13809
+ conversation: chat.conversation,
13810
+ credential: request.credential,
13811
+ provider: this.providerOf(request),
13812
+ text: request.text,
13813
+ messageId: request.messageId,
13814
+ group,
13815
+ userId: chat.sender.id,
13816
+ ownerName: chat.sender.name,
13817
+ model: review ? review.model || config.reviewModel || config.model : config.model,
13818
+ fallbackModel: config.fallbackModel,
13819
+ showChanges: !group && review === void 0,
13820
+ quote: request.quote ?? "",
13821
+ sent: request.sent ?? "",
13822
+ files: request.files ?? [],
13823
+ lang: review?.lang ?? chat.lang(),
13824
+ ...request.replyLang === void 0 ? {} : { replyLang: request.replyLang },
13825
+ ownThumbs: this.ownThumbs(request),
13826
+ notifies: this.ctx.deps.subscriptions !== void 0 && store.notifySubs.getNotifySub(chat.sender.id) !== null,
13827
+ review: review?.fields
13828
+ };
13829
+ }
13830
+ /** The language the sender chose to be answered in, for a chat turn (a review's answer follows its request's language). */
13831
+ replyLang(request) {
13832
+ if (request.review !== void 0) return {};
13833
+ const lang = chosenLang(this.ctx.deps.store.prefs.getPrefs(request.chat.sender.id));
13834
+ return lang === void 0 ? {} : { replyLang: lang };
13835
+ }
13836
+ hooks(request, run, turn, turnId) {
13837
+ const { review, chat } = request;
13838
+ const store = this.ctx.deps.store;
13839
+ return {
13840
+ onSession: (sessionId, sessionModel) => {
13841
+ if (review) review.onSession(sessionId);
13842
+ else this.recordSession(chat, sessionId, run, sessionModel);
13843
+ },
13844
+ onRunning: () => {
13845
+ turn.running.set();
13846
+ },
13847
+ onTurn: (sessionId, what, messageId) => {
13848
+ if (what === "started") store.turns.turnStarted(sessionId, { prompt: run.text, messageId, turnId });
13849
+ else if (what === "delivered") store.turns.turnDelivered(sessionId, { messageId, turnId });
13850
+ else store.turns.turnNotDelivered(sessionId, turnId);
13851
+ },
13852
+ stopRequested: () => turn.stopRequested,
13853
+ toolDenied: () => turn.toolDenied,
13854
+ stopConfirmed: () => turn.stopConfirmed,
13855
+ reportView: () => this.ctx.deps.reportView(chat.sender.id, chat.scope)
13856
+ };
13857
+ }
13858
+ async guarded(request, run, key, turn) {
13859
+ const turnId = this.ctx.deps.random.hexId();
13860
+ try {
13861
+ const outcome = await this.ctx.deps.turns.run(run, this.hooks(request, run, turn, turnId));
13862
+ if (request.review) await request.review.after(outcome);
13863
+ } catch (error) {
13864
+ const ref = this.ctx.deps.random.hexId().slice(0, 8);
13865
+ this.ctx.deps.logger.error("turn failed", { ref, error: errorName(error) });
13866
+ await this.tell(request, `Something went wrong on my side (ref ${ref}). Please try again.`);
13867
+ } finally {
13868
+ this.ctx.turns.drop(key, turn);
13869
+ }
13870
+ }
13871
+ async tell(request, text8) {
13872
+ try {
13873
+ await request.chat.say(text8);
13874
+ const report = this.ctx.deps.reportView(request.chat.sender.id, request.chat.scope);
13875
+ if (report !== void 0) await request.chat.send(report);
13876
+ } catch (error) {
13877
+ this.ctx.deps.logger.warn("a failed turn could not be said", { error: errorName(error) });
13878
+ }
13879
+ }
13880
+ /**
13881
+ * The turn has a session: it is the chat's session, and a 1:1 chat remembers it for /sessions, with what a session
13882
+ * this turn created was started with and the repository it is bound to (which uses up the `/repo` choice).
13883
+ */
13884
+ recordSession(chat, sessionId, run, sessionModel) {
13885
+ const { store, config } = this.ctx.deps;
13886
+ const userId = chat.sender.id;
13887
+ store.sessions.setSession(
13888
+ userId,
13889
+ { sessionId, pluginId: config.pluginId, model: sessionModel, shared: chat.scope !== "" },
13890
+ chat.scope
13891
+ );
13892
+ if (chat.scope) return;
13893
+ const created = run.sessionId !== sessionId;
13894
+ if (created && run.sessionId) store.repoHistory.forgetSession(userId, run.sessionId);
13895
+ const url = run.repo?.url ?? "";
13896
+ const bound = created && run.repo ? url.startsWith(`${REPO_HOST}/`) ? url.slice(REPO_HOST.length + 1) : url : "";
13897
+ store.repoHistory.noteSession(userId, sessionId, {
13898
+ title: created ? cpHead(squashSpaces(run.text), 50) : "",
13899
+ repo: bound,
13900
+ branch: bound && run.repo ? run.repo.branch : ""
13901
+ });
13902
+ if (created && run.repo) store.repoHistory.clearPendingRepo(userId);
13903
+ }
13904
+ };
13905
+
13906
+ // packages/core/src/routing/personal-text.ts
13907
+ var ELSEWHERE = "Files are not passed on from this chat while it talks to an agent; only your words were sent.";
13908
+ var PersonalText = class {
13909
+ constructor(ctx, commands, starter) {
13910
+ this.ctx = ctx;
13911
+ this.commands = commands;
13912
+ this.starter = starter;
13913
+ }
13914
+ ctx;
13915
+ commands;
13916
+ starter;
13917
+ /**
13918
+ * What the person wrote, with a word of the commands menu read as its command and a sentence help teaches read as the
13919
+ * command it means; a sentence for what this chat cannot do right now stays words. A choice picked on the assistant's card
13920
+ * is the words picked, never a command.
13921
+ */
13922
+ textOf(message, group = false) {
13923
+ if (message.choice === true) return message.text;
13924
+ const { github, assistant, remote, config } = this.ctx.deps;
13925
+ const userId = group ? void 0 : message.sender.id;
13926
+ const on = (test) => userId !== void 0 && test(userId);
13927
+ const attached = on((id) => remote.attached(id));
13928
+ const off = /* @__PURE__ */ new Set();
13929
+ const rules = [
13930
+ ["github", github !== void 0],
13931
+ ["board", on((id) => assistant.on(id))],
13932
+ ["brief", config.briefEnabled],
13933
+ ["brief now", on((id) => assistant.on(id))],
13934
+ ["agents", remote.enabled],
13935
+ ["agent", remote.enabled],
13936
+ ["agent detach", attached],
13937
+ ["stop it", attached],
13938
+ // ("停掉它" stops the attached agent; with the assistant it is the assistant's to read)
13939
+ ["new", !(attached && on((id) => assistant.on(id)))]
13940
+ // (in an attached chat: a new agent session, the assistant's)
13941
+ ];
13942
+ for (const [word2, enabled] of rules) if (!enabled) off.add(word2);
13943
+ if (remote.enabled && on((id) => assistant.on(id)) && menuCommand(message.text, group) === "/agent sessions" && !/agent/iu.test(message.text))
13944
+ return "/sessions";
13945
+ return naturalCommand(menuCommand(message.text, group), group, off);
13946
+ }
13947
+ async onText(message, chat, key) {
13948
+ const text8 = this.textOf(message);
13949
+ if (message.choice === true) {
13950
+ await this.sentOn(message, chat, key);
13951
+ return;
13952
+ }
13953
+ if (parseCommand(text8) === "unlink" && !message.text.startsWith("/")) {
13954
+ await chat.say(routingText.in(chat.lang()).unlinkAsk);
13955
+ return;
13956
+ }
13957
+ if (await this.commands.run(text8, chat, key, message)) return;
13958
+ if (!this.talksToAgent(message))
13959
+ await this.offerGithub(message, chat, key, text8, (m, fresh) => this.sentOn(m, chat, fresh));
13960
+ await this.sentOn(message, chat, key);
13961
+ }
13962
+ /**
13963
+ * Whether this 1:1 message goes to the remote session the chat is attached to: only words the person typed, and not
13964
+ * those that start by naming the bot. A chat whose session card is the window keeps its chat box for the bot: only a
13965
+ * reply that quotes the card goes to the session.
13966
+ */
13967
+ talksToAgent(message) {
13968
+ const { remote } = this.ctx.deps;
13969
+ const userId = message.sender.id;
13970
+ if (!remote.attached(userId) || message.provenance !== "typed" || message.namesBotFirst) return false;
13971
+ if (remote.windowMode(userId) === "card") return remote.quotesWindow(userId, message);
13972
+ return true;
13973
+ }
13974
+ /** The files that go to Claw with a message; the person is told about the ones that do not, and they are counted. */
13975
+ async attachments(message, chat, elsewhere = false) {
13976
+ const { messageText, metrics } = this.ctx.deps;
13977
+ const unreadable = messageText.unreadable(message);
13978
+ if (unreadable !== void 0) {
13979
+ metrics.filesDropped("unreadable", unreadable.count);
13980
+ await chat.say(unreadable.line);
13981
+ }
13982
+ if (message.files.length > 0 && elsewhere) {
13983
+ metrics.filesDropped("remote_agent", message.files.length);
13984
+ await chat.say(ELSEWHERE);
13985
+ return [];
13986
+ }
13987
+ return message.files;
13988
+ }
13989
+ /** What the chat is attached to, for the assistant (only where remote agents are on). */
13990
+ async attached(userId, key) {
13991
+ const { remote } = this.ctx.deps;
13992
+ return remote.enabled ? { attached: await remote.chatContext(userId, key) } : {};
13993
+ }
13994
+ /** A 1:1 message that is no command, as it goes on to the attached agent, the assistant or Claw. */
13995
+ async sentOn(message, chat, key) {
13996
+ const { messageText, assistant } = this.ctx.deps;
13997
+ const userId = chat.sender.id;
13998
+ const text8 = this.textOf(message);
13999
+ const toAgent = this.talksToAgent(message);
14000
+ const quote = toAgent && this.ctx.deps.remote.quotesWindow(userId, message) ? "" : messageText.quoteBlock(message, false);
14001
+ const sent = messageText.withLinks(text8, message);
14002
+ const prompt = messageText.withQuote(sent, quote);
14003
+ const files = await this.attachments(message, chat, toAgent);
14004
+ const control = message.choice !== true && this.sessionControl(userId, prompt, toAgent, quote, files, text8);
14005
+ if (control && assistant.understands(userId)) {
14006
+ await this.understand(message, chat, key);
14007
+ } else if (prompt && toAgent && !control) {
14008
+ await this.remotePrompt(message, chat, key, prompt);
14009
+ } else if (prompt || files.length > 0) {
14010
+ await this.toAssistantOrTurn(message, chat, key, { text: text8, quote, sent, prompt, files });
14011
+ }
14012
+ }
14013
+ /**
14014
+ * A message to an attached chat that is about the sessions themselves (switch, open a new one), with the assistant on to
14015
+ * read it: the words alone (a quote or a file goes to the agent with them).
14016
+ */
14017
+ sessionControl(userId, prompt, toAgent, quote, files, text8) {
14018
+ if (!prompt || !toAgent || quote || files.length > 0) return false;
14019
+ return this.ctx.deps.assistant.on(userId) && sessionControl(text8);
14020
+ }
14021
+ /** A message the assistant takes when it is on, else a turn of Claw's. */
14022
+ async toAssistantOrTurn(message, chat, key, said2) {
14023
+ const { assistant } = this.ctx.deps;
14024
+ const userId = chat.sender.id;
14025
+ if (assistant.on(userId) && await this.ctx.admission.assistantOverLimit(chat, message.id, key)) return;
14026
+ const routed = await assistant.route({
14027
+ userId,
14028
+ credential: key,
14029
+ prompt: said2.prompt || assistant.filesOnly(chat.lang()),
14030
+ conversation: chat.conversation,
14031
+ files: said2.files,
14032
+ ...await this.attached(userId, key)
14033
+ });
14034
+ if (routed) {
14035
+ logTurnStarted(this.ctx.deps.logger, message.clientPlatform, false, "assistant");
14036
+ return;
14037
+ }
14038
+ this.ctx.admission.dropLastHandoff(userId);
14039
+ await this.starter.start({
14040
+ chat,
14041
+ credential: key,
14042
+ text: said2.text,
14043
+ messageId: message.id,
14044
+ clientPlatform: message.clientPlatform,
14045
+ quote: said2.quote,
14046
+ sent: said2.sent,
14047
+ files: said2.files,
14048
+ typed: true
14049
+ });
14050
+ }
14051
+ /** A prompt for the attached agent, within the task limit (the slot is held until the remote turn counts as running). */
14052
+ async remotePrompt(message, chat, key, prompt) {
14053
+ const { remote } = this.ctx.deps;
14054
+ const request = {
14055
+ message,
14056
+ chat: chat.sender,
14057
+ credential: key,
14058
+ prompt,
14059
+ conversation: chat.conversation,
14060
+ provider: this.ctx.credentials.provider(chat.sender.id, key)
14061
+ };
14062
+ if (remote.running(chat.sender.id)) {
14063
+ await remote.prompt(request);
14064
+ return;
14065
+ }
14066
+ if (!await this.ctx.admission.admitRemote(chat, message.id, key)) return;
14067
+ try {
14068
+ await remote.prompt(request);
14069
+ } finally {
13430
14070
  this.ctx.admission.letGo(chat.sender.id);
13431
14071
  }
13432
14072
  }
@@ -13493,6 +14133,7 @@ var PersonalText = class {
13493
14133
  });
13494
14134
  const held = assistant.reachable(userId);
13495
14135
  if (held && await this.ctx.admission.assistantOverLimit(chat, message.id, key)) return;
14136
+ if (held) logTurnStarted(logger, message.clientPlatform, false, "assistant");
13496
14137
  store.assistantMessages.noteAssistantMessage(
13497
14138
  channelMessageId,
13498
14139
  userId,
@@ -13573,7 +14214,8 @@ var GroupRouter = class {
13573
14214
  async message(message, chat) {
13574
14215
  const words3 = this.words(message);
13575
14216
  learnLang(this.ctx.deps.store, chat.sender.id, message.text);
13576
- if (await this.groupOwn(message, chat, words3)) return;
14217
+ const choice = message.choice === true;
14218
+ if (!choice && await this.groupOwn(message, chat, words3)) return;
13577
14219
  const route = {
13578
14220
  sender: chat.sender,
13579
14221
  conversation: chat.conversation,
@@ -13582,10 +14224,13 @@ var GroupRouter = class {
13582
14224
  message,
13583
14225
  text: words3.text
13584
14226
  };
13585
- for (const interceptor of this.ctx.deps.handlers.groupInterceptors) if (await interceptor.intercept(route)) return;
14227
+ if (!choice) {
14228
+ for (const interceptor of this.ctx.deps.handlers.groupInterceptors)
14229
+ if (await interceptor.intercept(route)) return;
14230
+ }
13586
14231
  const key = await this.ctx.credentials.credential(chat, false);
13587
14232
  if (key === void 0) {
13588
- const keep = parseCommand(words3.text) === null;
14233
+ const keep = choice || parseCommand(words3.text) === null;
13589
14234
  await this.onboarding.member(message, chat, { ...words3, keep, identity: false });
13590
14235
  return;
13591
14236
  }
@@ -13628,7 +14273,7 @@ var GroupRouter = class {
13628
14273
  return !(word2 === "/agent" && remoteOn && (verb === "attach" || verb === "detach"));
13629
14274
  }
13630
14275
  async withKey(message, chat, key, words3) {
13631
- const command = parseCommand(words3.text);
14276
+ const command = message.choice === true ? null : parseCommand(words3.text);
13632
14277
  if (command === "new") await this.sessions.new(chat, chat.scope);
13633
14278
  else if (command === "stop") await this.sessions.stop(chat, key, chat.scope);
13634
14279
  else if (command === "whoami") await this.whoami(chat, key);
@@ -13643,7 +14288,8 @@ var GroupRouter = class {
13643
14288
  clientPlatform: message.clientPlatform,
13644
14289
  quote: this.withHistory(message, words3.quote),
13645
14290
  sent: words3.sent,
13646
- files
14291
+ files,
14292
+ typed: true
13647
14293
  });
13648
14294
  }
13649
14295
  }
@@ -13807,262 +14453,22 @@ var LinkCommands = class {
13807
14453
  if (!token.ok && token.error.kind === "not_bound") {
13808
14454
  const notYet = linkText.in(chat.lang());
13809
14455
  const card = await this.ctx.credentials.bindingCard(chat, notYet.notConfirmedYet);
13810
- await this.ctx.settle(action, chat, card, { fallback: notYet.notConfirmedLine });
13811
- return;
13812
- }
13813
- if (!token.ok) {
13814
- logger.warn("token exchange failed", { kind: token.error.kind });
13815
- const text8 = said2.platformUnreachable;
13816
- const card = this.ctx.views.note(`${text8}${said2.pressConfirmAgain}`, {
13817
- buttons: [CONFIRM_BUTTON],
13818
- title: said2.connectTitle({ bot: this.ctx.deps.config.botName })
13819
- });
13820
- await this.ctx.settle(action, chat, card, { fallback: text8 });
13821
- return;
13822
- }
13823
- const pending = store.onboarding.takePendingRequest(chat.sender.id);
13824
- await this.ctx.settle(action, chat, said2.connected + (pending === null ? said2.getStarted : said2.runningFromGroup));
13825
- if (pending !== null) await this.resume(chat, pending, true);
13826
- }
13827
- };
13828
-
13829
- // packages/core/src/routing/turn-start.ts
13830
- var MOBILE_PLATFORMS = ["ios", "ipados", "android"];
13831
- function isMobile(clientPlatform) {
13832
- return MOBILE_PLATFORMS.includes(clientPlatform.toLowerCase());
13833
- }
13834
- var TurnStarter = class {
13835
- constructor(ctx) {
13836
- this.ctx = ctx;
13837
- }
13838
- ctx;
13839
- running = /* @__PURE__ */ new Set();
13840
- /** The turns that run now (a test waits for them). */
13841
- settled() {
13842
- return Promise.all([...this.running]).then(() => void 0);
13843
- }
13844
- /** The turn that runs (its end is `done`), or undefined when none was started (the person was told why). */
13845
- async start(request) {
13846
- const { chat, review } = request;
13847
- const key = review ? reviewTurnKey(chat.sender.id, chat.scope, review.itemId, review.pull) : chatTurnKey(chat.sender.id, chat.scope);
13848
- const turn = new ActiveTurn(review !== void 0, review?.itemId ?? "");
13849
- const admitted = review === void 0 && await this.admit(request, key, turn);
13850
- if (admitted === void 0) return void 0;
13851
- if (!admitted) {
13852
- const queued = await this.queueBehind(key, turn, chat);
13853
- if (queued === "refused") return void 0;
13854
- if (queued === "waiting") return this.track(this.afterFront(request, key, turn));
13855
- }
13856
- return this.launch(request, key, turn);
13857
- }
13858
- track(task) {
13859
- this.running.add(task);
13860
- void task.finally(() => this.running.delete(task));
13861
- return { done: task };
13862
- }
13863
- /** The turn goes to Claw now; its end is `done`. */
13864
- async launch(request, key, turn) {
13865
- turn.launched = true;
13866
- let run;
13867
- try {
13868
- run = await this.runOf(request);
13869
- } catch (error) {
13870
- this.ctx.turns.drop(key, turn);
13871
- throw error;
13872
- }
13873
- return this.track(this.guarded(request, run, key, turn));
13874
- }
13875
- /**
13876
- * A queued turn: it goes once every turn in front of it is over, or stopped (Claw took its interrupt: Claw is free for the next
13877
- * message, whether or not that turn ever reports its end); the caller does not wait for it. Not at all when it was given up
13878
- * meanwhile (`/stop`, said there).
13879
- */
13880
- async afterFront(request, key, turn) {
13881
- for (; ; ) {
13882
- if (turn.over) return;
13883
- const turns = this.ctx.turns.of(key);
13884
- const at = turns.indexOf(turn);
13885
- if (at < 0) return;
13886
- const blocking = turns.slice(0, at).find((front) => !front.stopped.isSet);
13887
- if (blocking === void 0) break;
13888
- await Promise.race([blocking.ended.whenSet(), blocking.stopped.whenSet(), turn.ended.whenSet()]);
13889
- }
13890
- let started;
13891
- try {
13892
- started = await this.launch(request, key, turn);
13893
- } catch (error) {
13894
- const ref = this.ctx.deps.random.hexId().slice(0, 8);
13895
- this.ctx.deps.logger.error("a queued turn could not start", { ref, error: errorName(error) });
13896
- await this.tell(request, `Something went wrong on my side (ref ${ref}). Please try again.`);
13897
- return;
13898
- }
13899
- await started.done;
13900
- }
13901
- /** True when admitted as the chat's first turn; false when the chat has one (queue behind it); undefined: refused. */
13902
- async admit(request, key, turn) {
13903
- if (this.ctx.turns.has(key)) return false;
13904
- const { chat } = request;
13905
- return this.ctx.admission.lock.run(chat.sender.id, async () => {
13906
- if (this.ctx.turns.has(key)) return false;
13907
- if (await this.ctx.admission.overLimit(chat, request.messageId, "claw_turn", "", request.credential))
13908
- return void 0;
13909
- this.ctx.turns.claim(key, turn);
13910
- return true;
13911
- });
13912
- }
13913
- /**
13914
- * Registers `turn` behind the chat's running one: "front" when the chat has none after all (it runs now), "waiting" when it
13915
- * is queued (the person is told its place), "refused" (and told why) when it may not wait.
13916
- */
13917
- async queueBehind(key, turn, chat) {
13918
- const queued = this.ctx.turns.enqueue(key, turn);
13919
- const said2 = routingText.in(chat.lang());
13920
- if (queued === "still_working" || queued === "queue_full") {
13921
- await chat.say(queued === "still_working" ? said2.stillWorking : said2.queueFull({ n: QUEUED_TURNS_MAX }));
13922
- return "refused";
13923
- }
13924
- if (queued.front === turn) return "front";
13925
- const stoppedInFront = this.ctx.turns.of(key).every((front) => front === turn || front.stopped.isSet);
13926
- if (stoppedInFront) return "waiting";
13927
- try {
13928
- await chat.say(said2.queuedBehind({ n: queued.waiting }));
13929
- } catch (error) {
13930
- this.ctx.turns.drop(key, turn);
13931
- throw error;
13932
- }
13933
- return "waiting";
13934
- }
13935
- async sessionOf(request) {
13936
- const { chat, review } = request;
13937
- const { store, config } = this.ctx.deps;
13938
- if (review !== void 0) return review.itemSessionId;
13939
- const sessionId = store.sessions.getSession(chat.sender.id, chat.scope);
13940
- if (sessionId === null) return null;
13941
- if (Boolean(store.sessions.getSessionPlugin(chat.sender.id, chat.scope)) !== Boolean(config.pluginId)) {
13942
- await chat.say("Starting a new session so I can use the Primus platform tools.");
13943
- return null;
13944
- }
13945
- if (chat.scope && !store.sessions.getSessionShared(chat.sender.id, chat.scope)) return null;
13946
- return sessionId;
13947
- }
13948
- providerOf(request) {
13949
- const usesExchange = request.review !== void 0 || this.ctx.credentials.onExchange;
13950
- if (!this.ctx.deps.exchange.enabled || !usesExchange) return void 0;
13951
- return () => this.ctx.credentials.fresh(request.chat.sender.id, request.credential);
13952
- }
13953
- ownThumbs(request) {
13954
- return isMobile(request.clientPlatform) && request.review === void 0 && this.ctx.deps.config.ownThumbsMobile;
13955
- }
13956
- /** What the run reads of the chat's session: its id, the model it has, the repository a new one is bound to. */
13957
- async sessionPart(request) {
13958
- const { chat, review } = request;
13959
- const { store } = this.ctx.deps;
13960
- const sessionId = await this.sessionOf(request);
13961
- const chosen = sessionId === null && !chat.scope ? store.repoHistory.getPendingRepo(chat.sender.id) : null;
13962
- return {
13963
- sessionId,
13964
- sessionModel: sessionId === null || review ? "" : store.sessions.getSessionModel(chat.sender.id, chat.scope),
13965
- repo: chosen === null ? null : { url: `${REPO_HOST}/${chosen.repo}`, branch: chosen.branch }
13966
- };
13967
- }
13968
- async runOf(request) {
13969
- const { chat, review } = request;
13970
- const { store, config, logger } = this.ctx.deps;
13971
- const session = await this.sessionPart(request);
13972
- const group = chat.scope !== "";
13973
- logger.info("turn started from Teams client", {
13974
- platform: request.clientPlatform || "unknown",
13975
- chat: group ? "group" : "1:1"
13976
- });
13977
- return {
13978
- ...session,
13979
- conversation: chat.conversation,
13980
- credential: request.credential,
13981
- provider: this.providerOf(request),
13982
- text: request.text,
13983
- messageId: request.messageId,
13984
- group,
13985
- userId: chat.sender.id,
13986
- ownerName: chat.sender.name,
13987
- model: review ? review.model || config.reviewModel || config.model : config.model,
13988
- fallbackModel: config.fallbackModel,
13989
- showChanges: !group && review === void 0,
13990
- quote: request.quote ?? "",
13991
- sent: request.sent ?? "",
13992
- files: request.files ?? [],
13993
- lang: review?.lang ?? chat.lang(),
13994
- ownThumbs: this.ownThumbs(request),
13995
- notifies: this.ctx.deps.subscriptions !== void 0 && store.notifySubs.getNotifySub(chat.sender.id) !== null,
13996
- review: review?.fields
13997
- };
13998
- }
13999
- hooks(request, run, turn, turnId) {
14000
- const { review, chat } = request;
14001
- const store = this.ctx.deps.store;
14002
- return {
14003
- onSession: (sessionId, sessionModel) => {
14004
- if (review) review.onSession(sessionId);
14005
- else this.recordSession(chat, sessionId, run, sessionModel);
14006
- },
14007
- onRunning: () => {
14008
- turn.running.set();
14009
- },
14010
- onTurn: (sessionId, what, messageId) => {
14011
- if (what === "started") store.turns.turnStarted(sessionId, { prompt: run.text, messageId, turnId });
14012
- else if (what === "delivered") store.turns.turnDelivered(sessionId, { messageId, turnId });
14013
- else store.turns.turnNotDelivered(sessionId, turnId);
14014
- },
14015
- stopRequested: () => turn.stopRequested,
14016
- toolDenied: () => turn.toolDenied,
14017
- stopConfirmed: () => turn.stopConfirmed,
14018
- reportView: () => this.ctx.deps.reportView(chat.sender.id, chat.scope)
14019
- };
14020
- }
14021
- async guarded(request, run, key, turn) {
14022
- const turnId = this.ctx.deps.random.hexId();
14023
- try {
14024
- const outcome = await this.ctx.deps.turns.run(run, this.hooks(request, run, turn, turnId));
14025
- if (request.review) await request.review.after(outcome);
14026
- } catch (error) {
14027
- const ref = this.ctx.deps.random.hexId().slice(0, 8);
14028
- this.ctx.deps.logger.error("turn failed", { ref, error: errorName(error) });
14029
- await this.tell(request, `Something went wrong on my side (ref ${ref}). Please try again.`);
14030
- } finally {
14031
- this.ctx.turns.drop(key, turn);
14032
- }
14033
- }
14034
- async tell(request, text8) {
14035
- try {
14036
- await request.chat.say(text8);
14037
- const report = this.ctx.deps.reportView(request.chat.sender.id, request.chat.scope);
14038
- if (report !== void 0) await request.chat.send(report);
14039
- } catch (error) {
14040
- this.ctx.deps.logger.warn("a failed turn could not be said", { error: errorName(error) });
14456
+ await this.ctx.settle(action, chat, card, { fallback: notYet.notConfirmedLine });
14457
+ return;
14041
14458
  }
14042
- }
14043
- /**
14044
- * The turn has a session: it is the chat's session, and a 1:1 chat remembers it for /sessions, with what a session
14045
- * this turn created was started with and the repository it is bound to (which uses up the `/repo` choice).
14046
- */
14047
- recordSession(chat, sessionId, run, sessionModel) {
14048
- const { store, config } = this.ctx.deps;
14049
- const userId = chat.sender.id;
14050
- store.sessions.setSession(
14051
- userId,
14052
- { sessionId, pluginId: config.pluginId, model: sessionModel, shared: chat.scope !== "" },
14053
- chat.scope
14054
- );
14055
- if (chat.scope) return;
14056
- const created = run.sessionId !== sessionId;
14057
- if (created && run.sessionId) store.repoHistory.forgetSession(userId, run.sessionId);
14058
- const url = run.repo?.url ?? "";
14059
- const bound = created && run.repo ? url.startsWith(`${REPO_HOST}/`) ? url.slice(REPO_HOST.length + 1) : url : "";
14060
- store.repoHistory.noteSession(userId, sessionId, {
14061
- title: created ? cpHead(squashSpaces(run.text), 50) : "",
14062
- repo: bound,
14063
- branch: bound && run.repo ? run.repo.branch : ""
14064
- });
14065
- if (created && run.repo) store.repoHistory.clearPendingRepo(userId);
14459
+ if (!token.ok) {
14460
+ logger.warn("token exchange failed", { kind: token.error.kind });
14461
+ const text8 = said2.platformUnreachable;
14462
+ const card = this.ctx.views.note(`${text8}${said2.pressConfirmAgain}`, {
14463
+ buttons: [CONFIRM_BUTTON],
14464
+ title: said2.connectTitle({ bot: this.ctx.deps.config.botName })
14465
+ });
14466
+ await this.ctx.settle(action, chat, card, { fallback: text8 });
14467
+ return;
14468
+ }
14469
+ const pending = store.onboarding.takePendingRequest(chat.sender.id);
14470
+ await this.ctx.settle(action, chat, said2.connected + (pending === null ? said2.getStarted : said2.runningFromGroup));
14471
+ if (pending !== null) await this.resume(chat, pending, true);
14066
14472
  }
14067
14473
  };
14068
14474
 
@@ -14171,7 +14577,7 @@ var Router = class _Router {
14171
14577
  */
14172
14578
  async withinRate(message, chat) {
14173
14579
  const group = message.conversation.kind === "group";
14174
- if (group && !message.mentioned || _Router.isStop(message.text, group)) return true;
14580
+ if (group && !message.mentioned || message.choice !== true && _Router.isStop(message.text, group)) return true;
14175
14581
  const { limits, securityEvent, metrics } = this.ctx.deps;
14176
14582
  const verdict = limits.checkMessage(chat.sender.id);
14177
14583
  if (verdict.allowed) return true;
@@ -14228,22 +14634,6 @@ var Router = class _Router {
14228
14634
  }
14229
14635
  };
14230
14636
 
14231
- // packages/core/src/text/plain.ts
14232
- function clip3(text8, limit) {
14233
- return clipLine(squashSpaces(text8), limit);
14234
- }
14235
- function pyJson(value, options = {}) {
14236
- return options.compact ? compact(value) : pyJsonDumps(value);
14237
- }
14238
- function compact(value) {
14239
- if (Array.isArray(value)) return `[${value.map(compact).join(",")}]`;
14240
- if (value !== null && typeof value === "object") {
14241
- const entries = Object.entries(value);
14242
- return `{${entries.map(([k, v]) => `${pyJsonDumps(k)}:${compact(v)}`).join(",")}}`;
14243
- }
14244
- return pyJsonDumps(value);
14245
- }
14246
-
14247
14637
  // packages/core/src/approvals/sensitive.ts
14248
14638
  var WORDS = /* @__PURE__ */ new Set([
14249
14639
  "token",
@@ -14510,145 +14900,6 @@ function approvalView(request) {
14510
14900
  };
14511
14901
  }
14512
14902
 
14513
- // packages/core/src/approvals/click.ts
14514
- function viewMessage2(view7, fallbackText = "") {
14515
- return { body: { type: "view", view: view7, fallbackText } };
14516
- }
14517
-
14518
- // packages/core/src/proactive/quiet.ts
14519
- var APPROVAL_DEFAULT_SECONDS = 600;
14520
- var PERMISSION_DEFAULT_SECONDS = 600;
14521
- var PERMISSION_MAX_SECONDS = 3600;
14522
- var formatters = /* @__PURE__ */ new Map();
14523
- function formatter(zone) {
14524
- const known = formatters.get(zone);
14525
- if (known) return known;
14526
- try {
14527
- const made = new Intl.DateTimeFormat("en-US", {
14528
- timeZone: zone,
14529
- hour: "2-digit",
14530
- minute: "2-digit",
14531
- hourCycle: "h23"
14532
- });
14533
- formatters.set(zone, made);
14534
- return made;
14535
- } catch {
14536
- return void 0;
14537
- }
14538
- }
14539
- function zoneOf3(prefs) {
14540
- return prefs.timezone && formatter(prefs.timezone) ? prefs.timezone : DEFAULT_TIMEZONE;
14541
- }
14542
- function localMinutes(epochSeconds2, zone) {
14543
- const parts2 = (formatter(zone) ?? formatter(DEFAULT_TIMEZONE))?.formatToParts(new Date(epochSeconds2 * 1e3)) ?? [];
14544
- const value = (type) => Number(parts2.find((p) => p.type === type)?.value ?? "0");
14545
- return value("hour") * 60 + value("minute");
14546
- }
14547
- function minutesOf(clock2) {
14548
- const [hour = "0", minute = "0"] = clock2.split(":");
14549
- return Number(hour) * 60 + Number(minute);
14550
- }
14551
- function inQuiet(now, zone, start, end) {
14552
- const minute = localMinutes(now, zone);
14553
- const s = minutesOf(start);
14554
- const e = minutesOf(end);
14555
- if (s === e) return false;
14556
- return s < e ? s <= minute && minute < e : minute >= s || minute < e;
14557
- }
14558
- function muted(prefs, now) {
14559
- if (prefs.pausedUntil !== null && now < prefs.pausedUntil) return "paused";
14560
- if (prefs.quietEnabled && inQuiet(now, zoneOf3(prefs), prefs.quietStart, prefs.quietEnd)) return "quiet";
14561
- return void 0;
14562
- }
14563
- function isoSeconds2(raw) {
14564
- if (typeof raw !== "string") return void 0;
14565
- const match = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2}(?::?\d{2})?)$/.exec(
14566
- raw.trim()
14567
- );
14568
- if (!match) return void 0;
14569
- const at = Date.parse(raw.trim().replace(" ", "T"));
14570
- return Number.isNaN(at) ? void 0 : at / 1e3;
14571
- }
14572
- function approvalExpiry(event, now) {
14573
- const approval = event["approval"];
14574
- const raw = typeof approval === "object" && approval !== null ? approval["expires_at"] : void 0;
14575
- return isoSeconds2(raw) ?? now + APPROVAL_DEFAULT_SECONDS;
14576
- }
14577
- function remoteAskExpiry(ask3, now) {
14578
- const at = isoSeconds2(ask3["expires_at"]);
14579
- return at === void 0 ? now + PERMISSION_DEFAULT_SECONDS : Math.min(at, now + PERMISSION_MAX_SECONDS);
14580
- }
14581
-
14582
- // packages/core/src/approvals/questions.ts
14583
- import { z as z4 } from "zod";
14584
- var QUESTION_TOOL2 = "ask_user_question";
14585
- var QUESTIONS_MAX = 5;
14586
- var OPTIONS_MAX = 10;
14587
- var QUESTION_MAX = 500;
14588
- var OPTION_MAX = 120;
14589
- var ANSWER_MAX = 1e3;
14590
- function scalarText(value) {
14591
- if (value === null || value === void 0 || value === false || value === 0 || value === "") return "";
14592
- if (typeof value === "string") return value;
14593
- if (typeof value === "number") return String(value);
14594
- if (value === true) return "True";
14595
- return "";
14596
- }
14597
- var rawOption = z4.object({ id: z4.unknown(), label: z4.unknown() }).partial();
14598
- var rawQuestion = z4.object({ id: z4.unknown(), question: z4.unknown(), options: z4.unknown(), allow_multiple: z4.unknown() }).partial();
14599
- function optionsOf(raw) {
14600
- const out = [];
14601
- for (const item of Array.isArray(raw) ? raw : []) {
14602
- const option = rawOption.safeParse(item);
14603
- if (!option.success || typeof item !== "object" || Array.isArray(item)) continue;
14604
- const id = scalarText(option.data.id);
14605
- if (!pyStrip(id)) continue;
14606
- out.push({ id, label: clip3(scalarText(option.data.label) || id, OPTION_MAX) });
14607
- }
14608
- return out.slice(0, OPTIONS_MAX);
14609
- }
14610
- function cardQuestions(raw) {
14611
- const out = [];
14612
- for (const item of Array.isArray(raw) ? raw : []) {
14613
- const question = rawQuestion.safeParse(item);
14614
- if (!question.success || typeof item !== "object" || Array.isArray(item)) continue;
14615
- const id = scalarText(question.data.id);
14616
- const text8 = scalarText(question.data.question);
14617
- if (!pyStrip(id) || !pyStrip(text8)) continue;
14618
- out.push({
14619
- id,
14620
- question: clip3(text8, QUESTION_MAX),
14621
- options: optionsOf(question.data.options),
14622
- allowMultiple: question.data.allow_multiple === true
14623
- });
14624
- }
14625
- return out.slice(0, QUESTIONS_MAX);
14626
- }
14627
- function questionWire(question) {
14628
- return {
14629
- id: question.id,
14630
- question: question.question,
14631
- options: question.options.map((o) => ({ id: o.id, label: o.label })),
14632
- allow_multiple: question.allowMultiple
14633
- };
14634
- }
14635
- function answersOf(data) {
14636
- const answers = /* @__PURE__ */ new Map();
14637
- const skipped = [];
14638
- cardQuestions(data["questions"]).forEach((q, i) => {
14639
- const labels = new Map(q.options.map((o) => [o.id, o.label]));
14640
- const chosen = scalarText(own(data, `choice_${String(i)}`)).split(",").flatMap((c) => {
14641
- const label = labels.get(c);
14642
- return label === void 0 ? [] : [label];
14643
- });
14644
- const typed2 = cpHead(pyStrip(scalarText(own(data, `text_${String(i)}`))), ANSWER_MAX);
14645
- const answer2 = [chosen.join(", "), typed2].filter(Boolean).join(". ");
14646
- if (answer2) answers.set(q.id, answer2);
14647
- else skipped.push(q.id);
14648
- });
14649
- return { answers, skipped };
14650
- }
14651
-
14652
14903
  // packages/core/src/approvals/decide.ts
14653
14904
  function str(value) {
14654
14905
  return typeof value === "string" ? value : typeof value === "number" ? String(value) : "";
@@ -14951,16 +15202,18 @@ var ApprovalCards = class {
14951
15202
  var TURN_DEADLINE_SECONDS2 = 30 * 60;
14952
15203
  function turnApprovals(store) {
14953
15204
  return {
14954
- record: (userId, sessionId, toolUseId, tool2, expiresIn, envelope) => store.approvals.saveApproval({
15205
+ record: (userId, sessionId, toolUseId, tool2, expiresIn, envelope, questions) => store.approvals.saveApproval({
14955
15206
  userId,
14956
15207
  sessionId,
14957
15208
  toolUseId,
14958
15209
  tool: tool2,
14959
15210
  expiresAt: store.now() + (expiresIn || TURN_DEADLINE_SECONDS2),
14960
- envelope
15211
+ envelope,
15212
+ // (a question's, as its card shows them: what a typed answer is matched against)
15213
+ ...questions === void 0 ? {} : { questions: cardQuestions(questions).map((q) => questionWire(q)) }
14961
15214
  }),
14962
- sent: (sessionId, toolUseId) => {
14963
- store.approvals.approvalSent(sessionId, toolUseId);
15215
+ sent: (sessionId, toolUseId, cardId) => {
15216
+ store.approvals.approvalSent(sessionId, toolUseId, cardId);
14964
15217
  },
14965
15218
  unsent: (sessionId, toolUseId) => {
14966
15219
  store.approvals.forgetApproval(sessionId, toolUseId);
@@ -14982,7 +15235,15 @@ var approvalTurnCards = {
14982
15235
  return viewMessage2(
14983
15236
  {
14984
15237
  name: "question",
14985
- model: { questions: cardQuestions(ask3.questions), sessionId, toolUseId, group, ownerName, note: "" }
15238
+ model: {
15239
+ questions: cardQuestions(ask3.questions),
15240
+ sessionId,
15241
+ toolUseId,
15242
+ group,
15243
+ ownerName,
15244
+ note: "",
15245
+ typedReply: true
15246
+ }
14986
15247
  },
14987
15248
  QUESTION_TOOL2
14988
15249
  );
@@ -15338,6 +15599,10 @@ var AssistantChat = class {
15338
15599
  filesOnly(lang) {
15339
15600
  return assistantChatText.in(lang).filesOnly;
15340
15601
  }
15602
+ /** The language the person chose to be answered in, as the message's `channel_context` carries it (none: nothing is sent). */
15603
+ chosenLanguage(userId) {
15604
+ return replyLanguageContext(this.o.store.prefs.getPrefs(userId));
15605
+ }
15341
15606
  langOf(userId, text8) {
15342
15607
  return answerLang(this.o.store.prefs.getPrefs(userId).lang, this.o.clientLocale?.() ?? "", text8);
15343
15608
  }
@@ -15357,10 +15622,11 @@ var AssistantChat = class {
15357
15622
  const handing = { ...request };
15358
15623
  const sent = await this.handTo(handing, async (sessionId, ready) => {
15359
15624
  const folderId = typeof request.context["channel_message_id"] === "string" ? request.context["channel_message_id"] : "";
15360
- const context2 = request.files.length ? {
15625
+ const context2 = {
15361
15626
  ...request.context,
15362
- files: await this.handOverFiles(handing, sessionId, folderId || this.o.random.hexId(), request.files)
15363
- } : request.context;
15627
+ ...request.files.length ? { files: await this.handOverFiles(handing, sessionId, folderId || this.o.random.hexId(), request.files) } : {},
15628
+ ...this.chosenLanguage(request.userId)
15629
+ };
15364
15630
  await ready();
15365
15631
  return this.o.claw.assistant.send(request.credential, sessionId, request.prompt, {
15366
15632
  ...request.modelKey ? { modelKey: request.modelKey } : {},
@@ -15432,7 +15698,7 @@ var AssistantChat = class {
15432
15698
  async (_sessionId, ready) => {
15433
15699
  const files = request.files.length ? { files: await this.handOverFiles(handing, sessionId, this.o.random.hexId(), request.files) } : {};
15434
15700
  const attached = request.attached === void 0 ? {} : { attached: attachedOf(request.attached) };
15435
- const context2 = { ...files, ...attached };
15701
+ const context2 = { ...files, ...attached, ...this.chosenLanguage(request.userId) };
15436
15702
  await ready();
15437
15703
  return this.o.claw.assistant.send(request.credential, sessionId, request.prompt, {
15438
15704
  ...Object.keys(context2).length ? { context: context2 } : {},
@@ -15933,6 +16199,117 @@ var AssistantActions = class {
15933
16199
  }
15934
16200
  };
15935
16201
 
16202
+ // packages/core/src/assistant/choice-clicks.ts
16203
+ var CHOICE_VERB = "assistant_choice";
16204
+ var CHOICE_INPUT = "choice";
16205
+ function pickedOn(card, value) {
16206
+ const places = new Set(
16207
+ pyStr(value).split(",").map((p) => p.trim()).filter((p) => /^\d{1,2}$/.test(p)).map(Number)
16208
+ );
16209
+ return card.options.filter((_, i) => places.has(i));
16210
+ }
16211
+ function typedMessage(click, text8, id) {
16212
+ const { action } = click;
16213
+ return {
16214
+ type: "message",
16215
+ id,
16216
+ sender: click.sender,
16217
+ conversation: action.conversation,
16218
+ locale: action.locale,
16219
+ clientPlatform: action.clientPlatform,
16220
+ text: text8,
16221
+ mentioned: action.conversation.kind !== "personal",
16222
+ targeted: false,
16223
+ namesBotFirst: false,
16224
+ conversationName: "",
16225
+ mentions: [],
16226
+ links: [],
16227
+ quotes: [],
16228
+ files: [],
16229
+ unreadableFiles: [],
16230
+ provenance: "typed",
16231
+ choice: true
16232
+ // (an option that is a menu word or a help phrase is an answer, never that command)
16233
+ };
16234
+ }
16235
+ function langOf3(card, click) {
16236
+ if (card?.lang === "zh" || card?.lang === "en") return card.lang;
16237
+ return click.lang();
16238
+ }
16239
+ var AssistantChoiceClicks = class {
16240
+ constructor(deps) {
16241
+ this.deps = deps;
16242
+ }
16243
+ deps;
16244
+ verbs = [CHOICE_VERB];
16245
+ /** A 1:1 chat: answered with no Claw key (the message it sends is checked as any typed one is). */
16246
+ async beforeCredential(click) {
16247
+ await this.press(click);
16248
+ return true;
16249
+ }
16250
+ async inGroup(click) {
16251
+ await this.press(click);
16252
+ return true;
16253
+ }
16254
+ async press(click) {
16255
+ const buttons2 = this.deps.store.assistantButtons;
16256
+ const card = buttons2.getChoiceCard(pyStr(click.action.data["card"]));
16257
+ const lang = langOf3(card, click);
16258
+ const t4 = assistantCardText.in(lang);
16259
+ if (card === null || click.conversation.ref.kind === "personal" && card.userId !== click.sender.id) {
16260
+ await finish(click, t4.choiceGone);
16261
+ return;
16262
+ }
16263
+ if (card.state === "done") {
16264
+ await this.sent(click, card, card.chosen, lang, false);
16265
+ return;
16266
+ }
16267
+ if (card.state !== "open") {
16268
+ await click.settle(t4.choiceSending({ answer: card.chosen.join(t4.choiceJoin) }), { announce: false });
16269
+ return;
16270
+ }
16271
+ const chosen = pickedOn(card, click.action.data[CHOICE_INPUT]);
16272
+ if (!chosen.length || !card.multiple && chosen.length > 1) {
16273
+ await this.again(click, card, lang, t4.choiceNone);
16274
+ return;
16275
+ }
16276
+ if (!buttons2.claimChoiceCard(card.cardId, chosen)) {
16277
+ const now = buttons2.getChoiceCard(card.cardId) ?? card;
16278
+ if (now.state === "done") await this.sent(click, now, now.chosen, lang, false);
16279
+ else await click.settle(t4.choiceSending({ answer: now.chosen.join(t4.choiceJoin) }), { announce: false });
16280
+ return;
16281
+ }
16282
+ const text8 = chosen.join(t4.choiceJoin);
16283
+ try {
16284
+ await this.deps.deliver(typedMessage(click, text8, `${CHOICE_VERB}:${card.cardId}`));
16285
+ } catch (error) {
16286
+ buttons2.moveChoiceCard(card.cardId, "open", { onlyFrom: "busy" });
16287
+ this.deps.logger.warn("a choice could not be sent; its card is given back", { error: errorName(error) });
16288
+ await this.again(click, card, lang, t4.choiceFailed);
16289
+ return;
16290
+ }
16291
+ buttons2.moveChoiceCard(card.cardId, "done", { onlyFrom: "busy" });
16292
+ await this.sent(click, card, chosen, lang, true);
16293
+ }
16294
+ /** The card back, open, with `note` under its options. */
16295
+ async again(click, card, lang, note) {
16296
+ const model = { ...this.model(card, lang), note };
16297
+ await click.settle(viewMessage2({ name: "choiceCard", model }), { announce: false });
16298
+ }
16299
+ /** The card replaced by what was picked and sent (said as a line too when `announce`). */
16300
+ async sent(click, card, chosen, lang, announce) {
16301
+ const t4 = assistantCardText.in(lang);
16302
+ const view7 = viewMessage2({ name: "choiceChosen", model: { text: card.text, chosen, lang } });
16303
+ await click.settle(
16304
+ view7,
16305
+ announce ? { fallback: t4.choiceSent({ answer: chosen.join(t4.choiceJoin) }) } : { announce: false }
16306
+ );
16307
+ }
16308
+ model(card, lang) {
16309
+ return { text: card.text, cardId: card.cardId, options: card.options, multiple: card.multiple, note: "", lang };
16310
+ }
16311
+ };
16312
+
15936
16313
  // packages/core/src/assistant/auto-reply.ts
15937
16314
  function answersUnasked(watch) {
15938
16315
  return watch["state"] === "active" && watch["reply_allowed"] === true;
@@ -16941,6 +17318,24 @@ function suggestedRepliesOf(event, text8, styleOn, lang) {
16941
17318
  if (replies.length) return replies.slice(0, SUGGESTIONS_MAX);
16942
17319
  return [...ASSISTANT_SUGGESTED_REPLIES[lang ?? pickLang2(text8)]];
16943
17320
  }
17321
+ var CHOICE_OPTIONS_MIN = 2;
17322
+ var CHOICE_OPTIONS_MAX = 10;
17323
+ var CHOICE_OPTION_MAX = 80;
17324
+ function choiceOf(event) {
17325
+ const choice = record2(event["choices"]);
17326
+ if (event["type"] !== "assistant.say" || choice === void 0) return void 0;
17327
+ const raw = choice["options"];
17328
+ if (!Array.isArray(raw)) return void 0;
17329
+ const options = [];
17330
+ for (const entry of raw) {
17331
+ if (typeof entry !== "string" || /[\u0000-\u001f\u007f\u2028\u2029]/u.test(entry)) return void 0;
17332
+ const option = pySplit(entry).join(" ");
17333
+ if (!option || cpLength(option) > CHOICE_OPTION_MAX) return void 0;
17334
+ if (!options.includes(option)) options.push(option);
17335
+ }
17336
+ if (options.length < CHOICE_OPTIONS_MIN || options.length > CHOICE_OPTIONS_MAX) return void 0;
17337
+ return { options, multiple: choice["multiple"] === true };
17338
+ }
16944
17339
  var REMOTE_KINDS = /* @__PURE__ */ new Set(["done", "needs_you", "error", "resolved"]);
16945
17340
  function resolvedOf(remote) {
16946
17341
  const resolved = record2(remote["resolved"]);
@@ -17077,14 +17472,19 @@ ${text8}`;
17077
17472
  return all.length ? [...said2.slice(0, -1), { ...last, suggestions: toReplies(all.slice(0, SUGGESTIONS_MAX)) }] : said2;
17078
17473
  }
17079
17474
  /**
17080
- * The card with buttons the event asks for, recorded first: a result that waits, a write Claw holds, or a command put to the
17081
- * person; undefined for none. `holdsWords`: the card shows the words, which are then not said apart.
17475
+ * The card with buttons the event asks for, recorded first: a result that waits, a write Claw holds, a command or a choice put
17476
+ * to the person; undefined for none. `holdsWords`: the card shows the words, which are then not said apart.
17082
17477
  */
17083
17478
  card(userId, event, item, said2) {
17084
17479
  const inCard = said2.inCard ? said2.text : "";
17085
17480
  if (item?.canAccept)
17086
17481
  return { message: this.workCard(userId, item, said2.text, inCard, said2.lang), holdsWords: said2.inCard };
17087
- if (item !== null) return void 0;
17482
+ const asked3 = item === null ? this.askCard(userId, event, inCard, said2.lang) : void 0;
17483
+ if (asked3 !== void 0) return { message: asked3, holdsWords: said2.inCard };
17484
+ return this.choiceCard(userId, event, inCard, said2.lang, said2.inCard);
17485
+ }
17486
+ /** A write Claw holds for the person (Allow, Deny) or a command put to them (Run, Cancel), recorded first; undefined for none. */
17487
+ askCard(userId, event, inCard, lang) {
17088
17488
  const held = confirmWriteOf(event);
17089
17489
  if (held !== void 0) {
17090
17490
  const cardId2 = this.deps.store.assistantButtons.addConfirmCard(
@@ -17093,8 +17493,8 @@ ${text8}`;
17093
17493
  held.kind,
17094
17494
  held.allowLabel
17095
17495
  );
17096
- const model = { text: inCard || held.allowLabel, cardId: cardId2, allowLabel: held.allowLabel, lang: said2.lang };
17097
- return { message: view3({ name: "confirmWrite", model }), holdsWords: said2.inCard };
17496
+ const model = { text: inCard || held.allowLabel, cardId: cardId2, allowLabel: held.allowLabel, lang };
17497
+ return view3({ name: "confirmWrite", model });
17098
17498
  }
17099
17499
  const confirm = confirmActionOf(event, this.deps.exactCommand);
17100
17500
  if (confirm === void 0) return void 0;
@@ -17105,10 +17505,19 @@ ${text8}`;
17105
17505
  confirm.name,
17106
17506
  confirm.args
17107
17507
  );
17108
- return {
17109
- message: view3({ name: "actionCard", model: { text: inCard || command, cardId, command, lang: said2.lang } }),
17110
- holdsWords: said2.inCard
17111
- };
17508
+ return view3({ name: "actionCard", model: { text: inCard || command, cardId, command, lang } });
17509
+ }
17510
+ /**
17511
+ * A choice put to the person (`choices`): its options and Send, recorded first (what a press sends is read from the record).
17512
+ * Its Send sends what was picked as the person's own message.
17513
+ */
17514
+ choiceCard(userId, event, inCard, lang, holdsWords) {
17515
+ const choice = choiceOf(event);
17516
+ if (choice === void 0) return void 0;
17517
+ const { options, multiple } = choice;
17518
+ const cardId = this.deps.store.assistantButtons.addChoiceCard(userId, { text: inCard, options, multiple, lang });
17519
+ const model = { text: inCard, cardId, options, multiple, note: "", lang };
17520
+ return { message: view3({ name: "choiceCard", model }, inCard), holdsWords };
17112
17521
  }
17113
17522
  /** What was said, as AI answers with thumbs, each with the citations its own words cite (when the whole text cites at all). */
17114
17523
  said(event, text8, parts2) {
@@ -32555,6 +32964,7 @@ export {
32555
32964
  AssistantActionClicks,
32556
32965
  AssistantActions,
32557
32966
  AssistantChat as AssistantChatService,
32967
+ AssistantChoiceClicks,
32558
32968
  BOARD_CARDS,
32559
32969
  BOARD_LINES,
32560
32970
  BOARD_RETRY_MAX,
@@ -32844,6 +33254,7 @@ export {
32844
33254
  casefold,
32845
33255
  channelActions,
32846
33256
  channelContext,
33257
+ chatCredentialOf,
32847
33258
  chatTurnKey,
32848
33259
  clawText,
32849
33260
  cleanText,
@@ -32947,6 +33358,7 @@ export {
32947
33358
  lastTurns,
32948
33359
  learnLang,
32949
33360
  limitsText,
33361
+ logTurnStarted,
32950
33362
  looksInternal,
32951
33363
  looksLikeFailure,
32952
33364
  looksLikeRepo,
@@ -33147,6 +33559,7 @@ export {
33147
33559
  turnIdOf,
33148
33560
  two,
33149
33561
  typedAnswerText,
33562
+ typedAnswers,
33150
33563
  typedMode,
33151
33564
  unique,
33152
33565
  unreachable,