@agenticmail/core 0.9.26 → 0.9.28

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.cjs CHANGED
@@ -7514,10 +7514,26 @@ var PHONE_MISSION_STATES = [
7514
7514
  "failed",
7515
7515
  "cancelled"
7516
7516
  ];
7517
- var PHONE_SERVER_MAX_CALL_DURATION_SECONDS = 3600;
7517
+ var PHONE_SERVER_MAX_CALL_DURATION_SECONDS = 7200;
7518
7518
  var PHONE_SERVER_MAX_COST_PER_MISSION = 5;
7519
7519
  var PHONE_SERVER_MAX_ATTEMPTS = 3;
7520
7520
  var PHONE_TASK_MAX_LENGTH = 2e3;
7521
+ var PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST = 900;
7522
+ var PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL = 8;
7523
+ var PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS = 3600;
7524
+ var DEFAULT_EXTENSION_POLICY = {
7525
+ maxSecondsPerRequest: 300,
7526
+ // 5 minutes
7527
+ maxRequestsPerCall: 4,
7528
+ maxTotalExtensionSeconds: 1200
7529
+ // 20 minutes
7530
+ };
7531
+ var PHONE_SERVER_MAX_CALLBACK_CHAIN = 3;
7532
+ var DEFAULT_CALLBACK_POLICY = {
7533
+ allowAutoCallback: true,
7534
+ maxCallbackChain: 2
7535
+ };
7536
+ var PHONE_CALLBACK_MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
7521
7537
  var EU_DIAL_PREFIXES = [
7522
7538
  "+30",
7523
7539
  "+31",
@@ -7632,6 +7648,76 @@ function validateAlternativePolicy(value) {
7632
7648
  }
7633
7649
  return [];
7634
7650
  }
7651
+ function validateExtensionPolicy(value) {
7652
+ if (value === void 0) return [];
7653
+ if (!isRecord(value)) {
7654
+ return [issue("invalid-extension-policy", "policy.extensionPolicy", "extensionPolicy must be an object")];
7655
+ }
7656
+ const issues = [];
7657
+ const perReq = readPositiveInteger(value.maxSecondsPerRequest);
7658
+ if (perReq === null) {
7659
+ issues.push(issue(
7660
+ "invalid-extension-per-request",
7661
+ "policy.extensionPolicy.maxSecondsPerRequest",
7662
+ "maxSecondsPerRequest must be a positive integer"
7663
+ ));
7664
+ }
7665
+ const requests = readPositiveInteger(value.maxRequestsPerCall);
7666
+ if (requests === null) {
7667
+ issues.push(issue(
7668
+ "invalid-extension-requests",
7669
+ "policy.extensionPolicy.maxRequestsPerCall",
7670
+ "maxRequestsPerCall must be a positive integer"
7671
+ ));
7672
+ }
7673
+ const total = readPositiveInteger(value.maxTotalExtensionSeconds);
7674
+ if (total === null) {
7675
+ issues.push(issue(
7676
+ "invalid-extension-total",
7677
+ "policy.extensionPolicy.maxTotalExtensionSeconds",
7678
+ "maxTotalExtensionSeconds must be a positive integer"
7679
+ ));
7680
+ }
7681
+ return issues;
7682
+ }
7683
+ function validateCallbackPolicy(value) {
7684
+ if (value === void 0) return [];
7685
+ if (!isRecord(value)) {
7686
+ return [issue("invalid-callback-policy", "policy.callbackPolicy", "callbackPolicy must be an object")];
7687
+ }
7688
+ const issues = [];
7689
+ if (readBoolean(value.allowAutoCallback) === null) {
7690
+ issues.push(issue(
7691
+ "invalid-callback-allow",
7692
+ "policy.callbackPolicy.allowAutoCallback",
7693
+ "allowAutoCallback must be boolean"
7694
+ ));
7695
+ }
7696
+ const chain = readNonNegativeNumber(value.maxCallbackChain);
7697
+ if (chain === null || !Number.isInteger(chain)) {
7698
+ issues.push(issue(
7699
+ "invalid-callback-chain",
7700
+ "policy.callbackPolicy.maxCallbackChain",
7701
+ "maxCallbackChain must be a non-negative integer"
7702
+ ));
7703
+ }
7704
+ return issues;
7705
+ }
7706
+ function resolveExtensionPolicy(input) {
7707
+ const src = input ?? DEFAULT_EXTENSION_POLICY;
7708
+ return {
7709
+ maxSecondsPerRequest: Math.min(src.maxSecondsPerRequest, PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST),
7710
+ maxRequestsPerCall: Math.min(src.maxRequestsPerCall, PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL),
7711
+ maxTotalExtensionSeconds: Math.min(src.maxTotalExtensionSeconds, PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS)
7712
+ };
7713
+ }
7714
+ function resolveCallbackPolicy(input) {
7715
+ const src = input ?? DEFAULT_CALLBACK_POLICY;
7716
+ return {
7717
+ allowAutoCallback: src.allowAutoCallback,
7718
+ maxCallbackChain: Math.min(src.maxCallbackChain, PHONE_SERVER_MAX_CALLBACK_CHAIN)
7719
+ };
7720
+ }
7635
7721
  function validatePhoneMissionPolicy(policy) {
7636
7722
  const issues = [];
7637
7723
  if (!isRecord(policy)) {
@@ -7666,6 +7752,8 @@ function validatePhoneMissionPolicy(policy) {
7666
7752
  }
7667
7753
  issues.push(...validateConfirmPolicy(policy.confirmPolicy));
7668
7754
  issues.push(...validateAlternativePolicy(policy.alternativePolicy));
7755
+ issues.push(...validateExtensionPolicy(policy.extensionPolicy));
7756
+ issues.push(...validateCallbackPolicy(policy.callbackPolicy));
7669
7757
  if (issues.length > 0) return { ok: false, issues };
7670
7758
  return {
7671
7759
  ok: true,
@@ -7680,7 +7768,14 @@ function validatePhoneMissionPolicy(policy) {
7680
7768
  confirmPolicy: policy.confirmPolicy,
7681
7769
  alternativePolicy: {
7682
7770
  maxTimeShiftMinutes: policy.alternativePolicy.maxTimeShiftMinutes
7683
- }
7771
+ },
7772
+ // The extension + callback policies are optional in the caller's
7773
+ // input but we ALWAYS materialise them in the resolved policy so
7774
+ // every downstream consumer (the bridge, the scheduler, the
7775
+ // manager) can read a concrete value without juggling undefined.
7776
+ // Caller-omitted → DEFAULT_*. Caller-set → clamped to server caps.
7777
+ extensionPolicy: resolveExtensionPolicy(policy.extensionPolicy),
7778
+ callbackPolicy: resolveCallbackPolicy(policy.callbackPolicy)
7684
7779
  },
7685
7780
  issues: []
7686
7781
  };
@@ -7915,6 +8010,13 @@ function readOperatorQueries(mission) {
7915
8010
  if (!Array.isArray(value)) return [];
7916
8011
  return value.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item) && typeof item.id === "string" && typeof item.question === "string");
