switchroom 0.13.11 → 0.13.13

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.
@@ -26509,17 +26509,34 @@ var init_wait = __esm(() => {
26509
26509
  // src/cli/drive.ts
26510
26510
  var exports_drive = {};
26511
26511
  __export(exports_drive, {
26512
+ workspaceScopesForTier: () => workspaceScopesForTier,
26513
+ selectGoogleWorkspaceScopes: () => selectGoogleWorkspaceScopes,
26512
26514
  selectDriveAccountScopes: () => selectDriveAccountScopes,
26513
26515
  runDriveOAuthFlow: () => runDriveOAuthFlow,
26514
26516
  registerDriveCommand: () => registerDriveCommand,
26515
26517
  __test: () => __test,
26518
+ GOOGLE_SLIDES_SCOPE: () => GOOGLE_SLIDES_SCOPE,
26519
+ GOOGLE_SHEETS_SCOPE: () => GOOGLE_SHEETS_SCOPE,
26520
+ GOOGLE_DOCS_SCOPE: () => GOOGLE_DOCS_SCOPE,
26516
26521
  DRIVE_WRITE_SCOPES: () => DRIVE_WRITE_SCOPES,
26517
26522
  DRIVE_READONLY_SCOPES: () => DRIVE_READONLY_SCOPES
26518
26523
  });
26519
26524
  import { createInterface as createInterface2 } from "node:readline";
26525
+ function workspaceScopesForTier(tier) {
26526
+ const docScopes = [GOOGLE_DOCS_SCOPE, GOOGLE_SHEETS_SCOPE];
26527
+ if (tier === "extended" || tier === "complete") {
26528
+ return [...docScopes, GOOGLE_SLIDES_SCOPE];
26529
+ }
26530
+ return docScopes;
26531
+ }
26520
26532
  function selectDriveAccountScopes(write) {
26521
26533
  return write ? DRIVE_WRITE_SCOPES : DRIVE_READONLY_SCOPES;
26522
26534
  }
26535
+ function selectGoogleWorkspaceScopes(opts) {
26536
+ const base = selectDriveAccountScopes(opts.write);
26537
+ const workspace = workspaceScopesForTier(opts.tier ?? "core");
26538
+ return [...new Set([...base, ...workspace])];
26539
+ }
26523
26540
  function getVaultPath(configPath) {
26524
26541
  try {
26525
26542
  const config = loadConfig(configPath);
@@ -26932,7 +26949,7 @@ function registerDriveCommand(program3, deps = {}) {
26932
26949
  await runDisconnect({ agentName: agent }, deps);
26933
26950
  });
26934
26951
  }
26935
- var EXIT_OK = 0, EXIT_DENIED = 1, EXIT_TIMEOUT = 2, EXIT_RATE_LIMITED = 3, EXIT_ERROR = 4, EXIT_ABORTED = 130, DRIVE_READONLY_SCOPES, DRIVE_WRITE_SCOPES, DEFAULT_SCOPES, __test;
26952
+ var EXIT_OK = 0, EXIT_DENIED = 1, EXIT_TIMEOUT = 2, EXIT_RATE_LIMITED = 3, EXIT_ERROR = 4, EXIT_ABORTED = 130, DRIVE_READONLY_SCOPES, DRIVE_WRITE_SCOPES, DEFAULT_SCOPES, GOOGLE_DOCS_SCOPE = "https://www.googleapis.com/auth/documents", GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets", GOOGLE_SLIDES_SCOPE = "https://www.googleapis.com/auth/presentations", __test;
26936
26953
  var init_drive = __esm(() => {
26937
26954
  init_source();
26938
26955
  init_loader();
@@ -47314,8 +47331,8 @@ var {
47314
47331
  } = import__.default;
47315
47332
 
47316
47333
  // src/build-info.ts
47317
- var VERSION = "0.13.11";
47318
- var COMMIT_SHA = "5984798c";
47334
+ var VERSION = "0.13.13";
47335
+ var COMMIT_SHA = "dc583d57";
47319
47336
 
47320
47337
  // src/cli/agent.ts
47321
47338
  init_source();
@@ -54578,7 +54595,7 @@ function registerAccountAdd(accountParent) {
54578
54595
  accountParent.command("add <account>").description("Mint a Google OAuth refresh token for <account> and register it with the auth-broker. For Drive scopes the effective flow is desktop-loopback (device-code returns invalid_scope for Drive; OOB is retired) \u2014 use a Desktop OAuth client; on a headless host complete the browser step over an SSH port-forward. Add --write for create/edit (drive.file); default is read-only.").option("--replace", "Overwrite existing credentials for <account> (default refuses if account already registered)", false).option("--write", "Request Drive WRITE scope (drive.file: create + edit app-created files) in addition to read. Default is read-only \u2014 a read grant never silently becomes a write grant. Re-consent an existing account with `--replace --write`.", false).action(withConfigError(async (account, opts) => {
54579
54596
  const normalizedAccount = validateAndNormalizeAccountEmail(account);
54580
54597
  const [
54581
- { runDriveOAuthFlow: runDriveOAuthFlow2, selectDriveAccountScopes: selectDriveAccountScopes2 },
54598
+ { runDriveOAuthFlow: runDriveOAuthFlow2, selectGoogleWorkspaceScopes: selectGoogleWorkspaceScopes2 },
54582
54599
  { selectInitialTier: selectInitialTier2 },
54583
54600
  { brokerCall: brokerCall2 },
54584
54601
  { loadConfig: loadConfig2, resolvePath: resolvePath2 },
@@ -54647,7 +54664,11 @@ function registerAccountAdd(accountParent) {
54647
54664
  clientIdRaw = await resolveRef(clientIdRaw, "google_client_id");
54648
54665
  clientSecretRaw = await resolveRef(clientSecretRaw, "google_client_secret");
54649
54666
  }
54650
- const accountScopes = selectDriveAccountScopes2(opts.write ?? false);
54667
+ const tier = gw.tier ?? "core";
54668
+ const accountScopes = selectGoogleWorkspaceScopes2({
54669
+ write: opts.write ?? false,
54670
+ tier
54671
+ });
54651
54672
  const oauthCfg = {
54652
54673
  client_id: clientIdRaw,
54653
54674
  client_secret: clientSecretRaw,
@@ -54656,6 +54677,9 @@ function registerAccountAdd(accountParent) {
54656
54677
  if (opts.write) {
54657
54678
  console.log(source_default.yellow(" Requesting Drive WRITE scope (drive.file \u2014 create/edit app-created files)."));
54658
54679
  }
54680
+ console.log(source_default.gray(` Workspace tier: ${tier} \u2014 requesting Docs + Sheets` + (tier === "extended" || tier === "complete" ? " + Slides" : "") + " API scopes so the tier's tools can authenticate."));
54681
+ console.log(source_default.gray(` Changing the tier later requires re-running this command
54682
+ ` + " (`--replace`) \u2014 OAuth scopes are fixed at consent time."));
54659
54683
  const oauthEnv = {
54660
54684
  DISPLAY: process.env.DISPLAY,
54661
54685
  WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
@@ -73048,6 +73072,29 @@ function buildSeedCredentials(input) {
73048
73072
  }
73049
73073
  var AIOFILE_PIN = "aiofile==3.10.2";
73050
73074
  var AIOFILE_PKG = AIOFILE_PIN.split("==")[0];
73075
+ function requiredWorkspaceScopesForTier(tier) {
73076
+ const docs = [
73077
+ "https://www.googleapis.com/auth/documents",
73078
+ "https://www.googleapis.com/auth/spreadsheets"
73079
+ ];
73080
+ if (tier === "extended" || tier === "complete") {
73081
+ return [...docs, "https://www.googleapis.com/auth/presentations"];
73082
+ }
73083
+ return docs;
73084
+ }
73085
+ function findMissingWorkspaceScopes(seedScope, tier) {
73086
+ const have = new Set(seedScope.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 0));
73087
+ return requiredWorkspaceScopesForTier(tier).filter((s) => !have.has(s));
73088
+ }
73089
+ var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
73090
+ function buildMissingScopeWarning(missing, tier, accountEmail, hasWriteScope) {
73091
+ const short = missing.map((s) => s.replace(/^https:\/\/www\.googleapis\.com\/auth\//, "")).join(", ");
73092
+ return `drive-mcp-launcher: WARNING \u2014 the Google account '${accountEmail}' was ` + `consented WITHOUT the scope(s) needed for tier '${tier ?? "core"}': ` + `${short}.
73093
+ ` + ` The matching MCP tools (Docs / Sheets / Slides create+edit) will FAIL ` + `to authenticate. OAuth scopes are fixed at consent time \u2014 re-run on the ` + `host to re-mint the token with the correct scopes:
73094
+ ` + ` switchroom auth google account add ${accountEmail} --replace` + `${hasWriteScope ? " --write" : ""}
73095
+ ` + ` (scopes are derived from \`google_workspace.tier\` \u2014 set the tier ` + `before re-running${hasWriteScope ? "; --write preserves the existing " + "Drive write capability" : ""}). Drive read/file tools are unaffected.
73096
+ `;
73097
+ }
73051
73098
  function buildUvxArgs(tier) {
73052
73099
  const args = [
73053
73100
  "--from",
@@ -73072,6 +73119,9 @@ function buildChildEnv(baseEnv, credentialsDir, accountEmail) {
73072
73119
  delete env2.WORKSPACE_MCP_STATELESS_MODE;
73073
73120
  delete env2.GOOGLE_APPLICATION_CREDENTIALS;
73074
73121
  delete env2.WORKSPACE_MCP_SERVICE_ACCOUNT_FILE;
73122
+ if (!env2.WORKSPACE_MCP_PORT) {
73123
+ env2.WORKSPACE_MCP_PORT = env2.SWITCHROOM_GDRIVE_MCP_PORT ?? "8631";
73124
+ }
73075
73125
  return env2;
73076
73126
  }
73077
73127
  function classifyRootSchema(schema) {
@@ -73288,6 +73338,11 @@ async function runDriveMcpLauncher(opts) {
73288
73338
  process.exit(1);
73289
73339
  }
73290
73340
  const tier = opts.tier ?? configSecrets.tier;
73341
+ const missingScopes = findMissingWorkspaceScopes(brokerCreds.scope, tier);
73342
+ if (missingScopes.length > 0) {
73343
+ const hasWriteScope = brokerCreds.scope.split(/\s+/).map((s) => s.trim()).includes(DRIVE_FILE_SCOPE);
73344
+ process.stderr.write(buildMissingScopeWarning(missingScopes, tier, brokerCreds.accountEmail, hasWriteScope));
73345
+ }
73291
73346
  const args = buildUvxArgs(tier);
73292
73347
  const env2 = buildChildEnv(process.env, credentialsDir, brokerCreds.accountEmail);
73293
73348
  const { spawn: spawn5 } = await import("node:child_process");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.11",
3
+ "version": "0.13.13",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37383,6 +37383,19 @@ function recordSilentTurnEnd(args, deps) {
37383
37383
  writeSilentEndState(args, deps);
37384
37384
  return { exhausted: false };
37385
37385
  }
37386
+ var recordUndeliveredTurnEnd = recordSilentTurnEnd;
37387
+
37388
+ // final-answer-detect.ts
37389
+ var FINAL_ANSWER_MIN_CHARS = 200;
37390
+ function isFinalAnswerReply(input) {
37391
+ if (input.done === true)
37392
+ return true;
37393
+ if (!input.disableNotification)
37394
+ return true;
37395
+ if (input.text.length >= FINAL_ANSWER_MIN_CHARS)
37396
+ return true;
37397
+ return false;
37398
+ }
37386
37399
 
37387
37400
  // turn-flush-safety.ts
37388
37401
  var SILENT_MARKERS = new Set(["NO_REPLY", "HEARTBEAT_OK"]);
@@ -40593,28 +40606,12 @@ function isSilentFlushMarker2(text) {
40593
40606
  }
40594
40607
  return SILENT_MARKERS2.has(trimmed.toUpperCase());
40595
40608
  }
40596
- var REPLY_CALLED_TAIL_MIN_CHARS = 40;
40597
40609
  function decideTurnFlush(input) {
40598
40610
  const flushEnabled = input.flushEnabled !== false;
40599
40611
  if (!flushEnabled)
40600
40612
  return { kind: "skip", reason: "flag-disabled" };
40601
- if (input.replyCalled) {
40602
- const tailIdx = input.capturedTextLenAtLastReply ?? input.capturedText.length;
40603
- const tail = input.capturedText.slice(tailIdx).join(`
40604
- `).trim();
40605
- const minChars = input.replyCalledTailMinChars ?? REPLY_CALLED_TAIL_MIN_CHARS;
40606
- if (tail.length === 0) {
40607
- return { kind: "skip", reason: "reply-called" };
40608
- }
40609
- if (tail.length < minChars) {
40610
- return { kind: "skip", reason: "reply-called-no-new-text" };
40611
- }
40612
- if (input.chatId == null)
40613
- return { kind: "skip", reason: "no-inbound-chat" };
40614
- if (isSilentFlushMarker2(tail))
40615
- return { kind: "skip", reason: "silent-marker" };
40616
- return { kind: "flush", text: tail };
40617
- }
40613
+ if (input.replyCalled)
40614
+ return { kind: "skip", reason: "reply-called" };
40618
40615
  if (input.chatId == null)
40619
40616
  return { kind: "skip", reason: "no-inbound-chat" };
40620
40617
  const joined = input.capturedText.join(`
@@ -46654,14 +46651,6 @@ function startSubagentWatcher(config) {
46654
46651
  return;
46655
46652
  if (entry.state === "done" && !entry.completionNotified) {
46656
46653
  entry.completionNotified = true;
46657
- const desc = escapeHtml8(truncate3(entry.description, 80));
46658
- const summary = entry.lastSummaryLine ? ` \u2014 ${escapeHtml8(truncate3(entry.lastSummaryLine, 120))}` : "";
46659
- const tools = entry.toolCount > 0 ? ` (${entry.toolCount} tools)` : "";
46660
- try {
46661
- config.sendNotification(`\u2713 Worker done: ${desc}${tools}${summary}`);
46662
- } catch (err) {
46663
- log?.(`subagent-watcher: completion notification error: ${err.message}`);
46664
- }
46665
46654
  if (config.onFinish) {
46666
46655
  try {
46667
46656
  config.onFinish({
@@ -48027,9 +48016,9 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48027
48016
  }
48028
48017
 
48029
48018
  // ../src/build-info.ts
48030
- var VERSION = "0.13.11";
48031
- var COMMIT_SHA = "5984798c";
48032
- var COMMIT_DATE = "2026-05-22T15:59:07+10:00";
48019
+ var VERSION = "0.13.13";
48020
+ var COMMIT_SHA = "dc583d57";
48021
+ var COMMIT_DATE = "2026-05-22T22:02:14+10:00";
48033
48022
  var LATEST_PR = null;
48034
48023
  var COMMITS_AHEAD_OF_TAG = 3;
48035
48024
 
@@ -50446,6 +50435,7 @@ async function executeUpdateChecklist(args) {
50446
50435
  return { content: [{ type: "text", text: `checklist updated (id: ${message_id})` }] };
50447
50436
  }
50448
50437
  async function executeReply(args) {
50438
+ const turn = currentTurn;
50449
50439
  const chat_id = args.chat_id;
50450
50440
  if (!chat_id)
50451
50441
  throw new Error("reply: chat_id is required");
@@ -50731,6 +50721,9 @@ ${url}`;
50731
50721
  process.stderr.write(`telegram gateway: reply: endStatusReaction hook threw: ${err}
50732
50722
  `);
50733
50723
  }
50724
+ if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
50725
+ turn.finalAnswerDelivered = true;
50726
+ }
50734
50727
  }
50735
50728
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
50736
50729
  `);
@@ -50740,6 +50733,7 @@ ${url}`;
50740
50733
  return { content: [{ type: "text", text: result }] };
50741
50734
  }
50742
50735
  async function executeStreamReply(args) {
50736
+ const turn = currentTurn;
50743
50737
  if (!args.chat_id)
50744
50738
  throw new Error("stream_reply: chat_id is required");
50745
50739
  if (args.text == null || args.text === "")
@@ -50842,6 +50836,13 @@ async function executeStreamReply(args) {
50842
50836
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
50843
50837
  outboundDedup.record(sChatId, sThreadId, args.text, Date.now());
50844
50838
  }
50839
+ if (turn != null && isFinalAnswerReply({
50840
+ text: args.text ?? "",
50841
+ disableNotification: args.disable_notification === true,
50842
+ done: args.done === true
50843
+ })) {
50844
+ turn.finalAnswerDelivered = true;
50845
+ }
50845
50846
  return { content: [{ type: "text", text: `${result.status} (id: ${result.messageId ?? "pending"})` }] };
50846
50847
  }
50847
50848
  async function executeProgressUpdate(args) {
@@ -51593,8 +51594,8 @@ function handleSessionEvent(ev) {
51593
51594
  startedAt,
51594
51595
  gatewayReceiveAt: startedAt,
51595
51596
  replyCalled: false,
51597
+ finalAnswerDelivered: false,
51596
51598
  capturedText: [],
51597
- capturedTextLenAtLastReply: 0,
51598
51599
  orphanedReplyTimeoutId: null,
51599
51600
  registryKey: null,
51600
51601
  lastAssistantMsgId: null,
@@ -51659,7 +51660,6 @@ function handleSessionEvent(ev) {
51659
51660
  const name = ev.toolName;
51660
51661
  if (isTelegramReplyTool(name)) {
51661
51662
  turn.replyCalled = true;
51662
- turn.capturedTextLenAtLastReply = turn.capturedText.length;
51663
51663
  if (turn.orphanedReplyTimeoutId != null) {
51664
51664
  clearTimeout(turn.orphanedReplyTimeoutId);
51665
51665
  turn.orphanedReplyTimeoutId = null;
@@ -51822,13 +51822,8 @@ function handleSessionEvent(ev) {
51822
51822
  chatId: turn.sessionChatId,
51823
51823
  replyCalled: turn.replyCalled,
51824
51824
  capturedText: turn.capturedText,
51825
- capturedTextLenAtLastReply: turn.capturedTextLenAtLastReply,
51826
51825
  flushEnabled: TURN_FLUSH_SAFETY_ENABLED
51827
51826
  });
51828
- if (flushDecision.kind === "flush" && turn.replyCalled) {
51829
- process.stderr.write(`telegram gateway: WARN post-reply-tail flush (#1291) \u2014 model emitted ${flushDecision.text.length} chars after a prior reply call without a follow-up reply tool chat=${chatId} turnStartedAt=${turn.startedAt}
51830
- `);
51831
- }
51832
51827
  if (flushDecision.kind === "skip" && flushDecision.reason !== "reply-called") {
51833
51828
  process.stderr.write(`telegram gateway: turn-flush skipped \u2014 reason=${flushDecision.reason}
51834
51829
  `);
@@ -51899,6 +51894,7 @@ function handleSessionEvent(ev) {
51899
51894
  const backstopChatId = chatId;
51900
51895
  const backstopThreadId = threadId;
51901
51896
  const backstopCtrl = ctrl;
51897
+ turn.finalAnswerDelivered = true;
51902
51898
  const cardTakeover = progressDriver?.takeOverCard({
51903
51899
  chatId: backstopChatId,
51904
51900
  threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
@@ -52037,8 +52033,8 @@ function handleSessionEvent(ev) {
52037
52033
  longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
52038
52034
  ended_via: outboundMetrics.outboundCount > 0 ? "reply" : "silent"
52039
52035
  });
52040
- if (outboundMetrics.outboundCount === 0) {
52041
- const silentEnd = recordSilentTurnEnd({
52036
+ if (turn.finalAnswerDelivered === false) {
52037
+ const silentEnd = recordUndeliveredTurnEnd({
52042
52038
  chatId,
52043
52039
  threadId: threadId ?? null,
52044
52040
  turnKey: tKey
@@ -57308,20 +57304,6 @@ var didOneTimeSetup = false;
57308
57304
  agentCwd: watcherAgentDir,
57309
57305
  db: turnsDb,
57310
57306
  parentStateDir: STATE_DIR,
57311
- sendNotification: (text) => {
57312
- const ownerChatId = loadAccess().allowFrom[0];
57313
- if (!ownerChatId)
57314
- return;
57315
- swallowingApiCall(() => lockedBot.api.sendMessage(ownerChatId, text, {
57316
- parse_mode: "HTML",
57317
- link_preview_options: { is_disabled: true },
57318
- ...TOPIC_ID != null ? { message_thread_id: TOPIC_ID } : {}
57319
- }), {
57320
- chat_id: ownerChatId,
57321
- verb: "subagent-watcher-notification",
57322
- ...TOPIC_ID != null ? { threadId: TOPIC_ID } : {}
57323
- });
57324
- },
57325
57307
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}
57326
57308
  `),
57327
57309
  onStall: (agentId, idleMs, description) => {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * final-answer-detect.ts — #1664 "did this reply deliver the final answer?"
3
+ *
4
+ * Background. An agent often ends a turn with its real answer as plain
5
+ * assistant transcript text instead of a `reply` / `stream_reply` tool
6
+ * call. The gateway renders that transcript as a live Telegram draft
7
+ * (`sendMessageDraft`) and, at turn_end, retracts the draft — so the
8
+ * answer is never finalized and the user watches it vanish (#1664).
9
+ *
10
+ * The gateway's `replyCalled` flag flips on the FIRST reply / stream_reply
11
+ * tool use and stays true for the rest of the turn. It cannot distinguish
12
+ * "the model sent an interim ack" from "the model sent its real answer" —
13
+ * both set `replyCalled`. The silent-end re-prompt safety net needs a
14
+ * finer signal: it must engage when a turn ended with only an interim
15
+ * ack and the real answer left as transcript text.
16
+ *
17
+ * This module is that finer signal — a pure predicate the gateway calls
18
+ * for each reply that lands. A turn whose every reply was classified
19
+ * "interim" ends with `CurrentTurn.finalAnswerDelivered === false`, which
20
+ * triggers the re-prompt; a turn with at least one "final" reply does not.
21
+ *
22
+ * Keeping the policy in one unit-testable function is the point — the
23
+ * gateway is a multi-thousand-line module that's expensive to import in a
24
+ * test. See `telegram-plugin/tests/final-answer-detect.test.ts`.
25
+ *
26
+ * The fix re-prompts the model; it never materializes the draft into a
27
+ * message (`reference/principles.md`: the model communicates, the
28
+ * framework is the safety net). So a false "interim" classification is
29
+ * cheap (one extra re-prompt) and a false "final" classification is the
30
+ * dangerous one (a real answer left undelivered) — the length backstop
31
+ * exists to make the dangerous miss rare.
32
+ */
33
+
34
+ /**
35
+ * Length backstop for the final-answer classification. The pacing
36
+ * contract (`docs/telegram-style.md`) says interim updates pass
37
+ * `disable_notification: true` and the final answer omits it — so a
38
+ * notification-bearing reply is the primary "final answer" signal. But a
39
+ * model that mis-marks a genuinely substantive reply as interim
40
+ * (`disable_notification: true` on what is really the answer) would
41
+ * otherwise leave the turn looking undelivered. Any reply at or above
42
+ * this many characters therefore ALSO counts as the final answer,
43
+ * regardless of the notification flag. 200 chars is comfortably longer
44
+ * than a typical interim ack ("on it", "looking into that…", "give me a
45
+ * sec") and short enough that a real answer almost always clears it.
46
+ */
47
+ export const FINAL_ANSWER_MIN_CHARS = 200
48
+
49
+ export interface FinalAnswerReplyInput {
50
+ /** The reply text the model sent (the model's own answer text, before
51
+ * any HTML conversion or Telegraph-link substitution). */
52
+ text: string
53
+ /** The `disable_notification` argument the reply tool was called with.
54
+ * `true` is the pacing contract's "interim update" marker; the final
55
+ * answer omits it (effectively `false`). */
56
+ disableNotification: boolean
57
+ /** For `stream_reply` only: whether this call carried `done: true`. A
58
+ * `done: true` call explicitly closes the stream and IS the final
59
+ * answer by definition. Pass `false` for the plain `reply` tool. */
60
+ done?: boolean
61
+ }
62
+
63
+ /**
64
+ * Pure predicate: did this reply deliver the turn's final answer (as
65
+ * opposed to an interim ack)? `true` if ANY of:
66
+ *
67
+ * - `done === true` — a `stream_reply` terminal call; the model
68
+ * explicitly closed the stream, so this is the final answer.
69
+ * - `disableNotification === false` — the pacing contract's explicit
70
+ * "final answer" signal (interim updates set it `true`).
71
+ * - `text.length >= FINAL_ANSWER_MIN_CHARS` — the length backstop for
72
+ * a substantive answer mis-marked as interim.
73
+ *
74
+ * The gateway ORs this across every reply in a turn; once one reply
75
+ * qualifies, `CurrentTurn.finalAnswerDelivered` latches true and the
76
+ * silent-end re-prompt will not engage for that turn.
77
+ */
78
+ export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
79
+ if (input.done === true) return true
80
+ if (!input.disableNotification) return true
81
+ if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
82
+ return false
83
+ }