switchroom 0.20.6 → 0.20.7

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.
@@ -21997,7 +21997,7 @@ var init_schema = __esm(() => {
21997
21997
  reflect: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `reflect` LLM op (synthesis / mental-model " + "refresh). Emits `HINDSIGHT_API_REFLECT_LLM_*`. Absent \u2192 uses global."),
21998
21998
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
21999
21999
  }).optional().describe("LLM knob for the hindsight container. The flat `provider`/`model` set " + "the global default (backward-compatible); optional `retain`/`reflect`/" + "`consolidation` blocks override individual ops. All fields optional; " + "unset fields fall back to the hard-coded defaults."),
22000
- env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
22000
+ env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing; and " + "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS \u2014 the max chars of query text " + "dateparser.search_dates() is handed during temporal analysis (0 = " + "unlimited), made live by switchroom's temporal-offload image patch " + "(which moved the synchronous extraction off the shared asyncio loop onto " + "a single-worker thread and bounds its input, ending multi-second loop " + "stalls on multi-KB consolidation queries); unset means the image's baked " + "2000, set 0 to restore an unbounded full scan), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
22001
22001
  });
22002
22002
  MicrosoftWorkspaceConfigSchema = exports_external.object({
22003
22003
  microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
@@ -82242,6 +82242,9 @@ function closeFlushCompletionWindows(registry, now) {
82242
82242
  registry?.completeAll?.(now);
82243
82243
  return closed;
82244
82244
  }
82245
+ function parkedTurnStartCount() {
82246
+ return parkedTurnStarts.length;
82247
+ }
82245
82248
  function drainParkedTurnStartsForChat(deps, chatId, threadId) {
82246
82249
  const wantThread = envThreadIdNum(threadId);
82247
82250
  const drained = [];
@@ -87376,7 +87379,8 @@ var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
87376
87379
  "HINDSIGHT_API_RETAIN_WALL_TIMEOUT",
87377
87380
  "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT",
87378
87381
  "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S",
87379
- "HINDSIGHT_API_TEMPORAL_LANGUAGES"
87382
+ "HINDSIGHT_API_TEMPORAL_LANGUAGES",
87383
+ "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS"
87380
87384
  ]);
87381
87385
  var HINDSIGHT_WORKER_SLOT_TYPES = [
87382
87386
  "consolidation",
@@ -92780,7 +92784,24 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
92780
92784
  const pending = buffer.drain(agent);
92781
92785
  let redelivered = 0;
92782
92786
  let rebuffered = 0;
92787
+ let retracted = 0;
92783
92788
  for (const { merged, originals } of planBufferedRedelivery(pending)) {
92789
+ let proceed = true;
92790
+ if (buffer.beforeRedeliver != null) {
92791
+ try {
92792
+ proceed = buffer.beforeRedeliver(merged);
92793
+ } catch (e) {
92794
+ proceed = true;
92795
+ process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
92796
+ `);
92797
+ }
92798
+ }
92799
+ if (!proceed) {
92800
+ for (const o of originals)
92801
+ spool?.ack(o);
92802
+ retracted += originals.length;
92803
+ continue;
92804
+ }
92784
92805
  let delivered = false;
92785
92806
  try {
92786
92807
  delivered = send(merged);
@@ -92798,7 +92819,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
92798
92819
  rebuffered += originals.length;
92799
92820
  }
92800
92821
  }
92801
- return { drained: pending.length, redelivered, rebuffered };
92822
+ return { drained: pending.length, redelivered, rebuffered, retracted };
92802
92823
  }
92803
92824
  function isMergeableUserInbound(msg) {
92804
92825
  return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
@@ -92915,7 +92936,62 @@ function createPendingInboundBuffer(opts = {}) {
92915
92936
  for (const q of queues.values())
92916
92937
  n += q.length;
92917
92938
  return n;
92939
+ },
92940
+ beforeRedeliver: opts.beforeRedeliver
92941
+ };
92942
+ }
92943
+
92944
+ // gateway/represent-guard.ts
92945
+ function shouldSuppressRepresent(o, deps) {
92946
+ if (!deps.historyEnabled)
92947
+ return false;
92948
+ if (o.lastRepresentedAt == null) {
92949
+ if (o.openedAt == null)
92950
+ return false;
92951
+ return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId);
92952
+ }
92953
+ return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId);
92954
+ }
92955
+
92956
+ // gateway/represent-delivery-guard.ts
92957
+ function makeRepresentRedeliveryGuard(deps) {
92958
+ return (msg) => {
92959
+ if (!deps.enabled)
92960
+ return true;
92961
+ if (msg.meta?.source !== "obligation_represent")
92962
+ return true;
92963
+ const originTurnId = msg.meta?.origin_turn_id;
92964
+ if (originTurnId == null)
92965
+ return true;
92966
+ const o = deps.ledger.get(originTurnId);
92967
+ if (o == null) {
92968
+ deps.log(`telegram gateway: represent retracted at delivery \u2014 obligation already ` + `closed since decision (no re-fire) origin=${originTurnId}
92969
+ `);
92970
+ return false;
92971
+ }
92972
+ const suppress = shouldSuppressRepresent(o, {
92973
+ historyEnabled: deps.historyEnabled,
92974
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId) => deps.hasOutboundDeliveredSince(chatId, sinceMs, threadId, deps.minReplyChars)
92975
+ });
92976
+ if (suppress) {
92977
+ deps.ledger.close(originTurnId);
92978
+ deps.log(`telegram gateway: represent retracted at delivery \u2014 reply landed since ` + `decision (no re-fire) origin=${originTurnId}
92979
+ `);
92980
+ return false;
92918
92981
  }
92982
+ return true;
92983
+ };
92984
+ }
92985
+ function makeSessionBusyDrainDeferral(boundMs) {
92986
+ let deferringSince = null;
92987
+ return (busy, now) => {
92988
+ if (!busy || boundMs <= 0) {
92989
+ deferringSince = null;
92990
+ return false;
92991
+ }
92992
+ if (deferringSince == null)
92993
+ deferringSince = now;
92994
+ return now - deferringSince < boundMs;
92919
92995
  };
92920
92996
  }
92921
92997
 
@@ -92987,6 +93063,9 @@ class ObligationLedger {
92987
93063
  isOpen(originTurnId) {
92988
93064
  return this.open.has(originTurnId);
92989
93065
  }
93066
+ get(originTurnId) {
93067
+ return this.open.get(originTurnId);
93068
+ }
92990
93069
  hasOpen() {
92991
93070
  return this.open.size > 0;
92992
93071
  }
@@ -94512,7 +94591,24 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
94512
94591
  const pending = buffer.drain(agent);
94513
94592
  let redelivered = 0;
94514
94593
  let rebuffered = 0;
94594
+ let retracted = 0;
94515
94595
  for (const { merged, originals } of planBufferedRedelivery2(pending)) {
94596
+ let proceed = true;
94597
+ if (buffer.beforeRedeliver != null) {
94598
+ try {
94599
+ proceed = buffer.beforeRedeliver(merged);
94600
+ } catch (e) {
94601
+ proceed = true;
94602
+ process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
94603
+ `);
94604
+ }
94605
+ }
94606
+ if (!proceed) {
94607
+ for (const o of originals)
94608
+ spool?.ack(o);
94609
+ retracted += originals.length;
94610
+ continue;
94611
+ }
94516
94612
  let delivered = false;
94517
94613
  try {
94518
94614
  delivered = send(merged);
@@ -94530,7 +94626,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
94530
94626
  rebuffered += originals.length;
94531
94627
  }
94532
94628
  }
94533
- return { drained: pending.length, redelivered, rebuffered };
94629
+ return { drained: pending.length, redelivered, rebuffered, retracted };
94534
94630
  }
