@primusclaw/personal-agent 0.4.0 → 0.5.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
 
@@ -5746,6 +5757,8 @@ var turnText = defineCatalog({
5746
5757
  thinking: "Thinking\u2026",
5747
5758
  /** A turn still running when its stream has to close: the answer follows as a message of its own. */
5748
5759
  stillWorking: "Still working on it; the result will follow as a new message.",
5760
+ /** A turn whose stream is closed because a card now waits for the person's choice. */
5761
+ awaitingChoice: "Waiting for your choice in the card; I will continue once you pick.",
5749
5762
  interrupted: "Interrupted.",
5750
5763
  /** What a turn the person stopped ends with. */
5751
5764
  stopped: "Stopped.",
@@ -5767,6 +5780,7 @@ var turnText = defineCatalog({
5767
5780
  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
5781
  thinking: "\u601D\u8003\u4E2D\u2026",
5769
5782
  stillWorking: "\u4ECD\u5728\u5904\u7406\uFF0C\u5B8C\u6210\u540E\u53E6\u53D1\u7ED3\u679C\u3002",
5783
+ awaitingChoice: "\u8BF7\u5728\u5361\u7247\u91CC\u9009\u62E9\uFF0C\u9009\u597D\u540E\u6211\u63A5\u7740\u505A\u3002",
5770
5784
  interrupted: "\u5DF2\u4E2D\u65AD\u3002",
5771
5785
  stopped: "\u5DF2\u505C\u6B62\u3002",
5772
5786
  doneShort: "\u5B8C\u6210\u3002",
@@ -6949,6 +6963,8 @@ var StatusMessage = class {
6949
6963
  opening;
6950
6964
  expiry;
6951
6965
  again;
6966
+ /** The stream being closed because a card waits for the person (`awaitingChoice`). */
6967
+ choice;
6952
6968
  preparingTimer;
6953
6969
  lastText = "";
6954
6970
  lastAt = 0;
@@ -7027,20 +7043,61 @@ ${header}` : header;
7027
7043
  const live = this.live;
7028
7044
  if (!live) return;
7029
7045
  await sleepSeconds(this.clock, Math.max(STREAM_CLOSE_SECONDS - live.age(), 0), signal);
7030
- if (this.closed || !live.closable) return;
7046
+ await this.closeEarly(live, turnText.in(this.lang).stillWorking, "before the chat's limit", signal);
7047
+ }
7048
+ /**
7049
+ * 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
7050
+ * a message of its own. Nothing more is sent to the stream once this has begun.
7051
+ */
7052
+ async closeEarly(live, line2, why, signal) {
7053
+ if (this.closed || !live.closable || this.streamOver || this.expiring) return;
7031
7054
  this.expiring = true;
7032
7055
  if (this.stream && this.flusher && !this.flusher.done) await unlessAborted(this.flusher.settled(), signal);
7033
7056
  this.streamOver = true;
7034
- const line2 = turnText.in(this.lang).stillWorking;
7035
- const closed = await live.final(plainMessage(live.streamed ? `${live.streamed}
7057
+ const words3 = this.streamable() || live.streamed;
7058
+ const closed = await live.final(plainMessage(words3 ? `${words3}
7036
7059
 
7037
7060
  ${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");
7061
+ if (!closed.ok) this.log.warn(`the turn's stream could not be closed ${why}; the answer follows as a message`);
7040
7062
  this.log.info("the turn's stream was closed; the answer follows as a message of its own", {
7063
+ why,
7041
7064
  age: Math.round(live.age())
7042
7065
  });
7043
7066
  }
7067
+ /**
7068
+ * A card now waits for the person (a question of the agent): it is their turn, so the floating status must not go on
7069
+ * saying "Thinking…" until the chat's limit. An open stream is closed at once with AWAITING_CHOICE, and the answer comes
7070
+ * as a message of its own, as after the limit. Without a stream (the progress message) nothing changes: it has no
7071
+ * spinner. Never fails the turn.
7072
+ */
7073
+ awaitingChoice() {
7074
+ const live = this.live;
7075
+ if (this.closed || !live || this.streamOver || this.expiring || this.choice) return;
7076
+ this.choice = detach(async (signal) => {
7077
+ if (this.opening && !this.opening.done) await unlessAborted(this.opening.settled(), signal);
7078
+ if (!this.expiring) this.expiry?.cancel();
7079
+ await this.closeEarly(live, turnText.in(this.lang).awaitingChoice, "while a card waits for the person", signal);
7080
+ });
7081
+ }
7082
+ /**
7083
+ * Claw queued the message behind a turn it is still running: `notice` closes the stream at once, so it does not say
7084
+ * "Thinking…" for the whole wait. The answer comes as a message of its own, as after the limit. When the stream did not open
7085
+ * after all, `notice` is said as a message. False when there is no stream: the caller says it. Never fails the turn.
7086
+ */
7087
+ queued(notice) {
7088
+ const live = this.live;
7089
+ if (this.closed || !live || this.streamOver || this.expiring || this.choice) return false;
7090
+ this.choice = detach(async (signal) => {
7091
+ if (this.opening && !this.opening.done) await unlessAborted(this.opening.settled(), signal);
7092
+ if (this.closed || !live.closable || this.streamOver || this.expiring) {
7093
+ await this.surface.send(plainMessage(notice));
7094
+ return;
7095
+ }
7096
+ this.expiry?.cancel();
7097
+ await this.closeEarly(live, notice, "while the message waits behind another turn", signal);
7098
+ });
7099
+ return true;
7100
+ }
7044
7101
  /** The chat's id of the posted message; undefined when the post failed or its id never came (nothing is edited then). */
7045
7102
  async messageId(signal) {
7046
7103
  if (!this.posted) return void 0;
@@ -7185,8 +7242,7 @@ ${line2}` : line2));
7185
7242
  this.hard = this.hard || hard;
7186
7243
  this.stopPreparingTimer();
7187
7244
  if (this.flusher && !this.flusher.done && (hard || !this.sending)) this.flusher.cancel();
7188
- if (hard) this.again?.cancel();
7189
- if (hard) this.expiry?.cancel();
7245
+ if (hard) for (const task of [this.again, this.expiry, this.choice]) task?.cancel();
7190
7246
  if (hard && this.live?.closable && !this.streamOver) {
7191
7247
  this.streamOver = true;
7192
7248
  const live = this.live;
@@ -7220,7 +7276,7 @@ ${line2}` : line2));
7220
7276
  async quiesce() {
7221
7277
  this.close();
7222
7278
  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]) {
7279
+ for (const task of [this.opening, this.flusher, this.expiring ? this.expiry : void 0, this.choice]) {
7224
7280
  if (task) await task.settled();
7225
7281
  }
7226
7282
  }
@@ -7992,15 +8048,17 @@ async function ask2(ctx, sessionId, step) {
7992
8048
  const asked3 = isObject3(step.approval) && truthy(step.approval) ? approvalOf(ctx, sessionId, step.approval) : questionOf2(ctx, sessionId, step.question ?? {});
7993
8049
  const { tool: tool2, toolUseId, request, envelope } = asked3;
7994
8050
  const hooks = ctx.hooks;
7995
- if (toolUseId && hooks.onApproval?.(sessionId, toolUseId, tool2, waitSeconds(request["timeout_ms"]), envelope) === false)
8051
+ const questions = tool2 === QUESTION_TOOL ? request["questions"] : void 0;
8052
+ if (toolUseId && hooks.onApproval?.(sessionId, toolUseId, tool2, waitSeconds(request["timeout_ms"]), envelope, questions) === false)
7996
8053
  return;
8054
+ let cardId;
7997
8055
  try {
7998
- await ctx.deps.surface.deliver(envelope);
8056
+ cardId = await ctx.deps.surface.deliver(envelope);
7999
8057
  } catch (err2) {
8000
8058
  if (toolUseId) hooks.onApprovalUnsent?.(sessionId, toolUseId);
8001
8059
  throw err2;
8002
8060
  }
8003
- if (toolUseId) hooks.onApprovalSent?.(sessionId, toolUseId);
8061
+ if (toolUseId) hooks.onApprovalSent?.(sessionId, toolUseId, cardId);
8004
8062
  }
8005
8063
  async function readOnce(ctx, args, cursor, told) {
8006
8064
  const { sessionId, state, status, signal } = args;
@@ -8032,6 +8090,7 @@ async function take(ctx, args, step) {
8032
8090
  args.status.progress(step);
8033
8091
  if (truthy(step.approval) || truthy(step.question)) {
8034
8092
  await ask2(ctx, args.sessionId, step);
8093
+ if (!truthy(step.approval)) args.status.awaitingChoice();
8035
8094
  return void 0;
8036
8095
  }
8037
8096
  if (!step.finished) return void 0;
@@ -8418,8 +8477,9 @@ var TurnRun = class {
8418
8477
  state.writes = ctx.record.wrote;
8419
8478
  this.state = state;
8420
8479
  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));
8480
+ if (queuedByClaw(messageId) && !ctx.request.attach) {
8481
+ const notice = turnText.in(langOf(ctx)).queuedNotice;
8482
+ if (!this.status.queued(notice)) await ctx.deps.surface.send(plainMessage(notice));
8423
8483
  }
8424
8484
  return consume(ctx, { sessionId, state, status: this.status, running: this.running, signal });
8425
8485
  }
@@ -8573,6 +8633,10 @@ var TurnRun = class {
8573
8633
  await offerReport(ctx);
8574
8634
  }
8575
8635
  };
8636
+ var QUEUED_MESSAGE_PREFIX = "claw-pending-";
8637
+ function queuedByClaw(messageId) {
8638
+ return messageId === void 0 || messageId.startsWith(QUEUED_MESSAGE_PREFIX);
8639
+ }
8576
8640
  async function runTurn(ctx, controls) {
8577
8641
  const replyTo = streamReplyTo(ctx);
8578
8642
  if (!replyTo) detach(() => typing(ctx));
@@ -8782,9 +8846,9 @@ var TurnEngine = class {
8782
8846
  const review = run.review?.turn;
8783
8847
  return {
8784
8848
  ...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);
8849
+ onApproval: (sid, toolUseId, tool2, expiresIn, envelope, questions) => approvals.record(run.userId, sid, toolUseId, tool2, expiresIn, envelope, questions),
8850
+ onApprovalSent: (sid, toolUseId, cardId) => {
8851
+ approvals.sent(sid, toolUseId, cardId);
8788
8852
  },
8789
8853
  onApprovalUnsent: (sid, toolUseId) => {
8790
8854
  approvals.unsent(sid, toolUseId);
@@ -9697,6 +9761,14 @@ var CredentialUnavailable = class extends Error {
9697
9761
  };
9698
9762
  var IDENTITY_CARD_INTERVAL_SECONDS = 24 * 3600;
9699
9763
  var IDENTITY_CARD_NOTICE = "identity_card";
9764
+ function chatCredentialOf(links2, exchange) {
9765
+ return async (sender) => {
9766
+ const key = links2.getKey(sender.id) ?? void 0;
9767
+ if (exchange === void 0 || !exchange.enabled) return key;
9768
+ const token = await exchange.tokenFor(sender.id);
9769
+ return token.ok ? token.value : key;
9770
+ };
9771
+ }
9700
9772
  var Credentials = class {
9701
9773
  constructor(deps, views) {
9702
9774
  this.deps = deps;
@@ -13273,162 +13345,674 @@ var GroupRemote = class {
13273
13345
  }
13274
13346
  };
13275
13347
 
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
- }
13348
+ // packages/core/src/approvals/questions.ts
13349
+ import { z as z4 } from "zod";
13350
+
13351
+ // packages/core/src/text/plain.ts
13352
+ function clip3(text8, limit) {
13353
+ return clipLine(squashSpaces(text8), limit);
13354
+ }
13355
+ function pyJson(value, options = {}) {
13356
+ return options.compact ? compact(value) : pyJsonDumps(value);
13357
+ }
13358
+ function compact(value) {
13359
+ if (Array.isArray(value)) return `[${value.map(compact).join(",")}]`;
13360
+ if (value !== null && typeof value === "object") {
13361
+ const entries = Object.entries(value);
13362
+ return `{${entries.map(([k, v]) => `${pyJsonDumps(k)}:${compact(v)}`).join(",")}}`;
13376
13363
  }
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);
13364
+ return pyJsonDumps(value);
13365
+ }
13366
+
13367
+ // packages/core/src/approvals/questions.ts
13368
+ var QUESTION_TOOL2 = "ask_user_question";
13369
+ var QUESTIONS_MAX = 5;
13370
+ var OPTIONS_MAX = 10;
13371
+ var QUESTION_MAX = 500;
13372
+ var OPTION_MAX = 120;
13373
+ var ANSWER_MAX = 1e3;
13374
+ function scalarText(value) {
13375
+ if (value === null || value === void 0 || value === false || value === 0 || value === "") return "";
13376
+ if (typeof value === "string") return value;
13377
+ if (typeof value === "number") return String(value);
13378
+ if (value === true) return "True";
13379
+ return "";
13380
+ }
13381
+ var rawOption = z4.object({ id: z4.unknown(), label: z4.unknown() }).partial();
13382
+ var rawQuestion = z4.object({ id: z4.unknown(), question: z4.unknown(), options: z4.unknown(), allow_multiple: z4.unknown() }).partial();
13383
+ function optionsOf(raw) {
13384
+ const out = [];
13385
+ for (const item of Array.isArray(raw) ? raw : []) {
13386
+ const option = rawOption.safeParse(item);
13387
+ if (!option.success || typeof item !== "object" || Array.isArray(item)) continue;
13388
+ const id = scalarText(option.data.id);
13389
+ if (!pyStrip(id)) continue;
13390
+ out.push({ id, label: clip3(scalarText(option.data.label) || id, OPTION_MAX) });
13384
13391
  }
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)
13397
- });
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
13392
+ return out.slice(0, OPTIONS_MAX);
13393
+ }
13394
+ function cardQuestions(raw) {
13395
+ const out = [];
13396
+ for (const item of Array.isArray(raw) ? raw : []) {
13397
+ const question = rawQuestion.safeParse(item);
13398
+ if (!question.success || typeof item !== "object" || Array.isArray(item)) continue;
13399
+ const id = scalarText(question.data.id);
13400
+ const text8 = scalarText(question.data.question);
13401
+ if (!pyStrip(id) || !pyStrip(text8)) continue;
13402
+ out.push({
13403
+ id,
13404
+ question: clip3(text8, QUESTION_MAX),
13405
+ options: optionsOf(question.data.options),
13406
+ allowMultiple: question.data.allow_multiple === true
13409
13407
  });
13410
13408
  }
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 {
13430
- this.ctx.admission.letGo(chat.sender.id);
13431
- }
13409
+ return out.slice(0, QUESTIONS_MAX);
13410
+ }
13411
+ function questionWire(question) {
13412
+ return {
13413
+ id: question.id,
13414
+ question: question.question,
13415
+ options: question.options.map((o) => ({ id: o.id, label: o.label })),
13416
+ allow_multiple: question.allowMultiple
13417
+ };
13418
+ }
13419
+ function answersOf(data) {
13420
+ const answers = /* @__PURE__ */ new Map();
13421
+ const skipped = [];
13422
+ cardQuestions(data["questions"]).forEach((q, i) => {
13423
+ const labels = new Map(q.options.map((o) => [o.id, o.label]));
13424
+ const chosen = scalarText(own(data, `choice_${String(i)}`)).split(",").flatMap((c) => {
13425
+ const label = labels.get(c);
13426
+ return label === void 0 ? [] : [label];
13427
+ });
13428
+ const typed2 = cpHead(pyStrip(scalarText(own(data, `text_${String(i)}`))), ANSWER_MAX);
13429
+ const answer2 = [chosen.join(", "), typed2].filter(Boolean).join(". ");
13430
+ if (answer2) answers.set(q.id, answer2);
13431
+ else skipped.push(q.id);
13432
+ });
13433
+ return { answers, skipped };
13434
+ }
13435
+ function optionNamed(options, token) {
13436
+ const wanted = token.toLowerCase();
13437
+ const byLabel = options.find((o) => o.label.toLowerCase() === wanted || o.id.toLowerCase() === wanted);
13438
+ if (byLabel !== void 0 || !/^\d{1,2}$/.test(token)) return byLabel;
13439
+ return options[Number(token) - 1];
13440
+ }
13441
+ function typedAnswers(questions, typed2) {
13442
+ const reply = cpHead(pyStrip(typed2), ANSWER_MAX);
13443
+ const answers = /* @__PURE__ */ new Map();
13444
+ const only2 = questions.length === 1 ? questions[0] : void 0;
13445
+ if (only2 !== void 0) {
13446
+ const tokens2 = only2.allowMultiple ? reply.split(/\s*[,,、;;]\s*/u).filter(Boolean) : [reply];
13447
+ const named2 = tokens2.flatMap((t4) => optionNamed(only2.options, t4) ?? []);
13448
+ const labels = named2.length > 0 && named2.length === tokens2.length ? named2.map((o) => o.label) : [];
13449
+ answers.set(only2.id, labels.length ? labels.join(", ") : reply);
13450
+ } else {
13451
+ for (const q of questions) answers.set(q.id, reply);
13452
+ }
13453
+ return { answers, skipped: [] };
13454
+ }
13455
+
13456
+ // packages/core/src/approvals/click.ts
13457
+ function viewMessage2(view7, fallbackText = "") {
13458
+ return { body: { type: "view", view: view7, fallbackText } };
13459
+ }
13460
+
13461
+ // packages/core/src/proactive/quiet.ts
13462
+ var APPROVAL_DEFAULT_SECONDS = 600;
13463
+ var PERMISSION_DEFAULT_SECONDS = 600;
13464
+ var PERMISSION_MAX_SECONDS = 3600;
13465
+ var formatters = /* @__PURE__ */ new Map();
13466
+ function formatter(zone) {
13467
+ const known = formatters.get(zone);
13468
+ if (known) return known;
13469
+ try {
13470
+ const made = new Intl.DateTimeFormat("en-US", {
13471
+ timeZone: zone,
13472
+ hour: "2-digit",
13473
+ minute: "2-digit",
13474
+ hourCycle: "h23"
13475
+ });
13476
+ formatters.set(zone, made);
13477
+ return made;
13478
+ } catch {
13479
+ return void 0;
13480
+ }
13481
+ }
13482
+ function zoneOf3(prefs) {
13483
+ return prefs.timezone && formatter(prefs.timezone) ? prefs.timezone : DEFAULT_TIMEZONE;
13484
+ }
13485
+ function localMinutes(epochSeconds2, zone) {
13486
+ const parts2 = (formatter(zone) ?? formatter(DEFAULT_TIMEZONE))?.formatToParts(new Date(epochSeconds2 * 1e3)) ?? [];
13487
+ const value = (type) => Number(parts2.find((p) => p.type === type)?.value ?? "0");
13488
+ return value("hour") * 60 + value("minute");
13489
+ }
13490
+ function minutesOf(clock2) {
13491
+ const [hour = "0", minute = "0"] = clock2.split(":");
13492
+ return Number(hour) * 60 + Number(minute);
13493
+ }
13494
+ function inQuiet(now, zone, start, end) {
13495
+ const minute = localMinutes(now, zone);
13496
+ const s = minutesOf(start);
13497
+ const e = minutesOf(end);
13498
+ if (s === e) return false;
13499
+ return s < e ? s <= minute && minute < e : minute >= s || minute < e;
13500
+ }
13501
+ function muted(prefs, now) {
13502
+ if (prefs.pausedUntil !== null && now < prefs.pausedUntil) return "paused";
13503
+ if (prefs.quietEnabled && inQuiet(now, zoneOf3(prefs), prefs.quietStart, prefs.quietEnd)) return "quiet";
13504
+ return void 0;
13505
+ }
13506
+ function isoSeconds2(raw) {
13507
+ if (typeof raw !== "string") return void 0;
13508
+ 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(
13509
+ raw.trim()
13510
+ );
13511
+ if (!match) return void 0;
13512
+ const at = Date.parse(raw.trim().replace(" ", "T"));
13513
+ return Number.isNaN(at) ? void 0 : at / 1e3;
13514
+ }
13515
+ function approvalExpiry(event, now) {
13516
+ const approval = event["approval"];
13517
+ const raw = typeof approval === "object" && approval !== null ? approval["expires_at"] : void 0;
13518
+ return isoSeconds2(raw) ?? now + APPROVAL_DEFAULT_SECONDS;
13519
+ }
13520
+ function remoteAskExpiry(ask3, now) {
13521
+ const at = isoSeconds2(ask3["expires_at"]);
13522
+ return at === void 0 ? now + PERMISSION_DEFAULT_SECONDS : Math.min(at, now + PERMISSION_MAX_SECONDS);
13523
+ }
13524
+
13525
+ // packages/core/src/routing/question-reply.ts
13526
+ var TYPED_ANSWER_MARGIN_SECONDS = 15;
13527
+ async function answerWaitingQuestion(ctx, reply) {
13528
+ const { store, claw, logger } = ctx.deps;
13529
+ const { chat } = reply;
13530
+ const waiting = waitingQuestion(ctx, reply);
13531
+ if (waiting === void 0) return false;
13532
+ const { pending, questions } = waiting;
13533
+ const { sessionId, toolUseId } = pending;
13534
+ const key = await stillAsked(ctx, reply, pending);
13535
+ if (key === void 0) return false;
13536
+ if (!store.approvals.closeApproval(sessionId, toolUseId, "sending")) return false;
13537
+ const answers = typedAnswers(questions, reply.text);
13538
+ try {
13539
+ await claw.sessions.answer(key, sessionId, toolUseId, Object.fromEntries(answers.answers), answers.skipped);
13540
+ } catch (error) {
13541
+ if (!isClawOrNetwork(error)) throw error;
13542
+ const stale = error instanceof ClawError && [404, 409, 410].includes(error.status);
13543
+ store.approvals.closeApproval(sessionId, toolUseId, stale ? "expired" : "pending", { onlyIfPending: false });
13544
+ logger.warn("a typed answer to a question card was not taken; the message goes on as a new one", {
13545
+ error: statusOrKind(error)
13546
+ });
13547
+ return false;
13548
+ }
13549
+ store.approvals.closeApproval(sessionId, toolUseId, "decided", { onlyIfPending: false });
13550
+ logger.info("a typed message answered the question card waiting in the chat", {
13551
+ chat: chat.scope ? "group" : "1:1",
13552
+ questions: questions.length,
13553
+ card: pending.cardId ? "edited" : "not editable"
13554
+ });
13555
+ await settleCard(ctx, chat, pending.cardId, [...answers.answers], pickLang2(reply.text, questions[0]?.question));
13556
+ return true;
13557
+ }
13558
+ function waitingQuestion(ctx, reply) {
13559
+ const { store } = ctx.deps;
13560
+ const { chat } = reply;
13561
+ if (!pyStrip(reply.text)) return void 0;
13562
+ const sessionId = store.sessions.getSession(chat.sender.id, chat.scope);
13563
+ if (sessionId === null || store.reviewDelegation.isActingSession(sessionId)) return void 0;
13564
+ const pending = store.approvals.pendingQuestion(sessionId, chat.sender.id);
13565
+ if (pending === null || pending.expiresAt - store.now() < TYPED_ANSWER_MARGIN_SECONDS) return void 0;
13566
+ const questions = cardQuestions(pending.questions);
13567
+ return questions.length === 0 ? void 0 : { pending, questions };
13568
+ }
13569
+ async function stillAsked(ctx, reply, pending) {
13570
+ const { store, claw, logger } = ctx.deps;
13571
+ try {
13572
+ const key = await reply.credential();
13573
+ if ((await claw.sessions.turn(key, pending.sessionId)).running) return key;
13574
+ store.approvals.closeApproval(pending.sessionId, pending.toolUseId, "expired");
13575
+ return void 0;
13576
+ } catch (error) {
13577
+ if (!isClawOrNetwork(error)) throw error;
13578
+ logger.warn("a typed answer to a question card was not sent: the session's turn could not be read", {
13579
+ error: statusOrKind(error)
13580
+ });
13581
+ return void 0;
13582
+ }
13583
+ }
13584
+ async function settleCard(ctx, chat, cardId, answers, lang) {
13585
+ const { store, logger } = ctx.deps;
13586
+ const zone = zoneOf3(store.prefs.getPrefs(chat.sender.id));
13587
+ const answer2 = [...new Set(answers.map(([, a]) => a))].join(" / ");
13588
+ const words3 = approvalText.in(lang).questionReplied({ answer: answer2 });
13589
+ try {
13590
+ if (cardId) {
13591
+ const view7 = { name: "questionAnswered", model: { answers, when: store.now(), zone, replied: lang } };
13592
+ await chat.conversation.update(cardId, viewMessage2(view7, words3));
13593
+ } else {
13594
+ await chat.say(words3);
13595
+ }
13596
+ } catch (error) {
13597
+ logger.warn("the question card could not be marked answered", { error: errorName(error) });
13598
+ }
13599
+ }
13600
+
13601
+ // packages/core/src/routing/turn-start.ts
13602
+ var MOBILE_PLATFORMS = ["ios", "ipados", "android"];
13603
+ function isMobile(clientPlatform) {
13604
+ return MOBILE_PLATFORMS.includes(clientPlatform.toLowerCase());
13605
+ }
13606
+ function logTurnStarted(logger, clientPlatform, group, route) {
13607
+ logger.info("turn started from Teams client", {
13608
+ platform: clientPlatform || "unknown",
13609
+ chat: group ? "group" : "1:1",
13610
+ route
13611
+ });
13612
+ }
13613
+ var TurnStarter = class {
13614
+ constructor(ctx) {
13615
+ this.ctx = ctx;
13616
+ }
13617
+ ctx;
13618
+ running = /* @__PURE__ */ new Set();
13619
+ /** The turns that run now (a test waits for them). */
13620
+ settled() {
13621
+ return Promise.all([...this.running]).then(() => void 0);
13622
+ }
13623
+ /** The turn that runs (its end is `done`), or undefined when none was started (the person was told why). */
13624
+ async start(request) {
13625
+ const { chat, review } = request;
13626
+ if (await this.answersQuestion(request)) return void 0;
13627
+ const key = review ? reviewTurnKey(chat.sender.id, chat.scope, review.itemId, review.pull) : chatTurnKey(chat.sender.id, chat.scope);
13628
+ const turn = new ActiveTurn(review !== void 0, review?.itemId ?? "");
13629
+ const admitted = review === void 0 && await this.admit(request, key, turn);
13630
+ if (admitted === void 0) return void 0;
13631
+ if (!admitted) {
13632
+ const queued = await this.queueBehind(key, turn, chat);
13633
+ if (queued === "refused") return void 0;
13634
+ if (queued === "waiting") return this.track(this.afterFront(request, key, turn));
13635
+ }
13636
+ return this.launch(request, key, turn);
13637
+ }
13638
+ /** A typed message the waiting question card of the chat's session took as its answer (`question-reply.ts`). */
13639
+ async answersQuestion(request) {
13640
+ if (!request.typed || request.review !== void 0 || (request.files?.length ?? 0) > 0) return false;
13641
+ const provider = this.providerOf(request);
13642
+ return answerWaitingQuestion(this.ctx, {
13643
+ chat: request.chat,
13644
+ text: request.text,
13645
+ credential: provider ?? (() => Promise.resolve(request.credential))
13646
+ });
13647
+ }
13648
+ track(task) {
13649
+ this.running.add(task);
13650
+ void task.finally(() => this.running.delete(task));
13651
+ return { done: task };
13652
+ }
13653
+ /** The turn goes to Claw now; its end is `done`. */
13654
+ async launch(request, key, turn) {
13655
+ turn.launched = true;
13656
+ let run;
13657
+ try {
13658
+ run = await this.runOf(request);
13659
+ } catch (error) {
13660
+ this.ctx.turns.drop(key, turn);
13661
+ throw error;
13662
+ }
13663
+ return this.track(this.guarded(request, run, key, turn));
13664
+ }
13665
+ /**
13666
+ * 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
13667
+ * 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
13668
+ * meanwhile (`/stop`, said there).
13669
+ */
13670
+ async afterFront(request, key, turn) {
13671
+ for (; ; ) {
13672
+ if (turn.over) return;
13673
+ const turns = this.ctx.turns.of(key);
13674
+ const at = turns.indexOf(turn);
13675
+ if (at < 0) return;
13676
+ const blocking = turns.slice(0, at).find((front) => !front.stopped.isSet);
13677
+ if (blocking === void 0) break;
13678
+ await Promise.race([blocking.ended.whenSet(), blocking.stopped.whenSet(), turn.ended.whenSet()]);
13679
+ }
13680
+ let started;
13681
+ try {
13682
+ started = await this.launch(request, key, turn);
13683
+ } catch (error) {
13684
+ const ref = this.ctx.deps.random.hexId().slice(0, 8);
13685
+ this.ctx.deps.logger.error("a queued turn could not start", { ref, error: errorName(error) });
13686
+ await this.tell(request, `Something went wrong on my side (ref ${ref}). Please try again.`);
13687
+ return;
13688
+ }
13689
+ await started.done;
13690
+ }
13691
+ /** True when admitted as the chat's first turn; false when the chat has one (queue behind it); undefined: refused. */
13692
+ async admit(request, key, turn) {
13693
+ if (this.ctx.turns.has(key)) return false;
13694
+ const { chat } = request;
13695
+ return this.ctx.admission.lock.run(chat.sender.id, async () => {
13696
+ if (this.ctx.turns.has(key)) return false;
13697
+ if (await this.ctx.admission.overLimit(chat, request.messageId, "claw_turn", "", request.credential))
13698
+ return void 0;
13699
+ this.ctx.turns.claim(key, turn);
13700
+ return true;
13701
+ });
13702
+ }
13703
+ /**
13704
+ * Registers `turn` behind the chat's running one: "front" when the chat has none after all (it runs now), "waiting" when it
13705
+ * is queued (the person is told its place), "refused" (and told why) when it may not wait.
13706
+ */
13707
+ async queueBehind(key, turn, chat) {
13708
+ const queued = this.ctx.turns.enqueue(key, turn);
13709
+ const said2 = routingText.in(chat.lang());
13710
+ if (queued === "still_working" || queued === "queue_full") {
13711
+ await chat.say(queued === "still_working" ? said2.stillWorking : said2.queueFull({ n: QUEUED_TURNS_MAX }));
13712
+ return "refused";
13713
+ }
13714
+ if (queued.front === turn) return "front";
13715
+ const stoppedInFront = this.ctx.turns.of(key).every((front) => front === turn || front.stopped.isSet);
13716
+ if (stoppedInFront) return "waiting";
13717
+ try {
13718
+ await chat.say(said2.queuedBehind({ n: queued.waiting }));
13719
+ } catch (error) {
13720
+ this.ctx.turns.drop(key, turn);
13721
+ throw error;
13722
+ }
13723
+ return "waiting";
13724
+ }
13725
+ async sessionOf(request) {
13726
+ const { chat, review } = request;
13727
+ const { store, config } = this.ctx.deps;
13728
+ if (review !== void 0) return review.itemSessionId;
13729
+ const sessionId = store.sessions.getSession(chat.sender.id, chat.scope);
13730
+ if (sessionId === null) return null;
13731
+ if (Boolean(store.sessions.getSessionPlugin(chat.sender.id, chat.scope)) !== Boolean(config.pluginId)) {
13732
+ await chat.say("Starting a new session so I can use the Primus platform tools.");
13733
+ return null;
13734
+ }
13735
+ if (chat.scope && !store.sessions.getSessionShared(chat.sender.id, chat.scope)) return null;
13736
+ return sessionId;
13737
+ }
13738
+ providerOf(request) {
13739
+ const usesExchange = request.review !== void 0 || this.ctx.credentials.onExchange;
13740
+ if (!this.ctx.deps.exchange.enabled || !usesExchange) return void 0;
13741
+ return () => this.ctx.credentials.fresh(request.chat.sender.id, request.credential);
13742
+ }
13743
+ ownThumbs(request) {
13744
+ return isMobile(request.clientPlatform) && request.review === void 0 && this.ctx.deps.config.ownThumbsMobile;
13745
+ }
13746
+ /** What the run reads of the chat's session: its id, the model it has, the repository a new one is bound to. */
13747
+ async sessionPart(request) {
13748
+ const { chat, review } = request;
13749
+ const { store } = this.ctx.deps;
13750
+ const sessionId = await this.sessionOf(request);
13751
+ const chosen = sessionId === null && !chat.scope ? store.repoHistory.getPendingRepo(chat.sender.id) : null;
13752
+ return {
13753
+ sessionId,
13754
+ sessionModel: sessionId === null || review ? "" : store.sessions.getSessionModel(chat.sender.id, chat.scope),
13755
+ repo: chosen === null ? null : { url: `${REPO_HOST}/${chosen.repo}`, branch: chosen.branch }
13756
+ };
13757
+ }
13758
+ async runOf(request) {
13759
+ const { chat, review } = request;
13760
+ const { store, config, logger } = this.ctx.deps;
13761
+ const session = await this.sessionPart(request);
13762
+ const group = chat.scope !== "";
13763
+ logTurnStarted(logger, request.clientPlatform, group, review ? "review" : "legacy");
13764
+ return {
13765
+ ...session,
13766
+ conversation: chat.conversation,
13767
+ credential: request.credential,
13768
+ provider: this.providerOf(request),
13769
+ text: request.text,
13770
+ messageId: request.messageId,
13771
+ group,
13772
+ userId: chat.sender.id,
13773
+ ownerName: chat.sender.name,
13774
+ model: review ? review.model || config.reviewModel || config.model : config.model,
13775
+ fallbackModel: config.fallbackModel,
13776
+ showChanges: !group && review === void 0,
13777
+ quote: request.quote ?? "",
13778
+ sent: request.sent ?? "",
13779
+ files: request.files ?? [],
13780
+ lang: review?.lang ?? chat.lang(),
13781
+ ownThumbs: this.ownThumbs(request),
13782
+ notifies: this.ctx.deps.subscriptions !== void 0 && store.notifySubs.getNotifySub(chat.sender.id) !== null,
13783
+ review: review?.fields
13784
+ };
13785
+ }
13786
+ hooks(request, run, turn, turnId) {
13787
+ const { review, chat } = request;
13788
+ const store = this.ctx.deps.store;
13789
+ return {
13790
+ onSession: (sessionId, sessionModel) => {
13791
+ if (review) review.onSession(sessionId);
13792
+ else this.recordSession(chat, sessionId, run, sessionModel);
13793
+ },
13794
+ onRunning: () => {
13795
+ turn.running.set();
13796
+ },
13797
+ onTurn: (sessionId, what, messageId) => {
13798
+ if (what === "started") store.turns.turnStarted(sessionId, { prompt: run.text, messageId, turnId });
13799
+ else if (what === "delivered") store.turns.turnDelivered(sessionId, { messageId, turnId });
13800
+ else store.turns.turnNotDelivered(sessionId, turnId);
13801
+ },
13802
+ stopRequested: () => turn.stopRequested,
13803
+ toolDenied: () => turn.toolDenied,
13804
+ stopConfirmed: () => turn.stopConfirmed,
13805
+ reportView: () => this.ctx.deps.reportView(chat.sender.id, chat.scope)
13806
+ };
13807
+ }
13808
+ async guarded(request, run, key, turn) {
13809
+ const turnId = this.ctx.deps.random.hexId();
13810
+ try {
13811
+ const outcome = await this.ctx.deps.turns.run(run, this.hooks(request, run, turn, turnId));
13812
+ if (request.review) await request.review.after(outcome);
13813
+ } catch (error) {
13814
+ const ref = this.ctx.deps.random.hexId().slice(0, 8);
13815
+ this.ctx.deps.logger.error("turn failed", { ref, error: errorName(error) });
13816
+ await this.tell(request, `Something went wrong on my side (ref ${ref}). Please try again.`);
13817
+ } finally {
13818
+ this.ctx.turns.drop(key, turn);
13819
+ }
13820
+ }
13821
+ async tell(request, text8) {
13822
+ try {
13823
+ await request.chat.say(text8);
13824
+ const report = this.ctx.deps.reportView(request.chat.sender.id, request.chat.scope);
13825
+ if (report !== void 0) await request.chat.send(report);
13826
+ } catch (error) {
13827
+ this.ctx.deps.logger.warn("a failed turn could not be said", { error: errorName(error) });
13828
+ }
13829
+ }
13830
+ /**
13831
+ * The turn has a session: it is the chat's session, and a 1:1 chat remembers it for /sessions, with what a session
13832
+ * this turn created was started with and the repository it is bound to (which uses up the `/repo` choice).
13833
+ */
13834
+ recordSession(chat, sessionId, run, sessionModel) {
13835
+ const { store, config } = this.ctx.deps;
13836
+ const userId = chat.sender.id;
13837
+ store.sessions.setSession(
13838
+ userId,
13839
+ { sessionId, pluginId: config.pluginId, model: sessionModel, shared: chat.scope !== "" },
13840
+ chat.scope
13841
+ );
13842
+ if (chat.scope) return;
13843
+ const created = run.sessionId !== sessionId;
13844
+ if (created && run.sessionId) store.repoHistory.forgetSession(userId, run.sessionId);
13845
+ const url = run.repo?.url ?? "";
13846
+ const bound = created && run.repo ? url.startsWith(`${REPO_HOST}/`) ? url.slice(REPO_HOST.length + 1) : url : "";
13847
+ store.repoHistory.noteSession(userId, sessionId, {
13848
+ title: created ? cpHead(squashSpaces(run.text), 50) : "",
13849
+ repo: bound,
13850
+ branch: bound && run.repo ? run.repo.branch : ""
13851
+ });
13852
+ if (created && run.repo) store.repoHistory.clearPendingRepo(userId);
13853
+ }
13854
+ };
13855
+
13856
+ // packages/core/src/routing/personal-text.ts
13857
+ var ELSEWHERE = "Files are not passed on from this chat while it talks to an agent; only your words were sent.";
13858
+ var PersonalText = class {
13859
+ constructor(ctx, commands, starter) {
13860
+ this.ctx = ctx;
13861
+ this.commands = commands;
13862
+ this.starter = starter;
13863
+ }
13864
+ ctx;
13865
+ commands;
13866
+ starter;
13867
+ /**
13868
+ * What the person wrote, with a word of the commands menu read as its command and a sentence help teaches read as the
13869
+ * command it means; a sentence for what this chat cannot do right now stays words.
13870
+ */
13871
+ textOf(message, group = false) {
13872
+ const { github, assistant, remote, config } = this.ctx.deps;
13873
+ const userId = group ? void 0 : message.sender.id;
13874
+ const on = (test) => userId !== void 0 && test(userId);
13875
+ const attached = on((id) => remote.attached(id));
13876
+ const off = /* @__PURE__ */ new Set();
13877
+ const rules = [
13878
+ ["github", github !== void 0],
13879
+ ["board", on((id) => assistant.on(id))],
13880
+ ["brief", config.briefEnabled],
13881
+ ["brief now", on((id) => assistant.on(id))],
13882
+ ["agents", remote.enabled],
13883
+ ["agent", remote.enabled],
13884
+ ["agent detach", attached],
13885
+ ["stop it", attached],
13886
+ // ("停掉它" stops the attached agent; with the assistant it is the assistant's to read)
13887
+ ["new", !(attached && on((id) => assistant.on(id)))]
13888
+ // (in an attached chat: a new agent session, the assistant's)
13889
+ ];
13890
+ for (const [word2, enabled] of rules) if (!enabled) off.add(word2);
13891
+ if (remote.enabled && on((id) => assistant.on(id)) && menuCommand(message.text, group) === "/agent sessions" && !/agent/iu.test(message.text))
13892
+ return "/sessions";
13893
+ return naturalCommand(menuCommand(message.text, group), group, off);
13894
+ }
13895
+ async onText(message, chat, key) {
13896
+ const text8 = this.textOf(message);
13897
+ if (parseCommand(text8) === "unlink" && !message.text.startsWith("/")) {
13898
+ await chat.say(routingText.in(chat.lang()).unlinkAsk);
13899
+ return;
13900
+ }
13901
+ if (await this.commands.run(text8, chat, key, message)) return;
13902
+ if (!this.talksToAgent(message))
13903
+ await this.offerGithub(message, chat, key, text8, (m, fresh) => this.sentOn(m, chat, fresh));
13904
+ await this.sentOn(message, chat, key);
13905
+ }
13906
+ /**
13907
+ * Whether this 1:1 message goes to the remote session the chat is attached to: only words the person typed, and not
13908
+ * those that start by naming the bot. A chat whose session card is the window keeps its chat box for the bot: only a
13909
+ * reply that quotes the card goes to the session.
13910
+ */
13911
+ talksToAgent(message) {
13912
+ const { remote } = this.ctx.deps;
13913
+ const userId = message.sender.id;
13914
+ if (!remote.attached(userId) || message.provenance !== "typed" || message.namesBotFirst) return false;
13915
+ if (remote.windowMode(userId) === "card") return remote.quotesWindow(userId, message);
13916
+ return true;
13917
+ }
13918
+ /** The files that go to Claw with a message; the person is told about the ones that do not, and they are counted. */
13919
+ async attachments(message, chat, elsewhere = false) {
13920
+ const { messageText, metrics } = this.ctx.deps;
13921
+ const unreadable = messageText.unreadable(message);
13922
+ if (unreadable !== void 0) {
13923
+ metrics.filesDropped("unreadable", unreadable.count);
13924
+ await chat.say(unreadable.line);
13925
+ }
13926
+ if (message.files.length > 0 && elsewhere) {
13927
+ metrics.filesDropped("remote_agent", message.files.length);
13928
+ await chat.say(ELSEWHERE);
13929
+ return [];
13930
+ }
13931
+ return message.files;
13932
+ }
13933
+ /** What the chat is attached to, for the assistant (only where remote agents are on). */
13934
+ async attached(userId, key) {
13935
+ const { remote } = this.ctx.deps;
13936
+ return remote.enabled ? { attached: await remote.chatContext(userId, key) } : {};
13937
+ }
13938
+ /** A 1:1 message that is no command, as it goes on to the attached agent, the assistant or Claw. */
13939
+ async sentOn(message, chat, key) {
13940
+ const { messageText, assistant } = this.ctx.deps;
13941
+ const userId = chat.sender.id;
13942
+ const text8 = this.textOf(message);
13943
+ const toAgent = this.talksToAgent(message);
13944
+ const quote = toAgent && this.ctx.deps.remote.quotesWindow(userId, message) ? "" : messageText.quoteBlock(message, false);
13945
+ const sent = messageText.withLinks(text8, message);
13946
+ const prompt = messageText.withQuote(sent, quote);
13947
+ const files = await this.attachments(message, chat, toAgent);
13948
+ const control = this.sessionControl(userId, prompt, toAgent, quote, files, text8);
13949
+ if (control && assistant.understands(userId)) {
13950
+ await this.understand(message, chat, key);
13951
+ } else if (prompt && toAgent && !control) {
13952
+ await this.remotePrompt(message, chat, key, prompt);
13953
+ } else if (prompt || files.length > 0) {
13954
+ await this.toAssistantOrTurn(message, chat, key, { text: text8, quote, sent, prompt, files });
13955
+ }
13956
+ }
13957
+ /**
13958
+ * A message to an attached chat that is about the sessions themselves (switch, open a new one), with the assistant on to
13959
+ * read it: the words alone (a quote or a file goes to the agent with them).
13960
+ */
13961
+ sessionControl(userId, prompt, toAgent, quote, files, text8) {
13962
+ if (!prompt || !toAgent || quote || files.length > 0) return false;
13963
+ return this.ctx.deps.assistant.on(userId) && sessionControl(text8);
13964
+ }
13965
+ /** A message the assistant takes when it is on, else a turn of Claw's. */
13966
+ async toAssistantOrTurn(message, chat, key, said2) {
13967
+ const { assistant } = this.ctx.deps;
13968
+ const userId = chat.sender.id;
13969
+ if (assistant.on(userId) && await this.ctx.admission.assistantOverLimit(chat, message.id, key)) return;
13970
+ const routed = await assistant.route({
13971
+ userId,
13972
+ credential: key,
13973
+ prompt: said2.prompt || assistant.filesOnly(chat.lang()),
13974
+ conversation: chat.conversation,
13975
+ files: said2.files,
13976
+ ...await this.attached(userId, key)
13977
+ });
13978
+ if (routed) {
13979
+ logTurnStarted(this.ctx.deps.logger, message.clientPlatform, false, "assistant");
13980
+ return;
13981
+ }
13982
+ this.ctx.admission.dropLastHandoff(userId);
13983
+ await this.starter.start({
13984
+ chat,
13985
+ credential: key,
13986
+ text: said2.text,
13987
+ messageId: message.id,
13988
+ clientPlatform: message.clientPlatform,
13989
+ quote: said2.quote,
13990
+ sent: said2.sent,
13991
+ files: said2.files,
13992
+ typed: true
13993
+ });
13994
+ }
13995
+ /** A prompt for the attached agent, within the task limit (the slot is held until the remote turn counts as running). */
13996
+ async remotePrompt(message, chat, key, prompt) {
13997
+ const { remote } = this.ctx.deps;
13998
+ const request = {
13999
+ message,
14000
+ chat: chat.sender,
14001
+ credential: key,
14002
+ prompt,
14003
+ conversation: chat.conversation,
14004
+ provider: this.ctx.credentials.provider(chat.sender.id, key)
14005
+ };
14006
+ if (remote.running(chat.sender.id)) {
14007
+ await remote.prompt(request);
14008
+ return;
14009
+ }
14010
+ if (!await this.ctx.admission.admitRemote(chat, message.id, key)) return;
14011
+ try {
14012
+ await remote.prompt(request);
14013
+ } finally {
14014
+ this.ctx.admission.letGo(chat.sender.id);
14015
+ }
13432
14016
  }
13433
14017
  /**
13434
14018
  * Beside a question about the person's own GitHub while Claw has no token of theirs: the connect card first; once they
@@ -13493,6 +14077,7 @@ var PersonalText = class {
13493
14077
  });
13494
14078
  const held = assistant.reachable(userId);
13495
14079
  if (held && await this.ctx.admission.assistantOverLimit(chat, message.id, key)) return;
14080
+ if (held) logTurnStarted(logger, message.clientPlatform, false, "assistant");
13496
14081
  store.assistantMessages.noteAssistantMessage(
13497
14082
  channelMessageId,
13498
14083
  userId,
@@ -13630,439 +14215,200 @@ var GroupRouter = class {
13630
14215
  async withKey(message, chat, key, words3) {
13631
14216
  const command = parseCommand(words3.text);
13632
14217
  if (command === "new") await this.sessions.new(chat, chat.scope);
13633
- else if (command === "stop") await this.sessions.stop(chat, key, chat.scope);
13634
- else if (command === "whoami") await this.whoami(chat, key);
13635
- else {
13636
- const files = await this.text.attachments(message, chat);
13637
- if (!words3.sent && !words3.quote && files.length === 0) return;
13638
- await this.starter.start({
13639
- chat,
13640
- credential: key,
13641
- text: words3.text,
13642
- messageId: message.id,
13643
- clientPlatform: message.clientPlatform,
13644
- quote: this.withHistory(message, words3.quote),
13645
- sent: words3.sent,
13646
- files
13647
- });
13648
- }
13649
- }
13650
- /** The chat's recent messages, as untrusted background, before what the person quoted (either may be ""). */
13651
- withHistory(message, quote) {
13652
- const history = this.ctx.deps.groupHistory.background(message.conversation.id);
13653
- return history && quote ? `${history}
13654
-
13655
- ${quote}` : history || quote;
13656
- }
13657
- /** Which account, in a group: never the sender's email. */
13658
- async whoami(chat, key) {
13659
- const said2 = groupText.in(chat.lang());
13660
- const who = await this.ctx.owners.whoLine(key, false);
13661
- if (chat.zh()) {
13662
- const name = who === void 0 ? "" : (await this.ctx.owners.whoami(key))?.[0] ?? "";
13663
- await chat.say(name ? said2.whoami({ name: chat.sender.name, who: name }) : said2.whoamiUnknown);
13664
- return;
13665
- }
13666
- await chat.say(who === void 0 ? said2.whoamiUnknown : said2.whoami({ name: chat.sender.name, who }));
13667
- }
13668
- /**
13669
- * "停 825", from a person the assistant understands: their reviews of those pull requests are stopped in Claw at once
13670
- * (not said to the assistant, where it could wait while the merge happens). Placed by the assembly among the group's
13671
- * interceptors (after feedback, before review requests).
13672
- */
13673
- reviewStopInterceptor() {
13674
- return {
13675
- name: "review_stop",
13676
- intercept: async (route) => {
13677
- const { assistant, reviewStop: reviewStop2 } = this.ctx.deps;
13678
- if (reviewStop2 === void 0 || !assistant.understands(route.sender.id)) return false;
13679
- const pulls = reviewStop2(route.text);
13680
- if (pulls.length === 0) return false;
13681
- await this.stopReviews(this.ctx.chatOf(route.message, route.scope), pulls, route.text);
13682
- return true;
13683
- }
13684
- };
13685
- }
13686
- async stopReviews(chat, pulls, text8) {
13687
- const zh = hasCjk2(text8);
13688
- const said2 = groupText.in(zh ? "zh" : "en");
13689
- const key = await this.ctx.credentials.credential(chat, false);
13690
- let items;
13691
- try {
13692
- items = key === void 0 ? void 0 : await this.ctx.deps.claw.workItems.list(key, "in_progress");
13693
- } catch {
13694
- items = void 0;
13695
- }
13696
- if (key === void 0 || items === void 0) {
13697
- await chat.say(said2.stopUnreachable);
13698
- return;
13699
- }
13700
- const lines = [];
13701
- for (const [slug2, number] of pulls) {
13702
- const found = items.filter(
13703
- (i) => i.kind === "review" && i.pull !== null && i.pull.number === number && (slug2 === "" || slug2 === pyLower(`${i.pull.owner}/${i.pull.repo}`))
13704
- );
13705
- const [only2] = found;
13706
- if (only2 === void 0) lines.push(said2.stopNone({ number }));
13707
- else if (found.length > 1) lines.push(said2.stopMany({ number }));
13708
- else lines.push(await this.stopReview(key, only2, zh));
13709
- }
13710
- await chat.say(lines.join("\n"));
13711
- }
13712
- async stopReview(key, item, zh) {
13713
- const pull2 = item.pull ?? { owner: "", repo: "", number: 0 };
13714
- const short = `${pull2.owner}/${pull2.repo}#${String(pull2.number)}`;
13715
- let error;
13716
- try {
13717
- const decision = await this.ctx.deps.claw.workItems.stop(key, item.itemId);
13718
- error = decision.ok ? "" : decision.error;
13719
- if (decision.ok) this.ctx.itemStopped(item.itemId);
13720
- } catch (failure) {
13721
- if (failure instanceof ClawUnreachable)
13722
- return zh ? `${short}\uFF1A\u6CA1\u8FDE\u4E0A PrimusClaw\uFF0C\u8BF7\u518D\u8BF4\u4E00\u6B21\u3002` : `${short}: PrimusClaw did not answer; please say it again.`;
13723
- if (!(failure instanceof ClawError)) throw failure;
13724
- error = failure.status === 409 && isErrorWord(failure.code) ? failure.code : `error ${String(failure.status)}`;
13725
- }
13726
- const said2 = (Object.hasOwn(STOP_SAID, error) ? STOP_SAID[error] : void 0) ?? [
13727
- `\u6CA1\u80FD\u505C\u4E0B\uFF08${error}\uFF09\u3002`,
13728
- `it could not be stopped (${error}).`
13729
- ];
13730
- return zh ? `${short}\uFF1A${said2[0]}` : `${short}: ${said2[1]}`;
13731
- }
13732
- };
13733
-
13734
- // packages/core/src/routing/link-commands.ts
13735
- var LinkCommands = class {
13736
- constructor(ctx, resume2) {
13737
- this.ctx = ctx;
13738
- this.resume = resume2;
13739
- }
13740
- ctx;
13741
- resume;
13742
- async link(chat, code3, messageId) {
13743
- const { store } = this.ctx.deps;
13744
- const outcome = store.links.redeemPending(chat.sender.id, chat.sender.name, code3 ?? "", messageId || void 0);
13745
- const said2 = linkText.in("en");
13746
- if (outcome === "ok") await this.linked(chat);
13747
- else if (outcome === "limited") {
13748
- const minutes = Math.max(1, Math.ceil(store.links.linkWindowSeconds / 60));
13749
- await chat.say(said2.tooManyWrong({ minutes }));
13750
- } else if (outcome === "global_limit") await chat.say(said2.linkingPaused);
13751
- else await chat.say(said2.badCode);
13752
- }
13753
- async linked(chat) {
13754
- const { store, subscriptions } = this.ctx.deps;
13755
- const userId = chat.sender.id;
13756
- const key = store.links.getKey(userId) ?? "";
13757
- const clawUserId = key ? await this.ctx.owners.userIdOf(key) : void 0;
13758
- if (clawUserId !== void 0) store.links.setClawUserId(userId, clawUserId);
13759
- if (subscriptions !== void 0) await subscriptions.ensure(userId);
13760
- const pending = store.onboarding.takePendingRequest(userId);
13761
- const zh = chat.zh();
13762
- const said2 = linkText.in(zh ? "zh" : "en");
13763
- let who;
13764
- if (zh) {
13765
- const owner = key ? await this.ctx.owners.whoami(key) : void 0;
13766
- who = owner === void 0 ? "" : `\u5230 SaFE \u7528\u6237 ${owner[0]}` + (owner[1] ? `\uFF08${owner[1]}\uFF09` : "");
13767
- } else {
13768
- who = await this.ctx.owners.whoLine(key) ?? "";
13769
- }
13770
- await chat.say(said2.linkedAs({ who }) + (pending === null ? said2.getStarted : said2.runningFromGroup));
13771
- if (pending !== null) await this.resume(chat, pending, true);
13772
- }
13773
- async unlink(chat) {
13774
- const { store, remote, assistant, subscriptions, keyRevoker } = this.ctx.deps;
13775
- const userId = chat.sender.id;
13776
- remote.forgetPerson(userId);
13777
- assistant.forget(userId);
13778
- const stored2 = store.links.getKey(userId);
13779
- if (stored2 === null) {
13780
- store.sessions.clearSession(userId);
13781
- await chat.say(unlinkText.in(chat.lang()).bindingOnly);
13782
- return;
13783
- }
13784
- if (subscriptions !== void 0) await subscriptions.remove(userId, stored2);
13785
- const revoked = await keyRevoker.revoke(stored2);
13786
- store.links.unlink(userId);
13787
- const stillBound = await this.stillBound(userId);
13788
- const said2 = unlinkText.in(chat.lang());
13789
- const tail = (revoked ? said2.deleted : said2.notDeleted) + (stillBound ? said2.stillBound : "");
13790
- await chat.say(said2.unlinked({ tail }));
13791
- }
13792
- /** A chat that runs on the exchange stays connected through the person's binding, which only they can remove. */
13793
- async stillBound(userId) {
13794
- if (!this.ctx.credentials.onExchange) return false;
13795
- this.ctx.deps.exchange.invalidate(userId);
13796
- return (await this.ctx.deps.exchange.tokenFor(userId)).ok;
13797
- }
13798
- /**
13799
- * The person pressed the button that says they confirmed: the binding is looked for now. The worker cannot make it,
13800
- * only the person can; not there yet, the card comes back with the link that is still open (or a new one).
13801
- */
13802
- async confirmBinding(action, chat) {
13803
- const { exchange, store, logger } = this.ctx.deps;
13804
- const said2 = linkText.in("en");
13805
- exchange.invalidate(chat.sender.id);
13806
- const token = await exchange.tokenFor(chat.sender.id);
13807
- if (!token.ok && token.error.kind === "not_bound") {
13808
- const notYet = linkText.in(chat.lang());
13809
- 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));
14218
+ else if (command === "stop") await this.sessions.stop(chat, key, chat.scope);
14219
+ else if (command === "whoami") await this.whoami(chat, key);
14220
+ else {
14221
+ const files = await this.text.attachments(message, chat);
14222
+ if (!words3.sent && !words3.quote && files.length === 0) return;
14223
+ await this.starter.start({
14224
+ chat,
14225
+ credential: key,
14226
+ text: words3.text,
14227
+ messageId: message.id,
14228
+ clientPlatform: message.clientPlatform,
14229
+ quote: this.withHistory(message, words3.quote),
14230
+ sent: words3.sent,
14231
+ files,
14232
+ typed: true
14233
+ });
13855
14234
  }
13856
- return this.launch(request, key, turn);
13857
14235
  }
13858
- track(task) {
13859
- this.running.add(task);
13860
- void task.finally(() => this.running.delete(task));
13861
- return { done: task };
14236
+ /** The chat's recent messages, as untrusted background, before what the person quoted (either may be ""). */
14237
+ withHistory(message, quote) {
14238
+ const history = this.ctx.deps.groupHistory.background(message.conversation.id);
14239
+ return history && quote ? `${history}
14240
+
14241
+ ${quote}` : history || quote;
13862
14242
  }
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;
14243
+ /** Which account, in a group: never the sender's email. */
14244
+ async whoami(chat, key) {
14245
+ const said2 = groupText.in(chat.lang());
14246
+ const who = await this.ctx.owners.whoLine(key, false);
14247
+ if (chat.zh()) {
14248
+ const name = who === void 0 ? "" : (await this.ctx.owners.whoami(key))?.[0] ?? "";
14249
+ await chat.say(name ? said2.whoami({ name: chat.sender.name, who: name }) : said2.whoamiUnknown);
14250
+ return;
13872
14251
  }
13873
- return this.track(this.guarded(request, run, key, turn));
14252
+ await chat.say(who === void 0 ? said2.whoamiUnknown : said2.whoami({ name: chat.sender.name, who }));
13874
14253
  }
13875
14254
  /**
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).
14255
+ * "停 825", from a person the assistant understands: their reviews of those pull requests are stopped in Claw at once
14256
+ * (not said to the assistant, where it could wait while the merge happens). Placed by the assembly among the group's
14257
+ * interceptors (after feedback, before review requests).
13879
14258
  */
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;
14259
+ reviewStopInterceptor() {
14260
+ return {
14261
+ name: "review_stop",
14262
+ intercept: async (route) => {
14263
+ const { assistant, reviewStop: reviewStop2 } = this.ctx.deps;
14264
+ if (reviewStop2 === void 0 || !assistant.understands(route.sender.id)) return false;
14265
+ const pulls = reviewStop2(route.text);
14266
+ if (pulls.length === 0) return false;
14267
+ await this.stopReviews(this.ctx.chatOf(route.message, route.scope), pulls, route.text);
14268
+ return true;
14269
+ }
14270
+ };
14271
+ }
14272
+ async stopReviews(chat, pulls, text8) {
14273
+ const zh = hasCjk2(text8);
14274
+ const said2 = groupText.in(zh ? "zh" : "en");
14275
+ const key = await this.ctx.credentials.credential(chat, false);
14276
+ let items;
13891
14277
  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;
14278
+ items = key === void 0 ? void 0 : await this.ctx.deps.claw.workItems.list(key, "in_progress");
14279
+ } catch {
14280
+ items = void 0;
13898
14281
  }
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";
14282
+ if (key === void 0 || items === void 0) {
14283
+ await chat.say(said2.stopUnreachable);
14284
+ return;
13923
14285
  }
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;
14286
+ const lines = [];
14287
+ for (const [slug2, number] of pulls) {
14288
+ const found = items.filter(
14289
+ (i) => i.kind === "review" && i.pull !== null && i.pull.number === number && (slug2 === "" || slug2 === pyLower(`${i.pull.owner}/${i.pull.repo}`))
14290
+ );
14291
+ const [only2] = found;
14292
+ if (only2 === void 0) lines.push(said2.stopNone({ number }));
14293
+ else if (found.length > 1) lines.push(said2.stopMany({ number }));
14294
+ else lines.push(await this.stopReview(key, only2, zh));
13932
14295
  }
13933
- return "waiting";
14296
+ await chat.say(lines.join("\n"));
13934
14297
  }
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;
14298
+ async stopReview(key, item, zh) {
14299
+ const pull2 = item.pull ?? { owner: "", repo: "", number: 0 };
14300
+ const short = `${pull2.owner}/${pull2.repo}#${String(pull2.number)}`;
14301
+ let error;
14302
+ try {
14303
+ const decision = await this.ctx.deps.claw.workItems.stop(key, item.itemId);
14304
+ error = decision.ok ? "" : decision.error;
14305
+ if (decision.ok) this.ctx.itemStopped(item.itemId);
14306
+ } catch (failure) {
14307
+ if (failure instanceof ClawUnreachable)
14308
+ return zh ? `${short}\uFF1A\u6CA1\u8FDE\u4E0A PrimusClaw\uFF0C\u8BF7\u518D\u8BF4\u4E00\u6B21\u3002` : `${short}: PrimusClaw did not answer; please say it again.`;
14309
+ if (!(failure instanceof ClawError)) throw failure;
14310
+ error = failure.status === 409 && isErrorWord(failure.code) ? failure.code : `error ${String(failure.status)}`;
13944
14311
  }
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);
14312
+ const said2 = (Object.hasOwn(STOP_SAID, error) ? STOP_SAID[error] : void 0) ?? [
14313
+ `\u6CA1\u80FD\u505C\u4E0B\uFF08${error}\uFF09\u3002`,
14314
+ `it could not be stopped (${error}).`
14315
+ ];
14316
+ return zh ? `${short}\uFF1A${said2[0]}` : `${short}: ${said2[1]}`;
13952
14317
  }
13953
- ownThumbs(request) {
13954
- return isMobile(request.clientPlatform) && request.review === void 0 && this.ctx.deps.config.ownThumbsMobile;
14318
+ };
14319
+
14320
+ // packages/core/src/routing/link-commands.ts
14321
+ var LinkCommands = class {
14322
+ constructor(ctx, resume2) {
14323
+ this.ctx = ctx;
14324
+ this.resume = resume2;
13955
14325
  }
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;
14326
+ ctx;
14327
+ resume;
14328
+ async link(chat, code3, messageId) {
13959
14329
  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);
14330
+ const outcome = store.links.redeemPending(chat.sender.id, chat.sender.name, code3 ?? "", messageId || void 0);
14331
+ const said2 = linkText.in("en");
14332
+ if (outcome === "ok") await this.linked(chat);
14333
+ else if (outcome === "limited") {
14334
+ const minutes = Math.max(1, Math.ceil(store.links.linkWindowSeconds / 60));
14335
+ await chat.say(said2.tooManyWrong({ minutes }));
14336
+ } else if (outcome === "global_limit") await chat.say(said2.linkingPaused);
14337
+ else await chat.say(said2.badCode);
14338
+ }
14339
+ async linked(chat) {
14340
+ const { store, subscriptions } = this.ctx.deps;
14341
+ const userId = chat.sender.id;
14342
+ const key = store.links.getKey(userId) ?? "";
14343
+ const clawUserId = key ? await this.ctx.owners.userIdOf(key) : void 0;
14344
+ if (clawUserId !== void 0) store.links.setClawUserId(userId, clawUserId);
14345
+ if (subscriptions !== void 0) await subscriptions.ensure(userId);
14346
+ const pending = store.onboarding.takePendingRequest(userId);
14347
+ const zh = chat.zh();
14348
+ const said2 = linkText.in(zh ? "zh" : "en");
14349
+ let who;
14350
+ if (zh) {
14351
+ const owner = key ? await this.ctx.owners.whoami(key) : void 0;
14352
+ who = owner === void 0 ? "" : `\u5230 SaFE \u7528\u6237 ${owner[0]}` + (owner[1] ? `\uFF08${owner[1]}\uFF09` : "");
14353
+ } else {
14354
+ who = await this.ctx.owners.whoLine(key) ?? "";
14032
14355
  }
14356
+ await chat.say(said2.linkedAs({ who }) + (pending === null ? said2.getStarted : said2.runningFromGroup));
14357
+ if (pending !== null) await this.resume(chat, pending, true);
14033
14358
  }
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) });
14359
+ async unlink(chat) {
14360
+ const { store, remote, assistant, subscriptions, keyRevoker } = this.ctx.deps;
14361
+ const userId = chat.sender.id;
14362
+ remote.forgetPerson(userId);
14363
+ assistant.forget(userId);
14364
+ const stored2 = store.links.getKey(userId);
14365
+ if (stored2 === null) {
14366
+ store.sessions.clearSession(userId);
14367
+ await chat.say(unlinkText.in(chat.lang()).bindingOnly);
14368
+ return;
14041
14369
  }
14370
+ if (subscriptions !== void 0) await subscriptions.remove(userId, stored2);
14371
+ const revoked = await keyRevoker.revoke(stored2);
14372
+ store.links.unlink(userId);
14373
+ const stillBound = await this.stillBound(userId);
14374
+ const said2 = unlinkText.in(chat.lang());
14375
+ const tail = (revoked ? said2.deleted : said2.notDeleted) + (stillBound ? said2.stillBound : "");
14376
+ await chat.say(said2.unlinked({ tail }));
14377
+ }
14378
+ /** A chat that runs on the exchange stays connected through the person's binding, which only they can remove. */
14379
+ async stillBound(userId) {
14380
+ if (!this.ctx.credentials.onExchange) return false;
14381
+ this.ctx.deps.exchange.invalidate(userId);
14382
+ return (await this.ctx.deps.exchange.tokenFor(userId)).ok;
14042
14383
  }
