@agenticmail/core 0.9.26 → 0.9.27

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
@@ -5869,6 +5869,22 @@ var PHONE_SERVER_MAX_CALL_DURATION_SECONDS = 3600;
5869
5869
  var PHONE_SERVER_MAX_COST_PER_MISSION = 5;
5870
5870
  var PHONE_SERVER_MAX_ATTEMPTS = 3;
5871
5871
  var PHONE_TASK_MAX_LENGTH = 2e3;
5872
+ var PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST = 300;
5873
+ var PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL = 4;
5874
+ var PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS = 600;
5875
+ var DEFAULT_EXTENSION_POLICY = {
5876
+ maxSecondsPerRequest: 120,
5877
+ // 2 minutes
5878
+ maxRequestsPerCall: 2,
5879
+ maxTotalExtensionSeconds: 300
5880
+ // 5 minutes
5881
+ };
5882
+ var PHONE_SERVER_MAX_CALLBACK_CHAIN = 3;
5883
+ var DEFAULT_CALLBACK_POLICY = {
5884
+ allowAutoCallback: true,
5885
+ maxCallbackChain: 2
5886
+ };
5887
+ var PHONE_CALLBACK_MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
5872
5888
  var EU_DIAL_PREFIXES = [
5873
5889
  "+30",
5874
5890
  "+31",
@@ -5983,6 +5999,76 @@ function validateAlternativePolicy(value) {
5983
5999
  }
5984
6000
  return [];
5985
6001
  }
6002
+ function validateExtensionPolicy(value) {
6003
+ if (value === void 0) return [];
6004
+ if (!isRecord(value)) {
6005
+ return [issue("invalid-extension-policy", "policy.extensionPolicy", "extensionPolicy must be an object")];
6006
+ }
6007
+ const issues = [];
6008
+ const perReq = readPositiveInteger(value.maxSecondsPerRequest);
6009
+ if (perReq === null) {
6010
+ issues.push(issue(
6011
+ "invalid-extension-per-request",
6012
+ "policy.extensionPolicy.maxSecondsPerRequest",
6013
+ "maxSecondsPerRequest must be a positive integer"
6014
+ ));
6015
+ }
6016
+ const requests = readPositiveInteger(value.maxRequestsPerCall);
6017
+ if (requests === null) {
6018
+ issues.push(issue(
6019
+ "invalid-extension-requests",
6020
+ "policy.extensionPolicy.maxRequestsPerCall",
6021
+ "maxRequestsPerCall must be a positive integer"
6022
+ ));
6023
+ }
6024
+ const total = readPositiveInteger(value.maxTotalExtensionSeconds);
6025
+ if (total === null) {
6026
+ issues.push(issue(
6027
+ "invalid-extension-total",
6028
+ "policy.extensionPolicy.maxTotalExtensionSeconds",
6029
+ "maxTotalExtensionSeconds must be a positive integer"
6030
+ ));
6031
+ }
6032
+ return issues;
6033
+ }
6034
+ function validateCallbackPolicy(value) {
6035
+ if (value === void 0) return [];
6036
+ if (!isRecord(value)) {
6037
+ return [issue("invalid-callback-policy", "policy.callbackPolicy", "callbackPolicy must be an object")];
6038
+ }
6039
+ const issues = [];
6040
+ if (readBoolean(value.allowAutoCallback) === null) {
6041
+ issues.push(issue(
6042
+ "invalid-callback-allow",
6043
+ "policy.callbackPolicy.allowAutoCallback",
6044
+ "allowAutoCallback must be boolean"
6045
+ ));
6046
+ }
6047
+ const chain = readNonNegativeNumber(value.maxCallbackChain);
6048
+ if (chain === null || !Number.isInteger(chain)) {
6049
+ issues.push(issue(
6050
+ "invalid-callback-chain",
6051
+ "policy.callbackPolicy.maxCallbackChain",
6052
+ "maxCallbackChain must be a non-negative integer"
6053
+ ));
6054
+ }
6055
+ return issues;
6056
+ }
6057
+ function resolveExtensionPolicy(input) {
6058
+ const src = input ?? DEFAULT_EXTENSION_POLICY;
6059
+ return {
6060
+ maxSecondsPerRequest: Math.min(src.maxSecondsPerRequest, PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST),
6061
+ maxRequestsPerCall: Math.min(src.maxRequestsPerCall, PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL),
6062
+ maxTotalExtensionSeconds: Math.min(src.maxTotalExtensionSeconds, PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS)
6063
+ };
6064
+ }
6065
+ function resolveCallbackPolicy(input) {
6066
+ const src = input ?? DEFAULT_CALLBACK_POLICY;
6067
+ return {
6068
+ allowAutoCallback: src.allowAutoCallback,
6069
+ maxCallbackChain: Math.min(src.maxCallbackChain, PHONE_SERVER_MAX_CALLBACK_CHAIN)
6070
+ };
6071
+ }
5986
6072
  function validatePhoneMissionPolicy(policy) {
5987
6073
  const issues = [];
5988
6074
  if (!isRecord(policy)) {
@@ -6017,6 +6103,8 @@ function validatePhoneMissionPolicy(policy) {
6017
6103
  }
6018
6104
  issues.push(...validateConfirmPolicy(policy.confirmPolicy));
6019
6105
  issues.push(...validateAlternativePolicy(policy.alternativePolicy));
6106
+ issues.push(...validateExtensionPolicy(policy.extensionPolicy));
6107
+ issues.push(...validateCallbackPolicy(policy.callbackPolicy));
6020
6108
  if (issues.length > 0) return { ok: false, issues };
6021
6109
  return {
6022
6110
  ok: true,
@@ -6031,7 +6119,14 @@ function validatePhoneMissionPolicy(policy) {
6031
6119
  confirmPolicy: policy.confirmPolicy,
6032
6120
  alternativePolicy: {
6033
6121
  maxTimeShiftMinutes: policy.alternativePolicy.maxTimeShiftMinutes
6034
- }
6122
+ },
6123
+ // The extension + callback policies are optional in the caller's
6124
+ // input but we ALWAYS materialise them in the resolved policy so
6125
+ // every downstream consumer (the bridge, the scheduler, the
6126
+ // manager) can read a concrete value without juggling undefined.
6127
+ // Caller-omitted → DEFAULT_*. Caller-set → clamped to server caps.
6128
+ extensionPolicy: resolveExtensionPolicy(policy.extensionPolicy),
6129
+ callbackPolicy: resolveCallbackPolicy(policy.callbackPolicy)
6035
6130
  },
6036
6131
  issues: []
6037
6132
  };
@@ -6266,6 +6361,13 @@ function readOperatorQueries(mission) {
6266
6361
  if (!Array.isArray(value)) return [];
6267
6362
  return value.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item) && typeof item.id === "string" && typeof item.question === "string");