7917
8012
  }
8013
+ function readChainDepth(mission) {
8014
+ const raw = mission.metadata.callbackChainDepth;
8015
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
8016
+ const sc = mission.metadata.scheduledCallback;
8017
+ if (sc && Number.isFinite(sc.chainDepth)) return Math.floor(sc.chainDepth);
8018
+ return 0;
8019
+ }
7918
8020
  function escapeLike(value) {
7919
8021
  return value.replace(/[\\%_]/g, "\\$&");
7920
8022
  }
@@ -7933,6 +8035,26 @@ function buildCallbackTask(originalTask, query) {
7933
8035
  return `${continuity}
7934
8036
  ${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
7935
8037
  }
8038
+ function buildScheduledCallbackTask(originalTask, payload) {
8039
+ const continuity = [
8040
+ "# Call continuity \u2014 auto-callback",
8041
+ '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.',
8042
+ "",
8043
+ `## Why you arranged this callback
8044
+ ${payload.reason}`,
8045
+ "",
8046
+ `## Notes you left for yourself (your own summary at the end of the prior call)
8047
+ ${payload.agentSummary}`,
8048
+ "",
8049
+ `## What was actually said on the prior call (verbatim digest)
8050
+ ${payload.transcriptDigest}`,
8051
+ "",
8052
+ "# Original task"
8053
+ ].join("\n");
8054
+ const room = Math.max(0, PHONE_TASK_MAX_LENGTH - continuity.length - 1);
8055
+ return `${continuity}
8056
+ ${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
8057
+ }
7936
8058
  function parseJson(value, fallback) {
7937
8059
  if (!value) return fallback;
7938
8060
  try {
@@ -8605,6 +8727,134 @@ var PhoneManager = class {
8605
8727
  throw err;
8606
8728
  }
8607
8729
  }
8730
+ // ─── Scheduled callbacks (v0.9.81 — schedule_callback tool) ──────────
8731
+ /**
8732
+ * Persist a `schedule_callback` request to the mission. Called from
8733
+ * the realtime bridge's `onCallbackScheduled` hook. The scheduler
8734
+ * picks this up later when `payload.at <= now`. ChainDepth is
8735
+ * computed from the parent's metadata so {@link triggerScheduledCallback}
8736
+ * can enforce policy.callbackPolicy.maxCallbackChain without
8737
+ * walking back through the mission history.
8738
+ *
8739
+ * Returns the updated mission, or null if the mission isn't known.
8740
+ * Idempotent on the `mission.metadata.scheduledCallback.at` key: if
8741
+ * a scheduled callback already exists on the mission this writes a
8742
+ * SECOND copy on `scheduledCallbacks` as an audit trail but does
8743
+ * NOT overwrite the active record (the bridge only allows one per
8744
+ * call anyway; the audit log is a belt-and-braces guard against
8745
+ * the unusual case where a server restart re-runs the bridge logic).
8746
+ */
8747
+ armScheduledCallback(missionId, payload) {
8748
+ const mission = this.getMission(missionId);
8749
+ if (!mission) return null;
8750
+ const parentDepth = readChainDepth(mission);
8751
+ const record = {
8752
+ at: payload.at,
8753
+ reason: payload.reason,
8754
+ agentSummary: payload.agentSummary,
8755
+ transcriptDigest: payload.transcriptDigest,
8756
+ chainDepth: parentDepth + 1,
8757
+ status: "pending",
8758
+ armedAt: (/* @__PURE__ */ new Date()).toISOString()
8759
+ };
8760
+ return this.updateMissionStatus(mission.id, mission.status, {
8761
+ scheduledCallback: record
8762
+ }, [{
8763
+ at: record.armedAt,
8764
+ source: "system",
8765
+ text: `Scheduled callback armed for ${record.at} (chain depth ${record.chainDepth}). Reason: ${record.reason}`,
8766
+ metadata: { scheduledAt: record.at, chainDepth: record.chainDepth }
8767
+ }]);
8768
+ }
8769
+ /**
8770
+ * All missions with a `scheduledCallback.status === 'pending'` whose
8771
+ * `at` is <= now. The scheduler's per-tick worklist. Pass an upper
8772
+ * bound on count so a backlog doesn't dial every overdue callback in
8773
+ * one frame.
8774
+ */
8775
+ findDueScheduledCallbacks(nowIso, limit = 16) {
8776
+ const rows = this.db.prepare(
8777
+ "SELECT * FROM phone_missions WHERE metadata_json LIKE '%scheduledCallback%' AND metadata_json LIKE '%pending%' LIMIT ?"
8778
+ ).all(limit * 4);
8779
+ return rows.map(rowToMission).filter((mission) => {
8780
+ const sc = mission.metadata.scheduledCallback;
8781
+ return sc && sc.status === "pending" && sc.at <= nowIso;
8782
+ }).slice(0, limit);
8783
+ }
8784
+ /**
8785
+ * Dial a due scheduled callback. Mirrors {@link triggerCallback} for
8786
+ * the operator-query path:
8787
+ *
8788
+ * 1. Reject if the mission's policy.callbackPolicy disallows it OR
8789
+ * `chainDepth > maxCallbackChain` (no infinite chains).
8790
+ * 2. Transition status pending → dialing BEFORE dialing so a
8791
+ * concurrent tick can't double-dial.
8792
+ * 3. Build the continuation task with prior-call context and dial.
8793
+ * 4. On success: write `status: 'fired'` + the new mission id.
8794
+ * 5. On failure: write `status: 'pending'` + `lastError` so the
8795
+ * next tick can retry, then rethrow.
8796
+ *
8797
+ * Returns `null` if the mission isn't known or has no due callback.
8798
+ */
8799
+ async triggerScheduledCallback(missionId, options = {}) {
8800
+ const mission = this.getMission(missionId);
8801
+ if (!mission) return null;
8802
+ const sc = mission.metadata.scheduledCallback;
8803
+ if (!sc) return null;
8804
+ if (sc.status !== "pending") return null;
8805
+ const callbackPol = mission.policy.callbackPolicy;
8806
+ if (!callbackPol || !callbackPol.allowAutoCallback || sc.chainDepth > callbackPol.maxCallbackChain) {
8807
+ const updated = this.updateMissionStatus(mission.id, mission.status, {
8808
+ scheduledCallback: { ...sc, status: "failed", lastError: "policy denies callback (chain or disabled)" }
8809
+ }, [{
8810
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8811
+ source: "system",
8812
+ text: `Scheduled callback denied by policy (chainDepth=${sc.chainDepth}, max=${callbackPol?.maxCallbackChain ?? 0}).`
8813
+ }]);
8814
+ return updated ? { mission: updated, callbackMission: updated } : null;
8815
+ }
8816
+ this.updateMissionStatus(mission.id, mission.status, {
8817
+ scheduledCallback: { ...sc, status: "dialing" }
8818
+ }, [{
8819
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8820
+ source: "system",
8821
+ text: `Dialing scheduled callback (chain depth ${sc.chainDepth}).`
8822
+ }]);
8823
+ try {
8824
+ const result = await this.startMission(mission.agentId, {
8825
+ to: mission.to,
8826
+ task: buildScheduledCallbackTask(mission.task, {
8827
+ reason: sc.reason,
8828
+ agentSummary: sc.agentSummary,
8829
+ transcriptDigest: sc.transcriptDigest
8830
+ }),
8831
+ policy: mission.policy
8832
+ }, options);
8833
+ const callbackMission = this.updateMissionStatus(result.mission.id, result.mission.status, {
8834
+ callbackChainDepth: sc.chainDepth,
8835
+ callbackParentMissionId: mission.id
8836
+ }, []) ?? result.mission;
8837
+ const linked = this.updateMissionStatus(mission.id, mission.status, {
8838
+ scheduledCallback: {
8839
+ ...sc,
8840
+ status: "fired",
8841
+ firedAt: (/* @__PURE__ */ new Date()).toISOString(),
8842
+ callbackMissionId: result.mission.id
8843
+ }
8844
+ }, []);
8845
+ return { mission: linked, callbackMission };
8846
+ } catch (err) {
8847
+ const message = err?.message ?? String(err);
8848
+ this.updateMissionStatus(mission.id, mission.status, {
8849
+ scheduledCallback: { ...sc, status: "pending", lastError: message }
8850
+ }, [{
8851
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8852
+ source: "system",
8853
+ text: `Scheduled callback dial failed (${message}); will retry on next scheduler tick.`
8854
+ }]);
8855
+ throw err;
8856
+ }
8857
+ }
8608
8858
  build46ElksCallRequest(config, mission) {
8609
8859
  const timeout = Math.min(Math.max(mission.policy.maxCallDurationSeconds, 1), PHONE_SERVER_MAX_CALL_DURATION_SECONDS);
8610
8860
  return {
@@ -10444,6 +10694,71 @@ var LOAD_SKILL_TOOL = {
10444
10694
  additionalProperties: false
10445
10695
  }
10446
10696
  };
10697
+ var GET_CALL_STATUS_TOOL = {
10698
+ type: "function",
10699
+ name: "get_call_status",
10700
+ 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.",
10701
+ parameters: {
10702
+ type: "object",
10703
+ properties: {}
10704
+ }
10705
+ };
10706
+ var EXTEND_CALL_TIME_TOOL = {
10707
+ type: "function",
10708
+ name: "extend_call_time",
10709
+ 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.",
10710
+ parameters: {
10711
+ type: "object",
10712
+ properties: {
10713
+ seconds: {
10714
+ type: "number",
10715
+ description: "How many MORE seconds you need. Positive integer. The server caps each grant."
10716
+ },
10717
+ reason: {
10718
+ type: "string",
10719
+ description: "One short line on why \u2014 kept on the mission transcript for review."
10720
+ }
10721
+ },
10722
+ required: ["seconds"]
10723
+ }
10724
+ };
10725
+ var SCHEDULE_CALLBACK_TOOL = {
10726
+ type: "function",
10727
+ name: "schedule_callback",
10728
+ 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.',
10729
+ parameters: {
10730
+ type: "object",
10731
+ properties: {
10732
+ delay_seconds: {
10733
+ type: "number",
10734
+ description: 'How many seconds from now to call back. Minimum 30, maximum 604800 (7 days). Use larger delays generously \u2014 "tomorrow morning" is ~57600s.'
10735
+ },
10736
+ reason: {
10737
+ type: "string",
10738
+ 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".'
10739
+ },
10740
+ summary_for_next_call: {
10741
+ type: "string",
10742
+ 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."
10743
+ }
10744
+ },
10745
+ required: ["delay_seconds", "summary_for_next_call"]
10746
+ }
10747
+ };
10748
+ var END_CALL_TOOL = {
10749
+ type: "function",
10750
+ name: "end_call",
10751
+ description: `Hang up the call. You MUST call this AFTER you have said goodbye and the conversation is complete \u2014 saying "I'll hang up now" is not enough on its own, the call stays open until you actually call this tool. Use it when: the task is done and you have signed off, the caller has indicated the conversation is over, you have scheduled a callback and said goodbye, or the caller has stopped responding for an extended period. Once called the call drops immediately; you cannot un-hang-up.`,
10752
+ parameters: {
10753
+ type: "object",
10754
+ properties: {
10755
+ reason: {
10756
+ type: "string",
10757
+ description: 'One short line for the audit trail \u2014 e.g. "task complete", "caller said bye", "scheduled callback, signing off". Optional but recommended.'
10758
+ }
10759
+ }
10760
+ }
10761
+ };
10447
10762
  var REALTIME_TOOL_DEFINITIONS = {
10448
10763
  ask_operator: ASK_OPERATOR_TOOL,
10449
10764
  web_search: WEB_SEARCH_TOOL,
@@ -10451,7 +10766,11 @@ var REALTIME_TOOL_DEFINITIONS = {
10451
10766
  get_datetime: GET_DATETIME_TOOL,
10452
10767
  search_email: SEARCH_EMAIL_TOOL,
10453
10768
  search_skills: SEARCH_SKILLS_TOOL,
10454
- load_skill: LOAD_SKILL_TOOL
10769
+ load_skill: LOAD_SKILL_TOOL,
10770
+ get_call_status: GET_CALL_STATUS_TOOL,
10771
+ extend_call_time: EXTEND_CALL_TIME_TOOL,
10772
+ schedule_callback: SCHEDULE_CALLBACK_TOOL,
10773
+ end_call: END_CALL_TOOL
10455
10774
  };
10456
10775
  function buildRealtimeToolGuidance(tools) {
10457
10776
  if (tools.length === 0) return "";
@@ -10480,6 +10799,42 @@ function buildRealtimeToolGuidance(tools) {
10480
10799
  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.`
10481
10800
  );
10482
10801
  }
10802
+ if (names.has("get_call_status") || names.has("extend_call_time") || names.has("schedule_callback")) {
10803
+ lines.push(
10804
+ "# Managing your time on this call",
10805
+ "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.",
10806
+ "",
10807
+ "If you need more time to finish the job:",
10808
+ `- 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.`,
10809
+ "",
10810
+ "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:",
10811
+ `- 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.`,
10812
+ `- 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.`,
10813
+ "- You can ONLY schedule one callback per call. Choose deliberately.",
10814
+ "",
10815
+ "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.",
10816
+ "",
10817
+ "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."
10818
+ );
10819
+ }
10820
+ if (names.has("end_call")) {
10821
+ lines.push(
10822
+ "# Hanging up \u2014 you must call end_call to actually drop the line",
10823
+ `SAYING "I'll hang up now" or "goodbye" does NOT drop the call \u2014 the line stays open until you call the end_call tool. This is the single most important habit on a real phone call: after you have said your goodbye sentence, IMMEDIATELY call end_call({ reason: "..." }). Do not wait for the caller to hang up first; do not assume the system will close the line for you. Call end_call yourself.`,
10824
+ "",
10825
+ "When to call end_call:",
10826
+ "- The task is complete and you have just said goodbye.",
10827
+ '- The caller said "thanks, bye" or otherwise signalled they are done.',
10828
+ `- You scheduled a callback and said "I'll call you back at <when>".`,
10829
+ "- The caller has gone silent for an extended stretch and is clearly not coming back.",
10830
+ "- The operator told you to hang up.",
10831
+ "",
10832
+ "Do NOT call end_call:",
10833
+ "- Mid-conversation when there is still pending business.",
10834
+ "- Because you ran into a tool error \u2014 handle it and keep the call going.",
10835
+ "- During a hold while a tool is running \u2014 let the tool finish."
10836
+ );
10837
+ }
10483
10838
  return lines.join("\n");
10484
10839
  }
10485
10840
  function toolErrorText(err) {
@@ -10685,6 +11040,26 @@ ${task}`);
10685
11040
  '# 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
10686
11041
  );
10687
11042
  }
11043
+ const budget = opts.callBudget;
11044
+ if (budget && budget.seconds > 0) {
11045
+ const mins = Math.round(budget.seconds / 60);
11046
+ const human = mins >= 1 ? `about ${mins} minute(s)` : `${budget.seconds} seconds`;
11047
+ const tips = [];
11048
+ if (budget.extensionEnabled) {
11049
+ tips.push(
11050
+ "If you need more time, call extend_call_time({ seconds, reason }) BEFORE you run out. Auto-approved within the call's extension policy."
11051
+ );
11052
+ }
11053
+ if (budget.callbackEnabled) {
11054
+ tips.push(
11055
+ "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."
11056
+ );
11057
+ }
11058
+ sections.push(
11059
+ `# Your time on this call
11060
+ 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") : "")
11061
+ );
11062
+ }
10688
11063
  const toolGuidance = opts.toolGuidance?.trim();
10689
11064
  if (toolGuidance) {
10690
11065
  sections.push(toolGuidance);
@@ -10724,6 +11099,10 @@ function buildRealtimeSessionConfig(opts) {
10724
11099
  function buildOpenAIRealtimeUrl(model = DEFAULT_REALTIME_MODEL) {
10725
11100
  return `${OPENAI_REALTIME_URL}?model=${encodeURIComponent(model || DEFAULT_REALTIME_MODEL)}`;
10726
11101
  }
11102
+ var MAX_CALLBACK_SUMMARY_LENGTH = 1500;
11103
+ var MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH = 2500;
11104
+ var CALL_BUDGET_REMINDER_MARKS_SECONDS = [120, 30];
11105
+ var CALL_BUDGET_GRACE_SECONDS = 30;
10727
11106
  var RealtimeVoiceBridge = class {
10728
11107
  carrier;
10729
11108
  openai;
@@ -10734,6 +11113,46 @@ var RealtimeVoiceBridge = class {
10734
11113
  maxToolCallMs;
10735
11114
  onTranscript;
10736
11115
  onEnd;
11116
+ /** Injectable clock + timers (tests substitute fakes). */
11117
+ nowFn;
11118
+ setTimeoutFn;
11119
+ clearTimeoutFn;
11120
+ /** v0.9.81 — extension / callback state. */
11121
+ extensionPolicy;
11122
+ callbackPolicy;
11123
+ onCallbackScheduled;
11124
+ /** Initial soft budget, in seconds. 0 = no bridge-side timer (legacy). */
11125
+ initialBudgetSeconds;
11126
+ /** Wall-clock ms when the call started, set on first carrier hello. */
11127
+ callStartedAtMs = null;
11128
+ /** Wall-clock ms when the soft deadline fires. Bumped by extensions. */
11129
+ softDeadlineMs = null;
11130
+ /** Soft-end timer (fires once, then schedules the grace timer). */
11131
+ softEndTimer = null;
11132
+ /** Final hard-end timer that fires after the grace window. */
11133
+ graceEndTimer = null;
11134
+ /** Reminder timers for the T-N marks. Cleared/re-armed on extensions. */
11135
+ reminderTimers = [];
11136
+ /** Marks (in seconds-remaining) we've already fired this call. Dedup
11137
+ * prevents re-injecting the same reminder after an extension if the
11138
+ * new deadline still has us past the same mark. */
11139
+ firedReminderMarks = /* @__PURE__ */ new Set();
11140
+ /** Count of extensions granted this call. */
11141
+ extensionsUsed = 0;
11142
+ /** Total extra seconds granted across all extensions this call. */
11143
+ extensionSecondsUsed = 0;
11144
+ /** True once the agent's schedule_callback request was accepted. */
11145
+ callbackArmed = false;
11146
+ /** Captured for the API layer when the soft deadline fires. */
11147
+ endedByTimeBudgetFlag = false;
11148
+ /**
11149
+ * Sliding window of recent assistant + system utterances, used to
11150
+ * build the transcript digest carried into a scheduled callback.
11151
+ * Capped at {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH} chars so
11152
+ * the digest itself can always be produced cheaply even on a long
11153
+ * call.
11154
+ */
11155
+ recentUtterances = [];
10737
11156
  /** Carrier `hello`/`start` received — the call leg is live. */
10738
11157
  helloSeen = false;
10739
11158
  /** OpenAI socket open + `session.update` sent. */
@@ -10791,6 +11210,13 @@ var RealtimeVoiceBridge = class {
10791
11210
  this.maxToolCallMs = opts.maxToolCallMs ?? REALTIME_TOOL_CALL_TIMEOUT_MS;
10792
11211
  this.onTranscript = opts.onTranscript;
10793
11212
  this.onEnd = opts.onEnd;
11213
+ this.nowFn = opts.now ?? Date.now;
11214
+ this.setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
11215
+ this.clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
11216
+ this.initialBudgetSeconds = opts.callBudgetSeconds && opts.callBudgetSeconds > 0 ? Math.floor(opts.callBudgetSeconds) : 0;
11217
+ this.extensionPolicy = opts.extensionPolicy;
11218
+ this.callbackPolicy = opts.callbackPolicy;
11219
+ this.onCallbackScheduled = opts.onCallbackScheduled;
10794
11220
  }
10795
11221
  /** True once the bridge has ended. */
10796
11222
  get isEnded() {
@@ -10952,6 +11378,7 @@ var RealtimeVoiceBridge = class {
10952
11378
  from: event.from,
10953
11379
  to: event.to
10954
11380
  });
11381
+ this.startCallBudget();
10955
11382
  return;
10956
11383
  }
10957
11384
  if (event.kind === "audio") {
@@ -11030,14 +11457,20 @@ var RealtimeVoiceBridge = class {
11030
11457
  case "response.output_audio_transcript.done":
11031
11458
  case "response.audio_transcript.done": {
11032
11459
  const text = this.assistantTranscript.trim();
11033
- if (text) this.emitTranscript("agent", text);
11460
+ if (text) {
11461
+ this.emitTranscript("agent", text);
11462
+ this.noteUtterance(`Agent: ${text}`);
11463
+ }
11034
11464
  this.assistantTranscript = "";
11035
11465
  return;
11036
11466
  }
11037
11467
  // Caller speech transcription, when input transcription is on.
11038
11468
  case "conversation.item.input_audio_transcription.completed": {
11039
11469
  const text = typeof event.transcript === "string" ? event.transcript.trim() : "";
11040
- if (text) this.emitTranscript("provider", text, { speaker: "caller" });
11470
+ if (text) {
11471
+ this.emitTranscript("provider", text, { speaker: "caller" });
11472
+ this.noteUtterance(`Caller: ${text}`);
11473
+ }
11041
11474
  return;
11042
11475
  }
11043
11476
  // A new output item was added to the response. When it is a
@@ -11157,7 +11590,397 @@ var RealtimeVoiceBridge = class {
11157
11590
  });
11158
11591
  this.safeSend(this.openai, { type: "response.create" });
11159
11592
  }
11593
+ // ─── Call-budget timers / extensions / callback (v0.9.81) ─────────
11594
+ /** True if the agent's `schedule_callback` request has been accepted. */
11595
+ get isCallbackArmed() {
11596
+ return this.callbackArmed;
11597
+ }
11598
+ /**
11599
+ * Seconds remaining on the current soft deadline, floored at 0. Returns
11600
+ * the initial budget if hello hasn't fired yet, and `Infinity` if no
11601
+ * call budget was configured (legacy mode). Used by `get_call_status`.
11602
+ */
11603
+ getTimeRemainingSeconds() {
11604
+ if (this.initialBudgetSeconds <= 0) return Number.POSITIVE_INFINITY;
11605
+ if (this.callStartedAtMs == null || this.softDeadlineMs == null) {
11606
+ return this.initialBudgetSeconds;
11607
+ }
11608
+ return Math.max(0, Math.ceil((this.softDeadlineMs - this.nowFn()) / 1e3));
11609
+ }
11610
+ /**
11611
+ * Public extension state snapshot for `get_call_status`. Each value is
11612
+ * "what the agent has left" so the model can decide whether to call
11613
+ * `extend_call_time` at all — exposing both the per-request cap AND
11614
+ * the remaining budget makes greedy / unbounded extension attempts
11615
+ * impossible.
11616
+ */
11617
+ getExtensionStatus() {
11618
+ const pol = this.extensionPolicy;
11619
+ if (!pol) {
11620
+ return {
11621
+ extensionsUsed: 0,
11622
+ extensionsRemaining: 0,
11623
+ secondsUsedSoFar: 0,
11624
+ secondsAvailable: 0,
11625
+ maxSecondsPerRequest: 0
11626
+ };
11627
+ }
11628
+ return {
11629
+ extensionsUsed: this.extensionsUsed,
11630
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
11631
+ secondsUsedSoFar: this.extensionSecondsUsed,
11632
+ secondsAvailable: Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed),
11633
+ maxSecondsPerRequest: pol.maxSecondsPerRequest
11634
+ };
11635
+ }
11636
+ /**
11637
+ * Grant (or refuse) more time on the call. Auto-approved within all
11638
+ * three policy caps; the granted amount is the min of:
11639
+ *
11640
+ * - the agent's requested seconds (positive integer, clamped > 0)
11641
+ * - policy.maxSecondsPerRequest
11642
+ * - policy.maxTotalExtensionSeconds − seconds already granted
11643
+ *
11644
+ * AND we won't push the new deadline past the hard ceiling
11645
+ * (PHONE_SERVER_MAX_CALL_DURATION_SECONDS from call start). The
11646
+ * returned shape always includes a model-readable `reason` so a
11647
+ * partial grant ("you asked for 5 min, you got 2 min") doesn't
11648
+ * confuse the agent.
11649
+ *
11650
+ * Failure modes (granted: 0):
11651
+ * - no extension policy on this call
11652
+ * - max requests already used
11653
+ * - max total seconds already used
11654
+ * - call already ended
11655
+ * - non-positive `seconds`
11656
+ */
11657
+ extendCallTime(requestedSeconds, reason) {
11658
+ if (this.ended) {
11659
+ return {
11660
+ granted: false,
11661
+ secondsGranted: 0,
11662
+ secondsRemaining: 0,
11663
+ extensionsRemaining: 0,
11664
+ message: "The call has already ended; no more extensions can be granted."
11665
+ };
11666
+ }
11667
+ const pol = this.extensionPolicy;
11668
+ if (!pol) {
11669
+ return {
11670
+ granted: false,
11671
+ secondsGranted: 0,
11672
+ secondsRemaining: this.getTimeRemainingSeconds(),
11673
+ extensionsRemaining: 0,
11674
+ message: "Extensions are not enabled for this call."
11675
+ };
11676
+ }
11677
+ if (this.initialBudgetSeconds <= 0 || this.softDeadlineMs == null || this.callStartedAtMs == null) {
11678
+ return {
11679
+ granted: false,
11680
+ secondsGranted: 0,
11681
+ secondsRemaining: Number.POSITIVE_INFINITY,
11682
+ extensionsRemaining: pol.maxRequestsPerCall - this.extensionsUsed,
11683
+ message: "This call has no soft time budget, so extensions are a no-op."
11684
+ };
11685
+ }
11686
+ if (this.extensionsUsed >= pol.maxRequestsPerCall) {
11687
+ return {
11688
+ granted: false,
11689
+ secondsGranted: 0,
11690
+ secondsRemaining: this.getTimeRemainingSeconds(),
11691
+ extensionsRemaining: 0,
11692
+ message: `Out of extensions \u2014 already used ${pol.maxRequestsPerCall}/${pol.maxRequestsPerCall} this call. Wrap up or schedule a callback.`
11693
+ };
11694
+ }
11695
+ const remainingBudgetSeconds = Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed);
11696
+ if (remainingBudgetSeconds <= 0) {
11697
+ return {
11698
+ granted: false,
11699
+ secondsGranted: 0,
11700
+ secondsRemaining: this.getTimeRemainingSeconds(),
11701
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
11702
+ message: `Out of extension time \u2014 already granted ${this.extensionSecondsUsed}s of the ${pol.maxTotalExtensionSeconds}s cap.`
11703
+ };
11704
+ }
11705
+ const asked = Math.floor(requestedSeconds);
11706
+ if (!Number.isFinite(asked) || asked <= 0) {
11707
+ return {
11708
+ granted: false,
11709
+ secondsGranted: 0,
11710
+ secondsRemaining: this.getTimeRemainingSeconds(),
11711
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
11712
+ message: "extend_call_time requires a positive integer number of seconds."
11713
+ };
11714
+ }
11715
+ let granted = Math.min(asked, pol.maxSecondsPerRequest, remainingBudgetSeconds);
11716
+ const elapsedSeconds = Math.floor((this.nowFn() - this.callStartedAtMs) / 1e3);
11717
+ const hardCeilingRoom = Math.max(
11718
+ 0,
11719
+ PHONE_SERVER_MAX_CALL_DURATION_SECONDS - (elapsedSeconds + this.getTimeRemainingSeconds())
11720
+ );
11721
+ granted = Math.min(granted, hardCeilingRoom);
11722
+ if (granted <= 0) {
11723
+ return {
11724
+ granted: false,
11725
+ secondsGranted: 0,
11726
+ secondsRemaining: this.getTimeRemainingSeconds(),
11727
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
11728
+ message: "No extension granted \u2014 the call is already at the hard duration ceiling."
11729
+ };
11730
+ }
11731
+ this.extensionsUsed += 1;
11732
+ this.extensionSecondsUsed += granted;
11733
+ this.softDeadlineMs = this.softDeadlineMs + granted * 1e3;
11734
+ this.rearmBudgetTimers();
11735
+ this.emitTranscript("system", `Granted ${granted}s extension (#${this.extensionsUsed}). Reason: ${truncate2(asString5(reason) || "unspecified", 120)}`, {
11736
+ extensionsUsed: this.extensionsUsed,
11737
+ extensionSecondsUsed: this.extensionSecondsUsed,
11738
+ softDeadlineMs: this.softDeadlineMs
11739
+ });
11740
+ return {
11741
+ granted: true,
11742
+ secondsGranted: granted,
11743
+ secondsRemaining: this.getTimeRemainingSeconds(),
11744
+ extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
11745
+ message: `Granted ${granted} more seconds (${this.getTimeRemainingSeconds()}s now remaining). ${Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed)} extension(s) left after this one.`
11746
+ };
11747
+ }
11748
+ /**
11749
+ * Capture the agent's `schedule_callback` request. The bridge VALIDATES
11750
+ * (policy allows it, delay is in the legal window, summary present),
11751
+ * builds a transcript digest from {@link recentUtterances}, then fires
11752
+ * {@link onCallbackScheduled} so the API layer can persist + arm the
11753
+ * scheduler. The bridge does NOT itself dial — that's the scheduler's
11754
+ * job, fired at the requested wall-clock time.
11755
+ *
11756
+ * Returning `{ accepted: true }` arms `isCallbackArmed`, which the
11757
+ * end-of-call path uses to skip the legacy operator-query callback
11758
+ * flag (the agent has already declared its own follow-up plan).
11759
+ */
11760
+ scheduleCallback(req) {
11761
+ if (this.ended) {
11762
+ return { accepted: false, message: "Cannot schedule a callback \u2014 the call has already ended." };
11763
+ }
11764
+ if (this.callbackArmed) {
11765
+ return { accepted: false, message: "A callback has already been scheduled for this call. Stick with that plan." };
11766
+ }
11767
+ const pol = this.callbackPolicy;
11768
+ if (!pol || !pol.allowAutoCallback || pol.maxCallbackChain <= 0) {
11769
+ return {
11770
+ accepted: false,
11771
+ message: "Auto-callbacks are disabled by policy on this call. Tell the caller you will follow up another way."
11772
+ };
11773
+ }
11774
+ const delay = Math.floor(req.delaySeconds);
11775
+ const minDelay = 30;
11776
+ const maxDelay = 7 * 24 * 60 * 60;
11777
+ if (!Number.isFinite(delay) || delay < minDelay) {
11778
+ return {
11779
+ accepted: false,
11780
+ message: `Callbacks must be scheduled at least ${minDelay}s in the future.`
11781
+ };
11782
+ }
11783
+ if (delay > maxDelay) {
11784
+ return {
11785
+ accepted: false,
11786
+ message: `Callbacks must be scheduled within ${Math.floor(maxDelay / 86400)} days.`
11787
+ };
11788
+ }
11789
+ const summary = asString5(req.summary);
11790
+ if (!summary) {
11791
+ return {
11792
+ accepted: false,
11793
+ message: "schedule_callback requires a non-empty `summary` for the next call to pick up from."
11794
+ };
11795
+ }
11796
+ const at = new Date(this.nowFn() + delay * 1e3).toISOString();
11797
+ const digest = this.composeTranscriptDigest();
11798
+ const payload = {
11799
+ at,
11800
+ reason: truncate2(asString5(req.reason) || "no reason given", 240),
11801
+ agentSummary: truncate2(summary, MAX_CALLBACK_SUMMARY_LENGTH),
11802
+ transcriptDigest: digest
11803
+ };
11804
+ try {
11805
+ this.onCallbackScheduled?.(payload);
11806
+ } catch (err) {
11807
+ return { accepted: false, message: `Could not arm callback: ${errorText(err)}` };
11808
+ }
11809
+ this.callbackArmed = true;
11810
+ this.emitTranscript("system", `Callback scheduled for ${at}. Reason: ${payload.reason}`, {
11811
+ scheduledAt: at,
11812
+ summaryLength: payload.agentSummary.length,
11813
+ digestLength: digest.length
11814
+ });
11815
+ return {
11816
+ accepted: true,
11817
+ at,
11818
+ message: `Callback scheduled for ${at}. The next call will pick up with your summary + the transcript so far.`
11819
+ };
11820
+ }
11821
+ /**
11822
+ * Public time-budget snapshot for `get_call_status`. Bundles
11823
+ * everything the agent needs to decide whether to keep going, ask
11824
+ * for more time, or schedule a callback.
11825
+ */
11826
+ getCallStatus() {
11827
+ return {
11828
+ secondsRemaining: this.getTimeRemainingSeconds(),
11829
+ softDeadlineAt: this.softDeadlineMs ? new Date(this.softDeadlineMs).toISOString() : null,
11830
+ extension: this.getExtensionStatus(),
11831
+ callbackAvailable: !!this.callbackPolicy?.allowAutoCallback && (this.callbackPolicy?.maxCallbackChain ?? 0) > 0 && !this.callbackArmed,
11832
+ callbackArmed: this.callbackArmed
11833
+ };
11834
+ }
11835
+ /**
11836
+ * Arm the soft-deadline timer + reminder timers. Called once at hello.
11837
+ * No-op when the bridge has no budget configured.
11838
+ */
11839
+ startCallBudget() {
11840
+ if (this.initialBudgetSeconds <= 0) return;
11841
+ const nowMs = this.nowFn();
11842
+ this.callStartedAtMs = nowMs;
11843
+ this.softDeadlineMs = nowMs + this.initialBudgetSeconds * 1e3;
11844
+ this.emitTranscript("system", `Call budget armed: ${this.initialBudgetSeconds}s, soft deadline ${new Date(this.softDeadlineMs).toISOString()}.`, {
11845
+ budgetSeconds: this.initialBudgetSeconds
11846
+ });
11847
+ this.rearmBudgetTimers();
11848
+ }
11849
+ /**
11850
+ * Cancel all existing budget timers and re-arm them against
11851
+ * {@link softDeadlineMs}. Called at startCallBudget time AND after
11852
+ * every successful {@link extendCallTime} grant — the timers always
11853
+ * reflect the CURRENT deadline.
11854
+ */
11855
+ rearmBudgetTimers() {
11856
+ this.clearBudgetTimers();
11857
+ if (this.softDeadlineMs == null) return;
11858
+ const nowMs = this.nowFn();
11859
+ const msToDeadline = this.softDeadlineMs - nowMs;
11860
+ for (const mark of CALL_BUDGET_REMINDER_MARKS_SECONDS) {
11861
+ const msUntilMark = msToDeadline - mark * 1e3;
11862
+ if (msUntilMark <= 0) continue;
11863
+ if (this.firedReminderMarks.has(mark)) continue;
11864
+ const t = this.setTimeoutFn(() => {
11865
+ this.firedReminderMarks.add(mark);
11866
+ this.injectReminder(mark);
11867
+ }, msUntilMark);
11868
+ this.reminderTimers.push(t);
11869
+ }
11870
+ const msUntilSoftEnd = Math.max(0, msToDeadline);
11871
+ this.softEndTimer = this.setTimeoutFn(() => {
11872
+ this.softEndTimer = null;
11873
+ this.onSoftDeadline();
11874
+ }, msUntilSoftEnd);
11875
+ }
11876
+ /** Cancel all currently-armed budget timers. Idempotent. */
11877
+ clearBudgetTimers() {
11878
+ if (this.softEndTimer) {
11879
+ this.clearTimeoutFn(this.softEndTimer);
11880
+ this.softEndTimer = null;
11881
+ }
11882
+ if (this.graceEndTimer) {
11883
+ this.clearTimeoutFn(this.graceEndTimer);
11884
+ this.graceEndTimer = null;
11885
+ }
11886
+ for (const t of this.reminderTimers) {
11887
+ this.clearTimeoutFn(t);
11888
+ }
11889
+ this.reminderTimers = [];
11890
+ }
11891
+ /**
11892
+ * Inject a "you have ~N seconds left" system message into the live
11893
+ * OpenAI session. The model receives it as a `conversation.item.create`
11894
+ * with role:`system`, followed by `response.create` so it can decide
11895
+ * whether to acknowledge it out loud (often it just naturally
11896
+ * accelerates wrap-up; we don't force a verbal "I have 30 seconds").
11897
+ */
11898
+ injectReminder(secondsRemaining) {
11899
+ if (this.ended || !this.openaiReady) return;
11900
+ 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.`;
11901
+ this.safeSend(this.openai, {
11902
+ type: "conversation.item.create",
11903
+ item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
11904
+ });
11905
+ this.safeSend(this.openai, { type: "response.create" });
11906
+ this.emitTranscript("system", `Time reminder injected at T-${secondsRemaining}s.`);
11907
+ }
11908
+ /**
11909
+ * Fires once the soft deadline elapses. Injects a "your time is up"
11910
+ * system message + schedules the grace-window hard end. If the agent
11911
+ * uses the grace window to call `schedule_callback` or `extend_call_time`
11912
+ * the latter can push the deadline forward again — that's fine, the
11913
+ * grace timer is cancelled by {@link extendCallTime} via rearmBudgetTimers.
11914
+ */
11915
+ onSoftDeadline() {
11916
+ if (this.ended) return;
11917
+ if (this.openaiReady) {
11918
+ 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.`;
11919
+ this.safeSend(this.openai, {
11920
+ type: "conversation.item.create",
11921
+ item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
11922
+ });
11923
+ this.safeSend(this.openai, { type: "response.create" });
11924
+ }
11925
+ this.emitTranscript("system", `Soft deadline reached; ${CALL_BUDGET_GRACE_SECONDS}s grace window started.`);
11926
+ this.endedByTimeBudgetFlag = true;
11927
+ this.graceEndTimer = this.setTimeoutFn(() => {
11928
+ this.graceEndTimer = null;
11929
+ if (this.ended) return;
11930
+ this.emitTranscript("system", "Grace window elapsed \u2014 bridge ending call.");
11931
+ this.end("time-budget-exceeded");
11932
+ }, CALL_BUDGET_GRACE_SECONDS * 1e3);
11933
+ }
11934
+ /**
11935
+ * Add an utterance to the rolling buffer used for the callback
11936
+ * transcript digest. Bounded by char count, not entry count, so a
11937
+ * burst of short turns doesn't get pruned prematurely.
11938
+ */
11939
+ noteUtterance(line) {
11940
+ if (!line) return;
11941
+ this.recentUtterances.push(line);
11942
+ let total = this.recentUtterances.reduce((n, s) => n + s.length, 0);
11943
+ while (total > MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH * 2 && this.recentUtterances.length > 1) {
11944
+ const dropped = this.recentUtterances.shift();
11945
+ total -= dropped.length;
11946
+ }
11947
+ }
11948
+ /**
11949
+ * Compose a transcript digest from the rolling buffer. Used as the
11950
+ * "context from the previous call" payload in {@link scheduleCallback}.
11951
+ * Always honours {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH}.
11952
+ */
11953
+ composeTranscriptDigest() {
11954
+ const joined = this.recentUtterances.join("\n");
11955
+ if (joined.length <= MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH) return joined;
11956
+ return "\u2026\n" + joined.slice(joined.length - MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH + 2);
11957
+ }
11160
11958
  // ─── Teardown ─────────────────────────────────────────
11959
+ /**
11960
+ * v0.9.82 — agent-initiated hangup. Called when the `end_call` tool
11961
+ * fires. Logs a marker, then routes through {@link end} so the
11962
+ * carrier sees the bye frame and `onEnd` fires exactly once (the
11963
+ * same teardown path the human-hangup case takes). The "agent-
11964
+ * requested" reason flows through to the mission transcript so a
11965
+ * post-call audit can tell apart "agent hung up" from "human hung
11966
+ * up" from "time budget exceeded".
11967
+ *
11968
+ * Returns the structured result the tool handler echoes back to the
11969
+ * model — even though by the time the model receives it the line
11970
+ * will already be closed, keeping a consistent return shape lets the
11971
+ * executor JSON-stringify deterministically.
11972
+ */
11973
+ endByAgentRequest(reason) {
11974
+ if (this.ended) {
11975
+ return { ok: false, message: "Call has already ended." };
11976
+ }
11977
+ const trimmed = (reason ?? "").trim();
11978
+ this.emitTranscript("system", `Agent requested hangup. Reason: ${trimmed || "unspecified"}`, {
11979
+ endedByAgent: true
11980
+ });
11981
+ this.end("agent-requested");
11982
+ return { ok: true, message: "Call ended." };
11983
+ }
11161
11984
  /**
11162
11985
  * End the bridge. Idempotent — the first call wins, later calls are
11163
11986
  * no-ops. Sends the carrier's end-of-call frame (if it has one — 46elks
@@ -11166,6 +11989,7 @@ var RealtimeVoiceBridge = class {
11166
11989
  end(reason) {
11167
11990
  if (this.ended) return;
11168
11991
  this.ended = true;
11992
+ this.clearBudgetTimers();
11169
11993
  if (this.droppedFrames > 0) {
11170
11994
  this.onTranscript?.({
11171
11995
  source: "system",
@@ -11194,7 +12018,11 @@ var RealtimeVoiceBridge = class {
11194
12018
  this.openai.close();
11195
12019
  } catch {
11196
12020
  }
11197
- this.onEnd?.({ reason, pendingToolCalls });
12021
+ if (this.endedByTimeBudgetFlag) {
12022
+ this.onEnd?.({ reason, pendingToolCalls, endedByTimeBudget: true });
12023
+ } else {
12024
+ this.onEnd?.({ reason, pendingToolCalls });
12025
+ }
11198
12026
  }
11199
12027
  // ─── Internals ────────────────────────────────────────
11200
12028
  noteDroppedFrame() {