94535
94631
  function isMergeableUserInbound2(msg) {
94536
94632
  return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
@@ -95425,6 +95521,9 @@ class ObligationLedger2 {
95425
95521
  isOpen(originTurnId) {
95426
95522
  return this.open.has(originTurnId);
95427
95523
  }
95524
+ get(originTurnId) {
95525
+ return this.open.get(originTurnId);
95526
+ }
95428
95527
  hasOpen() {
95429
95528
  return this.open.size > 0;
95430
95529
  }
@@ -95587,23 +95686,24 @@ function driveEscalation(args) {
95587
95686
  });
95588
95687
  }
95589
95688
 
95590
- // gateway/represent-guard.ts
95591
- function shouldSuppressRepresent(o, deps) {
95592
- if (!deps.historyEnabled)
95593
- return false;
95594
- if (o.lastRepresentedAt == null) {
95595
- if (o.openedAt == null)
95596
- return false;
95597
- return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId);
95598
- }
95599
- return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId);
95600
- }
95601
-
95602
95689
  // gateway/escalation-bridge-gate.ts
95603
95690
  function shouldDeferEscalationForBridge(deps) {
95604
95691
  return !deps.bridgeAlive;
95605
95692
  }
95606
95693
 
95694
+ // gateway/stream-render.ts
95695
+ init_history();
95696
+ init_rich_send();
95697
+ var PARKED_TURN_START_TTL_MS2 = 30 * 60000;
95698
+ var QUEUED_CARD_ENABLED2 = process.env.SWITCHROOM_QUEUED_CARD !== "0";
95699
+ var BUZZ_ENABLED2 = process.env.BUZZ_ENABLED === "1" || process.env.BUZZ_ENABLED === "true";
95700
+ var BUZZ_ORIGIN_STAMP_ACTIVE2 = BUZZ_ENABLED2 && isBuzzTurnRoutingEnabled();
95701
+ var parkedTurnStarts2 = [];
95702
+ var flushCompletionTracker2 = new FlushCompletionTracker;
95703
+ function parkedTurnStartCount2() {
95704
+ return parkedTurnStarts2.length;
95705
+ }
95706
+
95607
95707
  // gateway/obligation-wiring.ts
