switchroom 0.19.23 → 0.19.24

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.
@@ -21042,17 +21042,19 @@ var init_schema = __esm(() => {
21042
21042
  model: exports_external.string().min(1).optional().describe("Per-op model (upstream `HINDSIGHT_API_<OP>_LLM_MODEL`). Absent \u2192 " + "inherit the global `hindsight.llm.model`."),
21043
21043
  provider: exports_external.string().min(1).optional().describe("Per-op provider (upstream `HINDSIGHT_API_<OP>_LLM_PROVIDER`). " + "Absent \u2192 inherit the global `hindsight.llm.provider`."),
21044
21044
  base_url: exports_external.string().min(1).optional().describe("Per-op base URL (upstream `HINDSIGHT_API_<OP>_LLM_BASE_URL`). " + "Optional passthrough; absent \u2192 inherit the global."),
21045
- api_key: exports_external.string().min(1).optional().describe("Per-op API key (upstream `HINDSIGHT_API_<OP>_LLM_API_KEY`). Literal " + "or `vault:` reference. Optional passthrough; absent \u2192 inherit global.")
21045
+ api_key: exports_external.string().min(1).optional().describe("Per-op API key (upstream `HINDSIGHT_API_<OP>_LLM_API_KEY`). Literal " + "or `vault:` reference. Optional passthrough; absent \u2192 inherit global."),
21046
+ context_window: exports_external.number().int().positive().optional().describe("Context window (tokens) of the backend serving THIS op. NOT an " + "upstream env var \u2014 switchroom derives the op's token budget " + "(consolidation batch size / max-completion caps / reflect " + "max-context cap) from it so a single call can never overflow the " + "window. Absent \u2192 inherit " + "`hindsight.llm.context_window`, else a per-provider default " + "(conservative for non-`claude-code` providers, which usually mean " + "a local llama.cpp/Ollama slot; a self-hosted `base_url` \u2014 loopback, " + "RFC1918, `.local`/`.internal` \u2014 forces the conservative default too, " + "regardless of the provider NAME, since the endpoint is where the " + "traffic actually terminates). All three lanes (`retain`, " + "`reflect`, `consolidation`) are budgeted independently.")
21046
21047
  }).describe("Per-operation LLM override. Every field optional; an unset field (or " + "an omitted op block) inherits the global `hindsight.llm.*`, which is " + "already the engine's fallback \u2014 switchroom emits only the vars set.");
21047
21048
  HindsightConfigSchema = exports_external.object({
21048
21049
  llm: exports_external.object({
21049
21050
  provider: exports_external.string().min(1).optional().describe("Hindsight LLM provider (upstream `HINDSIGHT_API_LLM_PROVIDER`). " + "Defaults to `claude-code` (subscription-honest, broker-fed OAuth). " + "Any litellm-routable provider the upstream image supports is valid. " + "Serves as the GLOBAL default for every op absent a per-op override."),
21050
21051
  model: exports_external.string().min(1).optional().describe("Hindsight LLM model (upstream `HINDSIGHT_API_LLM_MODEL`). Defaults " + "to HINDSIGHT_DEFAULT_MODEL. Any model your LiteLLM proxy can route " + "is valid, e.g. `openrouter/z-ai/glm-5.2` when routing through the " + "fleet proxy. With provider=claude-code this value is ALSO exported " + "as `ANTHROPIC_MODEL` to the claude subprocess. Serves as the GLOBAL " + "default for every op absent a per-op override."),
21052
+ context_window: exports_external.number().int().positive().optional().describe("GLOBAL context window (tokens) of the backend serving hindsight's " + "LLM ops \u2014 the declared size switchroom derives every token budget " + "from. Set this to the real window of whatever you point " + "`hindsight.llm` at (e.g. 32768 for a llama.cpp slot launched with " + "`-c 65536 -np 2`, 131072 for a large-window OpenRouter model). " + "Absent \u2192 a per-provider default: 200000 for `claude-code`, a " + "conservative 32768 for everything else. Overflowing a local " + "backend's window does NOT error \u2014 llama.cpp context-shift silently " + "drops the system prompt and the model answers conversationally " + "with HTTP 200 \u2014 so this value is what makes the failure " + "detectable at setup time instead of never."),
21051
21053
  retain: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `retain` LLM op (memory ingestion). Emits " + "`HINDSIGHT_API_RETAIN_LLM_*`. Absent \u2192 uses the global model/provider."),
21052
21054
  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."),
21053
21055
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
21054
21056
  }).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."),
21055
- 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, " + "LLM_MAX_CONCURRENT, RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT), 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).")
21057
+ 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, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT), 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), 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).")
21056
21058
  });
