@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/bin.js CHANGED
@@ -7742,7 +7742,9 @@ function settingsToVoiceConfig(s, proxy) {
7742
7742
  model: "securecoms-fast",
7743
7743
  apiKey: proxy.apiKey,
7744
7744
  timeoutMs: 2500,
7745
- maxTokens: 80
7745
+ maxTokens: 80,
7746
+ // Spec 7.2: tells the proxy this bridge scopes voice turns. Sent to the SecureComs proxy only.
7747
+ headers: { "x-securecoms-bridge-caps": "voice-scope-v1" }
7746
7748
  },
7747
7749
  settings: s
7748
7750
  };
@@ -7750,6 +7752,7 @@ function settingsToVoiceConfig(s, proxy) {
7750
7752
 
7751
7753
  // src/voice/persona.ts
7752
7754
  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.";
7755
+ 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.";
7753
7756
  var schema = z2.object({
7754
7757
  persona: z2.object({
7755
7758
  display_name: z2.string().min(1).optional(),
@@ -7762,6 +7765,8 @@ var schema = z2.object({
7762
7765
  model: z2.string().min(1),
7763
7766
  api_key: z2.string().min(1).optional(),
7764
7767
  api_key_env: z2.string().min(1).optional(),
7768
+ // E.2: only the exact string "direct" opts in; any other value is ignored (today's proxy behavior), never an error.
7769
+ route: z2.unknown().optional(),
7765
7770
  timeout_ms: z2.number().int().min(500).max(1e4).default(2500),
7766
7771
  max_tokens: z2.number().int().min(16).max(400).default(80)
7767
7772
  })
@@ -7786,7 +7791,8 @@ function parseVoiceConfig(raw, env) {
7786
7791
  model: v.fast_path.model,
7787
7792
  apiKey,
7788
7793
  timeoutMs: v.fast_path.timeout_ms,
7789
- maxTokens: v.fast_path.max_tokens
7794
+ maxTokens: v.fast_path.max_tokens,
7795
+ ...v.fast_path.route === "direct" ? { route: "direct" } : {}
7790
7796
  }
7791
7797
  };
7792
7798
  }
@@ -34056,7 +34062,8 @@ var FastLlmClient = class {
34056
34062
  try {
34057
34063
  res = await this.fetchImpl(`${this.cfg.baseUrl}${this.cfg.path ?? "/chat/completions"}`, {
34058
34064
  method: "POST",
34059
- headers: { "content-type": "application/json", ...this.key ? { authorization: `Bearer ${this.key}` } : {} },
34065
+ // Final fix T8: configured headers first, so they can never displace content-type or the bearer.
34066
+ headers: { ...this.cfg.headers, "content-type": "application/json", ...this.key ? { authorization: `Bearer ${this.key}` } : {} },
34060
34067
  body: JSON.stringify({ model: this.cfg.model, messages, max_tokens: this.cfg.maxTokens, temperature: 0.4 }),
34061
34068
  signal: ac.signal
34062
34069
  });
@@ -34160,7 +34167,7 @@ function buildFastMessages(args2) {
34160
34167
  const sp = systemPrompt(args2.persona, args2.settings, args2.agentName);
34161
34168
  const head = sp.slice(0, sp.length - CONTINUITY_CLAUSE.length).trimEnd();
34162
34169
  const notesLine = args2.notesDeferLine ? ` ${FAST_NOTES_DEFER_LINE}` : "";
34163
- const system = `${head} ${args2.guidance.trim()}${notesLine} ${FAST_DEFER_RULE} ${CONTINUITY_CLAUSE}`;
34170
+ const system = `${head} ${args2.guidance.trim()}${notesLine} ${FAST_DEFER_RULE} ${VOICE_CALL_CLAUSE}`;
34164
34171
  const recent = args2.memory.recent.map((t) => ({
34165
34172
  role: t.role === "agent" ? "assistant" : "user",
34166
34173
  content: t.text
@@ -34197,6 +34204,160 @@ function normalizeFastReply(raw, maxChars = 200, maxSentences = 2) {
34197
34204
  return sentences.slice(0, maxSentences).join(" ").slice(0, maxChars);
34198
34205
  }
34199
34206
 
34207
+ // src/voice/voice-scope.ts
34208
+ function isVoiceScopedTurn(t) {
34209
+ return t.speaker !== void 0 || t.transcribed === true || t.senderIsRelay || t.text.trim() === VOICE_CALL_START_MARKER;
34210
+ }
34211
+ function admitsToCallBuffer(t) {
34212
+ return t.speaker !== void 0 || t.transcribed === true;
34213
+ }
34214
+
34215
+ // src/voice/call-buffer.ts
34216
+ var CALL_BUFFER_CAP = 50;
34217
+ var CALL_BUFFER_IDLE_MS = 30 * 6e4;
34218
+ var CALL_BUFFER_ID_CAP = 5e3;
34219
+ var CallBuffer = class {
34220
+ constructor(opts) {
34221
+ this.opts = opts;
34222
+ }
34223
+ channels = /* @__PURE__ */ new Map();
34224
+ /** messageId -> channelId, insertion ordered (oldest first) for the cap. */
34225
+ voiceIds = /* @__PURE__ */ new Map();
34226
+ /** messageId -> { channelId, the channel's bufSeq at the mark, the channel's generation then }. */
34227
+ seqNotes = /* @__PURE__ */ new Map();
34228
+ /**
34229
+ * Final fix I-1: messageId -> { channelId, the channel's bufSeq just before the turn's own peer entry }. A row the
34230
+ * backend redelivers (a failed ack, runtime_inbox.full backpressure) is ingested again; its id here means its text is
34231
+ * already in the call, so it is not appended again and its snapshot stops before its own entry (A-6).
34232
+ */
34233
+ admitted = /* @__PURE__ */ new Map();
34234
+ /** The last bufSeq handed out per channel (kept across resets, so the sequence never runs backwards). */
34235
+ lastSeq = /* @__PURE__ */ new Map();
34236
+ nextSeq = 1;
34237
+ /** Per-channel reset generation (advanced by every reset, the idle rule included). */
34238
+ gens = /* @__PURE__ */ new Map();
34239
+ /**
34240
+ * Append a spoken turn's text, once per turn id (final fix I-1). Returns false (and appends nothing) when the id was
34241
+ * already admitted in this call, or when the buffer is disabled.
34242
+ */
34243
+ appendPeer(channelId, text, messageId) {
34244
+ if (!this.opts.enabled) return false;
34245
+ if (this.admittedAt(messageId) !== void 0) return false;
34246
+ const before = this.seqAt(channelId);
34247
+ this.append(channelId, { role: "peer", text });
34248
+ this.admitted.set(messageId, { channelId, before });
34249
+ if (this.admitted.size > CALL_BUFFER_ID_CAP) this.admitted.delete(this.admitted.keys().next().value);
34250
+ return true;
34251
+ }
34252
+ /**
34253
+ * Final fix I-1: for a turn already admitted in this call, the channel's bufSeq just before its own entry (the end seq
34254
+ * of its first snapshot); undefined for a turn not admitted (or forgotten by a reset, the idle rule or the id cap).
34255
+ */
34256
+ admittedAt(messageId) {
34257
+ const a = this.admitted.get(messageId);
34258
+ if (a === void 0) return void 0;
34259
+ this.expireIfIdle(a.channelId);
34260
+ return this.admitted.get(messageId)?.before;
34261
+ }
34262
+ appendAgent(channelId, text, origin) {
34263
+ this.append(channelId, { role: "agent", text, origin });
34264
+ }
34265
+ /**
34266
+ * Record a voice-scoped turn's id (for reply admission only, A-4) and note the channel's seq at this point. Final fix
34267
+ * I-1: a turn already admitted (a redelivered row) keeps the seq of its first admission, so the note never moves past
34268
+ * the turn's own entry.
34269
+ */
34270
+ markVoiceScoped(channelId, messageId) {
34271
+ if (!this.opts.enabled) return;
34272
+ this.expireIfIdle(channelId);
34273
+ this.voiceIds.delete(messageId);
34274
+ this.voiceIds.set(messageId, channelId);
34275
+ if (this.voiceIds.size > CALL_BUFFER_ID_CAP) this.voiceIds.delete(this.voiceIds.keys().next().value);
34276
+ const seq = this.admittedAt(messageId) ?? this.seqAt(channelId);
34277
+ this.seqNotes.delete(messageId);
34278
+ this.seqNotes.set(messageId, { channelId, seq, generation: this.generation(channelId) });
34279
+ if (this.seqNotes.size > CALL_BUFFER_ID_CAP) this.seqNotes.delete(this.seqNotes.keys().next().value);
34280
+ }
34281
+ /** A-4: read ONLY by the bridge's `afterPublish` reply admission. */
34282
+ isVoiceScopedId(messageId) {
34283
+ const channelId = this.voiceIds.get(messageId);
34284
+ if (channelId === void 0) return false;
34285
+ this.expireIfIdle(channelId);
34286
+ return this.voiceIds.has(messageId);
34287
+ }
34288
+ /** The channel's mark noted when `messageId` was marked (the release re-read), or undefined (e.g. after a restart). */
34289
+ seqNoted(messageId) {
34290
+ const note = this.seqNotes.get(messageId);
34291
+ if (note === void 0) return void 0;
34292
+ this.expireIfIdle(note.channelId);
34293
+ const kept = this.seqNotes.get(messageId);
34294
+ return kept === void 0 ? void 0 : { seq: kept.seq, generation: kept.generation };
34295
+ }
34296
+ /** The bufSeq of the channel's newest entry (0 before any append). A snapshot's end seq. */
34297
+ seqAt(channelId) {
34298
+ this.expireIfIdle(channelId);
34299
+ return this.lastSeq.get(channelId) ?? 0;
34300
+ }
34301
+ /**
34302
+ * The channel's last `limit` entries, oldest first. With `upTo` (final fix I-1, a redelivered turn's re-snapshot),
34303
+ * only entries at or below that bufSeq.
34304
+ */
34305
+ recent(channelId, limit, upTo) {
34306
+ if (limit <= 0) return [];
34307
+ this.expireIfIdle(channelId);
34308
+ const all = this.channels.get(channelId)?.entries ?? [];
34309
+ const entries = upTo === void 0 ? all : all.filter((e) => e.bufSeq <= upTo);
34310
+ return entries.slice(-limit).map(deliver);
34311
+ }
34312
+ /**
34313
+ * The channel's AGENT entries appended after `since.seq` (lines spoken or answered since a snapshot), oldest first.
34314
+ * Final fix M-4: nothing when the channel has been reset since the mark (a later call's lines).
34315
+ */
34316
+ agentAfter(channelId, since) {
34317
+ if (this.generation(channelId) !== since.generation) return [];
34318
+ const entries = this.channels.get(channelId)?.entries ?? [];
34319
+ return entries.filter((e) => e.role === "agent" && e.bufSeq > since.seq).map(deliver);
34320
+ }
34321
+ /**
34322
+ * The channel's reset generation: it changes on every reset (explicit or idle) and on nothing else. A caller that
34323
+ * holds text across an await (the coalesce window) compares it to skip an append into a call that has since ended.
34324
+ */
34325
+ generation(channelId) {
34326
+ this.expireIfIdle(channelId);
34327
+ return this.gens.get(channelId) ?? 0;
34328
+ }
34329
+ /** Clear the channel: its entries, its voice-scoped ids, its seq notes and its admitted ids. Only ever removes. */
34330
+ reset(channelId) {
34331
+ this.gens.set(channelId, (this.gens.get(channelId) ?? 0) + 1);
34332
+ this.channels.delete(channelId);
34333
+ for (const [id, c] of this.voiceIds) if (c === channelId) this.voiceIds.delete(id);
34334
+ for (const [id, n] of this.seqNotes) if (n.channelId === channelId) this.seqNotes.delete(id);
34335
+ for (const [id, a] of this.admitted) if (a.channelId === channelId) this.admitted.delete(id);
34336
+ }
34337
+ append(channelId, e) {
34338
+ if (!this.opts.enabled) return;
34339
+ this.expireIfIdle(channelId);
34340
+ let ch = this.channels.get(channelId);
34341
+ if (ch === void 0) {
34342
+ ch = { entries: [], lastAppend: 0 };
34343
+ this.channels.set(channelId, ch);
34344
+ }
34345
+ const bufSeq = this.nextSeq++;
34346
+ this.lastSeq.set(channelId, bufSeq);
34347
+ ch.entries.push({ ...e, bufSeq });
34348
+ if (ch.entries.length > this.opts.cap) ch.entries.splice(0, ch.entries.length - this.opts.cap);
34349
+ ch.lastAppend = this.opts.now();
34350
+ }
34351
+ /** Reset (c): more than `idleMs` since the channel's last append clears it, checked on every read and append. */
34352
+ expireIfIdle(channelId) {
34353
+ const ch = this.channels.get(channelId);
34354
+ if (ch !== void 0 && this.opts.now() - ch.lastAppend > this.opts.idleMs) this.reset(channelId);
34355
+ }
34356
+ };
34357
+ function deliver(e) {
34358
+ return { role: e.role, text: e.text, seq: 0, ...e.origin ? { origin: e.origin } : {} };
34359
+ }
34360
+
34200
34361
  // src/vault.ts
34201
34362
  var VaultIntegrityError = class extends Error {
34202
34363
  constructor(message, cause) {
@@ -34504,6 +34665,7 @@ function lineHash(line) {
34504
34665
  return createHash5("sha256").update(line.trim(), "utf8").digest("hex");
34505
34666
  }
34506
34667
  var LOCAL_REPORT_FILE = "local-config.json";
34668
+ var LOCAL_PERSONA_FILE_NAMES = ["IDENTITY.md", "SOUL.md", "AGENTS.md"];
34507
34669
  var SECRET_PATH_COUNT_MAX = 1e4;
34508
34670
  var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
34509
34671
  function parseSecretPathsReport(raw) {
@@ -34585,18 +34747,23 @@ function readLocalReport(home, identityId) {
34585
34747
  const hasSearchKey = doc.has_search_key === true;
34586
34748
  const hasLlmKey = doc.has_llm_key === true;
34587
34749
  const raw = doc.persona;
34588
- const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.fromEntries(
34589
- Object.entries(raw).filter(([, v]) => typeof v === "string")
34590
- ) : {};
34750
+ const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
34751
+ const personaFiles = [];
34752
+ for (const name of LOCAL_PERSONA_FILE_NAMES) {
34753
+ const text = persona[name];
34754
+ if (typeof text === "string" && text.trim() !== "") {
34755
+ personaFiles.push({ name, bytes: Buffer.byteLength(text, "utf8") });
34756
+ }
34757
+ }
34591
34758
  const capRaw = doc.owner_dm_search_cap_effective;
34592
34759
  const ownerDmCap = typeof capRaw === "number" && Number.isInteger(capRaw) && capRaw >= 0 && capRaw <= OWNER_DM_SEARCH_DAILY_CAP_MAX ? capRaw : void 0;
34593
34760
  return {
34594
- persona,
34761
+ local_persona_files: personaFiles,
34595
34762
  has_search_key: hasSearchKey,
34596
34763
  has_llm_key: hasLlmKey,
34597
34764
  has_extract_provider: doc.has_extract_provider === true,
34598
34765
  ...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
34599
- legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
34766
+ legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
34600
34767
  secret_paths: parseSecretPathsReport(doc.secret_paths)
34601
34768
  };
34602
34769
  }
@@ -34681,16 +34848,6 @@ async function applyLegacyMemoryFactDecisions(home, identityId, rawDecisions, re
34681
34848
  }
34682
34849
  return { sourcesDeleted, factsAssigned };
34683
34850
  }
34684
- function readManagedMemory(home, identityId) {
34685
- const p = join18(runtimeConfigDir(home, identityId), MEMORY_FILE);
34686
- if (!existsSync4(p)) return null;
34687
- try {
34688
- const body = readFileSync10(p, "utf8").trim();
34689
- return body || null;
34690
- } catch {
34691
- return null;
34692
- }
34693
- }
34694
34851
  function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
34695
34852
  const dir = runtimeConfigDir(home, identityId);
34696
34853
  const path = join18(dir, MEMORY_FILE);
@@ -35397,6 +35554,10 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
35397
35554
  var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
35398
35555
  Object.freeze({ tool: "current_time", mode: "read" })
35399
35556
  ]);
35557
+ var RELAYED_VOICE_GRANTS = Object.freeze([
35558
+ Object.freeze({ tool: "current_time", mode: "read" }),
35559
+ Object.freeze({ tool: "web_search", mode: "read" })
35560
+ ]);
35400
35561
  var NO_TOOL_GRANTS = Object.freeze([]);
35401
35562
  var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
35402
35563
  var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
@@ -35756,6 +35917,12 @@ var BridgeAgent = class _BridgeAgent {
35756
35917
  workspaceDir: this.cfg.memoryWorkspaceDir,
35757
35918
  factsCharBudget: this.cfg.memoryFactsCharBudget
35758
35919
  });
35920
+ this.callBuffer = new CallBuffer({
35921
+ cap: CALL_BUFFER_CAP,
35922
+ idleMs: CALL_BUFFER_IDLE_MS,
35923
+ now: () => Date.now(),
35924
+ enabled: (this.cfg.memoryProvider ?? "securecoms") !== "none"
35925
+ });
35759
35926
  this.provider = new MlsGroupKeyProvider({
35760
35927
  store: this.store,
35761
35928
  backend: this.backend,
@@ -35889,6 +36056,9 @@ var BridgeAgent = class _BridgeAgent {
35889
36056
  this.injectedFastLlm = !!deps.fastLlm;
35890
36057
  this.fileVoiceConfig = cfg.voice;
35891
36058
  this.fastLlm = deps.fastLlm ?? (cfg.voice?.fastPath.enabled ? new FastLlmClient(cfg.voice.fastPath, this.fetchImpl) : void 0);
36059
+ if (this.fileVoiceConfig?.fastPath.route === "direct" && !this.fileVoiceConfig.fastPath.apiKey) {
36060
+ writeServiceLog("warn", "voice.fast_path_direct_no_key");
36061
+ }
35892
36062
  this.voiceSettingsSource = cfg.voice ? "file" : "none";
35893
36063
  this.latency = deps.latency ?? new LatencyRecorder();
35894
36064
  this.voiceCoalesceWindowMs = Math.max(0, deps.voiceCoalesceWindowMs ?? voiceCoalesceWindowFromEnv());
@@ -36579,6 +36749,11 @@ var BridgeAgent = class _BridgeAgent {
36579
36749
  startedAt = 0;
36580
36750
  store;
36581
36751
  memory;
36752
+ /**
36753
+ * Voice fast-path privacy, Task 4b (spec §2.1, OQ-1 ruled): the current calls' spoken turns, in process only. The
36754
+ * ONLY history a voice-scoped turn sees; read through voiceScopedHistory, once per turn, at ingest.
36755
+ */
36756
+ callBuffer;
36582
36757
  /**
36583
36758
  * Markdown memory 7a: the one owner of every note mutation and of the
36584
36759
  * delivery cache (spec §6.3, §7.2). One per store, so one per identity.
@@ -36683,6 +36858,7 @@ var BridgeAgent = class _BridgeAgent {
36683
36858
  const s = parseVoiceSettings(await res.json());
36684
36859
  if (s.identityDisplayName) this.lastOwnDisplayName = s.identityDisplayName;
36685
36860
  const proxy = settingsToVoiceConfig(s, { baseUrl: this.cfg.apiBase, apiKey: this.cfg.apiKey });
36861
+ const direct = this.fileVoiceConfig?.fastPath.route === "direct" ? this.fileVoiceConfig.fastPath : void 0;
36686
36862
  if (s.version === 0 && this.fileVoiceConfig) {
36687
36863
  const merged = {
36688
36864
  ...s,
@@ -36691,16 +36867,16 @@ var BridgeAgent = class _BridgeAgent {
36691
36867
  quirks: this.fileVoiceConfig.persona.quirks
36692
36868
  };
36693
36869
  this.voiceSettings = merged;
36694
- this.cfg.voice = { persona: this.fileVoiceConfig.persona, fastPath: proxy.fastPath, settings: merged };
36870
+ this.cfg.voice = { persona: this.fileVoiceConfig.persona, fastPath: direct ?? proxy.fastPath, settings: merged };
36695
36871
  this.voiceSettingsVersion = 0;
36696
36872
  this.voiceSettingsSource = "file";
36697
36873
  } else {
36698
36874
  this.voiceSettings = s;
36699
- this.cfg.voice = proxy;
36875
+ this.cfg.voice = direct ? { ...proxy, fastPath: direct } : proxy;
36700
36876
  this.voiceSettingsVersion = s.version;
36701
36877
  this.voiceSettingsSource = "backend";
36702
36878
  }
36703
- if (!this.injectedFastLlm) {
36879
+ if (!this.injectedFastLlm && !direct) {
36704
36880
  this.fastLlm = new FastLlmClient(this.cfg.voice.fastPath, this.fetchImpl);
36705
36881
  }
36706
36882
  writeServiceLog("info", "voice.settings_loaded", {
@@ -36929,12 +37105,10 @@ var BridgeAgent = class _BridgeAgent {
36929
37105
  const report = {
36930
37106
  applied_version: version,
36931
37107
  paths: checkConfiguredPaths(skillsJson),
36932
- // Read AFTER the prunes above, so the owner's next view of memory is
36933
- // the file as it now stands, not the one they just pruned from.
36934
- memory: readManagedMemory(this.home, this.cfg.identityId),
36935
- // E13: pass through what the runtime reported. The daemon does not
36936
- // interpret any of it.
36937
- local_persona: local?.persona ?? null,
37108
+ // F-0 (G-5): the report carries no content. No MEMORY.md text, no persona text, no legacy
37109
+ // text. E13 needs only to know which of the runtime's own persona files hold something, so
37110
+ // this is [{name, bytes}] for each non-blank file and nothing else.
37111
+ local_persona_files: local?.local_persona_files ?? [],
36938
37112
  // Only the runtime knows whether a search key file is configured — it
36939
37113
  // is an environment value on that process, invisible to the daemon and
36940
37114
  // to the backend. The backend needs it to generate a truthful tool
@@ -36967,9 +37141,9 @@ var BridgeAgent = class _BridgeAgent {
36967
37141
  ...this.reportedClearGeneration(),
36968
37142
  // 7b spec §9.2 (Task 19): capability versus state. Read-only: the report never starts the embedder.
36969
37143
  ...this.reportedRecall(),
36970
- // Inert legacy files only. `readLocalReport` revalidates the current
36971
- // regular-file bytes and strips every local path before this leaves the
36972
- // machine.
37144
+ // Inert legacy files only, metadata only. `readLocalReport` revalidates the current
37145
+ // regular-file bytes and strips every local path and every `content` before this
37146
+ // leaves the machine.
36973
37147
  legacy_memory: local?.legacy_memory ?? [],
36974
37148
  // §B.4: counts of server-named secret paths inside / allowlisted /
36975
37149
  // outside the secrets directory, and the runtime's mode. Numbers and
@@ -38226,6 +38400,9 @@ var BridgeAgent = class _BridgeAgent {
38226
38400
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
38227
38401
  const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
38228
38402
  if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
38403
+ if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(body.text) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
38404
+ this.callBuffer.appendAgent(channelId, body.text, "reply");
38405
+ }
38229
38406
  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";
38230
38407
  if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
38231
38408
  if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
@@ -38996,6 +39173,27 @@ var BridgeAgent = class _BridgeAgent {
38996
39173
  mayReadHistoryForTurn(messageId, authorization) {
38997
39174
  return !this.isOwnerVerifyFailed(messageId) && this.mayReadOpenClawMemoryForTurn(authorization);
38998
39175
  }
39176
+ /**
39177
+ * Voice fast-path privacy (spec §2.1, OQ-1 ruled): the ONLY history source of a voice-scoped turn, the call buffer.
39178
+ * Never reads the memory provider, the store, the unified store or the 7b index. A-6: called once per turn, at ingest
39179
+ * (takeVoiceSnapshot), after the reset signals and before the turn's own append.
39180
+ */
39181
+ voiceScopedHistory(channelId, limit, upTo) {
39182
+ return this.callBuffer.recent(channelId, limit, upTo);
39183
+ }
39184
+ /**
39185
+ * Task 4b (A-6): the turn's snapshot, taken BEFORE its own text is appended (audit finding 2). Final fix I-1: a turn
39186
+ * already admitted to this call (a redelivered row) is snapshotted up to just before its own entry, as it was the
39187
+ * first time, so its snapshot never contains itself.
39188
+ */
39189
+ takeVoiceSnapshot(channelId, messageId) {
39190
+ const upTo = this.callBuffer.admittedAt(messageId);
39191
+ return {
39192
+ recent: this.voiceScopedHistory(channelId, _BridgeAgent.TURN_MEMORY_LIMIT, upTo),
39193
+ endSeq: upTo ?? this.callBuffer.seqAt(channelId),
39194
+ generation: this.callBuffer.generation(channelId)
39195
+ };
39196
+ }
38999
39197
  /** An inbound turn may receive OpenClaw memory only from an active owner DM. */
39000
39198
  mayReadOpenClawMemoryForTurn(authorization) {
39001
39199
  return this.cfg.memoryProvider !== "openclaw" || authorization.sender_membership === "active" && authorization.audience === "owner_dm";
@@ -39005,16 +39203,26 @@ var BridgeAgent = class _BridgeAgent {
39005
39203
  * stays owner-DM-only; every provider failure degrades to empty context and
39006
39204
  * never falls back to the runtime's retired plaintext JSON files.
39007
39205
  */
39008
- async memoryForTurn(channelId, messageId, authorization, limit = 50) {
39206
+ async memoryForTurn(channelId, messageId, authorization, limit = 50, voice) {
39009
39207
  if (!this.mayReadHistoryForTurn(messageId, authorization)) {
39010
39208
  return { recent: [], facts: [] };
39011
39209
  }
39210
+ if (voice) return { recent: this.voiceFrameTurns(voice.snapshot?.recent ?? []), facts: [] };
39012
39211
  try {
39013
39212
  return this.fenceMemory(channelId, await this.memory.context(channelId, limit), holdsManifestGrant(authorization));
39014
39213
  } catch {
39015
39214
  return { recent: [], facts: [] };
39016
39215
  }
39017
39216
  }
39217
+ /**
39218
+ * Task 6 (spec §2.2 A-1, N1): call-buffer entries as runtime-frame turns. Under the voice fence (a pin is loaded) the
39219
+ * fast model's own lines (origin "fast_path") are dropped, as fenceMemory drops them from store history; the fast
39220
+ * prompt keeps them. The internal origin is not carried into the frame; `seq` stays 0 (A-5).
39221
+ */
39222
+ voiceFrameTurns(entries) {
39223
+ const fenced = this.voiceFenceOn();
39224
+ return entries.filter((e) => !(fenced && e.origin === "fast_path")).map((e) => ({ seq: e.seq, role: e.role, text: e.text }));
39225
+ }
39018
39226
  /**
39019
39227
  * 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
39020
39228
  * turn (manifest, fallback, greeting, relayed, constants-only alike). It never depends on a turn's grants.
@@ -39049,7 +39257,7 @@ var BridgeAgent = class _BridgeAgent {
39049
39257
  if (!strict && !ctx.recent.some((t) => t.role === "agent")) return ctx;
39050
39258
  const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
39051
39259
  const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
39052
- const meta = securecoms ? this.store.memoryLineMeta(channelId, ctx.recent.filter((t) => strict || t.role === "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
39260
+ 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();
39053
39261
  const fence = this.fenceContext(channelId);
39054
39262
  const recent = ctx.recent.filter((t) => {
39055
39263
  const m = meta.get(t.seq);
@@ -39068,7 +39276,7 @@ var BridgeAgent = class _BridgeAgent {
39068
39276
  if (recent.length === 0) return false;
39069
39277
  const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
39070
39278
  const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
39071
- const meta = securecoms ? this.store.memoryLineMeta(m.channel_id, recent.filter((t) => t.role !== "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
39279
+ 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();
39072
39280
  return recent.some((t) => t.role !== "agent" && isRelayClassPeer({ peerId: t.peerId, modality: meta.get(t.seq)?.modality }, ownerId));
39073
39281
  }
39074
39282
  /**
@@ -39364,9 +39572,18 @@ var BridgeAgent = class _BridgeAgent {
39364
39572
  writeServiceLog("warn", "memory.recall_prepare_failed", {}, e);
39365
39573
  }
39366
39574
  }
39575
+ /**
39576
+ * Voice fast-path privacy, Task 7 (spec §4): the frame-keyed delivery check. A voice-scoped frame gets no notes, no
39577
+ * recall and no unified items, whatever its authorization says. Keys on the frame's own marks, NEVER on the call
39578
+ * buffer's id set (a reset evicts ids; the staged frame must stay scoped).
39579
+ */
39580
+ frameIsVoiceScoped(m) {
39581
+ return m.voice_scoped === true || m.transcribed === true || m.speaker !== void 0;
39582
+ }
39367
39583
  /** ws flush (synchronous). Q1 to Q3 and Q6 again at delivery, on the ORIGINAL message; Q4 lives in withConsumerRecall. */
39368
39584
  recallForDelivery(m) {
39369
39585
  if (this.unifiedOn) return void 0;
39586
+ if (this.frameIsVoiceScoped(m)) return void 0;
39370
39587
  if (!this.recallProviderOn() || !this.notesReadable(m.authorization)) return void 0;
39371
39588
  return this.recall.deliverable(m.message_id, m.channel_id);
39372
39589
  }
@@ -39599,6 +39816,7 @@ var BridgeAgent = class _BridgeAgent {
39599
39816
  /** ws flush (synchronous). The predicate and the owner scope again at delivery, on the ORIGINAL message (H-4, Review Focus 3). */
39600
39817
  unifiedForDelivery(m) {
39601
39818
  if (!this.unifiedProviderOn()) return void 0;
39819
+ if (this.frameIsVoiceScoped(m)) return void 0;
39602
39820
  const a = m.authorization;
39603
39821
  if (!(a?.memory_write === true || a?.relay_speaker === "owner")) return void 0;
39604
39822
  const scopeId = this.ownerScopeNow(m.channel_id);
@@ -39834,6 +40052,7 @@ var BridgeAgent = class _BridgeAgent {
39834
40052
  notesForDelivery(m) {
39835
40053
  if (this.unifiedOn) return { generation: this.notes.appliedGeneration() };
39836
40054
  const generation = this.notes.appliedGeneration();
40055
+ if (this.frameIsVoiceScoped(m)) return { generation };
39837
40056
  if (!this.notesReadable(m.authorization)) return { generation };
39838
40057
  const d = this.notes.deliverable(m.channel_id);
39839
40058
  if (d.miss) {
@@ -40193,7 +40412,7 @@ var BridgeAgent = class _BridgeAgent {
40193
40412
  * deferring fast reply was actually published — F3).
40194
40413
  */
40195
40414
  async speakAndSettle(turn, plan, held, heldAfterAck, fastText, guidance, fastSpeaker, greeting) {
40196
- const outcome = plan.kind === "ack" ? await this.sendFastLine(turn.channelId, turn.messageId, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
40415
+ const outcome = plan.kind === "ack" ? await this.sendFastLine(turn, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
40197
40416
  if (outcome === "answered") await this.settleHeldRuntimeMessage(held, false);
40198
40417
  else await this.settleHeldRuntimeMessage(outcome === "deferred" ? heldAfterAck : held, true);
40199
40418
  }
@@ -40212,19 +40431,29 @@ var BridgeAgent = class _BridgeAgent {
40212
40431
  async settleFastReplies() {
40213
40432
  while (this.fastReplyChains.size) await Promise.all([...this.fastReplyChains.values()]);
40214
40433
  }
40215
- /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
40216
- async sendSpoken(channelId, text, messageId) {
40434
+ /**
40435
+ * Send a line the voice fast path speaks: its memory record is "voice" (H1).
40436
+ * Task 4b (spec §2.1 agent appends (1), A-1, A-2): once published, the line enters the call buffer as "fast_path",
40437
+ * unless the turn it answers failed owner verification (its id, or any fragment of a coalesced turn). A line answering
40438
+ * a turn the ingest did not find voice-scoped is not appended either (defense in depth: the fast path fires only on
40439
+ * voice-scoped turns, Task 5).
40440
+ */
40441
+ async sendSpoken(channelId, text, messageId, turn) {
40217
40442
  this.spokenReplyIds.add(messageId);
40218
40443
  try {
40219
40444
  await this.send(channelId, text, messageId);
40220
40445
  } finally {
40221
40446
  this.spokenReplyIds.delete(messageId);
40222
40447
  }
40448
+ if (turn.voiceScoped === true && turn.anyFragmentVerifyFailed !== true && !this.isOwnerVerifyFailed(turn.messageId)) {
40449
+ this.callBuffer.appendAgent(channelId, text, "fast_path");
40450
+ }
40223
40451
  }
40224
40452
  /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
40225
- async sendFastLine(channelId, messageId, line) {
40453
+ async sendFastLine(turn, line) {
40454
+ const { channelId, messageId } = turn;
40226
40455
  try {
40227
- await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
40456
+ await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId), turn);
40228
40457
  this.fastPathSent++;
40229
40458
  return true;
40230
40459
  } catch (e) {
@@ -40238,8 +40467,8 @@ var BridgeAgent = class _BridgeAgent {
40238
40467
  * the fast model's reply — or, in `ack_only`, the greeting template — is
40239
40468
  * spoken, and the outcome decides whether the runtime answers next.
40240
40469
  *
40241
- * MUST NEVER throw: every failure path (memory lookup, the LLM call,
40242
- * normalization, or the send itself) is caught, counted in
40470
+ * MUST NEVER throw: every failure path (the LLM call, normalization,
40471
+ * or the send itself) is caught, counted in
40243
40472
  * `fastPathFailed`, and logged WITHOUT the turn text or the model's reply.
40244
40473
  * The `fastReplyId(...)` send id is deterministic, so a duplicate that
40245
40474
  * slipped past the in-memory dedupe (e.g. a restart) resolves to the same
@@ -40253,20 +40482,18 @@ var BridgeAgent = class _BridgeAgent {
40253
40482
  try {
40254
40483
  if ((this.cfg.voiceFastPathMode ?? "full") === "ack_only") {
40255
40484
  if (!isGreeting) return "failed";
40256
- const ok = await this.sendFastLine(m.channelId, m.messageId, ackOnlyGreeting(this.voiceAgentName(), speaker?.displayName));
40485
+ const ok = await this.sendFastLine(m, ackOnlyGreeting(this.voiceAgentName(), speaker?.displayName));
40257
40486
  return ok ? "answered" : "failed";
40258
40487
  }
40259
40488
  const fastLlm = this.fastLlm;
40260
40489
  if (!fastLlm || !this.cfg.voice) return "failed";
40261
- let memory;
40262
- if (!this.mayReadHistoryForTurn(m.messageId, m.authorization)) {
40263
- memory = { recent: [], facts: [] };
40264
- } else {
40265
- try {
40266
- memory = await this.memory.context(m.channelId, FAST_PATH_MEMORY_TURNS);
40267
- } catch {
40268
- memory = { recent: [], facts: [] };
40269
- }
40490
+ let memory = { recent: [], facts: [] };
40491
+ const snapshot = m.voiceSnapshot;
40492
+ if (m.voiceScoped === true && snapshot !== void 0 && m.anyFragmentVerifyFailed !== true && this.mayReadHistoryForTurn(m.messageId, m.authorization)) {
40493
+ memory = {
40494
+ recent: [...snapshot.recent, ...this.callBuffer.agentAfter(m.channelId, { seq: snapshot.endSeq, generation: snapshot.generation })].slice(-FAST_PATH_MEMORY_TURNS),
40495
+ facts: []
40496
+ };
40270
40497
  }
40271
40498
  const messages = buildFastMessages({
40272
40499
  persona: this.cfg.voice.persona,
@@ -40289,7 +40516,7 @@ var BridgeAgent = class _BridgeAgent {
40289
40516
  const refusal = !isGreeting && isRefusal(split3.reply);
40290
40517
  const defer = split3.defer || refusal;
40291
40518
  const reply = refusal ? deferAck(m.messageId) : split3.reply;
40292
- await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
40519
+ await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId), m);
40293
40520
  this.fastPathSent++;
40294
40521
  if (defer) this.fastPathDeferred++;
40295
40522
  if (refusal) this.fastPathRefusalDeferred++;
@@ -40353,6 +40580,15 @@ var BridgeAgent = class _BridgeAgent {
40353
40580
  async memoryAtRelease(message) {
40354
40581
  const prior = message.memory;
40355
40582
  if (!prior || !this.mayReadHistoryForTurn(message.message_id, message.authorization)) return prior;
40583
+ if (this.frameIsVoiceScoped(message)) {
40584
+ const tails = this.voiceCoalescedTails.get(message.message_id) ?? [];
40585
+ if (tails.some((id) => this.isOwnerVerifyFailed(id))) return prior;
40586
+ const noted = this.callBuffer.seqNoted(message.message_id);
40587
+ if (noted === void 0) return prior;
40588
+ const spoken2 = this.voiceFrameTurns(this.callBuffer.agentAfter(message.channel_id, noted));
40589
+ if (spoken2.length === 0) return prior;
40590
+ return { recent: [...prior.recent, ...spoken2].slice(-_BridgeAgent.TURN_MEMORY_LIMIT), facts: [] };
40591
+ }
40356
40592
  let fresh;
40357
40593
  try {
40358
40594
  fresh = await this.memory.context(message.channel_id, _BridgeAgent.TURN_MEMORY_LIMIT);
@@ -40864,9 +41100,9 @@ var BridgeAgent = class _BridgeAgent {
40864
41100
  });
40865
41101
  }
40866
41102
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
40867
- async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId) {
41103
+ async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped) {
40868
41104
  const held = await this.heldPosition(channelId);
40869
- const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict });
41105
+ const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict, voiceScoped });
40870
41106
  if (!(auth.audience === "team_room" && auth.tool_grants.length > 0)) return auth;
40871
41107
  if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
40872
41108
  writeServiceLog("warn", "room_grant.untracked");
@@ -40881,17 +41117,22 @@ var BridgeAgent = class _BridgeAgent {
40881
41117
  * guess. The returned object and grant array are frozen before they cross the
40882
41118
  * daemon boundary so one consumer cannot widen a later execution check.
40883
41119
  *
40884
- * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
40885
- * the kill switch is on, the channel is a DM, and EITHER
41120
+ * Grants (architect ruling 2026-10-02; P-5 and G-1, spec §9): an owner-DM
41121
+ * constant only when the kill switch is on, the channel is a DM, and EITHER
40886
41122
  * (a) audience is owner_dm AND the sender is the channel's pinned DM
40887
41123
  * counterpart (`channelOwner`, never "anomalous"). A server-marked
40888
41124
  * `is_agent_owner` on a member who is NOT the DM's single human
40889
41125
  * counterpart gets nothing; on the counterpart it is trusted (see
40890
- * LIMIT below); OR
41126
+ * LIMIT below). OWNER_DM_DEFAULT_READ_GRANTS on a typed turn;
41127
+ * RELAYED_VOICE_GRANTS and no memory_write when `opts.voiceScoped`
41128
+ * (G-1: the ingest predicate isVoiceScopedTurn, spec §1); OR
40891
41129
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
40892
41130
  * relayed `speaker_identity_id` (passed by the caller only for relay
40893
41131
  * turns) is that same pinned owner, whose roster row is the agent's
40894
- * owner. A speaker claim from any non-relay sender gets nothing.
41132
+ * owner: RELAYED_VOICE_GRANTS. A speaker claim from any non-relay
41133
+ * sender gets nothing.
41134
+ * RELAYED_VOICE_GRANTS is never manifest-eligible: effectiveToolGrants gives
41135
+ * the constant by reference, or the fallback when a good manifest is held.
40895
41136
  * Both need the roster fetched at `heldEpoch` (the agent's current MLS
40896
41137
  * epoch for the channel), or the DM marked epoch-fresh at this ingest
40897
41138
  * cycle's refresh (audit M-B4).
@@ -40935,9 +41176,11 @@ var BridgeAgent = class _BridgeAgent {
40935
41176
  const ownerDirect = audience === "owner_dm" && senderId === ownerId;
40936
41177
  const owner = roster?.get(ownerId);
40937
41178
  const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
40938
- if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
41179
+ if (ownerDirect) tool_grants = opts.voiceScoped === true ? RELAYED_VOICE_GRANTS : OWNER_DM_DEFAULT_READ_GRANTS;
41180
+ if (ownerRelayed) tool_grants = RELAYED_VOICE_GRANTS;
40939
41181
  if (ownerRelayed) relaySpeaker = "owner";
40940
- if (ownerDirect && relaySpeakerId === void 0 && (this.cfg.memoryProvider ?? "securecoms") === "securecoms" && // Final review M-4: the live payload flag too, so with notes off the
41182
+ if (ownerDirect && relaySpeakerId === void 0 && // G-1: never on a voice-scoped turn.
41183
+ opts.voiceScoped !== true && (this.cfg.memoryProvider ?? "securecoms") === "securecoms" && // Final review M-4: the live payload flag too, so with notes off the
40941
41184
  // runtime does not offer memory_note at all. Not memoryConfigConfirmed:
40942
41185
  // a turn the boot drain pulls before the boot refetch keeps its mark,
40943
41186
  // and the read and write gates (notesProviderOn) hold it until then.
@@ -40954,11 +41197,11 @@ var BridgeAgent = class _BridgeAgent {
40954
41197
  const binding = this.manifestBinding;
40955
41198
  const boundNow = binding !== null && binding.state === "bound" && binding.counter === this.toolGrantCounter;
40956
41199
  const signedTargets = boundNow ? binding.signedTargets : { endpoints: 0, databases: 0 };
40957
- if (tool_grants === OWNER_DM_DEFAULT_READ_GRANTS) {
40958
- const eligible = relaySpeakerId === void 0 && opts.ownerVerdict?.kind === "verified" && manifest?.owner_dm_channel_id === channelId && boundNow && this.manifestConsumer !== null;
41200
+ if (tool_grants === OWNER_DM_DEFAULT_READ_GRANTS || tool_grants === RELAYED_VOICE_GRANTS) {
41201
+ const eligible = relaySpeakerId === void 0 && opts.ownerVerdict?.kind === "verified" && manifest?.owner_dm_channel_id === channelId && boundNow && this.manifestConsumer !== null && tool_grants !== RELAYED_VOICE_GRANTS;
40959
41202
  const r = effectiveToolGrants({
40960
41203
  audience: "owner_dm",
40961
- base: OWNER_DM_DEFAULT_READ_GRANTS,
41204
+ base: tool_grants,
40962
41205
  manifest,
40963
41206
  eligible,
40964
41207
  manifestSwitchOn: this.ownerDmManifestGrants,
@@ -41680,6 +41923,7 @@ var BridgeAgent = class _BridgeAgent {
41680
41923
  const acked = [];
41681
41924
  for (const m of entries) {
41682
41925
  try {
41926
+ if (m.voiceMode === false) this.callBuffer.reset(m.channelId);
41683
41927
  const encId = m.envelope.enclave_id;
41684
41928
  if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
41685
41929
  this.channelEnclaveRecorded.add(m.channelId);
@@ -41837,6 +42081,7 @@ var BridgeAgent = class _BridgeAgent {
41837
42081
  continue;
41838
42082
  }
41839
42083
  if (typeof parsed.text === "string") {
42084
+ if (parsed.text.trim() === VOICE_CALL_START_MARKER) this.callBuffer.reset(m.channelId);
41840
42085
  const meta = this.freshMeta(m.channelId);
41841
42086
  const senderType = m.envelope.sender_type;
41842
42087
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
@@ -41878,23 +42123,39 @@ var BridgeAgent = class _BridgeAgent {
41878
42123
  const senderName = this.freshRoster(m.channelId)?.get(
41879
42124
  m.envelope.sender_id
41880
42125
  )?.name;
42126
+ const voiceScoped = isVoiceScopedTurn({
42127
+ speaker,
42128
+ transcribed: parsed.transcribed,
42129
+ senderIsRelay: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.is_relay === true,
42130
+ text: parsed.text
42131
+ });
41881
42132
  const ownerVerdict = await this.verifyOwnerTurn(m);
41882
42133
  const turnAuthorization = await this.authorizeTurn(
41883
42134
  m.channelId,
41884
42135
  m.messageId,
41885
42136
  m.envelope.sender_id,
41886
42137
  ownerVerdict,
41887
- speaker?.identityId
42138
+ speaker?.identityId,
42139
+ voiceScoped
41888
42140
  );
41889
42141
  await this.prepareNotesForTurn(m.channelId, turnAuthorization);
41890
- const runtimeMemory = this.memoryForTurn(m.channelId, m.messageId, turnAuthorization);
41891
42142
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
41892
42143
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
41893
- const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
42144
+ const fastPathEligible = Boolean(voiceScoped && m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
41894
42145
  const turnText = parsed.text;
41895
42146
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
41896
42147
  const windowEligible = voiceWindowEligible({ voiceMode: m.voiceMode, speaker, transcribed: parsed.transcribed, voiceGuidance, isCallStart });
41897
42148
  const coalescible = this.voiceCoalesceWindowMs > 0 && windowEligible;
42149
+ const callPeer = admitsToCallBuffer({ speaker, transcribed: parsed.transcribed });
42150
+ let voiceSnapshot;
42151
+ if (voiceScoped) {
42152
+ this.callBuffer.markVoiceScoped(m.channelId, m.messageId);
42153
+ if (!coalescible) {
42154
+ voiceSnapshot = this.takeVoiceSnapshot(m.channelId, m.messageId);
42155
+ if (callPeer && !this.isOwnerVerifyFailed(m.messageId)) this.callBuffer.appendPeer(m.channelId, parsed.text, m.messageId);
42156
+ }
42157
+ }
42158
+ const runtimeMemory = this.memoryForTurn(m.channelId, m.messageId, turnAuthorization, void 0, voiceScoped ? { snapshot: voiceSnapshot } : void 0);
41898
42159
  if (!coalescible) {
41899
42160
  const kind2 = m.voiceMode === true || parsed.transcribed === true ? "voice" : "text";
41900
42161
  if (this.unifiedOn) await this.prepareUnifiedForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization, kind: kind2 });
@@ -41910,7 +42171,9 @@ var BridgeAgent = class _BridgeAgent {
41910
42171
  const fastGreeting = isCallStart && fastPathFiring;
41911
42172
  const runtimeVoice = (ackSent) => m.voiceMode ? {
41912
42173
  voice: true,
41913
- guidance: ackSent ? `${voiceGuidance} ${FAST_PATH_SPOKEN_GUIDANCE}` : voiceGuidance
42174
+ // Task 6 (OQ-3): a voice-scoped frame's guidance ends with VOICE_CALL_CLAUSE, on all three frames
42175
+ // (held, coalesced after-ack, after-ack); a typed turn during a call keeps its guidance.
42176
+ guidance: `${ackSent ? `${voiceGuidance} ${FAST_PATH_SPOKEN_GUIDANCE}` : voiceGuidance}${voiceScoped ? ` ${VOICE_CALL_CLAUSE}` : ""}`
41914
42177
  } : {};
41915
42178
  const runtimeEntry = (ackSent, memory) => ({
41916
42179
  channel_id: m.channelId,
@@ -41931,6 +42194,7 @@ var BridgeAgent = class _BridgeAgent {
41931
42194
  // identify: the envelope's own claim (relay or on-device
41932
42195
  // transcript) or a relay-attributed turn.
41933
42196
  ...parsed.transcribed === true || speaker !== void 0 ? { transcribed: true } : {},
42197
+ ...voiceScoped ? { voice_scoped: true } : {},
41934
42198
  ...runtimeVoice(ackSent)
41935
42199
  });
41936
42200
  if (coalescible) {
@@ -41960,6 +42224,7 @@ var BridgeAgent = class _BridgeAgent {
41960
42224
  acked.push(m.queueId);
41961
42225
  continue;
41962
42226
  }
42227
+ const bufferGeneration = this.callBuffer.generation(m.channelId);
41963
42228
  const held = runtimeEntry(false, await runtimeMemory);
41964
42229
  if (!await this.stageHeldRuntimeMessage(held)) continue;
41965
42230
  this.noteVoiceCoalesced(m.messageId);
@@ -41970,7 +42235,8 @@ var BridgeAgent = class _BridgeAgent {
41970
42235
  closed: false,
41971
42236
  pending: Promise.resolve(),
41972
42237
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
41973
- itemCapture: itemCaptureAtReceipt.get(m.channelId)
42238
+ itemCapture: itemCaptureAtReceipt.get(m.channelId),
42239
+ bufferGeneration
41974
42240
  };
41975
42241
  this.voiceCoalescing.set(windowKey, win);
41976
42242
  const openedAt = Date.now();
@@ -41986,9 +42252,18 @@ var BridgeAgent = class _BridgeAgent {
41986
42252
  const [firstId, ...tailIds] = win.ids;
41987
42253
  if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
41988
42254
  const text = win.parts.join(" ");
42255
+ const sameCall = this.callBuffer.generation(channelId) === win.bufferGeneration;
42256
+ const anyFragmentVerifyFailed = win.ids.some((id) => this.isOwnerVerifyFailed(id));
42257
+ let windowSnapshot;
42258
+ if (sameCall) {
42259
+ this.callBuffer.markVoiceScoped(channelId, firstId);
42260
+ windowSnapshot = this.takeVoiceSnapshot(channelId, firstId);
42261
+ }
42262
+ if (callPeer && sameCall && !anyFragmentVerifyFailed) this.callBuffer.appendPeer(channelId, text, firstId);
41989
42263
  if (this.unifiedOn) await this.prepareUnifiedForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
41990
42264
  else await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
41991
- const merged = { ...held, text };
42265
+ const windowMemory = await this.memoryForTurn(channelId, firstId, turnAuthorization, void 0, { snapshot: anyFragmentVerifyFailed ? void 0 : windowSnapshot });
42266
+ const merged = { ...held, text, memory: windowMemory };
41992
42267
  if (this.unifiedOn) {
41993
42268
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
41994
42269
  const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
@@ -42016,7 +42291,7 @@ var BridgeAgent = class _BridgeAgent {
42016
42291
  const release = this.reserveFastReply(channelId);
42017
42292
  try {
42018
42293
  await this.speakAndSettle(
42019
- { channelId, messageId: firstId, senderId, authorization: turnAuthorization },
42294
+ { channelId, messageId: firstId, senderId, authorization: turnAuthorization, voiceScoped: voiceScoped && sameCall, voiceSnapshot: windowSnapshot, anyFragmentVerifyFailed },
42020
42295
  plan,
42021
42296
  merged,
42022
42297
  { ...merged, ...runtimeVoice(true) },
@@ -42048,7 +42323,9 @@ var BridgeAgent = class _BridgeAgent {
42048
42323
  channelId: m.channelId,
42049
42324
  messageId: m.messageId,
42050
42325
  senderId: m.envelope.sender_id,
42051
- authorization: turnAuthorization
42326
+ authorization: turnAuthorization,
42327
+ voiceScoped,
42328
+ voiceSnapshot
42052
42329
  };
42053
42330
  const guidanceForFast = voiceGuidance;
42054
42331
  const greeting = fastGreeting;
@@ -44171,10 +44448,13 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
44171
44448
  sendJson(sock, wsErrorFrame("invalid_request"));
44172
44449
  return;
44173
44450
  }
44451
+ const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
44452
+ 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);
44174
44453
  const sendOpts = {
44175
44454
  agentTurn: isAgentTurn(agent, m),
44176
44455
  ...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
44177
- ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {}
44456
+ ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
44457
+ ...callBufferReply ? { callBufferReply: true } : {}
44178
44458
  };
44179
44459
  if (m.a2a && typeof m.a2a === "object") {
44180
44460
  const a = m.a2a;