95608
95708
  function createObligationWiring(deps) {
95609
95709
  const {
@@ -95682,7 +95782,9 @@ function createObligationWiring(deps) {
95682
95782
  return;
95683
95783
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
95684
95784
  const now = Date.now();
95685
- const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now);
95785
+ const liveTurn = getCurrentTurn();
95786
+ const sessionBusy = liveTurn != null && liveTurn.endedAt == null || parkedTurnStartCount2() > 0;
95787
+ const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && (agentHasInFlightBackgroundWork(now) || sessionBusy);
95686
95788
  const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0 ? {
95687
95789
  now,
95688
95790
  graceMs: OBLIGATION_ESCALATE_GRACE_MS,
@@ -95694,7 +95796,7 @@ function createObligationWiring(deps) {
95694
95796
  if (decision.action === "none" || o == null) {
95695
95797
  if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60000) {
95696
95798
  lastBgWorkDeferLogMs = now;
95697
- process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
95799
+ process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `or busy CLI session behind a poke-cleared turn ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
95698
95800
  `);
95699
95801
  }
95700
95802
  return;
@@ -102479,10 +102581,10 @@ function startOutboxSweep(deps) {
102479
102581
  }
102480
102582
 
102481
102583
  // ../src/build-info.ts
102482
- var VERSION2 = "0.20.6";
102483
- var COMMIT_SHA = "4b1ad9b1";
102484
- var COMMIT_DATE = "2026-08-04T07:34:19Z";
102485
- var LATEST_PR = 4340;
102584
+ var VERSION2 = "0.20.7";
102585
+ var COMMIT_SHA = "bc107185";
102586
+ var COMMIT_DATE = "2026-08-04T10:20:28Z";
102587
+ var LATEST_PR = 4343;
102486
102588
  var COMMITS_AHEAD_OF_TAG = 0;
102487
102589
 
102488
102590
  // gateway/boot-version.ts
@@ -108501,7 +108603,8 @@ var pendingInboundBuffer = createPendingInboundBuffer({
108501
108603
  const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
108502
108604
  swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
108503
108605
  },
108504
- onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
108606
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
108607
+ beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) })
108505
108608
  });
108506
108609
  function agentHasInFlightBackgroundWork(now) {
108507
108610
  if (countRunningWorkers() > 0)
@@ -109532,12 +109635,16 @@ if (isGatewayMain)
109532
109635
  }
109533
109636
  })();
109534
109637
  var IDLE_DRAIN_INTERVAL_MS = 5000;