21057
21059
  MicrosoftWorkspaceConfigSchema = exports_external.object({
21058
21060
  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)."),
@@ -63265,7 +63267,8 @@ function createSendGate(config) {
63265
63267
  pending: null,
63266
63268
  running: false,
63267
63269
  suppressedUntilMs: 0,
63268
- editWindowTs: []
63270
+ editWindowTs: [],
63271
+ criticalWake: null
63269
63272
  };
63270
63273
  perMessage.set(key, state);
63271
63274
  }
@@ -63351,9 +63354,11 @@ function createSendGate(config) {
63351
63354
  }
63352
63355
  }
63353
63356
  }
63354
- async function admitLoop(buckets, deadline) {
63357
+ async function admitLoop(buckets, deadline, interrupt) {
63355
63358
  let counted = false;
63356
63359
  for (;; ) {
63360
+ if (interrupt?.fired)
63361
+ return false;
63357
63362
  const now = clock.now();
63358
63363
  let wait = 0;
63359
63364
  for (const b of buckets)
@@ -63369,11 +63374,12 @@ function createSendGate(config) {
63369
63374
  counters.queued++;
63370
63375
  counted = true;
63371
63376
  }
63372
- await clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait);
63377
+ const nap = clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait);
63378
+ await (interrupt ? Promise.race([nap, interrupt.promise]) : nap);
63373
63379
  }
63374
63380
  }
63375
- function admit(buckets) {
63376
- return admitLoop(buckets);
63381
+ function admit(buckets, interrupt) {
63382
+ return admitLoop(buckets, undefined, interrupt);
63377
63383
  }
63378
63384
  let criticalTail = Promise.resolve();
63379
63385
  function criticalSerialize(job) {
@@ -63391,6 +63397,24 @@ function createSendGate(config) {
63391
63397
  }
63392
63398
  })();
63393
63399
  }