6268
6363
  }
6364
+ function readChainDepth(mission) {
6365
+ const raw = mission.metadata.callbackChainDepth;
6366
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
6367
+ const sc = mission.metadata.scheduledCallback;
6368
+ if (sc && Number.isFinite(sc.chainDepth)) return Math.floor(sc.chainDepth);
6369
+ return 0;
6370
+ }
6269
6371
  function escapeLike(value) {
6270
6372
  return value.replace(/[\\%_]/g, "\\$&");
6271
6373
  }
@@ -6284,6 +6386,26 @@ function buildCallbackTask(originalTask, query) {
6284
6386
  return `${continuity}
6285
6387
  ${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
6286
6388
  }
6389
+ function buildScheduledCallbackTask(originalTask, payload) {
6390
+ const continuity = [
6391
+ "# Call continuity \u2014 auto-callback",
6392
+ 'You were on a call with this person earlier and scheduled this follow-up before signing off. Open by acknowledging it naturally \u2014 e.g. "Hi, this is <your name> calling back as I said I would." Then continue the conversation from where you left off.',
6393
+ "",
6394
+ `## Why you arranged this callback
6395
+ ${payload.reason}`,
6396
+ "",
6397
+ `## Notes you left for yourself (your own summary at the end of the prior call)
6398
+ ${payload.agentSummary}`,
6399
+ "",
6400
+ `## What was actually said on the prior call (verbatim digest)
6401
+ ${payload.transcriptDigest}`,
6402
+ "",
6403
+ "# Original task"
6404
+ ].join("\n");
6405
+ const room = Math.max(0, PHONE_TASK_MAX_LENGTH - continuity.length - 1);
6406
+ return `${continuity}
6407
+ ${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
6408
+ }
6287
6409
  function parseJson(value, fallback) {
6288
6410
  if (!value) return fallback;
6289
6411
  try {
@@ -6956,6 +7078,134 @@ var PhoneManager = class {
6956
7078
  throw err;
6957
7079
  }
6958
7080
  }
7081
+ // ─── Scheduled callbacks (v0.9.81 — schedule_callback tool) ──────────
7082
+ /**
7083
+ * Persist a `schedule_callback` request to the mission. Called from
7084
+ * the realtime bridge's `onCallbackScheduled` hook. The scheduler
7085
+ * picks this up later when `payload.at <= now`. ChainDepth is
7086
+ * computed from the parent's metadata so {@link triggerScheduledCallback}
7087
+ * can enforce policy.callbackPolicy.maxCallbackChain without
7088
+ * walking back through the mission history.
7089
+ *
7090
+ * Returns the updated mission, or null if the mission isn't known.
7091
+ * Idempotent on the `mission.metadata.scheduledCallback.at` key: if
7092
+ * a scheduled callback already exists on the mission this writes a
7093
+ * SECOND copy on `scheduledCallbacks` as an audit trail but does
7094
+ * NOT overwrite the active record (the bridge only allows one per
7095
+ * call anyway; the audit log is a belt-and-braces guard against
7096
+ * the unusual case where a server restart re-runs the bridge logic).
7097
+ */
7098
+ armScheduledCallback(missionId, payload) {
7099
+ const mission = this.getMission(missionId);
7100
+ if (!mission) return null;
7101
+ const parentDepth = readChainDepth(mission);
7102
+ const record = {
7103
+ at: payload.at,
7104
+ reason: payload.reason,
7105
+ agentSummary: payload.agentSummary,
7106
+ transcriptDigest: payload.transcriptDigest,
7107
+ chainDepth: parentDepth + 1,
7108
+ status: "pending",
7109
+ armedAt: (/* @__PURE__ */ new Date()).toISOString()
7110
+ };
7111
+ return this.updateMissionStatus(mission.id, mission.status, {
7112
+ scheduledCallback: record
7113
+ }, [{
7114
+ at: record.armedAt,
7115
+ source: "system",
7116
+ text: `Scheduled callback armed for ${record.at} (chain depth ${record.chainDepth}). Reason: ${record.reason}`,
7117
+ metadata: { scheduledAt: record.at, chainDepth: record.chainDepth }
7118
+ }]);
7119
+ }
7120
+ /**
7121
+ * All missions with a `scheduledCallback.status === 'pending'` whose
7122
+ * `at` is <= now. The scheduler's per-tick worklist. Pass an upper
7123
+ * bound on count so a backlog doesn't dial every overdue callback in
7124
+ * one frame.
7125
+ */
7126
+ findDueScheduledCallbacks(nowIso, limit = 16) {
7127
+ const rows = this.db.prepare(
7128
+ "SELECT * FROM phone_missions WHERE metadata_json LIKE '%scheduledCallback%' AND metadata_json LIKE '%pending%' LIMIT ?"
7129
+ ).all(limit * 4);
7130
+ return rows.map(rowToMission).filter((mission) => {
7131
+ const sc = mission.metadata.scheduledCallback;
7132
+ return sc && sc.status === "pending" && sc.at <= nowIso;
7133
+ }).slice(0, limit);
7134
+ }
7135
+ /**
7136
+ * Dial a due scheduled callback. Mirrors {@link triggerCallback} for
7137
+ * the operator-query path:
7138
+ *
7139
+ * 1. Reject if the mission's policy.callbackPolicy disallows it OR
7140
+ * `chainDepth > maxCallbackChain` (no infinite chains).
7141
+ * 2. Transition status pending → dialing BEFORE dialing so a
7142
+ * concurrent tick can't double-dial.
7143
+ * 3. Build the continuation task with prior-call context and dial.
7144
+ * 4. On success: write `status: 'fired'` + the new mission id.
7145
+ * 5. On failure: write `status: 'pending'` + `lastError` so the
7146
+ * next tick can retry, then rethrow.
7147
+ *
7148
+ * Returns `null` if the mission isn't known or has no due callback.
7149
+ */
7150
+ async triggerScheduledCallback(missionId, options = {}) {
7151
+ const mission = this.getMission(missionId);
7152
+ if (!mission) return null;
7153
+ const sc = mission.metadata.scheduledCallback;
7154
+ if (!sc) return null;
7155
+ if (sc.status !== "pending") return null;
7156
+ const callbackPol = mission.policy.callbackPolicy;
7157
+ if (!callbackPol || !callbackPol.allowAutoCallback || sc.chainDepth > callbackPol.maxCallbackChain) {
7158
+ const updated = this.updateMissionStatus(mission.id, mission.status, {
7159
+ scheduledCallback: { ...sc, status: "failed", lastError: "policy denies callback (chain or disabled)" }
7160
+ }, [{
7161
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7162
+ source: "system",
7163
+ text: `Scheduled callback denied by policy (chainDepth=${sc.chainDepth}, max=${callbackPol?.maxCallbackChain ?? 0}).`
7164
+ }]);
7165
+ return updated ? { mission: updated, callbackMission: updated } : null;
7166
+ }
7167
+ this.updateMissionStatus(mission.id, mission.status, {
7168
+ scheduledCallback: { ...sc, status: "dialing" }
7169
+ }, [{
7170
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7171
+ source: "system",
7172
+ text: `Dialing scheduled callback (chain depth ${sc.chainDepth}).`
7173
+ }]);
7174
+ try {
7175
+ const result = await this.startMission(mission.agentId, {
7176
+ to: mission.to,
7177
+ task: buildScheduledCallbackTask(mission.task, {
7178
+ reason: sc.reason,
7179
+ agentSummary: sc.agentSummary,
7180
+ transcriptDigest: sc.transcriptDigest
7181
+ }),
7182
+ policy: mission.policy
7183
+ }, options);
7184
+ const callbackMission = this.updateMissionStatus(result.mission.id, result.mission.status, {
7185
+ callbackChainDepth: sc.chainDepth,
7186
+ callbackParentMissionId: mission.id
7187
+ }, []) ?? result.mission;
7188
+ const linked = this.updateMissionStatus(mission.id, mission.status, {
7189
+ scheduledCallback: {
7190
+ ...sc,
7191
+ status: "fired",
7192
+ firedAt: (/* @__PURE__ */ new Date()).toISOString(),
7193
+ callbackMissionId: result.mission.id
7194
+ }
7195
+ }, []);
7196
+ return { mission: linked, callbackMission };
7197
+ } catch (err) {
7198
+ const message = err?.message ?? String(err);
7199
+ this.updateMissionStatus(mission.id, mission.status, {
7200
+ scheduledCallback: { ...sc, status: "pending", lastError: message }
7201
+ }, [{
7202
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7203
+ source: "system",
7204
+ text: `Scheduled callback dial failed (${message}); will retry on next scheduler tick.`
7205
+ }]);
7206
+ throw err;
7207
+ }
7208
+ }
6959
7209
  build46ElksCallRequest(config, mission) {
6960
7210
  const timeout = Math.min(Math.max(mission.policy.maxCallDurationSeconds, 1), PHONE_SERVER_MAX_CALL_DURATION_SECONDS);
6961
7211
  return {
@@ -8795,6 +9045,57 @@ var LOAD_SKILL_TOOL = {
8795
9045
  additionalProperties: false
8796
9046
  }
8797
9047
  };
9048
+ var GET_CALL_STATUS_TOOL = {
9049
+ type: "function",
9050
+ name: "get_call_status",
9051
+ description: "Check how much time you have left on this call and whether you can request more time or schedule a callback. Use this whenever you are unsure if there is room to keep going. Returns: secondsRemaining, soft deadline timestamp, extensions used/remaining/availableSeconds, callbackAvailable.",
9052
+ parameters: {
9053
+ type: "object",
9054
+ properties: {}
9055
+ }
9056
+ };
9057
+ var EXTEND_CALL_TIME_TOOL = {
9058
+ type: "function",
9059
+ name: "extend_call_time",
9060
+ description: "Request more time on this call. Auto-approved within your call's extension policy \u2014 you do not need to ask the operator. Use this BEFORE your time runs out. If the grant is partial or refused the response tells you exactly what you have left; do not retry-loop.",
9061
+ parameters: {
9062
+ type: "object",
9063
+ properties: {
9064
+ seconds: {
9065
+ type: "number",
9066
+ description: "How many MORE seconds you need. Positive integer. The server caps each grant."
9067
+ },
9068
+ reason: {
9069
+ type: "string",
9070
+ description: "One short line on why \u2014 kept on the mission transcript for review."
9071
+ }
9072
+ },
9073
+ required: ["seconds"]
9074
+ }
9075
+ };
9076
+ var SCHEDULE_CALLBACK_TOOL = {
9077
+ type: "function",
9078
+ name: "schedule_callback",
9079
+ description: 'Arrange an auto-callback to this same number at a later time. Use this when you cannot finish in the time available, or when the caller needs a break, or when context dictates a follow-up ("call me after 5pm", "try again tomorrow"). The next call picks up with your summary + the transcript so far automatically \u2014 say goodbye and the system handles re-dialing. Only ONE callback can be scheduled per call; choose carefully.',
9080
+ parameters: {
9081
+ type: "object",
9082
+ properties: {
9083
+ delay_seconds: {
9084
+ type: "number",
9085
+ description: 'How many seconds from now to call back. Minimum 30, maximum 604800 (7 days). Use larger delays generously \u2014 "tomorrow morning" is ~57600s.'
9086
+ },
9087
+ reason: {
9088
+ type: "string",
9089
+ description: 'One short line for the audit trail \u2014 e.g. "ran out of time before confirming the booking" or "caller asked me to ring back after 5pm".'
9090
+ },
9091
+ summary_for_next_call: {
9092
+ type: "string",
9093
+ description: "What the next call agent MUST know to pick up where you left off \u2014 facts you collected, the caller's name, what was agreed, what is still open. Be concrete; this is all the next agent has from your call."
9094
+ }
9095
+ },
9096
+ required: ["delay_seconds", "summary_for_next_call"]
9097
+ }
9098
+ };
8798
9099
  var REALTIME_TOOL_DEFINITIONS = {
8799
9100
  ask_operator: ASK_OPERATOR_TOOL,
8800
9101
  web_search: WEB_SEARCH_TOOL,
@@ -8802,7 +9103,10 @@ var REALTIME_TOOL_DEFINITIONS = {
8802
9103
  get_datetime: GET_DATETIME_TOOL,
8803
9104
  search_email: SEARCH_EMAIL_TOOL,
8804
9105
  search_skills: SEARCH_SKILLS_TOOL,
8805
- load_skill: LOAD_SKILL_TOOL
9106
+ load_skill: LOAD_SKILL_TOOL,
9107
+ get_call_status: GET_CALL_STATUS_TOOL,
9108
+ extend_call_time: EXTEND_CALL_TIME_TOOL,
9109
+ schedule_callback: SCHEDULE_CALLBACK_TOOL
8806
9110
  };
8807
9111
  function buildRealtimeToolGuidance(tools) {
8808
9112
  if (tools.length === 0) return "";
@@ -8831,6 +9135,24 @@ function buildRealtimeToolGuidance(tools) {
8831
9135
  A skill's rendered playbook is now part of your instructions for the rest of the call. You can load a second skill if a new situation comes up \u2014 but the model keeps a max of two loaded; a third load drops the oldest. Pick skills deliberately.`
8832
9136
  );
8833
9137
  }
9138
+ if (names.has("get_call_status") || names.has("extend_call_time") || names.has("schedule_callback")) {
9139
+ lines.push(
9140
+ "# Managing your time on this call",
9141
+ "You have a fixed time budget. The system will quietly remind you when you have about 2 minutes left and again at about 30 seconds left \u2014 pace your conversation so you can wrap up cleanly.",
9142
+ "",
9143
+ "If you need more time to finish the job:",
9144
+ `- Call extend_call_time({ seconds: 120, reason: "..." }) BEFORE you run out. Requests are auto-approved within the call's extension policy \u2014 you do not need to ask the operator. The response tells you exactly how much you got and how much extension budget remains.`,
9145
+ "",
9146
+ "If you cannot finish in time, the caller wants you to call back later, OR you sense the conversation has hit a natural pause that needs a follow-up:",
9147
+ `- Call schedule_callback({ delay_seconds: <when>, reason: "...", summary_for_next_call: "..." }) BEFORE you sign off. The summary you pass becomes the next call's starting context \u2014 be concrete: caller's name, what was agreed, what's still open, any commitments you made.`,
9148
+ `- Then politely close: "Thanks for your time \u2014 I'll call you back at <when>." The system dials back automatically with your summary + the conversation so far loaded for the next agent.`,
9149
+ "- You can ONLY schedule one callback per call. Choose deliberately.",
9150
+ "",
9151
+ "You can check the current status (time left, extensions used, callback availability) at any moment with get_call_status \u2014 but do not fixate; keep the call moving.",
9152
+ "",
9153
+ "When in doubt \u2014 out of time, out of extensions, the caller is uncertain \u2014 preferred order is: wrap up gracefully \u2192 schedule_callback \u2192 sign off. Never go silent or invent excuses; the right move is always a clean handoff to a future call."
9154
+ );
9155
+ }
8834
9156
  return lines.join("\n");
8835
9157
  }
8836
9158
  function toolErrorText(err) {
@@ -9036,6 +9358,26 @@ ${task}`);
9036
9358
  '# What you already know\nThe following is your own long-term memory \u2014 knowledge, preferences, and lessons you have accumulated over time. Treat it as your own experience and act on it naturally. Do not read it aloud or mention that it is "memory"; simply know it.\n\n' + memory
9037
9359
  );
9038
9360
  }
9361
+ const budget = opts.callBudget;
9362
+ if (budget && budget.seconds > 0) {
9363
+ const mins = Math.round(budget.seconds / 60);
9364
+ const human = mins >= 1 ? `about ${mins} minute(s)` : `${budget.seconds} seconds`;
9365
+ const tips = [];
9366
+ if (budget.extensionEnabled) {
9367
+ tips.push(
9368
+ "If you need more time, call extend_call_time({ seconds, reason }) BEFORE you run out. Auto-approved within the call's extension policy."
9369
+ );
9370
+ }
9371
+ if (budget.callbackEnabled) {
9372
+ tips.push(
9373
+ "If you cannot finish in time, the caller wants you to ring back later, or the conversation naturally pauses for a follow-up, call schedule_callback({ delay_seconds, reason, summary_for_next_call }) BEFORE signing off. The next call automatically picks up with your summary and the transcript so far."
9374
+ );
9375
+ }
9376
+ sections.push(
9377
+ `# Your time on this call
9378
+ You have ${human} for this call. The system will quietly remind you at the 2-minute and 30-second marks \u2014 pace the conversation so you can wrap up cleanly.` + (tips.length > 0 ? "\n\n" + tips.join("\n") : "")
9379
+ );
9380
+ }
9039
9381
  const toolGuidance = opts.toolGuidance?.trim();
9040
9382
  if (toolGuidance) {
9041
9383
  sections.push(toolGuidance);
@@ -9075,6 +9417,10 @@ function buildRealtimeSessionConfig(opts) {
9075
9417
  function buildOpenAIRealtimeUrl(model = DEFAULT_REALTIME_MODEL) {
9076
9418
  return `${OPENAI_REALTIME_URL}?model=${encodeURIComponent(model || DEFAULT_REALTIME_MODEL)}`;
9077
9419
  }
9420
+ var MAX_CALLBACK_SUMMARY_LENGTH = 1500;
9421
+ var MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH = 2500;
9422
+ var CALL_BUDGET_REMINDER_MARKS_SECONDS = [120, 30];
9423
+ var CALL_BUDGET_GRACE_SECONDS = 30;
9078
9424
  var RealtimeVoiceBridge = class {
9079
9425
  carrier;
9080
9426
  openai;
@@ -9085,6 +9431,46 @@ var RealtimeVoiceBridge = class {
9085
9431
  maxToolCallMs;
9086
9432
  onTranscript;
9087
9433
  onEnd;
9434
+ /** Injectable clock + timers (tests substitute fakes). */
9435
+ nowFn;
9436
+ setTimeoutFn;
9437
+ clearTimeoutFn;
9438
+ /** v0.9.81 — extension / callback state. */
9439
+ extensionPolicy;
9440
+ callbackPolicy;
9441
+ onCallbackScheduled;
9442
+ /** Initial soft budget, in seconds. 0 = no bridge-side timer (legacy). */
9443
+ initialBudgetSeconds;
9444
+ /** Wall-clock ms when the call started, set on first carrier hello. */
9445
+ callStartedAtMs = null;
9446
+ /** Wall-clock ms when the soft deadline fires. Bumped by extensions. */
9447
+ softDeadlineMs = null;
9448
+ /** Soft-end timer (fires once, then schedules the grace timer). */
9449
+ softEndTimer = null;
9450
+ /** Final hard-end timer that fires after the grace window. */
9451
+ graceEndTimer = null;
9452
+ /** Reminder timers for the T-N marks. Cleared/re-armed on extensions. */
9453
+ reminderTimers = [];
9454
+ /** Marks (in seconds-remaining) we've already fired this call. Dedup
9455
+ * prevents re-injecting the same reminder after an extension if the
9456
+ * new deadline still has us past the same mark. */
9457
+ firedReminderMarks = /* @__PURE__ */ new Set();
9458
+ /** Count of extensions granted this call. */
9459
+ extensionsUsed = 0;
9460
+ /** Total extra seconds granted across all extensions this call. */
9461
+ extensionSecondsUsed = 0;
9462
+ /** True once the agent's schedule_callback request was accepted. */
9463
+ callbackArmed = false;
9464
+ /** Captured for the API layer when the soft deadline fires. */
9465
+ endedByTimeBudgetFlag = false;
9466
+ /**
9467
+ * Sliding window of recent assistant + system utterances, used to
9468
+ * build the transcript digest carried into a scheduled callback.
9469
+ * Capped at {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH} chars so
9470
+ * the digest itself can always be produced cheaply even on a long
9471
+ * call.
9472
+ */
9473
+ recentUtterances = [];
9088
9474
  /** Carrier `hello`/`start` received — the call leg is live. */
9089
9475
  helloSeen = false;
9090
9476
  /** OpenAI socket open + `session.update` sent. */
@@ -9142,6 +9528,13 @@ var RealtimeVoiceBridge = class {
9142
9528
  this.maxToolCallMs = opts.maxToolCallMs ?? REALTIME_TOOL_CALL_TIMEOUT_MS;
9143
9529
  this.onTranscript = opts.onTranscript;
9144
9530
  this.onEnd = opts.onEnd;
9531
+ this.nowFn = opts.now ?? Date.now;
9532
+ this.setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
9533
+ this.clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
9534
+ this.initialBudgetSeconds = opts.callBudgetSeconds && opts.callBudgetSeconds > 0 ? Math.floor(opts.callBudgetSeconds) : 0;
9535
+ this.extensionPolicy = opts.extensionPolicy;
9536
+ this.callbackPolicy = opts.callbackPolicy;
9537
+ this.onCallbackScheduled = opts.onCallbackScheduled;
9145
9538
  }
9146
9539
  /** True once the bridge has ended. */
9147
9540
  get isEnded() {
@@ -9303,6 +9696,7 @@ var RealtimeVoiceBridge = class {
9303
9696
  from: event.from,
9304
9697
  to: event.to
9305
9698
  });
9699
+ this.startCallBudget();
9306
9700
  return;
9307
9701
  }
9308
9702
  if (event.kind === "audio") {
@@ -9381,14 +9775,20 @@ var RealtimeVoiceBridge = class {
9381
9775
  case "response.output_audio_transcript.done":
9382
9776
  case "response.audio_transcript.done": {
9383
9777
  const text = this.assistantTranscript.trim();
9384
- if (text) this.emitTranscript("agent", text);
9778
+ if (text) {
9779
+ this.emitTranscript("agent", text);
9780
+ this.noteUtterance(`Agent: ${text}`);
9781
+ }
9385
9782
  this.assistantTranscript = "";
9386
9783
  return;
9387
9784
  }
9388
9785
  // Caller speech transcription, when input transcription is on.
9389
9786
  case "conversation.item.input_audio_transcription.completed": {
9390
9787
  const text = typeof event.transcript === "string" ? event.transcript.trim() : "";
9391
- if (text) this.emitTranscript("provider", text, { speaker: "caller" });
9788
+ if (text) {
9789
+ this.emitTranscript("provider", text, { speaker: "caller" });
9790
+ this.noteUtterance(`Caller: ${text}`);
9791
+ }
9392
9792
  return;
9393
9793
  }
9394
9794
  // A new output item was added to the response. When it is a
@@ -9508,6 +9908,369 @@ var RealtimeVoiceBridge = class {
9508
9908
  });
9509
9909
  this.safeSend(this.openai, { type: "response.create" });
9510
9910
  }
9911
+ // ─── Call-budget timers / extensions / callback (v0.9.81) ─────────
9912
+ /** True if the agent's `schedule_callback` request has been accepted. */
9913
+ get isCallbackArmed() {
9914
+ return this.callbackArmed;
9915
+ }
9916
+ /**
9917
+ * Seconds remaining on the current soft deadline, floored at 0. Returns
9918
+ * the initial budget if hello hasn't fired yet, and `Infinity` if no
9919
+ * call budget was configured (legacy mode). Used by `get_call_status`.
9920
+ */
9921
+ getTimeRemainingSeconds() {
9922
+ if (this.initialBudgetSeconds <= 0) return Number.POSITIVE_INFINITY;
9923
+ if (this.callStartedAtMs == null || this.softDeadlineMs == null) {
9924
+ return this.initialBudgetSeconds;
9925
+ }
9926
+ return Math.max(0, Math.ceil((this.softDeadlineMs - this.nowFn()) / 1e3));
9927
+ }
9928
+ /**
9929
+ * Public extension state snapshot for `get_call_status`. Each value is
9930
+ * "what the agent has left" so the model can decide whether to call
9931
+ * `extend_call_time` at all — exposing both the per-request cap AND
9932
+ * the remaining budget makes greedy / unbounded extension attempts
9933
+ * impossible.
9934
+ */
9935
+ getExtensionStatus() {
9936
+ const pol = this.extensionPolicy;
9937
+ if (!pol) {
9938
+ return {
9939
+ extensionsUsed: 0,
9940
+ extensionsRemaining: 0,
9941
+ secondsUsedSoFar: 0,
9942
+ secondsAvailable: 0,
9943
+ maxSecondsPerRequest: 0
9944
+ };
9945
+ }
9946
+ return {
9947
+ extensionsUsed: this.extensionsUsed,
9948
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
9949
+ secondsUsedSoFar: this.extensionSecondsUsed,
9950
+ secondsAvailable: Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed),
9951
+ maxSecondsPerRequest: pol.maxSecondsPerRequest
9952
+ };
9953
+ }
9954
+ /**
9955
+ * Grant (or refuse) more time on the call. Auto-approved within all
9956
+ * three policy caps; the granted amount is the min of:
9957
+ *
9958
+ * - the agent's requested seconds (positive integer, clamped > 0)
9959
+ * - policy.maxSecondsPerRequest
9960
+ * - policy.maxTotalExtensionSeconds − seconds already granted
9961
+ *
9962
+ * AND we won't push the new deadline past the hard ceiling
9963
+ * (PHONE_SERVER_MAX_CALL_DURATION_SECONDS from call start). The
9964
+ * returned shape always includes a model-readable `reason` so a
9965
+ * partial grant ("you asked for 5 min, you got 2 min") doesn't
9966
+ * confuse the agent.
9967
+ *
9968
+ * Failure modes (granted: 0):
9969
+ * - no extension policy on this call
9970
+ * - max requests already used
9971
+ * - max total seconds already used
9972
+ * - call already ended
9973
+ * - non-positive `seconds`
9974
+ */
9975
+ extendCallTime(requestedSeconds, reason) {
9976
+ if (this.ended) {
9977
+ return {
9978
+ granted: false,
9979
+ secondsGranted: 0,
9980
+ secondsRemaining: 0,
9981
+ extensionsRemaining: 0,
9982
+ message: "The call has already ended; no more extensions can be granted."
9983
+ };
9984
+ }
9985
+ const pol = this.extensionPolicy;
9986
+ if (!pol) {
9987
+ return {
9988
+ granted: false,
9989
+ secondsGranted: 0,
9990
+ secondsRemaining: this.getTimeRemainingSeconds(),
9991
+ extensionsRemaining: 0,
9992
+ message: "Extensions are not enabled for this call."
9993
+ };
9994
+ }
9995
+ if (this.initialBudgetSeconds <= 0 || this.softDeadlineMs == null || this.callStartedAtMs == null) {
9996
+ return {
9997
+ granted: false,
9998
+ secondsGranted: 0,
9999
+ secondsRemaining: Number.POSITIVE_INFINITY,
10000
+ extensionsRemaining: pol.maxRequestsPerCall - this.extensionsUsed,
10001
+ message: "This call has no soft time budget, so extensions are a no-op."
10002
+ };
10003
+ }
10004
+ if (this.extensionsUsed >= pol.maxRequestsPerCall) {
10005
+ return {
10006
+ granted: false,
10007
+ secondsGranted: 0,
10008
+ secondsRemaining: this.getTimeRemainingSeconds(),
10009
+ extensionsRemaining: 0,
10010
+ message: `Out of extensions \u2014 already used ${pol.maxRequestsPerCall}/${pol.maxRequestsPerCall} this call. Wrap up or schedule a callback.`
10011
+ };
10012
+ }
10013
+ const remainingBudgetSeconds = Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed);
10014
+ if (remainingBudgetSeconds <= 0) {
10015
+ return {
10016
+ granted: false,
10017
+ secondsGranted: 0,
10018
+ secondsRemaining: this.getTimeRemainingSeconds(),
10019
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
10020
+ message: `Out of extension time \u2014 already granted ${this.extensionSecondsUsed}s of the ${pol.maxTotalExtensionSeconds}s cap.`
10021
+ };
10022
+ }
10023
+ const asked = Math.floor(requestedSeconds);
10024
+ if (!Number.isFinite(asked) || asked <= 0) {
10025
+ return {
10026
+ granted: false,
10027
+ secondsGranted: 0,
10028
+ secondsRemaining: this.getTimeRemainingSeconds(),
10029
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
10030
+ message: "extend_call_time requires a positive integer number of seconds."
10031
+ };
10032
+ }
10033
+ let granted = Math.min(asked, pol.maxSecondsPerRequest, remainingBudgetSeconds);
10034
+ const elapsedSeconds = Math.floor((this.nowFn() - this.callStartedAtMs) / 1e3);
10035
+ const maxAllowedFromStart = 3600;
10036
+ const hardCeilingRoom = Math.max(0, maxAllowedFromStart - (elapsedSeconds + this.getTimeRemainingSeconds()));
10037
+ granted = Math.min(granted, hardCeilingRoom);
10038
+ if (granted <= 0) {
10039
+ return {
10040
+ granted: false,
10041
+ secondsGranted: 0,
10042
+ secondsRemaining: this.getTimeRemainingSeconds(),
10043
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
10044
+ message: "No extension granted \u2014 the call is already at the hard duration ceiling."
10045
+ };
10046
+ }
10047
+ this.extensionsUsed += 1;
10048
+ this.extensionSecondsUsed += granted;
10049
+ this.softDeadlineMs = this.softDeadlineMs + granted * 1e3;
10050
+ this.rearmBudgetTimers();
10051
+ this.emitTranscript("system", `Granted ${granted}s extension (#${this.extensionsUsed}). Reason: ${truncate2(asString5(reason) || "unspecified", 120)}`, {
10052
+ extensionsUsed: this.extensionsUsed,
10053
+ extensionSecondsUsed: this.extensionSecondsUsed,
10054
+ softDeadlineMs: this.softDeadlineMs
10055
+ });
10056
+ return {
10057
+ granted: true,
10058
+ secondsGranted: granted,
10059
+ secondsRemaining: this.getTimeRemainingSeconds(),
10060
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
10061
+ message: `Granted ${granted} more seconds (${this.getTimeRemainingSeconds()}s now remaining). ${Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed)} extension(s) left after this one.`
10062
+ };
10063
+ }
10064
+ /**
10065
+ * Capture the agent's `schedule_callback` request. The bridge VALIDATES
10066
+ * (policy allows it, delay is in the legal window, summary present),
10067
+ * builds a transcript digest from {@link recentUtterances}, then fires
10068
+ * {@link onCallbackScheduled} so the API layer can persist + arm the
10069
+ * scheduler. The bridge does NOT itself dial — that's the scheduler's
10070
+ * job, fired at the requested wall-clock time.
10071
+ *
10072
+ * Returning `{ accepted: true }` arms `isCallbackArmed`, which the
10073
+ * end-of-call path uses to skip the legacy operator-query callback
10074
+ * flag (the agent has already declared its own follow-up plan).
10075
+ */
10076
+ scheduleCallback(req) {
10077
+ if (this.ended) {
10078
+ return { accepted: false, message: "Cannot schedule a callback \u2014 the call has already ended." };
10079
+ }
10080
+ if (this.callbackArmed) {
10081
+ return { accepted: false, message: "A callback has already been scheduled for this call. Stick with that plan." };
10082
+ }
10083
+ const pol = this.callbackPolicy;
10084
+ if (!pol || !pol.allowAutoCallback || pol.maxCallbackChain <= 0) {
10085
+ return {
10086
+ accepted: false,
10087
+ message: "Auto-callbacks are disabled by policy on this call. Tell the caller you will follow up another way."
10088
+ };
10089
+ }
10090
+ const delay = Math.floor(req.delaySeconds);
10091
+ const minDelay = 30;
10092
+ const maxDelay = 7 * 24 * 60 * 60;
10093
+ if (!Number.isFinite(delay) || delay < minDelay) {
10094
+ return {
10095
+ accepted: false,
10096
+ message: `Callbacks must be scheduled at least ${minDelay}s in the future.`
10097
+ };
10098
+ }
10099
+ if (delay > maxDelay) {
10100
+ return {
10101
+ accepted: false,
10102
+ message: `Callbacks must be scheduled within ${Math.floor(maxDelay / 86400)} days.`
10103
+ };
10104
+ }
10105
+ const summary = asString5(req.summary);
10106
+ if (!summary) {
10107
+ return {
10108
+ accepted: false,
10109
+ message: "schedule_callback requires a non-empty `summary` for the next call to pick up from."
10110
+ };
10111
+ }
10112
+ const at = new Date(this.nowFn() + delay * 1e3).toISOString();
10113
+ const digest = this.composeTranscriptDigest();
10114
+ const payload = {
10115
+ at,
10116
+ reason: truncate2(asString5(req.reason) || "no reason given", 240),
10117
+ agentSummary: truncate2(summary, MAX_CALLBACK_SUMMARY_LENGTH),
10118
+ transcriptDigest: digest
10119
+ };
10120
+ try {
10121
+ this.onCallbackScheduled?.(payload);
10122
+ } catch (err) {
10123
+ return { accepted: false, message: `Could not arm callback: ${errorText(err)}` };
10124
+ }
10125
+ this.callbackArmed = true;
10126
+ this.emitTranscript("system", `Callback scheduled for ${at}. Reason: ${payload.reason}`, {
10127
+ scheduledAt: at,
10128
+ summaryLength: payload.agentSummary.length,
10129
+ digestLength: digest.length
10130
+ });
10131
+ return {
10132
+ accepted: true,
10133
+ at,
10134
+ message: `Callback scheduled for ${at}. The next call will pick up with your summary + the transcript so far.`
10135
+ };
10136
+ }
10137
+ /**
10138
+ * Public time-budget snapshot for `get_call_status`. Bundles
10139
+ * everything the agent needs to decide whether to keep going, ask
10140
+ * for more time, or schedule a callback.
10141
+ */
10142
+ getCallStatus() {
10143
+ return {
10144
+ secondsRemaining: this.getTimeRemainingSeconds(),
10145
+ softDeadlineAt: this.softDeadlineMs ? new Date(this.softDeadlineMs).toISOString() : null,
10146
+ extension: this.getExtensionStatus(),
10147
+ callbackAvailable: !!this.callbackPolicy?.allowAutoCallback && (this.callbackPolicy?.maxCallbackChain ?? 0) > 0 && !this.callbackArmed,
10148
+ callbackArmed: this.callbackArmed
10149
+ };
10150
+ }
10151
+ /**
10152
+ * Arm the soft-deadline timer + reminder timers. Called once at hello.
10153
+ * No-op when the bridge has no budget configured.
10154
+ */
10155
+ startCallBudget() {
10156
+ if (this.initialBudgetSeconds <= 0) return;
10157
+ const nowMs = this.nowFn();
10158
+ this.callStartedAtMs = nowMs;
10159
+ this.softDeadlineMs = nowMs + this.initialBudgetSeconds * 1e3;
10160
+ this.emitTranscript("system", `Call budget armed: ${this.initialBudgetSeconds}s, soft deadline ${new Date(this.softDeadlineMs).toISOString()}.`, {
10161
+ budgetSeconds: this.initialBudgetSeconds
10162
+ });
10163
+ this.rearmBudgetTimers();
10164
+ }
10165
+ /**
10166
+ * Cancel all existing budget timers and re-arm them against
10167
+ * {@link softDeadlineMs}. Called at startCallBudget time AND after
10168
+ * every successful {@link extendCallTime} grant — the timers always
10169
+ * reflect the CURRENT deadline.
10170
+ */
10171
+ rearmBudgetTimers() {
10172
+ this.clearBudgetTimers();
10173
+ if (this.softDeadlineMs == null) return;
10174
+ const nowMs = this.nowFn();
10175
+ const msToDeadline = this.softDeadlineMs - nowMs;
10176
+ for (const mark of CALL_BUDGET_REMINDER_MARKS_SECONDS) {
10177
+ const msUntilMark = msToDeadline - mark * 1e3;
10178
+ if (msUntilMark <= 0) continue;
10179
+ if (this.firedReminderMarks.has(mark)) continue;
10180
+ const t = this.setTimeoutFn(() => {
10181
+ this.firedReminderMarks.add(mark);
10182
+ this.injectReminder(mark);
10183
+ }, msUntilMark);
10184
+ this.reminderTimers.push(t);
10185
+ }
10186
+ const msUntilSoftEnd = Math.max(0, msToDeadline);
10187
+ this.softEndTimer = this.setTimeoutFn(() => {
10188
+ this.softEndTimer = null;
10189
+ this.onSoftDeadline();
10190
+ }, msUntilSoftEnd);
10191
+ }
10192
+ /** Cancel all currently-armed budget timers. Idempotent. */
10193
+ clearBudgetTimers() {
10194
+ if (this.softEndTimer) {
10195
+ this.clearTimeoutFn(this.softEndTimer);
10196
+ this.softEndTimer = null;
10197
+ }
10198
+ if (this.graceEndTimer) {
10199
+ this.clearTimeoutFn(this.graceEndTimer);
10200
+ this.graceEndTimer = null;
10201
+ }
10202
+ for (const t of this.reminderTimers) {
10203
+ this.clearTimeoutFn(t);
10204
+ }
10205
+ this.reminderTimers = [];
10206
+ }
10207
+ /**
10208
+ * Inject a "you have ~N seconds left" system message into the live
10209
+ * OpenAI session. The model receives it as a `conversation.item.create`
10210
+ * with role:`system`, followed by `response.create` so it can decide
10211
+ * whether to acknowledge it out loud (often it just naturally
10212
+ * accelerates wrap-up; we don't force a verbal "I have 30 seconds").
10213
+ */
10214
+ injectReminder(secondsRemaining) {
10215
+ if (this.ended || !this.openaiReady) return;
10216
+ const text = secondsRemaining >= 60 ? `[system] About ${Math.round(secondsRemaining / 60)} minute(s) left on this call. Start wrapping up \u2014 if you need more time, call extend_call_time; if you can't finish, call schedule_callback before signing off.` : `[system] About ${secondsRemaining}s left on this call. Wrap up now: thank the caller, give a clear next step, and sign off.`;
10217
+ this.safeSend(this.openai, {
10218
+ type: "conversation.item.create",
10219
+ item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
10220
+ });
10221
+ this.safeSend(this.openai, { type: "response.create" });
10222
+ this.emitTranscript("system", `Time reminder injected at T-${secondsRemaining}s.`);
10223
+ }
10224
+ /**
10225
+ * Fires once the soft deadline elapses. Injects a "your time is up"
10226
+ * system message + schedules the grace-window hard end. If the agent
10227
+ * uses the grace window to call `schedule_callback` or `extend_call_time`
10228
+ * the latter can push the deadline forward again — that's fine, the
10229
+ * grace timer is cancelled by {@link extendCallTime} via rearmBudgetTimers.
10230
+ */
10231
+ onSoftDeadline() {
10232
+ if (this.ended) return;
10233
+ if (this.openaiReady) {
10234
+ const text = `[system] Your time on this call is up. You have ~${CALL_BUDGET_GRACE_SECONDS} seconds to wrap up. If you need to continue with this person, call schedule_callback NOW with the time and a summary. Then thank them, give a clear next step, and sign off. The line will close at the end of the grace window.`;
10235
+ this.safeSend(this.openai, {
10236
+ type: "conversation.item.create",
10237
+ item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
10238
+ });
10239
+ this.safeSend(this.openai, { type: "response.create" });
10240
+ }
10241
+ this.emitTranscript("system", `Soft deadline reached; ${CALL_BUDGET_GRACE_SECONDS}s grace window started.`);
10242
+ this.endedByTimeBudgetFlag = true;
10243
+ this.graceEndTimer = this.setTimeoutFn(() => {
10244
+ this.graceEndTimer = null;
10245
+ if (this.ended) return;
10246
+ this.emitTranscript("system", "Grace window elapsed \u2014 bridge ending call.");
10247
+ this.end("time-budget-exceeded");
10248
+ }, CALL_BUDGET_GRACE_SECONDS * 1e3);
10249
+ }
10250
+ /**
10251
+ * Add an utterance to the rolling buffer used for the callback
10252
+ * transcript digest. Bounded by char count, not entry count, so a
10253
+ * burst of short turns doesn't get pruned prematurely.
10254
+ */
10255
+ noteUtterance(line) {
10256
+ if (!line) return;
10257
+ this.recentUtterances.push(line);
10258
+ let total = this.recentUtterances.reduce((n, s) => n + s.length, 0);
10259
+ while (total > MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH * 2 && this.recentUtterances.length > 1) {
10260
+ const dropped = this.recentUtterances.shift();
10261
+ total -= dropped.length;
10262
+ }
10263
+ }
10264
+ /**
10265
+ * Compose a transcript digest from the rolling buffer. Used as the
10266
+ * "context from the previous call" payload in {@link scheduleCallback}.
10267
+ * Always honours {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH}.
10268
+ */
10269
+ composeTranscriptDigest() {
10270
+ const joined = this.recentUtterances.join("\n");
10271
+ if (joined.length <= MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH) return joined;
10272
+ return "\u2026\n" + joined.slice(joined.length - MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH + 2);
10273
+ }
9511
10274
  // ─── Teardown ─────────────────────────────────────────
9512
10275
  /**
9513
10276
  * End the bridge. Idempotent — the first call wins, later calls are
@@ -9517,6 +10280,7 @@ var RealtimeVoiceBridge = class {
9517
10280
  end(reason) {
9518
10281
  if (this.ended) return;
9519
10282
  this.ended = true;
10283
+ this.clearBudgetTimers();
9520
10284
  if (this.droppedFrames > 0) {
9521
10285
  this.onTranscript?.({
9522
10286
  source: "system",
@@ -9545,7 +10309,11 @@ var RealtimeVoiceBridge = class {
9545
10309
  this.openai.close();
9546
10310
  } catch {
9547
10311
  }
9548
- this.onEnd?.({ reason, pendingToolCalls });
10312
+ if (this.endedByTimeBudgetFlag) {
10313
+ this.onEnd?.({ reason, pendingToolCalls, endedByTimeBudget: true });
10314
+ } else {
10315
+ this.onEnd?.({ reason, pendingToolCalls });
10316
+ }
9549
10317
  }
9550
10318
  // ─── Internals ────────────────────────────────────────
9551
10319
  noteDroppedFrame() {