14043
14384
  /**
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).
14385
+ * The person pressed the button that says they confirmed: the binding is looked for now. The worker cannot make it,
14386
+ * only the person can; not there yet, the card comes back with the link that is still open (or a new one).
14046
14387
  */
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);
14388
+ async confirmBinding(action, chat) {
14389
+ const { exchange, store, logger } = this.ctx.deps;
14390
+ const said2 = linkText.in("en");
14391
+ exchange.invalidate(chat.sender.id);
14392
+ const token = await exchange.tokenFor(chat.sender.id);
14393
+ if (!token.ok && token.error.kind === "not_bound") {
14394
+ const notYet = linkText.in(chat.lang());
14395
+ const card = await this.ctx.credentials.bindingCard(chat, notYet.notConfirmedYet);
14396
+ await this.ctx.settle(action, chat, card, { fallback: notYet.notConfirmedLine });
14397
+ return;
14398
+ }
14399
+ if (!token.ok) {
14400
+ logger.warn("token exchange failed", { kind: token.error.kind });
14401
+ const text8 = said2.platformUnreachable;
14402
+ const card = this.ctx.views.note(`${text8}${said2.pressConfirmAgain}`, {
14403
+ buttons: [CONFIRM_BUTTON],
14404
+ title: said2.connectTitle({ bot: this.ctx.deps.config.botName })
14405
+ });
14406
+ await this.ctx.settle(action, chat, card, { fallback: text8 });
14407
+ return;
14408
+ }
14409
+ const pending = store.onboarding.takePendingRequest(chat.sender.id);
14410
+ await this.ctx.settle(action, chat, said2.connected + (pending === null ? said2.getStarted : said2.runningFromGroup));
14411
+ if (pending !== null) await this.resume(chat, pending, true);
14066
14412
  }
