@securecoms/agent-bridge 0.30.3 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -33431,7 +33431,8 @@ var FastLlmClient = class {
33431
33431
  try {
33432
33432
  res = await this.fetchImpl(`${this.cfg.baseUrl}${this.cfg.path ?? "/chat/completions"}`, {
33433
33433
  method: "POST",
33434
- headers: { "content-type": "application/json", ...this.key ? { authorization: `Bearer ${this.key}` } : {} },
33434
+ // Final fix T8: configured headers first, so they can never displace content-type or the bearer.
33435
+ headers: { ...this.cfg.headers, "content-type": "application/json", ...this.key ? { authorization: `Bearer ${this.key}` } : {} },
33435
33436
  body: JSON.stringify({ model: this.cfg.model, messages, max_tokens: this.cfg.maxTokens, temperature: 0.4 }),
33436
33437
  signal: ac.signal
33437
33438
  });
@@ -33635,7 +33636,9 @@ function settingsToVoiceConfig(s, proxy) {
33635
33636
  model: "securecoms-fast",
33636
33637
  apiKey: proxy.apiKey,
33637
33638
  timeoutMs: 2500,
33638
- maxTokens: 80
33639
+ maxTokens: 80,
33640
+ // Spec 7.2: tells the proxy this bridge scopes voice turns. Sent to the SecureComs proxy only.
33641
+ headers: { "x-securecoms-bridge-caps": "voice-scope-v1" }
33639
33642
  },
33640
33643
  settings: s
33641
33644
  };
@@ -33643,6 +33646,7 @@ function settingsToVoiceConfig(s, proxy) {
33643
33646
 
33644
33647
  // src/voice/persona.ts
33645
33648
  var CONTINUITY_CLAUSE = "The text messages in this chat and this voice call are one and the same ongoing conversation, and the earlier turns you can see ARE those messages. If asked whether you remember or received a message, answer from that history \u2014 never say you cannot access previous messages.";
33649
+ var VOICE_CALL_CLAUSE = "This is a live voice call. The earlier turns you can see are from this call only; during a call you cannot see the typed chat, saved notes or memory. If asked about a message, file or note you cannot see, say plainly that you can't see the chat while on a call and offer to pick it up in the chat afterwards. Never guess at what it said.";
33646
33650
  var schema = z2.object({
33647
33651
  persona: z2.object({
33648
33652
  display_name: z2.string().min(1).optional(),
@@ -33655,6 +33659,8 @@ var schema = z2.object({
33655
33659
  model: z2.string().min(1),
33656
33660
  api_key: z2.string().min(1).optional(),
33657
33661
  api_key_env: z2.string().min(1).optional(),
33662
+ // E.2: only the exact string "direct" opts in; any other value is ignored (today's proxy behavior), never an error.
33663
+ route: z2.unknown().optional(),
33658
33664
  timeout_ms: z2.number().int().min(500).max(1e4).default(2500),
33659
33665
  max_tokens: z2.number().int().min(16).max(400).default(80)
33660
33666
  })
@@ -33679,7 +33685,8 @@ function parseVoiceConfig(raw, env) {
33679
33685
  model: v.fast_path.model,
33680
33686
  apiKey,
33681
33687
  timeoutMs: v.fast_path.timeout_ms,
33682
- maxTokens: v.fast_path.max_tokens
33688
+ maxTokens: v.fast_path.max_tokens,
33689
+ ...v.fast_path.route === "direct" ? { route: "direct" } : {}
33683
33690
  }
33684
33691
  };
33685
33692
  }
@@ -33723,7 +33730,7 @@ function buildFastMessages(args2) {
33723
33730
  const sp = systemPrompt(args2.persona, args2.settings, args2.agentName);
33724
33731
  const head = sp.slice(0, sp.length - CONTINUITY_CLAUSE.length).trimEnd();
33725
33732
  const notesLine = args2.notesDeferLine ? ` ${FAST_NOTES_DEFER_LINE}` : "";
33726
- const system = `${head} ${args2.guidance.trim()}${notesLine} ${FAST_DEFER_RULE} ${CONTINUITY_CLAUSE}`;
33733
+ const system = `${head} ${args2.guidance.trim()}${notesLine} ${FAST_DEFER_RULE} ${VOICE_CALL_CLAUSE}`;
33727
33734
  const recent = args2.memory.recent.map((t) => ({
33728
33735
  role: t.role === "agent" ? "assistant" : "user",
33729
33736
  content: t.text
@@ -33760,6 +33767,160 @@ function normalizeFastReply(raw, maxChars = 200, maxSentences = 2) {
33760
33767
  return sentences.slice(0, maxSentences).join(" ").slice(0, maxChars);
33761
33768
  }
33762
33769
 
33770
+ // src/voice/voice-scope.ts
33771
+ function isVoiceScopedTurn(t) {
33772
+ return t.speaker !== void 0 || t.transcribed === true || t.senderIsRelay || t.text.trim() === VOICE_CALL_START_MARKER;
33773
+ }
33774
+ function admitsToCallBuffer(t) {
33775
+ return t.speaker !== void 0 || t.transcribed === true;
33776
+ }
33777
+
33778
+ // src/voice/call-buffer.ts
33779
+ var CALL_BUFFER_CAP = 50;
33780
+ var CALL_BUFFER_IDLE_MS = 30 * 6e4;
33781
+ var CALL_BUFFER_ID_CAP = 5e3;
33782
+ var CallBuffer = class {
33783
+ constructor(opts) {
33784
+ this.opts = opts;
33785
+ }
33786
+ channels = /* @__PURE__ */ new Map();
33787
+ /** messageId -> channelId, insertion ordered (oldest first) for the cap. */
33788
+ voiceIds = /* @__PURE__ */ new Map();
33789
+ /** messageId -> { channelId, the channel's bufSeq at the mark, the channel's generation then }. */
33790
+ seqNotes = /* @__PURE__ */ new Map();
33791
+ /**
33792
+ * Final fix I-1: messageId -> { channelId, the channel's bufSeq just before the turn's own peer entry }. A row the
33793
+ * backend redelivers (a failed ack, runtime_inbox.full backpressure) is ingested again; its id here means its text is
33794
+ * already in the call, so it is not appended again and its snapshot stops before its own entry (A-6).
33795
+ */
33796
+ admitted = /* @__PURE__ */ new Map();
33797
+ /** The last bufSeq handed out per channel (kept across resets, so the sequence never runs backwards). */
33798
+ lastSeq = /* @__PURE__ */ new Map();
33799
+ nextSeq = 1;
33800
+ /** Per-channel reset generation (advanced by every reset, the idle rule included). */
33801
+ gens = /* @__PURE__ */ new Map();
33802
+ /**
33803
+ * Append a spoken turn's text, once per turn id (final fix I-1). Returns false (and appends nothing) when the id was
33804
+ * already admitted in this call, or when the buffer is disabled.
33805
+ */
33806
+ appendPeer(channelId, text, messageId) {
33807
+ if (!this.opts.enabled) return false;
33808
+ if (this.admittedAt(messageId) !== void 0) return false;
33809
+ const before = this.seqAt(channelId);
33810
+ this.append(channelId, { role: "peer", text });
33811
+ this.admitted.set(messageId, { channelId, before });
33812
+ if (this.admitted.size > CALL_BUFFER_ID_CAP) this.admitted.delete(this.admitted.keys().next().value);
33813
+ return true;
33814
+ }
33815
+ /**
33816
+ * Final fix I-1: for a turn already admitted in this call, the channel's bufSeq just before its own entry (the end seq
33817
+ * of its first snapshot); undefined for a turn not admitted (or forgotten by a reset, the idle rule or the id cap).
33818
+ */
33819
+ admittedAt(messageId) {
33820
+ const a = this.admitted.get(messageId);
33821
+ if (a === void 0) return void 0;
33822
+ this.expireIfIdle(a.channelId);
33823
+ return this.admitted.get(messageId)?.before;
33824
+ }
33825
+ appendAgent(channelId, text, origin) {
33826
+ this.append(channelId, { role: "agent", text, origin });
33827
+ }
33828
+ /**
33829
+ * Record a voice-scoped turn's id (for reply admission only, A-4) and note the channel's seq at this point. Final fix
33830
+ * I-1: a turn already admitted (a redelivered row) keeps the seq of its first admission, so the note never moves past
33831
+ * the turn's own entry.
33832
+ */
33833
+ markVoiceScoped(channelId, messageId) {
33834
+ if (!this.opts.enabled) return;
33835
+ this.expireIfIdle(channelId);
33836
+ this.voiceIds.delete(messageId);
33837
+ this.voiceIds.set(messageId, channelId);
33838
+ if (this.voiceIds.size > CALL_BUFFER_ID_CAP) this.voiceIds.delete(this.voiceIds.keys().next().value);
33839
+ const seq = this.admittedAt(messageId) ?? this.seqAt(channelId);
33840
+ this.seqNotes.delete(messageId);
33841
+ this.seqNotes.set(messageId, { channelId, seq, generation: this.generation(channelId) });
33842
+ if (this.seqNotes.size > CALL_BUFFER_ID_CAP) this.seqNotes.delete(this.seqNotes.keys().next().value);
33843
+ }
33844
+ /** A-4: read ONLY by the bridge's `afterPublish` reply admission. */
33845
+ isVoiceScopedId(messageId) {
33846
+ const channelId = this.voiceIds.get(messageId);
33847
+ if (channelId === void 0) return false;
33848
+ this.expireIfIdle(channelId);
33849
+ return this.voiceIds.has(messageId);
33850
+ }
33851
+ /** The channel's mark noted when `messageId` was marked (the release re-read), or undefined (e.g. after a restart). */
33852
+ seqNoted(messageId) {
33853
+ const note = this.seqNotes.get(messageId);
33854
+ if (note === void 0) return void 0;
33855
+ this.expireIfIdle(note.channelId);
33856
+ const kept = this.seqNotes.get(messageId);
33857
+ return kept === void 0 ? void 0 : { seq: kept.seq, generation: kept.generation };
33858
+ }
33859
+ /** The bufSeq of the channel's newest entry (0 before any append). A snapshot's end seq. */
33860
+ seqAt(channelId) {
33861
+ this.expireIfIdle(channelId);
33862
+ return this.lastSeq.get(channelId) ?? 0;
33863
+ }
33864
+ /**
33865
+ * The channel's last `limit` entries, oldest first. With `upTo` (final fix I-1, a redelivered turn's re-snapshot),
33866
+ * only entries at or below that bufSeq.
33867
+ */
33868
+ recent(channelId, limit, upTo) {
33869
+ if (limit <= 0) return [];
33870
+ this.expireIfIdle(channelId);
33871
+ const all = this.channels.get(channelId)?.entries ?? [];
33872
+ const entries = upTo === void 0 ? all : all.filter((e) => e.bufSeq <= upTo);
33873
+ return entries.slice(-limit).map(deliver);
33874
+ }
33875
+ /**
33876
+ * The channel's AGENT entries appended after `since.seq` (lines spoken or answered since a snapshot), oldest first.
33877
+ * Final fix M-4: nothing when the channel has been reset since the mark (a later call's lines).
33878
+ */
33879
+ agentAfter(channelId, since) {
33880
+ if (this.generation(channelId) !== since.generation) return [];
33881
+ const entries = this.channels.get(channelId)?.entries ?? [];
33882
+ return entries.filter((e) => e.role === "agent" && e.bufSeq > since.seq).map(deliver);
33883
+ }
33884
+ /**
33885
+ * The channel's reset generation: it changes on every reset (explicit or idle) and on nothing else. A caller that
33886
+ * holds text across an await (the coalesce window) compares it to skip an append into a call that has since ended.
33887
+ */
33888
+ generation(channelId) {
33889
+ this.expireIfIdle(channelId);
33890
+ return this.gens.get(channelId) ?? 0;
33891
+ }
33892
+ /** Clear the channel: its entries, its voice-scoped ids, its seq notes and its admitted ids. Only ever removes. */
33893
+ reset(channelId) {
33894
+ this.gens.set(channelId, (this.gens.get(channelId) ?? 0) + 1);
33895
+ this.channels.delete(channelId);
33896
+ for (const [id, c] of this.voiceIds) if (c === channelId) this.voiceIds.delete(id);
33897
+ for (const [id, n] of this.seqNotes) if (n.channelId === channelId) this.seqNotes.delete(id);
33898
+ for (const [id, a] of this.admitted) if (a.channelId === channelId) this.admitted.delete(id);
33899
+ }
33900
+ append(channelId, e) {
33901
+ if (!this.opts.enabled) return;
33902
+ this.expireIfIdle(channelId);
33903
+ let ch = this.channels.get(channelId);
33904
+ if (ch === void 0) {
33905
+ ch = { entries: [], lastAppend: 0 };
33906
+ this.channels.set(channelId, ch);
33907
+ }
33908
+ const bufSeq = this.nextSeq++;
33909
+ this.lastSeq.set(channelId, bufSeq);
33910
+ ch.entries.push({ ...e, bufSeq });
33911
+ if (ch.entries.length > this.opts.cap) ch.entries.splice(0, ch.entries.length - this.opts.cap);
33912
+ ch.lastAppend = this.opts.now();
33913
+ }
33914
+ /** Reset (c): more than `idleMs` since the channel's last append clears it, checked on every read and append. */
33915
+ expireIfIdle(channelId) {
33916
+ const ch = this.channels.get(channelId);
33917
+ if (ch !== void 0 && this.opts.now() - ch.lastAppend > this.opts.idleMs) this.reset(channelId);
33918
+ }
33919
+ };
33920
+ function deliver(e) {
33921
+ return { role: e.role, text: e.text, seq: 0, ...e.origin ? { origin: e.origin } : {} };
33922
+ }
33923
+
33763
33924
  // src/voice/defer-intent.ts
33764
33925
  var NEEDS_RUNTIME = [
33765
33926
  // Current time / date / day.
@@ -34139,6 +34300,7 @@ function lineHash(line) {
34139
34300
  return createHash5("sha256").update(line.trim(), "utf8").digest("hex");
34140
34301
  }
34141
34302
  var LOCAL_REPORT_FILE = "local-config.json";
34303
+ var LOCAL_PERSONA_FILE_NAMES = ["IDENTITY.md", "SOUL.md", "AGENTS.md"];
34142
34304
  var SECRET_PATH_COUNT_MAX = 1e4;
34143
34305
  var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
34144
34306
  function parseSecretPathsReport(raw) {
@@ -34220,18 +34382,23 @@ function readLocalReport(home, identityId) {
34220
34382
  const hasSearchKey = doc.has_search_key === true;
34221
34383
  const hasLlmKey = doc.has_llm_key === true;
34222
34384
  const raw = doc.persona;
34223
- const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.fromEntries(
34224
- Object.entries(raw).filter(([, v]) => typeof v === "string")
34225
- ) : {};
34385
+ const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
34386
+ const personaFiles = [];
34387
+ for (const name of LOCAL_PERSONA_FILE_NAMES) {
34388
+ const text = persona[name];
34389
+ if (typeof text === "string" && text.trim() !== "") {
34390
+ personaFiles.push({ name, bytes: Buffer.byteLength(text, "utf8") });
34391
+ }
34392
+ }
34226
34393
  const capRaw = doc.owner_dm_search_cap_effective;
34227
34394
  const ownerDmCap = typeof capRaw === "number" && Number.isInteger(capRaw) && capRaw >= 0 && capRaw <= OWNER_DM_SEARCH_DAILY_CAP_MAX ? capRaw : void 0;
34228
34395
  return {
34229
- persona,
34396
+ local_persona_files: personaFiles,
34230
34397
  has_search_key: hasSearchKey,
34231
34398
  has_llm_key: hasLlmKey,
34232
34399
  has_extract_provider: doc.has_extract_provider === true,
34233
34400
  ...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
34234
- legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
34401
+ legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
34235
34402
  secret_paths: parseSecretPathsReport(doc.secret_paths)
34236
34403
  };
34237
34404
  }
@@ -34316,16 +34483,6 @@ async function applyLegacyMemoryFactDecisions(home, identityId, rawDecisions, re
34316
34483
  }
34317
34484
  return { sourcesDeleted, factsAssigned };
34318
34485
  }
34319
- function readManagedMemory(home, identityId) {
34320
- const p = join15(runtimeConfigDir(home, identityId), MEMORY_FILE);
34321
- if (!existsSync4(p)) return null;
34322
- try {
34323
- const body = readFileSync8(p, "utf8").trim();
34324
- return body || null;
34325
- } catch {
34326
- return null;
34327
- }
34328
- }
34329
34486
  function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
34330
34487
  const dir = runtimeConfigDir(home, identityId);
34331
34488
  const path = join15(dir, MEMORY_FILE);
@@ -35113,6 +35270,10 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
35113
35270
  var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
35114
35271
  Object.freeze({ tool: "current_time", mode: "read" })
35115
35272
  ]);
35273
+ var RELAYED_VOICE_GRANTS = Object.freeze([
35274
+ Object.freeze({ tool: "current_time", mode: "read" }),
35275
+ Object.freeze({ tool: "web_search", mode: "read" })
35276
+ ]);
35116
35277
  var NO_TOOL_GRANTS = Object.freeze([]);
35117
35278
  var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
35118
35279
  var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
@@ -35472,6 +35633,12 @@ var BridgeAgent = class _BridgeAgent {
35472
35633
  workspaceDir: this.cfg.memoryWorkspaceDir,
35473
35634
  factsCharBudget: this.cfg.memoryFactsCharBudget
35474
35635
  });
35636
+ this.callBuffer = new CallBuffer({
35637
+ cap: CALL_BUFFER_CAP,
35638
+ idleMs: CALL_BUFFER_IDLE_MS,
35639
+ now: () => Date.now(),
35640
+ enabled: (this.cfg.memoryProvider ?? "securecoms") !== "none"
35641
+ });
35475
35642
  this.provider = new MlsGroupKeyProvider({
35476
35643
  store: this.store,
35477
35644
  backend: this.backend,
@@ -35605,6 +35772,9 @@ var BridgeAgent = class _BridgeAgent {
35605
35772
  this.injectedFastLlm = !!deps.fastLlm;
35606
35773
  this.fileVoiceConfig = cfg.voice;
35607
35774
  this.fastLlm = deps.fastLlm ?? (cfg.voice?.fastPath.enabled ? new FastLlmClient(cfg.voice.fastPath, this.fetchImpl) : void 0);
35775
+ if (this.fileVoiceConfig?.fastPath.route === "direct" && !this.fileVoiceConfig.fastPath.apiKey) {
35776
+ writeServiceLog2("warn", "voice.fast_path_direct_no_key");
35777
+ }
35608
35778
  this.voiceSettingsSource = cfg.voice ? "file" : "none";
35609
35779
  this.latency = deps.latency ?? new LatencyRecorder();
35610
35780
  this.voiceCoalesceWindowMs = Math.max(0, deps.voiceCoalesceWindowMs ?? voiceCoalesceWindowFromEnv());
@@ -36295,6 +36465,11 @@ var BridgeAgent = class _BridgeAgent {
36295
36465
  startedAt = 0;
36296
36466
  store;
36297
36467
  memory;
36468
+ /**
36469
+ * Voice fast-path privacy, Task 4b (spec §2.1, OQ-1 ruled): the current calls' spoken turns, in process only. The
36470
+ * ONLY history a voice-scoped turn sees; read through voiceScopedHistory, once per turn, at ingest.
36471
+ */
36472
+ callBuffer;
36298
36473
  /**
36299
36474
  * Markdown memory 7a: the one owner of every note mutation and of the
36300
36475
  * delivery cache (spec §6.3, §7.2). One per store, so one per identity.
@@ -36399,6 +36574,7 @@ var BridgeAgent = class _BridgeAgent {
36399
36574
  const s = parseVoiceSettings(await res.json());
36400
36575
  if (s.identityDisplayName) this.lastOwnDisplayName = s.identityDisplayName;
36401
36576
  const proxy = settingsToVoiceConfig(s, { baseUrl: this.cfg.apiBase, apiKey: this.cfg.apiKey });
36577
+ const direct = this.fileVoiceConfig?.fastPath.route === "direct" ? this.fileVoiceConfig.fastPath : void 0;
36402
36578
  if (s.version === 0 && this.fileVoiceConfig) {
36403
36579
  const merged = {
36404
36580
  ...s,
@@ -36407,16 +36583,16 @@ var BridgeAgent = class _BridgeAgent {
36407
36583
  quirks: this.fileVoiceConfig.persona.quirks
36408
36584
  };
36409
36585
  this.voiceSettings = merged;
36410
- this.cfg.voice = { persona: this.fileVoiceConfig.persona, fastPath: proxy.fastPath, settings: merged };
36586
+ this.cfg.voice = { persona: this.fileVoiceConfig.persona, fastPath: direct ?? proxy.fastPath, settings: merged };
36411
36587
  this.voiceSettingsVersion = 0;
36412
36588
  this.voiceSettingsSource = "file";
36413
36589
  } else {
36414
36590
  this.voiceSettings = s;
36415
- this.cfg.voice = proxy;
36591
+ this.cfg.voice = direct ? { ...proxy, fastPath: direct } : proxy;
36416
36592
  this.voiceSettingsVersion = s.version;
36417
36593
  this.voiceSettingsSource = "backend";
36418
36594
  }
36419
- if (!this.injectedFastLlm) {
36595
+ if (!this.injectedFastLlm && !direct) {
36420
36596
  this.fastLlm = new FastLlmClient(this.cfg.voice.fastPath, this.fetchImpl);
36421
36597
  }
36422
36598
  writeServiceLog2("info", "voice.settings_loaded", {
@@ -36645,12 +36821,10 @@ var BridgeAgent = class _BridgeAgent {
36645
36821
  const report = {
36646
36822
  applied_version: version,
36647
36823
  paths: checkConfiguredPaths(skillsJson),
36648
- // Read AFTER the prunes above, so the owner's next view of memory is
36649
- // the file as it now stands, not the one they just pruned from.
36650
- memory: readManagedMemory(this.home, this.cfg.identityId),
36651
- // E13: pass through what the runtime reported. The daemon does not
36652
- // interpret any of it.
36653
- local_persona: local?.persona ?? null,
36824
+ // F-0 (G-5): the report carries no content. No MEMORY.md text, no persona text, no legacy
36825
+ // text. E13 needs only to know which of the runtime's own persona files hold something, so
36826
+ // this is [{name, bytes}] for each non-blank file and nothing else.
36827
+ local_persona_files: local?.local_persona_files ?? [],
36654
36828
  // Only the runtime knows whether a search key file is configured — it
36655
36829
  // is an environment value on that process, invisible to the daemon and
36656
36830
  // to the backend. The backend needs it to generate a truthful tool
@@ -36683,9 +36857,9 @@ var BridgeAgent = class _BridgeAgent {
36683
36857
  ...this.reportedClearGeneration(),
36684
36858
  // 7b spec §9.2 (Task 19): capability versus state. Read-only: the report never starts the embedder.
36685
36859
  ...this.reportedRecall(),
36686
- // Inert legacy files only. `readLocalReport` revalidates the current
36687
- // regular-file bytes and strips every local path before this leaves the
36688
- // machine.
36860
+ // Inert legacy files only, metadata only. `readLocalReport` revalidates the current
36861
+ // regular-file bytes and strips every local path and every `content` before this
36862
+ // leaves the machine.
36689
36863
  legacy_memory: local?.legacy_memory ?? [],
36690
36864
  // §B.4: counts of server-named secret paths inside / allowlisted /
36691
36865
  // outside the secrets directory, and the runtime's mode. Numbers and
@@ -37942,6 +38116,9 @@ var BridgeAgent = class _BridgeAgent {
37942
38116
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
37943
38117
  const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
37944
38118
  if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
38119
+ if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(body.text) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
38120
+ this.callBuffer.appendAgent(channelId, body.text, "reply");
38121
+ }
37945
38122
  const origin = this.spokenReplyIds.has(id) ? "fast_path" : !this.taintScope(channelId) ? "reply" : opts.inReplyTo !== void 0 && this.cleanTurns.has(opts.inReplyTo) && !this.taintedTurns.has(opts.inReplyTo) ? "reply" : "untrusted";
37946
38123
  if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
37947
38124
  if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
@@ -38712,6 +38889,27 @@ var BridgeAgent = class _BridgeAgent {
38712
38889
  mayReadHistoryForTurn(messageId, authorization) {
38713
38890
  return !this.isOwnerVerifyFailed(messageId) && this.mayReadOpenClawMemoryForTurn(authorization);
38714
38891
  }
38892
+ /**
38893
+ * Voice fast-path privacy (spec §2.1, OQ-1 ruled): the ONLY history source of a voice-scoped turn, the call buffer.
38894
+ * Never reads the memory provider, the store, the unified store or the 7b index. A-6: called once per turn, at ingest
38895
+ * (takeVoiceSnapshot), after the reset signals and before the turn's own append.
38896
+ */
38897
+ voiceScopedHistory(channelId, limit, upTo) {
38898
+ return this.callBuffer.recent(channelId, limit, upTo);
38899
+ }
38900
+ /**
38901
+ * Task 4b (A-6): the turn's snapshot, taken BEFORE its own text is appended (audit finding 2). Final fix I-1: a turn
38902
+ * already admitted to this call (a redelivered row) is snapshotted up to just before its own entry, as it was the
38903
+ * first time, so its snapshot never contains itself.
38904
+ */
38905
+ takeVoiceSnapshot(channelId, messageId) {
38906
+ const upTo = this.callBuffer.admittedAt(messageId);
38907
+ return {
38908
+ recent: this.voiceScopedHistory(channelId, _BridgeAgent.TURN_MEMORY_LIMIT, upTo),
38909
+ endSeq: upTo ?? this.callBuffer.seqAt(channelId),
38910
+ generation: this.callBuffer.generation(channelId)
38911
+ };
38912
+ }
38715
38913
  /** An inbound turn may receive OpenClaw memory only from an active owner DM. */
38716
38914
  mayReadOpenClawMemoryForTurn(authorization) {
38717
38915
  return this.cfg.memoryProvider !== "openclaw" || authorization.sender_membership === "active" && authorization.audience === "owner_dm";
@@ -38721,16 +38919,26 @@ var BridgeAgent = class _BridgeAgent {
38721
38919
  * stays owner-DM-only; every provider failure degrades to empty context and
38722
38920
  * never falls back to the runtime's retired plaintext JSON files.
38723
38921
  */
38724
- async memoryForTurn(channelId, messageId, authorization, limit = 50) {
38922
+ async memoryForTurn(channelId, messageId, authorization, limit = 50, voice) {
38725
38923
  if (!this.mayReadHistoryForTurn(messageId, authorization)) {
38726
38924
  return { recent: [], facts: [] };
38727
38925
  }
38926
+ if (voice) return { recent: this.voiceFrameTurns(voice.snapshot?.recent ?? []), facts: [] };
38728
38927
  try {
38729
38928
  return this.fenceMemory(channelId, await this.memory.context(channelId, limit), holdsManifestGrant(authorization));
38730
38929
  } catch {
38731
38930
  return { recent: [], facts: [] };
38732
38931
  }
38733
38932
  }
38933
+ /**
38934
+ * Task 6 (spec §2.2 A-1, N1): call-buffer entries as runtime-frame turns. Under the voice fence (a pin is loaded) the
38935
+ * fast model's own lines (origin "fast_path") are dropped, as fenceMemory drops them from store history; the fast
38936
+ * prompt keeps them. The internal origin is not carried into the frame; `seq` stays 0 (A-5).
38937
+ */
38938
+ voiceFrameTurns(entries) {
38939
+ const fenced = this.voiceFenceOn();
38940
+ return entries.filter((e) => !(fenced && e.origin === "fast_path")).map((e) => ({ seq: e.seq, role: e.role, text: e.text }));
38941
+ }
38734
38942
  /**
38735
38943
  * Owner grants plan 4 Task 16 (T-30, audit r2 H-1): the voice fence is on whenever a pin is loaded, on EVERY runtime
38736
38944
  * turn (manifest, fallback, greeting, relayed, constants-only alike). It never depends on a turn's grants.
@@ -38765,7 +38973,7 @@ var BridgeAgent = class _BridgeAgent {
38765
38973
  if (!strict && !ctx.recent.some((t) => t.role === "agent")) return ctx;
38766
38974
  const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
38767
38975
  const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
38768
- const meta = securecoms ? this.store.memoryLineMeta(channelId, ctx.recent.filter((t) => strict || t.role === "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
38976
+ const meta = securecoms ? this.store.memoryLineMeta(channelId, ctx.recent.filter((t) => (strict || t.role === "agent") && t.seq !== 0).map((t) => t.seq)) : /* @__PURE__ */ new Map();
38769
38977
  const fence = this.fenceContext(channelId);
38770
38978
  const recent = ctx.recent.filter((t) => {
38771
38979
  const m = meta.get(t.seq);
@@ -38784,7 +38992,7 @@ var BridgeAgent = class _BridgeAgent {
38784
38992
  if (recent.length === 0) return false;
38785
38993
  const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
38786
38994
  const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
38787
- const meta = securecoms ? this.store.memoryLineMeta(m.channel_id, recent.filter((t) => t.role !== "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
38995
+ const meta = securecoms ? this.store.memoryLineMeta(m.channel_id, recent.filter((t) => t.role !== "agent" && t.seq !== 0).map((t) => t.seq)) : /* @__PURE__ */ new Map();
38788
38996
  return recent.some((t) => t.role !== "agent" && isRelayClassPeer({ peerId: t.peerId, modality: meta.get(t.seq)?.modality }, ownerId));
38789
38997
  }
38790
38998
  /**
@@ -39080,9 +39288,18 @@ var BridgeAgent = class _BridgeAgent {
39080
39288
  writeServiceLog2("warn", "memory.recall_prepare_failed", {}, e);
39081
39289
  }
39082
39290
  }
39291
+ /**
39292
+ * Voice fast-path privacy, Task 7 (spec §4): the frame-keyed delivery check. A voice-scoped frame gets no notes, no
39293
+ * recall and no unified items, whatever its authorization says. Keys on the frame's own marks, NEVER on the call
39294
+ * buffer's id set (a reset evicts ids; the staged frame must stay scoped).
39295
+ */
39296
+ frameIsVoiceScoped(m) {
39297
+ return m.voice_scoped === true || m.transcribed === true || m.speaker !== void 0;
39298
+ }
39083
39299
  /** ws flush (synchronous). Q1 to Q3 and Q6 again at delivery, on the ORIGINAL message; Q4 lives in withConsumerRecall. */
39084
39300
  recallForDelivery(m) {
39085
39301
  if (this.unifiedOn) return void 0;
39302
+ if (this.frameIsVoiceScoped(m)) return void 0;
39086
39303
  if (!this.recallProviderOn() || !this.notesReadable(m.authorization)) return void 0;
39087
39304
  return this.recall.deliverable(m.message_id, m.channel_id);
39088
39305
  }
@@ -39315,6 +39532,7 @@ var BridgeAgent = class _BridgeAgent {
39315
39532
  /** ws flush (synchronous). The predicate and the owner scope again at delivery, on the ORIGINAL message (H-4, Review Focus 3). */
39316
39533
  unifiedForDelivery(m) {
39317
39534
  if (!this.unifiedProviderOn()) return void 0;
39535
+ if (this.frameIsVoiceScoped(m)) return void 0;
39318
39536
  const a = m.authorization;
39319
39537
  if (!(a?.memory_write === true || a?.relay_speaker === "owner")) return void 0;
39320
39538
  const scopeId = this.ownerScopeNow(m.channel_id);
@@ -39550,6 +39768,7 @@ var BridgeAgent = class _BridgeAgent {
39550
39768
  notesForDelivery(m) {
39551
39769
  if (this.unifiedOn) return { generation: this.notes.appliedGeneration() };
39552
39770
  const generation = this.notes.appliedGeneration();
39771
+ if (this.frameIsVoiceScoped(m)) return { generation };
39553
39772
  if (!this.notesReadable(m.authorization)) return { generation };
39554
39773
  const d = this.notes.deliverable(m.channel_id);
39555
39774
  if (d.miss) {
@@ -39909,7 +40128,7 @@ var BridgeAgent = class _BridgeAgent {
39909
40128
  * deferring fast reply was actually published — F3).
39910
40129
  */
39911
40130
  async speakAndSettle(turn, plan, held, heldAfterAck, fastText, guidance, fastSpeaker, greeting) {
39912
- const outcome = plan.kind === "ack" ? await this.sendFastLine(turn.channelId, turn.messageId, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
40131
+ const outcome = plan.kind === "ack" ? await this.sendFastLine(turn, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
39913
40132
  if (outcome === "answered") await this.settleHeldRuntimeMessage(held, false);
39914
40133
  else await this.settleHeldRuntimeMessage(outcome === "deferred" ? heldAfterAck : held, true);
39915
40134
  }
@@ -39928,19 +40147,29 @@ var BridgeAgent = class _BridgeAgent {
39928
40147
  async settleFastReplies() {
39929
40148
  while (this.fastReplyChains.size) await Promise.all([...this.fastReplyChains.values()]);
39930
40149
  }
39931
- /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
39932
- async sendSpoken(channelId, text, messageId) {
40150
+ /**
40151
+ * Send a line the voice fast path speaks: its memory record is "voice" (H1).
40152
+ * Task 4b (spec §2.1 agent appends (1), A-1, A-2): once published, the line enters the call buffer as "fast_path",
40153
+ * unless the turn it answers failed owner verification (its id, or any fragment of a coalesced turn). A line answering
40154
+ * a turn the ingest did not find voice-scoped is not appended either (defense in depth: the fast path fires only on
40155
+ * voice-scoped turns, Task 5).
40156
+ */
40157
+ async sendSpoken(channelId, text, messageId, turn) {
39933
40158
  this.spokenReplyIds.add(messageId);
39934
40159
  try {
39935
40160
  await this.send(channelId, text, messageId);
39936
40161
  } finally {
39937
40162
  this.spokenReplyIds.delete(messageId);
39938
40163
  }
40164
+ if (turn.voiceScoped === true && turn.anyFragmentVerifyFailed !== true && !this.isOwnerVerifyFailed(turn.messageId)) {
40165
+ this.callBuffer.appendAgent(channelId, text, "fast_path");
40166
+ }
39939
40167
  }
39940
40168
  /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
39941
- async sendFastLine(channelId, messageId, line) {
40169
+ async sendFastLine(turn, line) {
40170
+ const { channelId, messageId } = turn;
39942
40171
  try {
39943
- await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
40172
+ await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId), turn);
39944
40173
  this.fastPathSent++;
39945
40174
  return true;
39946
40175
  } catch (e) {
@@ -39954,8 +40183,8 @@ var BridgeAgent = class _BridgeAgent {
39954
40183
  * the fast model's reply — or, in `ack_only`, the greeting template — is
39955
40184
  * spoken, and the outcome decides whether the runtime answers next.
39956
40185
  *
39957
- * MUST NEVER throw: every failure path (memory lookup, the LLM call,
39958
- * normalization, or the send itself) is caught, counted in
40186
+ * MUST NEVER throw: every failure path (the LLM call, normalization,
40187
+ * or the send itself) is caught, counted in
39959
40188
  * `fastPathFailed`, and logged WITHOUT the turn text or the model's reply.
39960
40189
  * The `fastReplyId(...)` send id is deterministic, so a duplicate that
39961
40190
  * slipped past the in-memory dedupe (e.g. a restart) resolves to the same
@@ -39969,20 +40198,18 @@ var BridgeAgent = class _BridgeAgent {
39969
40198
  try {
39970
40199
  if ((this.cfg.voiceFastPathMode ?? "full") === "ack_only") {
39971
40200
  if (!isGreeting) return "failed";
39972
- const ok = await this.sendFastLine(m.channelId, m.messageId, ackOnlyGreeting(this.voiceAgentName(), speaker?.displayName));
40201
+ const ok = await this.sendFastLine(m, ackOnlyGreeting(this.voiceAgentName(), speaker?.displayName));
39973
40202
  return ok ? "answered" : "failed";
39974
40203
  }
39975
40204
  const fastLlm = this.fastLlm;
39976
40205
  if (!fastLlm || !this.cfg.voice) return "failed";
39977
- let memory;
39978
- if (!this.mayReadHistoryForTurn(m.messageId, m.authorization)) {
39979
- memory = { recent: [], facts: [] };
39980
- } else {
39981
- try {
39982
- memory = await this.memory.context(m.channelId, FAST_PATH_MEMORY_TURNS);
39983
- } catch {
39984
- memory = { recent: [], facts: [] };
39985
- }
40206
+ let memory = { recent: [], facts: [] };
40207
+ const snapshot = m.voiceSnapshot;
40208
+ if (m.voiceScoped === true && snapshot !== void 0 && m.anyFragmentVerifyFailed !== true && this.mayReadHistoryForTurn(m.messageId, m.authorization)) {
40209
+ memory = {
40210
+ recent: [...snapshot.recent, ...this.callBuffer.agentAfter(m.channelId, { seq: snapshot.endSeq, generation: snapshot.generation })].slice(-FAST_PATH_MEMORY_TURNS),
40211
+ facts: []
40212
+ };
39986
40213
  }
39987
40214
  const messages = buildFastMessages({
39988
40215
  persona: this.cfg.voice.persona,
@@ -40005,7 +40232,7 @@ var BridgeAgent = class _BridgeAgent {
40005
40232
  const refusal = !isGreeting && isRefusal(split3.reply);
40006
40233
  const defer = split3.defer || refusal;
40007
40234
  const reply = refusal ? deferAck(m.messageId) : split3.reply;
40008
- await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
40235
+ await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId), m);
40009
40236
  this.fastPathSent++;
40010
40237
  if (defer) this.fastPathDeferred++;
40011
40238
  if (refusal) this.fastPathRefusalDeferred++;
@@ -40069,6 +40296,15 @@ var BridgeAgent = class _BridgeAgent {
40069
40296
  async memoryAtRelease(message) {
40070
40297
  const prior = message.memory;
40071
40298
  if (!prior || !this.mayReadHistoryForTurn(message.message_id, message.authorization)) return prior;
40299
+ if (this.frameIsVoiceScoped(message)) {
40300
+ const tails = this.voiceCoalescedTails.get(message.message_id) ?? [];
40301
+ if (tails.some((id) => this.isOwnerVerifyFailed(id))) return prior;
40302
+ const noted = this.callBuffer.seqNoted(message.message_id);
40303
+ if (noted === void 0) return prior;
40304
+ const spoken2 = this.voiceFrameTurns(this.callBuffer.agentAfter(message.channel_id, noted));
40305
+ if (spoken2.length === 0) return prior;
40306
+ return { recent: [...prior.recent, ...spoken2].slice(-_BridgeAgent.TURN_MEMORY_LIMIT), facts: [] };
40307
+ }
40072
40308
  let fresh;
40073
40309
  try {
40074
40310
  fresh = await this.memory.context(message.channel_id, _BridgeAgent.TURN_MEMORY_LIMIT);
@@ -40580,9 +40816,9 @@ var BridgeAgent = class _BridgeAgent {
40580
40816
  });
40581
40817
  }
40582
40818
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
40583
- async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId) {
40819
+ async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped) {
40584
40820
  const held = await this.heldPosition(channelId);
40585
- const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict });
40821
+ const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict, voiceScoped });
40586
40822
  if (!(auth.audience === "team_room" && auth.tool_grants.length > 0)) return auth;
40587
40823
  if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
40588
40824
  writeServiceLog2("warn", "room_grant.untracked");
@@ -40597,17 +40833,22 @@ var BridgeAgent = class _BridgeAgent {
40597
40833
  * guess. The returned object and grant array are frozen before they cross the
40598
40834
  * daemon boundary so one consumer cannot widen a later execution check.
40599
40835
  *
40600
- * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
40601
- * the kill switch is on, the channel is a DM, and EITHER
40836
+ * Grants (architect ruling 2026-10-02; P-5 and G-1, spec §9): an owner-DM
40837
+ * constant only when the kill switch is on, the channel is a DM, and EITHER
40602
40838
  * (a) audience is owner_dm AND the sender is the channel's pinned DM
40603
40839
  * counterpart (`channelOwner`, never "anomalous"). A server-marked
40604
40840
  * `is_agent_owner` on a member who is NOT the DM's single human
40605
40841
  * counterpart gets nothing; on the counterpart it is trusted (see
40606
- * LIMIT below); OR
40842
+ * LIMIT below). OWNER_DM_DEFAULT_READ_GRANTS on a typed turn;
40843
+ * RELAYED_VOICE_GRANTS and no memory_write when `opts.voiceScoped`
40844
+ * (G-1: the ingest predicate isVoiceScopedTurn, spec §1); OR
40607
40845
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
40608
40846
  * relayed `speaker_identity_id` (passed by the caller only for relay
40609
40847
  * turns) is that same pinned owner, whose roster row is the agent's
40610
- * owner. A speaker claim from any non-relay sender gets nothing.
40848
+ * owner: RELAYED_VOICE_GRANTS. A speaker claim from any non-relay
40849
+ * sender gets nothing.
40850
+ * RELAYED_VOICE_GRANTS is never manifest-eligible: effectiveToolGrants gives
40851
+ * the constant by reference, or the fallback when a good manifest is held.
40611
40852
  * Both need the roster fetched at `heldEpoch` (the agent's current MLS
40612
40853
  * epoch for the channel), or the DM marked epoch-fresh at this ingest
40613
40854
  * cycle's refresh (audit M-B4).
@@ -40651,9 +40892,11 @@ var BridgeAgent = class _BridgeAgent {
40651
40892
  const ownerDirect = audience === "owner_dm" && senderId === ownerId;
40652
40893
  const owner = roster?.get(ownerId);
40653
40894
  const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
40654
- if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
40895
+ if (ownerDirect) tool_grants = opts.voiceScoped === true ? RELAYED_VOICE_GRANTS : OWNER_DM_DEFAULT_READ_GRANTS;
40896
+ if (ownerRelayed) tool_grants = RELAYED_VOICE_GRANTS;
40655
40897
  if (ownerRelayed) relaySpeaker = "owner";
40656
- if (ownerDirect && relaySpeakerId === void 0 && (this.cfg.memoryProvider ?? "securecoms") === "securecoms" && // Final review M-4: the live payload flag too, so with notes off the
40898
+ if (ownerDirect && relaySpeakerId === void 0 && // G-1: never on a voice-scoped turn.
40899
+ opts.voiceScoped !== true && (this.cfg.memoryProvider ?? "securecoms") === "securecoms" && // Final review M-4: the live payload flag too, so with notes off the
40657
40900
  // runtime does not offer memory_note at all. Not memoryConfigConfirmed:
40658
40901
  // a turn the boot drain pulls before the boot refetch keeps its mark,
40659
40902
  // and the read and write gates (notesProviderOn) hold it until then.
@@ -40670,11 +40913,11 @@ var BridgeAgent = class _BridgeAgent {
40670
40913
  const binding = this.manifestBinding;
40671
40914
  const boundNow = binding !== null && binding.state === "bound" && binding.counter === this.toolGrantCounter;
40672
40915
  const signedTargets = boundNow ? binding.signedTargets : { endpoints: 0, databases: 0 };
40673
- if (tool_grants === OWNER_DM_DEFAULT_READ_GRANTS) {
40674
- const eligible = relaySpeakerId === void 0 && opts.ownerVerdict?.kind === "verified" && manifest?.owner_dm_channel_id === channelId && boundNow && this.manifestConsumer !== null;
40916
+ if (tool_grants === OWNER_DM_DEFAULT_READ_GRANTS || tool_grants === RELAYED_VOICE_GRANTS) {
40917
+ const eligible = relaySpeakerId === void 0 && opts.ownerVerdict?.kind === "verified" && manifest?.owner_dm_channel_id === channelId && boundNow && this.manifestConsumer !== null && tool_grants !== RELAYED_VOICE_GRANTS;
40675
40918
  const r = effectiveToolGrants({
40676
40919
  audience: "owner_dm",
40677
- base: OWNER_DM_DEFAULT_READ_GRANTS,
40920
+ base: tool_grants,
40678
40921
  manifest,
40679
40922
  eligible,
40680
40923
  manifestSwitchOn: this.ownerDmManifestGrants,
@@ -41396,6 +41639,7 @@ var BridgeAgent = class _BridgeAgent {
41396
41639
  const acked = [];
41397
41640
  for (const m of entries) {
41398
41641
  try {
41642
+ if (m.voiceMode === false) this.callBuffer.reset(m.channelId);
41399
41643
  const encId = m.envelope.enclave_id;
41400
41644
  if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
41401
41645
  this.channelEnclaveRecorded.add(m.channelId);
@@ -41553,6 +41797,7 @@ var BridgeAgent = class _BridgeAgent {
41553
41797
  continue;
41554
41798
  }
41555
41799
  if (typeof parsed.text === "string") {
41800
+ if (parsed.text.trim() === VOICE_CALL_START_MARKER) this.callBuffer.reset(m.channelId);
41556
41801
  const meta = this.freshMeta(m.channelId);
41557
41802
  const senderType = m.envelope.sender_type;
41558
41803
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
@@ -41594,23 +41839,39 @@ var BridgeAgent = class _BridgeAgent {
41594
41839
  const senderName = this.freshRoster(m.channelId)?.get(
41595
41840
  m.envelope.sender_id
41596
41841
  )?.name;
41842
+ const voiceScoped = isVoiceScopedTurn({
41843
+ speaker,
41844
+ transcribed: parsed.transcribed,
41845
+ senderIsRelay: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.is_relay === true,
41846
+ text: parsed.text
41847
+ });
41597
41848
  const ownerVerdict = await this.verifyOwnerTurn(m);
41598
41849
  const turnAuthorization = await this.authorizeTurn(
41599
41850
  m.channelId,
41600
41851
  m.messageId,
41601
41852
  m.envelope.sender_id,
41602
41853
  ownerVerdict,
41603
- speaker?.identityId
41854
+ speaker?.identityId,
41855
+ voiceScoped
41604
41856
  );
41605
41857
  await this.prepareNotesForTurn(m.channelId, turnAuthorization);
41606
- const runtimeMemory = this.memoryForTurn(m.channelId, m.messageId, turnAuthorization);
41607
41858
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
41608
41859
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
41609
- const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
41860
+ const fastPathEligible = Boolean(voiceScoped && m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
41610
41861
  const turnText = parsed.text;
41611
41862
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
41612
41863
  const windowEligible = voiceWindowEligible({ voiceMode: m.voiceMode, speaker, transcribed: parsed.transcribed, voiceGuidance, isCallStart });
41613
41864
  const coalescible = this.voiceCoalesceWindowMs > 0 && windowEligible;
41865
+ const callPeer = admitsToCallBuffer({ speaker, transcribed: parsed.transcribed });
41866
+ let voiceSnapshot;
41867
+ if (voiceScoped) {
41868
+ this.callBuffer.markVoiceScoped(m.channelId, m.messageId);
41869
+ if (!coalescible) {
41870
+ voiceSnapshot = this.takeVoiceSnapshot(m.channelId, m.messageId);
41871
+ if (callPeer && !this.isOwnerVerifyFailed(m.messageId)) this.callBuffer.appendPeer(m.channelId, parsed.text, m.messageId);
41872
+ }
41873
+ }
41874
+ const runtimeMemory = this.memoryForTurn(m.channelId, m.messageId, turnAuthorization, void 0, voiceScoped ? { snapshot: voiceSnapshot } : void 0);
41614
41875
  if (!coalescible) {
41615
41876
  const kind2 = m.voiceMode === true || parsed.transcribed === true ? "voice" : "text";
41616
41877
  if (this.unifiedOn) await this.prepareUnifiedForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization, kind: kind2 });
@@ -41626,7 +41887,9 @@ var BridgeAgent = class _BridgeAgent {
41626
41887
  const fastGreeting = isCallStart && fastPathFiring;
41627
41888
  const runtimeVoice = (ackSent) => m.voiceMode ? {
41628
41889
  voice: true,
41629
- guidance: ackSent ? `${voiceGuidance} ${FAST_PATH_SPOKEN_GUIDANCE}` : voiceGuidance
41890
+ // Task 6 (OQ-3): a voice-scoped frame's guidance ends with VOICE_CALL_CLAUSE, on all three frames
41891
+ // (held, coalesced after-ack, after-ack); a typed turn during a call keeps its guidance.
41892
+ guidance: `${ackSent ? `${voiceGuidance} ${FAST_PATH_SPOKEN_GUIDANCE}` : voiceGuidance}${voiceScoped ? ` ${VOICE_CALL_CLAUSE}` : ""}`
41630
41893
  } : {};
41631
41894
  const runtimeEntry = (ackSent, memory) => ({
41632
41895
  channel_id: m.channelId,
@@ -41647,6 +41910,7 @@ var BridgeAgent = class _BridgeAgent {
41647
41910
  // identify: the envelope's own claim (relay or on-device
41648
41911
  // transcript) or a relay-attributed turn.
41649
41912
  ...parsed.transcribed === true || speaker !== void 0 ? { transcribed: true } : {},
41913
+ ...voiceScoped ? { voice_scoped: true } : {},
41650
41914
  ...runtimeVoice(ackSent)
41651
41915
  });
41652
41916
  if (coalescible) {
@@ -41676,6 +41940,7 @@ var BridgeAgent = class _BridgeAgent {
41676
41940
  acked.push(m.queueId);
41677
41941
  continue;
41678
41942
  }
41943
+ const bufferGeneration = this.callBuffer.generation(m.channelId);
41679
41944
  const held = runtimeEntry(false, await runtimeMemory);
41680
41945
  if (!await this.stageHeldRuntimeMessage(held)) continue;
41681
41946
  this.noteVoiceCoalesced(m.messageId);
@@ -41686,7 +41951,8 @@ var BridgeAgent = class _BridgeAgent {
41686
41951
  closed: false,
41687
41952
  pending: Promise.resolve(),
41688
41953
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
41689
- itemCapture: itemCaptureAtReceipt.get(m.channelId)
41954
+ itemCapture: itemCaptureAtReceipt.get(m.channelId),
41955
+ bufferGeneration
41690
41956
  };
41691
41957
  this.voiceCoalescing.set(windowKey, win);
41692
41958
  const openedAt = Date.now();
@@ -41702,9 +41968,18 @@ var BridgeAgent = class _BridgeAgent {
41702
41968
  const [firstId, ...tailIds] = win.ids;
41703
41969
  if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
41704
41970
  const text = win.parts.join(" ");
41971
+ const sameCall = this.callBuffer.generation(channelId) === win.bufferGeneration;
41972
+ const anyFragmentVerifyFailed = win.ids.some((id) => this.isOwnerVerifyFailed(id));
41973
+ let windowSnapshot;
41974
+ if (sameCall) {
41975
+ this.callBuffer.markVoiceScoped(channelId, firstId);
41976
+ windowSnapshot = this.takeVoiceSnapshot(channelId, firstId);
41977
+ }
41978
+ if (callPeer && sameCall && !anyFragmentVerifyFailed) this.callBuffer.appendPeer(channelId, text, firstId);
41705
41979
  if (this.unifiedOn) await this.prepareUnifiedForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
41706
41980
  else await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
41707
- const merged = { ...held, text };
41981
+ const windowMemory = await this.memoryForTurn(channelId, firstId, turnAuthorization, void 0, { snapshot: anyFragmentVerifyFailed ? void 0 : windowSnapshot });
41982
+ const merged = { ...held, text, memory: windowMemory };
41708
41983
  if (this.unifiedOn) {
41709
41984
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
41710
41985
  const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
@@ -41732,7 +42007,7 @@ var BridgeAgent = class _BridgeAgent {
41732
42007
  const release = this.reserveFastReply(channelId);
41733
42008
  try {
41734
42009
  await this.speakAndSettle(
41735
- { channelId, messageId: firstId, senderId, authorization: turnAuthorization },
42010
+ { channelId, messageId: firstId, senderId, authorization: turnAuthorization, voiceScoped: voiceScoped && sameCall, voiceSnapshot: windowSnapshot, anyFragmentVerifyFailed },
41736
42011
  plan,
41737
42012
  merged,
41738
42013
  { ...merged, ...runtimeVoice(true) },
@@ -41764,7 +42039,9 @@ var BridgeAgent = class _BridgeAgent {
41764
42039
  channelId: m.channelId,
41765
42040
  messageId: m.messageId,
41766
42041
  senderId: m.envelope.sender_id,
41767
- authorization: turnAuthorization
42042
+ authorization: turnAuthorization,
42043
+ voiceScoped,
42044
+ voiceSnapshot
41768
42045
  };
41769
42046
  const guidanceForFast = voiceGuidance;
41770
42047
  const greeting = fastGreeting;
@@ -44033,10 +44310,13 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
44033
44310
  sendJson(sock, wsErrorFrame("invalid_request"));
44034
44311
  return;
44035
44312
  }
44313
+ const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
44314
+ const callBufferReply = session.consumer === "runtime" && session.perToolGrants === true && session.harnessClaimed !== true && typeof m.in_reply_to === "string" && record !== void 0 && session.deliveryToken.get(m.in_reply_to) !== void 0 && record.channel_id === m.channel_id && !agent.isRuntimeMessageRetired(m.in_reply_to);
44036
44315
  const sendOpts = {
44037
44316
  agentTurn: isAgentTurn(agent, m),
44038
44317
  ...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
44039
- ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {}
44318
+ ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
44319
+ ...callBufferReply ? { callBufferReply: true } : {}
44040
44320
  };
44041
44321
  if (m.a2a && typeof m.a2a === "object") {
44042
44322
  const a = m.a2a;