109638
+ var idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS);
109535
109639
  if (isGatewayMain && !STATIC) {
109536
109640
  setInterval(() => {
109537
109641
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
109538
109642
  const r = idleDrainTick(pendingInboundBuffer, selfAgent, () => {
109539
109643
  if (turnInFlightForGate())
109540
109644
  return false;
109645
+ const sessionBusy = currentTurn != null && currentTurn.endedAt == null || parkedTurnStartCount() > 0;
109646
+ if (idleDrainBusyDefer(sessionBusy, Date.now()))
109647
+ return false;
109541
109648
  const c = ipcServer.getClient(selfAgent);
109542
109649
  return c != null && c.isAlive();
109543
109650
  }, (m) => ipcServer.sendToAgent(selfAgent, m), inboundSpool, trackRedeliveredInbound);
@@ -461,7 +461,7 @@ import {
461
461
  type SendReplyGatewayDeps,
462
462
  type DeliverCapturedProseDeps,
463
463
  } from './outbound-send-path.js'
464
- import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
464
+ import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows, parkedTurnStartCount } from './stream-render.js'
465
465
  import { createNarrativeLane } from './narrative-lane.js'
466
466
  import {
467
467
  parseAgentCallback,
@@ -659,6 +659,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
659
659
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
660
660
  import { buildDiffPreviewCard } from './diff-preview-card.js'
661
661
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
662
+ import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
662
663
  import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
663
664
  import {
664
665
  ObligationLedger,
@@ -9788,6 +9789,11 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9788
9789
  // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9789
9790
  // push) populates it. Every `subagent_handback` push funnels through here.
9790
9791
  onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
9792
+ // F1 (fix/represent-double-send-delivery-recheck) — delivery-time represent
9793
+ // retract. Every drain path hands its buffered inbounds through this gate; a
9794
+ // stale `obligation_represent` (its reply landed since the sweep decided it) is
9795
+ // dropped here rather than duplicated into the CLI queue. See represent-delivery-guard.ts.
9796
+ beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) }),
9791
9797
  })
9792
9798
 
9793
9799
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -11742,30 +11748,19 @@ if (isGatewayMain) (() => { // #2996 P0c: gated — starts webhook-ingest UDS s
11742
11748
  })()
11743
11749
 
11744
11750
  // ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
11745
- // pendingInboundBuffer otherwise drains only on (a) bridge re-register
11746
- // (onClientRegistered) or (b) the silence-poke framework fallback
11747
- // clearing a wedged turn (#1546). NEITHER fires when a message is
11748
- // buffered during a bridge-IPC flap that then settles with no
11749
- // subsequent clean re-register AND claude is idle (no active turn
11750
- // silence-poke never arms). The message orphans until a manual restart
11751
- // (finn, 2026-05-19 buffered "verify with mff-query.py cashflow"
11752
- // while idle; last `bridge registered` predated the buffer push, so
11753
- // onClientRegistered's drain never ran for it).
11754
- //
11755
- // This is the third drain trigger. It's gated to be zero-cost and
11756
- // zero-churn: skip entirely when nothing is buffered (one Map.get, no
11757
- // log), when the bridge isn't alive (exactly sendToAgent's own guard —
11758
- // so we never drain into a dead bridge and re-buffer/log-spin), OR
11759
- // when a turn is in flight. The turn gate is #1556: a message
11760
- // delivered while a turn is active is NOT safely queued by the bridge
11761
- // — claude types it into its TUI composer and the auto-submit races
11762
- // turn-completion, stranding it (the lawgpt wedge). Draining only at
11763
- // `activeTurnStartedAt.size === 0` guarantees the channel notification
11764
- // lands at an idle prompt and submits as a fresh turn. Only when there
11765
- // IS a buffered message AND a live bridge AND no active turn do we
11766
- // reuse the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
11767
- // per-message miss).
11751
+ // The THIRD drain trigger, beside bridge re-register (onClientRegistered) and
11752
+ // the silence-poke framework fallback (#1546): neither fires when a message is
11753
+ // buffered during a bridge-IPC flap that settles with no clean re-register while
11754
+ // claude is idle, so it orphans until a manual restart (finn, 2026-05-19). Gated
11755
+ // zero-cost/zero-churn: skip when nothing is buffered, when the bridge is dead,
11756
+ // or when a turn is in flight (#1556 — a message delivered mid-turn is typed into
11757
+ // claude's TUI composer and the auto-submit races turn-completion, stranding it).
11768
11758
  const IDLE_DRAIN_INTERVAL_MS = 5000