14067
14413
  };
14068
14414
 
@@ -14228,22 +14574,6 @@ var Router = class _Router {
14228
14574
  }
14229
14575
  };
14230
14576
 
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
14577
  // packages/core/src/approvals/sensitive.ts
14248
14578
  var WORDS = /* @__PURE__ */ new Set([
14249
14579
  "token",
@@ -14510,145 +14840,6 @@ function approvalView(request) {
14510
14840
  };
14511
14841
  }
14512
14842
 
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
14843
  // packages/core/src/approvals/decide.ts
14653
14844
  function str(value) {
14654
14845
  return typeof value === "string" ? value : typeof value === "number" ? String(value) : "";
@@ -14951,16 +15142,18 @@ var ApprovalCards = class {
14951
15142
  var TURN_DEADLINE_SECONDS2 = 30 * 60;
14952
15143
  function turnApprovals(store) {
14953
15144
  return {
14954
- record: (userId, sessionId, toolUseId, tool2, expiresIn, envelope) => store.approvals.saveApproval({
15145
+ record: (userId, sessionId, toolUseId, tool2, expiresIn, envelope, questions) => store.approvals.saveApproval({
14955
15146
  userId,
14956
15147
  sessionId,
14957
15148
  toolUseId,
14958
15149
  tool: tool2,
14959
15150
  expiresAt: store.now() + (expiresIn || TURN_DEADLINE_SECONDS2),
14960
- envelope
15151
+ envelope,
15152
+ // (a question's, as its card shows them: what a typed answer is matched against)
15153
+ ...questions === void 0 ? {} : { questions: cardQuestions(questions).map((q) => questionWire(q)) }
14961
15154
  }),
14962
- sent: (sessionId, toolUseId) => {
14963
- store.approvals.approvalSent(sessionId, toolUseId);
15155
+ sent: (sessionId, toolUseId, cardId) => {
15156
+ store.approvals.approvalSent(sessionId, toolUseId, cardId);
14964
15157
  },
14965
15158
  unsent: (sessionId, toolUseId) => {
14966
15159
  store.approvals.forgetApproval(sessionId, toolUseId);
@@ -14982,7 +15175,15 @@ var approvalTurnCards = {
14982
15175
  return viewMessage2(
14983
15176
  {
14984
15177
  name: "question",
14985
- model: { questions: cardQuestions(ask3.questions), sessionId, toolUseId, group, ownerName, note: "" }
15178
+ model: {
15179
+ questions: cardQuestions(ask3.questions),
15180
+ sessionId,
15181
+ toolUseId,
15182
+ group,
15183
+ ownerName,
15184
+ note: "",
15185
+ typedReply: true
15186
+ }
14986
15187
  },
14987
15188
  QUESTION_TOOL2
14988
15189
  );
@@ -32844,6 +33045,7 @@ export {
32844
33045
  casefold,
32845
33046
  channelActions,
32846
33047
  channelContext,
33048
+ chatCredentialOf,
32847
33049
  chatTurnKey,
32848
33050
  clawText,
32849
33051
  cleanText,
@@ -32947,6 +33149,7 @@ export {
32947
33149
  lastTurns,
32948
33150
  learnLang,
32949
33151
  limitsText,
33152
+ logTurnStarted,
32950
33153
  looksInternal,
32951
33154
  looksLikeFailure,
32952
33155
  looksLikeRepo,
@@ -33147,6 +33350,7 @@ export {
33147
33350
  turnIdOf,
33148
33351
  two,
33149
33352
  typedAnswerText,
33353
+ typedAnswers,
33150
33354
  typedMode,
33151
33355
  unique,
33152
33356
  unreachable,