63400
+ function makeInterrupt() {
63401
+ let resolve6;
63402
+ const interrupt = {
63403
+ fired: false,
63404
+ promise: new Promise((r) => {
63405
+ resolve6 = r;
63406
+ })
63407
+ };
63408
+ return {
63409
+ interrupt,
63410
+ fire: () => {
63411
+ if (interrupt.fired)
63412
+ return;
63413
+ interrupt.fired = true;
63414
+ resolve6();
63415
+ }
63416
+ };
63417
+ }
63394
63418
  async function admitPriority(buckets, priority) {
63395
63419
  const now = clock.now();
63396
63420
  if (priority === "cosmetic") {
@@ -63488,7 +63512,18 @@ function createSendGate(config) {
63488
63512
  continue;
63489
63513
  }
63490
63514
  } else {
63491
- await admit(bucketsFor(opts));
63515
+ const { interrupt, fire } = makeInterrupt();
63516
+ state.criticalWake = fire;
63517
+ let admitted;
63518
+ try {
63519
+ admitted = await admit(bucketsFor(opts), interrupt);
63520
+ } finally {
63521
+ state.criticalWake = null;
63522
+ }
63523
+ if (!admitted) {
63524
+ supersede(state, p);
63525
+ continue;
63526
+ }
63492
63527
  }
63493
63528
  state.lastSentMs = clock.now();
63494
63529
  if (perMessageEditMaxPerWindow > 0 && p.priorityClass === "cosmetic") {
@@ -63513,6 +63548,31 @@ function createSendGate(config) {
63513
63548
  state.running = false;
63514
63549
  }
63515
63550
  }
63551
+ function supersede(state, p) {
63552
+ const next = state.pending;
63553
+ if (!next) {
63554
+ state.pending = p;
63555
+ return;
63556
+ }
63557
+ next.priorityClass = maxPriority(next.priorityClass, p.priorityClass);
63558
+ next.promise.then(p.resolve, p.reject);
63559
+ }
63560
+ function editUnderPressure(state, opts, now) {
63561
+ if (state.suppressedUntilMs > now)
63562
+ return true;
63563
+ for (const b of bucketsFor(opts)) {
63564
+ if (b.msUntilAvailable(now) > 0)
63565
+ return true;
63566
+ }
63567
+ return false;
63568
+ }
63569
+ function settleForCaller(state, pending, opts, priority, now) {
63570
+ if (priority !== "cosmetic" || !editUnderPressure(state, opts, now)) {
63571
+ return pending.promise;
63572
+ }
63573
+ pending.promise.catch(() => {});
63574
+ return Promise.resolve(undefined);
63575
+ }
63516
63576
  function handleEdit(fn, opts) {
63517
63577
  const messageId = opts.messageId;
63518
63578
  const key = messageKey(opts.chat_id, messageId);
@@ -63521,16 +63581,6 @@ function createSendGate(config) {
63521
63581
  const existing = perMessage.get(key);
63522
63582
  maybeEvict(now, existing === undefined);
63523
63583
  const state = existing ?? messageState(key);
63524
- if ((opts.priorityClass ?? "useful") === "cosmetic") {
63525
- let wait = 0;
63526
- for (const b of bucketsFor(opts))
63527
- wait = Math.max(wait, b.msUntilAvailable(now));
63528
- const msgWait = state.suppressedUntilMs > now ? state.suppressedUntilMs - now : 0;
63529
- if (wait > 0 || msgWait > 0) {
63530
- counters.shed++;
63531
- return Promise.resolve(SEND_GATE_SHED2);
63532
- }
63533
- }
63534
63584
  if (state.pending) {
63535
63585
  state.pending.priorityClass = maxPriority(state.pending.priorityClass, opts.priorityClass ?? "useful");
63536
63586
  if (state.pending.hash !== hash) {
@@ -63538,7 +63588,9 @@ function createSendGate(config) {
63538
63588
  state.pending.hash = hash;
63539
63589
  state.pending.fn = fn;
63540
63590
  }
63541
- return state.pending.promise;
63591
+ if (state.pending.priorityClass === "critical")
63592
+ state.criticalWake?.();
63593
+ return settleForCaller(state, state.pending, opts, opts.priorityClass ?? "useful", now);
63542
63594
  }
63543
63595
  if (hash === state.lastHash) {
63544
63596
  counters.dropped++;
@@ -63559,9 +63611,13 @@ function createSendGate(config) {
63559
63611
  priorityClass: opts.priorityClass ?? "useful"
63560
63612
  };
63561
63613
  state.pending = pending;
63562
- if (!state.running)
63614
+ if (state.running) {
63615
+ if (pending.priorityClass === "critical")
63616
+ state.criticalWake?.();
63617
+ } else {
63563
63618
  drive(state, opts);
63564
- return promise;
63619
+ }
63620
+ return settleForCaller(state, pending, opts, pending.priorityClass, now);
63565
63621
  }
63566
63622
  async function gate(fn, opts) {
63567
63623
  if (!enabled2)
@@ -75869,6 +75925,30 @@ function makeKey3(chatId, threadId) {
75869
75925
  }
75870
75926
 
75871
75927
  // reply-owner-resolve.ts
75928
+ function latestEndedAccepted(candidates) {
75929
+ if (candidates.latestEndedTurnId == null)
75930
+ return false;
75931
+ const age = candidates.latestEndedAgeMs;
75932
+ const ttl = candidates.latestEndedTtlMs;
75933
+ if (age === null)
75934
+ return false;
75935
+ if (age === undefined || ttl == null)
75936
+ return true;
75937
+ return age <= ttl;
75938
+ }
75939
+ function decideContentGateBypass(input) {
75940
+ if (input.tier === "live")
75941
+ return true;
75942
+ if (input.tier === "none")
75943
+ return false;
75944
+ if (input.handbackCouldOwnReply)
75945
+ return false;
75946
+ if (input.candidates == null)
75947
+ return false;
75948
+ if (!latestEndedAccepted(input.candidates))
75949
+ return false;
75950
+ return input.resolvedTurnId != null && input.resolvedTurnId === input.candidates.latestEndedTurnId;
75951
+ }
75872
75952
  function decideAnswerLatchSuppression(input) {
75873
75953
  if (input.superseded)
75874
75954
  return false;
@@ -76500,14 +76580,23 @@ async function sendReply(deps, req) {
76500
76580
  let supersedeFlushIds = [];
76501
76581
  {
76502
76582
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
76503
- const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
76583
+ const {
76584
+ turn: ownerTurn,
76585
+ tier: ownerTier,
76586
+ candidates: ownerCandidates
76587
+ } = resolveReplyOwnerTurn(turn, chat_id, args);
76504
76588
  const resolvedTurnId = ownerTurn?.turnId ?? null;
76505
76589
  const ownerEndedAt = ownerTurn?.endedAt ?? null;
76506
76590
  const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
76507
76591
  const handbackAt = getLastSubagentHandbackAt(chat_id);
76508
76592
  const now = Date.now();
76509
76593
  const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
76510
- const replyIsOwnAnswer = ownerTier === "live" || ownerTier === "latest-ended" && !handbackCouldOwnReply;
76594
+ const replyIsOwnAnswer = decideContentGateBypass({
76595
+ tier: ownerTier,
76596
+ resolvedTurnId,
76597
+ candidates: ownerCandidates,
76598
+ handbackCouldOwnReply
76599
+ });
76511
76600
  const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
76512
76601
  if (decision.supersede) {
76513
76602
  process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
@@ -76532,7 +76621,7 @@ async function sendReply(deps, req) {
76532
76621
  replyMatchesFlushedAnswer
76533
76622
  });
76534
76623
  if (decision.reason === "new-content") {
76535
- process.stderr.write(`telegram gateway: reply: flush supersede declined \u2014 new content (#3429) ` + `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}; sending fresh
76624
+ process.stderr.write(`telegram gateway: reply: flush supersede declined \u2014 new content (#3429) ` + `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)} ` + `tier=${ownerTier} latestEnded=${JSON.stringify(ownerCandidates.latestEndedTurnId)} ` + `handbackInWindow=${handbackCouldOwnReply}; sending fresh
76536
76625
  `);
76537
76626
  }
76538
76627
  if (suppressByLatch) {
@@ -80062,12 +80151,14 @@ function isTurnFlushSafetyEnabled(env = process.env) {
80062
80151
  }
80063
80152
 
80064
80153
  // reply-owner-resolve.ts
80065
- function latestEndedAccepted(candidates) {
80154
+ function latestEndedAccepted2(candidates) {
80066
80155
  if (candidates.latestEndedTurnId == null)
80067
80156
  return false;
80068
80157
  const age = candidates.latestEndedAgeMs;
80069
80158
  const ttl = candidates.latestEndedTtlMs;
80070
- if (age == null || ttl == null)
80159
+ if (age === null)
80160
+ return false;
80161
+ if (age === undefined || ttl == null)
80071
80162
  return true;
80072
80163
  return age <= ttl;
80073
80164
  }
@@ -80078,7 +80169,7 @@ function resolveReplyOwnerTier(candidates) {
80078
80169
  return "origin";
80079
80170
  if (candidates.quotedTurnId != null)
80080
80171
  return "quoted";
80081
- if (latestEndedAccepted(candidates))
80172
+ if (latestEndedAccepted2(candidates))
80082
80173
  return "latest-ended";
80083
80174
  return "none";
80084
80175
  }
@@ -83038,6 +83129,10 @@ var HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT = 4;
83038
83129
  var HINDSIGHT_DEFAULT_RETAIN_LLM_MAX_CONCURRENT = 1;
83039
83130
  var HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_MAX_CONCURRENT = 1;
83040
83131
  var HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16 = "true";
83132
+ var HINDSIGHT_DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 128;
83133
+ var HINDSIGHT_DEFAULT_LLM_STRICT_SCHEMA = "true";
83134
+ var HINDSIGHT_DEFAULT_LLM_MAX_RETRIES = 2;
83135
+ var HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_PARALLELISM = 2;
83041
83136
  var HINDSIGHT_PERF_DEFAULTS_UNGATED = [
83042
83137
  [
83043
83138
  "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE",
@@ -83051,10 +83146,18 @@ var HINDSIGHT_PERF_DEFAULTS_UNGATED = [
83051
83146
  "HINDSIGHT_API_LINK_EXPANSION_TIMEOUT",
83052
83147
  String(HINDSIGHT_DEFAULT_LINK_EXPANSION_TIMEOUT_S)
83053
83148
  ],
83054
- ["HINDSIGHT_API_LLM_REASONING_EFFORT", HINDSIGHT_DEFAULT_LLM_REASONING_EFFORT]
83149
+ ["HINDSIGHT_API_LLM_REASONING_EFFORT", HINDSIGHT_DEFAULT_LLM_REASONING_EFFORT],
83150
+ [
83151
+ "HINDSIGHT_API_CONSOLIDATION_LLM_PARALLELISM",
83152
+ String(HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_PARALLELISM)
83153
+ ]
83055
83154
  ];
83056
83155
  var HINDSIGHT_PERF_DEFAULTS_GPU = [
83057
- ["HINDSIGHT_API_RERANKER_LOCAL_FP16", HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16]
83156
+ ["HINDSIGHT_API_RERANKER_LOCAL_FP16", HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16],
83157
+ [
83158
+ "HINDSIGHT_API_RERANKER_LOCAL_BATCH_SIZE",
83159
+ String(HINDSIGHT_DEFAULT_RERANKER_LOCAL_BATCH_SIZE)
83160
+ ]
83058
83161
  ];
83059
83162
  var HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM = [
83060
83163
  ["HINDSIGHT_API_LLM_MAX_CONCURRENT", String(HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT)],
@@ -83065,13 +83168,21 @@ var HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM = [
83065
83168
  [
83066
83169
  "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT",
83067
83170
  String(HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_MAX_CONCURRENT)
83068
- ]
83171
+ ],
83172
+ ["HINDSIGHT_API_LLM_STRICT_SCHEMA", HINDSIGHT_DEFAULT_LLM_STRICT_SCHEMA],
83173
+ ["HINDSIGHT_API_LLM_MAX_RETRIES", String(HINDSIGHT_DEFAULT_LLM_MAX_RETRIES)]
83069
83174
  ];
83175
+ var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
83176
+ "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY"
83177
+ ]);
83070
83178
  var HINDSIGHT_PERF_ENV_KEYS = new Set([
83071
- ...HINDSIGHT_PERF_DEFAULTS_UNGATED,
83072
- ...HINDSIGHT_PERF_DEFAULTS_GPU,
83073
- ...HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM
83074
- ].map(([k]) => k));
83179
+ ...[
83180
+ ...HINDSIGHT_PERF_DEFAULTS_UNGATED,
83181
+ ...HINDSIGHT_PERF_DEFAULTS_GPU,
83182
+ ...HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM
83183
+ ].map(([k]) => k),
83184
+ ...HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS
83185
+ ]);
83075
83186
 
83076
83187
  // ../src/setup/hindsight-pg-defaults.ts
83077
83188
  var HINDSIGHT_PG_MEM_LIMIT_MIB_FOR_DERIVATION = 8 * 1024;
@@ -83094,6 +83205,10 @@ var HINDSIGHT_PG_DEFAULTS = [
83094
83205
  ];
83095
83206
  var HINDSIGHT_PG_ENV_KEYS = new Set(HINDSIGHT_PG_DEFAULTS.map(([k]) => k));
83096
83207
 
83208
+ // ../src/setup/hindsight-context-budget.ts
83209
+ var HINDSIGHT_UPSTREAM_RETAIN_CHUNK_SIZE = 3000;
83210
+ var HINDSIGHT_RETAIN_MAX_COMPLETION_FLOOR = HINDSIGHT_UPSTREAM_RETAIN_CHUNK_SIZE + 72;
83211
+
83097
83212
  // ../src/setup/hindsight.ts
83098
83213
  var HINDSIGHT_DEFAULT_API_PORT = 18888;
83099
83214
  var HINDSIGHT_DEFAULT_MCP_URL = `http://127.0.0.1:${HINDSIGHT_DEFAULT_API_PORT}/mcp/`;
@@ -88540,6 +88655,19 @@ function resolveAnswerThreadId(input) {
88540
88655
  return input.liveThreadId;
88541
88656
  }
88542
88657
 
88658
+ // gateway/latest-turn-lookup.ts
88659
+ function latestTurnForChat(turns, chatId, opts) {
88660
+ let latest = null;
88661
+ for (const t of turns) {
88662
+ if (t.sessionChatId !== chatId)
88663
+ continue;
88664
+ if (opts.endedOnly && t.endedAt == null)
88665
+ continue;
88666
+ latest = t;
88667
+ }
88668
+ return latest;
88669
+ }
88670
+
88543
88671
  // gateway/turns-jsonl-rotate.ts
88544
88672
  var TURNS_JSONL_MAX_BYTES = 5 * 1024 * 1024;
88545
88673
  var DEFAULT_AGENT_STATE_DIR = "/state/agent";
@@ -96421,10 +96549,10 @@ function startOutboxSweep(deps) {
96421
96549
  }
96422
96550
 
96423
96551
  // ../src/build-info.ts
96424
- var VERSION2 = "0.19.23";
96425
- var COMMIT_SHA = "56e3c01e";
96426
- var COMMIT_DATE = "2026-07-26T13:24:39Z";
96427
- var LATEST_PR = 3711;
96552
+ var VERSION2 = "0.19.24";
96553
+ var COMMIT_SHA = "0fa8ffb8";
96554
+ var COMMIT_DATE = "2026-07-27T05:19:34Z";
96555
+ var LATEST_PR = 3744;
96428
96556
  var COMMITS_AHEAD_OF_TAG = 0;
96429
96557
 
96430
96558
  // gateway/boot-version.ts
@@ -99395,18 +99523,13 @@ function findTurnByOriginId(originTurnId) {
99395
99523
  return recentTurnsById.get(originTurnId) ?? null;
99396
99524
  }
99397
99525
  var LATE_REPLY_TOPIC_RECOVERY_ENABLED = process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== "0";
99398
- function findLatestEndedTurnForChat(chatId) {
99399
- let latest = null;
99400
- for (const t of recentTurnsById.values()) {
99401
- if (t.sessionChatId === chatId)
99402
- latest = t;
99403
- }
99404
- return latest;
99526
+ function findLatestTurnForChat(chatId, opts) {
99527
+ return latestTurnForChat(recentTurnsById.values(), chatId, opts);
99405
99528
  }
99406
99529
  function resolveReplyOwnerTurn(liveTurn, chatId, args) {
99407
99530
  const origin = findTurnByOriginId(args.origin_turn_id);
99408
99531
  const quoted = findTurnByQuotedMessageId(chatId, args.reply_to);
99409
- const latestEnded = findLatestEndedTurnForChat(chatId);
99532
+ const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true });
99410
99533
  const byId = new Map;
99411
99534
  for (const t of [latestEnded, quoted, origin, liveTurn]) {
99412
99535
  if (t != null)
@@ -99423,10 +99546,10 @@ function resolveReplyOwnerTurn(liveTurn, chatId, args) {
99423
99546
  };
99424
99547
  const tier = resolveReplyOwnerTier(candidates);
99425
99548
  const winnerId = resolveReplyOwnerTurnId(candidates);
99426
- return { turn: winnerId != null ? byId.get(winnerId) ?? null : null, tier };
99549
+ return { turn: winnerId != null ? byId.get(winnerId) ?? null : null, tier, candidates };
99427
99550
  }
99428
99551
  function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
99429
- const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
99552
+ const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestTurnForChat(chatId, { endedOnly: false }) : null;
99430
99553
  const threadId = resolveAnswerThreadId({
99431
99554
  explicitThreadId,
99432
99555
  originResolved: originTurn != null,
@@ -489,7 +489,7 @@ import {
489
489
  import {
490
490
  resolveReplyOwnerTurnId,
491
491
  resolveReplyOwnerTier,
492
- type ReplyOwnerTier,
492
+ type ReplyOwnerTier, type ReplyOwnerCandidates,
493
493
  type AnswerDeliveredLatch,
494
494
  } from '../reply-owner-resolve.js'
495
495
  import { SubagentHandbackMarker } from './subagent-handback-marker.js'
@@ -717,6 +717,7 @@ import {
717
717
  } from './emission-authority.js'
718
718
  import { CurrentTurnMap } from './current-turn-map.js'
719
719
  import { resolveAnswerThreadId } from './answer-thread-resolve.js'
720
+ import { latestTurnForChat } from './latest-turn-lookup.js'
720
721
  import { decideObligationTurnEnd } from './obligation-turn-end.js'
721
722
  import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
722
723
  import {
@@ -3484,12 +3485,14 @@ export type CurrentTurn = {
3484
3485
  flushedAnswerText: string | null
3485
3486
  // 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
3486
3487
  // ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
3487
- // The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
3488
- // authority (it drives message deletion), so `resolveReplyOwnerTurn` only
3489
- // honours a latest-ended turn whose `endedAt` is within the supersede TTL —
3488
+ // The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
3489
+ // message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
3490
+ // turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
3491
+ // START, so the tail entry may still be RUNNING) AND within the supersede TTL —
3490
3492
  // otherwise a late reply belonging to an OLDER turn could resolve its owner to
3491
- // a NEWER turn sitting at the registry tail and delete that newer turn's legit
3492
- // answer. Unbounded routing use of `findLatestEndedTurnForChat` is unaffected.
3493
+ // a NEWER turn at the registry tail and delete that turn's legit answer. The
3494
+ // unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
3495
+ // topic to deliver into and deletes nothing.
3493
3496
  endedAt: number | null
3494
3497
  // #1675 (over-ping safety net): wall-clock ms of the first reply
3495
3498
  // this turn that landed with `disable_notification: false` (a real
@@ -3999,21 +4002,18 @@ const LATE_REPLY_TOPIC_RECOVERY_ENABLED =
3999
4002
  process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== '0'
4000
4003
 
4001
4004
  /**
4002
- * The most-recently-started turn for a chat from the bounded recently-ended
4003
- * registry — the deterministic fallback for a LATE answer reply when the model
4004
- * echoed no `origin_turn_id` and `currentTurn` has already cleared. Iterates in
4005
- * insertion order so the last match is the most recent turn for that chat.
4006
- * Returns null when the chat has no remembered turn (so the caller keeps the
4007
- * legacy result). NB: this is the chat's own most-recent TURN, not the
4008
- * `chatThreadMap` last-seen-any-message heuristic that caused the wrong-topic
4009
- * bug — a late reply almost always belongs to the turn that just ended.
4005
+ * The most recent turn for a chat from the bounded recent-turn registry — the
4006
+ * deterministic fallback for a LATE answer reply when the model echoed no
4007
+ * `origin_turn_id` and `currentTurn` has already cleared. Returns null when the
4008
+ * chat has no matching turn (so the caller keeps the legacy result). NB: this is
4009
+ * the chat's own most-recent TURN, not the `chatThreadMap` last-seen-any-message
4010
+ * heuristic that caused the wrong-topic bug. `endedOnly` selects the consumer's
4011
+ * semantics — routing takes the tail entry even when that turn is still RUNNING;
4012
+ * the destructive `latest-ended` owner tier takes only a genuinely ENDED turn,
4013
+ * because the registry is populated at turn START (#3725; see the module).
4010
4014
  */
4011
- function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
4012
- let latest: CurrentTurn | null = null
4013
- for (const t of recentTurnsById.values()) {
4014
- if (t.sessionChatId === chatId) latest = t
4015
- }
4016
- return latest
4015
+ function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null {
4016
+ return latestTurnForChat(recentTurnsById.values(), chatId, opts)
4017
4017
  }
4018
4018
 
4019
4019
  /**
@@ -4029,7 +4029,7 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
4029
4029
  * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
4030
4030
  * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
4031
4031
  * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
4032
- * 4. `findLatestEndedTurnForChat(chat_id)` — the chat's last-ended turn.
4032
+ * 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
4033
4033
  * Returns the CurrentTurn for the winning id (so callers can read its
4034
4034
  * `answerDelivered` latch), or null when every lookup missed.
4035
4035
  */
@@ -4037,10 +4037,10 @@ function resolveReplyOwnerTurn(
4037
4037
  liveTurn: CurrentTurn | null,
4038
4038
  chatId: string,
4039
4039
  args: Record<string, unknown>,
4040
- ): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
4040
+ ): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
4041
4041
  const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
4042
4042
  const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
4043
- const latestEnded = findLatestEndedTurnForChat(chatId)
4043
+ const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
4044
4044
  const byId = new Map<string, CurrentTurn>()
4045
4045
  // Populate lowest-precedence first so a higher tier's turn wins the id slot
4046
4046
  // when two lookups resolve the same turn (they carry the same turnId anyway).
@@ -4049,12 +4049,12 @@ function resolveReplyOwnerTurn(
4049
4049
  }
4050
4050
  // F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
4051
4051
  // latest-ended turn can't inherit deletion authority over a newer turn's flush
4052
- // record. `endedAt` is null only for a turn still resolvable but not yet ended
4053
- // (not a supersede risk); leave the age unset then (unbounded) rather than
4054
- // fabricate one.
4052
+ // record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
4053
+ // and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
4054
+ // candidate at all, and an explicit null age now fails CLOSED downstream.
4055
4055
  const latestEndedAgeMs =
4056
4056
  latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4057
- const candidates = {
4057
+ const candidates: ReplyOwnerCandidates = {
4058
4058
  liveTurnId: liveTurn?.turnId ?? null,
4059
4059
  originTurnId: origin?.turnId ?? null,
4060
4060
  quotedTurnId: quoted?.turnId ?? null,
@@ -4062,15 +4062,14 @@ function resolveReplyOwnerTurn(
4062
4062
  latestEndedAgeMs,
4063
4063
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4064
4064
  }
4065
- // #3429 — the WINNING tier travels with the turn. A positive tier
4066
- // (live/origin/quoted) means the reply is this turn's own answer and the
4067
- // supersede fires regardless of text; the ambiguous `latest-ended` fallback
4068
- // keeps the content gate (it cannot tell a late own-reply from an async
4069
- // sub-agent handback). Both derive from the SAME candidates, so the id and the
4070
- // tier can never disagree.
4065
+ // #3429 — the winning tier AND the candidate set it came from travel with the
4066
+ // turn. Tier alone no longer decides the content-gate bypass: the
4067
+ // model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
4068
+ // framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
4069
+ // derive from these SAME candidates, so they can never disagree.
4071
4070
  const tier = resolveReplyOwnerTier(candidates)
4072
4071
  const winnerId = resolveReplyOwnerTurnId(candidates)
4073
- return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
4072
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
4074
4073
  }
4075
4074
 
4076
4075
  /**
@@ -4115,7 +4114,7 @@ function resolveAnswerThreadWithLog(
4115
4114
  explicitThreadId == null &&
4116
4115
  originTurn == null &&
4117
4116
  liveTurn == null
4118
- ? findLatestEndedTurnForChat(chatId)
4117
+ ? findLatestTurnForChat(chatId, { endedOnly: false })
4119
4118
  : null
4120
4119
  const threadId = resolveAnswerThreadId({
4121
4120
  explicitThreadId,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The recently-seen-turn registry scan, extracted from `gateway.ts` as a pure
3
+ * function so its ENDED-ness contract is unit-testable (#3725; gateway.ts is not
4
+ * importable in tests — the repo's `decideTurnFlush` / `resolveReplyOwnerTurnId`
5
+ * pattern).
6
+ *
7
+ * ## Why `endedOnly` exists (#3725)
8
+ *
9
+ * `recentTurnsById` is populated at turn **start** (`rememberRecentTurn` fires
10
+ * from the `enqueue` lifecycle event in `stream-render.ts`), and the atom is
11
+ * built with `endedAt: null`; `endedAt` is stamped later, in `turn-end.ts`. So
12
+ * the registry's tail entry for a chat is the most-recently-STARTED turn, which
13
+ * may still be RUNNING. The registry is also chat-wide and thread-agnostic, so
14
+ * on a forum a turn running in ANOTHER topic sits at the tail.
15
+ *
16
+ * That distinction is load-bearing because the two consumers want different
17
+ * things:
18
+ *
19
+ * - **Routing** (`resolveAnswerThreadWithLog`) wants the chat's most recent
20
+ * turn whether or not it has ended — it only picks a topic to deliver into,
21
+ * and a still-running turn's topic is a perfectly good (indeed better)
22
+ * answer than falling back to General. `endedOnly: false`.
23
+ * - **Owner resolution** (`resolveReplyOwnerTurn` → the `latest-ended`
24
+ * supersede tier) wants a genuinely ENDED turn: that tier carries
25
+ * DESTRUCTIVE authority (it drives message deletion) and is bounded by the
26
+ * supersede TTL measured from `endedAt`. A turn with `endedAt == null` has
27
+ * no age, so it could not be TTL-bounded at all — it was an unbounded
28
+ * anchor for the corroborated content-gate bypass (#3725). A still-running
29
+ * turn must be resolved by the `live` tier, never by this fallback.
30
+ * `endedOnly: true`.
31
+ */
32
+
33
+ /** The registry-atom shape this scan needs (structural — `CurrentTurn` in the
34
+ * gateway satisfies it without importing the gateway's type). */
35
+ export interface LatestTurnLookupAtom {
36
+ /** The chat the turn belongs to. */
37
+ sessionChatId: string
38
+ /** Wall-clock ms the turn ENDED, or null while it is still running. */
39
+ endedAt: number | null
40
+ }
41
+
42
+ /**
43
+ * The last turn for `chatId` in registry insertion order — i.e. the most recent
44
+ * one. With `endedOnly: true` the scan skips turns that have not ended yet, so
45
+ * the result is the most-recently-ended turn (which may NOT be the tail entry).
46
+ * Returns null when the chat has no matching turn.
47
+ */
48
+ export function latestTurnForChat<T extends LatestTurnLookupAtom>(
49
+ turns: Iterable<T>,
50
+ chatId: string,
51
+ opts: { endedOnly: boolean },
52
+ ): T | null {
53
+ let latest: T | null = null
54
+ for (const t of turns) {
55
+ if (t.sessionChatId !== chatId) continue
56
+ if (opts.endedOnly && t.endedAt == null) continue
57
+ latest = t
58
+ }
59
+ return latest
60
+ }