11759
+ // F2 (drain half) — bounded busy-defer. A ~5-min poke can clear the machine turn
11760
+ // while the CLI is still producing the answer; draining a represent into that busy
11761
+ // session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
11762
+ // bounded so a wedged session still drains (F1 then retracts it post-reply).
11763
+ const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS)
11769
11764
  if (isGatewayMain && !STATIC) {
11770
11765
  setInterval(() => {
11771
11766
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
@@ -11775,8 +11770,11 @@ if (isGatewayMain && !STATIC) {
11775
11770
  () => {
11776
11771
  // #1556: never drain mid-turn — that re-creates the composer
11777
11772
  // wedge this buffer exists to prevent. Gate reads the delivery
11778
- // machine (turnInFlightForGate).
11773
+ // machine (turnInFlightForGate) AND, per F2, stream-render's live-turn
11774
+ // mirror (a poke can clear the former while the latter is still busy).
11779
11775
  if (turnInFlightForGate()) return false
11776
+ const sessionBusy = (currentTurn != null && currentTurn.endedAt == null) || parkedTurnStartCount() > 0
11777
+ if (idleDrainBusyDefer(sessionBusy, Date.now())) return false
11780
11778
  const c = ipcServer.getClient(selfAgent)
11781
11779
  return c != null && c.isAlive()
11782
11780
  },
@@ -178,6 +178,15 @@ export class ObligationLedger {
178
178
  return this.open.has(originTurnId)
179
179
  }
180
180
 
181
+ /** The open obligation for `originTurnId`, or undefined if none is open. Read-
182
+ * only accessor for the delivery-time represent re-check (represent-delivery-
183
+ * guard.ts): a represent buffered while the obligation was open must be re-
184
+ * evaluated against the obligation's CURRENT cutoff at the moment it is handed
185
+ * to the CLI bridge, since a reply may have landed between decision and drain. */
186
+ get(originTurnId: string): Obligation | undefined {
187
+ return this.open.get(originTurnId)
188
+ }
189
+
181
190
  hasOpen(): boolean {
182
191
  return this.open.size > 0
183
192
  }
@@ -23,6 +23,7 @@ import { buildObligationRepresentInbound, type Obligation } from './obligation-l
23
23
  import { driveEscalation } from './escalation-drive.js'
24
24
  import { shouldSuppressRepresent } from './represent-guard.js'
25
25
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
26
+ import { parkedTurnStartCount } from './stream-render.js'
26
27
 
27
28
  export function createObligationWiring(deps: ObligationWiringDeps) {
28
29
  const {
@@ -161,13 +162,26 @@ function obligationSweep(): void {
161
162
  if (turnInFlightForGate()) return // a turn is running — let it finish/answer
162
163
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
163
164
  const now = Date.now()
165
+ // Session-busy signal (F2, decision half). A ~5-min silence poke can clear the
166
+ // machine turn (`turnInFlightForGate()` above → false) while the CLI session is
167
+ // STILL producing the answer — stream-render's live-turn mirror (`currentTurn`
168
+ // whose endedAt is null, or a parked turn-start) still knows it is busy. Without
169
+ // this, the sweep decides + buffers a represent mid-answer; the real reply lands
170
+ // seconds later and the model consumes the queued represent → a duplicate answer
171
+ // (the two-authorities-disagree defect). We treat a busy mirror the SAME as
172
+ // in-flight background work: defer the represent, BOUNDED by the same grace, so a
173
+ // wedged/hung session cannot silence a genuinely-unanswered turn forever.
174
+ const liveTurn = getCurrentTurn()
175
+ const sessionBusy = (liveTurn != null && liveTurn.endedAt == null) || parkedTurnStartCount() > 0
164
176
  // Background-work grace: while genuine autonomous sub-agent work is in flight
165
177
  // (a running worker, or an orphaned foreground sub-agent — neither visible to
166
- // the turn machine), an obligation younger than the ceiling is NOT re-presented
167
- // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
168
- // always eventually fires. =0 disables it.
178
+ // the turn machine), OR the CLI session is still busy behind a poke-cleared
179
+ // machine turn, an obligation younger than the ceiling is NOT re-presented
180
+ // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS (measured from
181
+ // openedAt in decideAtIdle) so escalation always eventually fires. =0 disables it.
169
182
  const backgroundWorkActive =
170
- OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
183
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 &&
184
+ (agentHasInFlightBackgroundWork(now) || sessionBusy)
171
185
  // Grace window: skip an obligation whose handling turn ended < grace ago — its
172
186
  // trailing slow/worker answer may still be landing (over-escalation fix).
173
187
  // Per-represent grace: skip an obligation re-presented < grace ago — prevents
@@ -189,6 +203,7 @@ function obligationSweep(): void {
189
203
  lastBgWorkDeferLogMs = now
190
204
  process.stderr.write(
191
205
  `telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
206
+ `or busy CLI session behind a poke-cleared turn ` +
192
207
  `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
193
208
  )
194
209
  }
@@ -49,6 +49,16 @@ export interface PendingInboundBuffer {
49
49
  depth: (agent: string) => number
50
50
  /** Test-only: total depth across all agents. */
51
51
  totalDepth: () => number
52
+ /**
53
+ * Delivery-time gate consulted by `redeliverBufferedInbound` for EVERY message
54
+ * it is about to hand to the bridge. Returns TRUE to proceed, FALSE to RETRACT
55
+ * (drop) the message (the spool entry is acked so it is not boot-replayed, and
56
+ * it is NOT re-buffered). The gateway wires this to the represent delivery
57
+ * re-check (represent-delivery-guard.ts): a represent buffered while its
58
+ * obligation was open must be re-evaluated at drain time, since a reply may have
59
+ * landed between the sweep's decision and this drain. Undefined ⇒ never retract.
60
+ */
61
+ beforeRedeliver?: (msg: InboundMessage) => boolean
52
62
  }
53
63
 
54
64
  export interface PendingInboundBufferOptions {
@@ -94,6 +104,13 @@ export interface PendingInboundBufferOptions {
94
104
  * breaks the push hot path.
95
105
  */
96
106
  onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
107
+ /**
108
+ * Delivery-time retract gate — see `PendingInboundBuffer.beforeRedeliver`. The
109
+ * gateway supplies the represent delivery re-check here so EVERY drain path
110
+ * (idle-drain, bridge re-register, silence-poke fallback, turn-end) inherits it
111
+ * by construction rather than by per-call-site discipline.
112
+ */
113
+ beforeRedeliver?: (msg: InboundMessage) => boolean
97
114
  }
98
115
 
99
116
  /**
@@ -123,16 +140,38 @@ export function redeliverBufferedInbound(
123
140
  // silently dropped (clerk 2026-06-03). `send` returning true only means
124
141
  // the bytes reached the bridge, NOT that claude consumed them.
125
142
  onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
126
- ): { drained: number; redelivered: number; rebuffered: number } {
143
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
127
144
  const pending = buffer.drain(agent)
128
145
  let redelivered = 0
129
146
  let rebuffered = 0
147
+ let retracted = 0
130
148
  // Collapse consecutive same-sender Telegram user messages into one turn
131
149
  // (see planBufferedRedelivery) so a forwarded burst that spanned a turn
132
150
  // boundary doesn't fan out into N sequential replies. System inbounds
133
151
  // (vault grants, approvals, cron, handbacks — anything with meta.source)
134
152
  // are never merged and are delivered individually exactly as before.
135
153
  for (const { merged, originals } of planBufferedRedelivery(pending)) {
154
+ // Delivery-time retract (F1): a buffered `obligation_represent` whose reply
155
+ // has landed since the sweep decided it is stale — drop it rather than hand
156
+ // a duplicate to the CLI queue. Ack the spool so it is not boot-replayed and
157
+ // do NOT re-buffer. The gateway closure closes the ledger + logs the retract.
158
+ // FAIL-OPEN: a throwing predicate must never silence or lose a real message,
159
+ // nor abort the drain loop (which would strand every following message and
160
+ // crash the setInterval tick). On throw we treat it as "deliver".
161
+ let proceed = true
162
+ if (buffer.beforeRedeliver != null) {
163
+ try {
164
+ proceed = buffer.beforeRedeliver(merged)
165
+ } catch (e) {
166
+ proceed = true
167
+ process.stderr.write(`redeliver beforeRedeliver threw — failing open (deliver): ${String(e)}\n`)
168
+ }
169
+ }
170
+ if (!proceed) {
171
+ for (const o of originals) spool?.ack(o)
172
+ retracted += originals.length
173
+ continue
174
+ }
136
175
  let delivered = false
137
176
  try {
138
177
  delivered = send(merged)
@@ -157,7 +196,7 @@ export function redeliverBufferedInbound(
157
196
  rebuffered += originals.length
158
197
  }
159
198
  }
160
- return { drained: pending.length, redelivered, rebuffered }
199
+ return { drained: pending.length, redelivered, rebuffered, retracted }
161
200
  }
162
201
 
163
202
  /** True when `msg` is an ordinary Telegram user message eligible to be
@@ -312,7 +351,7 @@ export function idleDrainTick(
312
351
  // enrols redelivered inbounds in the deliver-until-acked queue (parity with
313
352
  // the bridgeUp drain — clerk lost-message incident, 2026-06-03).
314
353
  onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
315
- ): { drained: number; redelivered: number; rebuffered: number } | null {
354
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } | null {
316
355
  if (!agent) return null
317
356
  if (buffer.depth(agent) === 0) return null
318
357
  if (!isBridgeAlive()) return null
@@ -412,5 +451,6 @@ export function createPendingInboundBuffer(
412
451
  for (const q of queues.values()) n += q.length
413
452
  return n
414
453
  },
454
+ beforeRedeliver: opts.beforeRedeliver,
415
455
  }
416
456
  }