switchroom 0.19.35 → 0.19.36

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.
Files changed (29) hide show
  1. package/dist/cli/skill-validate-pretool.mjs +15 -2
  2. package/dist/cli/switchroom.js +672 -441
  3. package/dist/host-control/main.js +149 -3
  4. package/package.json +4 -2
  5. package/profiles/_base/start.sh.hbs +37 -12
  6. package/telegram-plugin/dist/gateway/gateway.js +311 -93
  7. package/telegram-plugin/format.ts +70 -15
  8. package/telegram-plugin/gateway/gateway.ts +22 -29
  9. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  10. package/telegram-plugin/gateway/model-command.ts +34 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  12. package/telegram-plugin/operator-events.ts +40 -16
  13. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  14. package/telegram-plugin/secret-detect/index.ts +24 -1
  15. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  16. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  17. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  18. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  19. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  20. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  21. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  22. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  23. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  25. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  26. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  27. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  28. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
@@ -6936,10 +6936,18 @@ function listItemContent(line) {
6936
6936
  function isFullyBolded(fragment) {
6937
6937
  return /^\*\*[^*]+\*\*[.,:;!?]?$/.test(fragment.trim());
6938
6938
  }
6939
+ function isPseudoHeadingBlock(block) {
6940
+ const lines = block.split(`
6941
+ `).filter((l) => l.trim() !== "");
6942
+ if (lines.length !== 1)
6943
+ return false;
6944
+ const t = lines[0].trim();
6945
+ return t.length <= PSEUDO_HEADING_MAX_CHARS && isFullyBolded(t);
6946
+ }
6939
6947
  function unbold(fragment) {
6940
6948
  return fragment.replace(/\*\*([^*]+)\*\*/g, "$1");
6941
6949
  }
6942
- function stripExcessBold(text) {
6950
+ function stripExcessBold(text, onStrip) {
6943
6951
  if (!text.includes("**"))
6944
6952
  return text;
6945
6953
  const nonce = Math.random().toString(36).slice(2);
@@ -6950,10 +6958,15 @@ function stripExcessBold(text) {
6950
6958
  let boldChars = 0;
6951
6959
  for (const m of visible.matchAll(/\*\*([^*]+)\*\*/g))
6952
6960
  boldChars += m[1].length;
6953
- if (boldChars / visible.length > 0.3) {
6954
- return restore(unbold(masked));
6955
- }
6961
+ const ratio = boldChars / visible.length;
6956
6962
  const blocks = masked.split(/\n{2,}/);
6963
+ if (ratio > 0.3) {
6964
+ const rebuilt2 = blocks.map((block) => isPseudoHeadingBlock(block) ? block : unbold(block));
6965
+ const out2 = rejoinBlocks(masked, rebuilt2);
6966
+ if (out2 !== masked)
6967
+ onStrip?.({ rule: "global", ratio });
6968
+ return restore(out2);
6969
+ }
6957
6970
  const rebuilt = blocks.map((block) => {
6958
6971
  const lines = block.split(`
6959
6972
  `).filter((l) => l.trim() !== "");
@@ -6970,17 +6983,23 @@ function stripExcessBold(text) {
6970
6983
  return block;
6971
6984
  if (!lines.every((l) => isFullyBolded(l)))
6972
6985
  return block;
6973
- if (lines.length === 1 && lines[0].trim().length <= 48)
6986
+ if (isPseudoHeadingBlock(block))
6974
6987
  return block;
6975
6988
  return unbold(block);
6976
6989
  });
6990
+ const out = rejoinBlocks(masked, rebuilt);
6991
+ if (out !== masked)
6992
+ onStrip?.({ rule: "per-block", ratio });
6993
+ return restore(out);
6994
+ }
6995
+ function rejoinBlocks(masked, rebuilt) {
6977
6996
  const seps = masked.match(/\n{2,}/g) ?? [];
6978
6997
  let out = rebuilt[0] ?? "";
6979
6998
  for (let i = 1;i < rebuilt.length; i++)
6980
6999
  out += (seps[i - 1] ?? `
6981
7000
 
6982
7001
  `) + rebuilt[i];
6983
- return restore(out);
7002
+ return out;
6984
7003
  }
6985
7004
  function isListItemLine(line) {
6986
7005
  const t = line.trimStart();
@@ -7183,7 +7202,7 @@ function backOffOpenInline(text, cut) {
7183
7202
  }
7184
7203
  return earliest;
7185
7204
  }
7186
- var RICH_MESSAGE_MAX_CHARS = 32768, PARAGRAPH_SPACER = "\u00a0", INLINE_SPAN_PATTERNS;
7205
+ var RICH_MESSAGE_MAX_CHARS = 32768, PARAGRAPH_SPACER = "\u00a0", PSEUDO_HEADING_MAX_CHARS = 48, INLINE_SPAN_PATTERNS;
7187
7206
  var init_format = __esm(() => {
7188
7207
  INLINE_SPAN_PATTERNS = [
7189
7208
  /`[^`\n]+`/g,
@@ -12660,16 +12679,22 @@ var init_patterns = __esm(() => {
12660
12679
  },
12661
12680
  {
12662
12681
  rule_id: "bearer_auth_header",
12663
- regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/g,
12682
+ regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/gi,
12664
12683
  captureIndex: 1,
12665
12684
  slugHint: "bearer_token"
12666
12685
  },
12667
12686
  {
12668
12687
  rule_id: "bearer_loose",
12669
- regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
12688
+ regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/gi,
12670
12689
  captureIndex: 1,
12671
12690
  slugHint: "bearer_token"
12672
12691
  },
12692
+ {
12693
+ rule_id: "basic_auth_header",
12694
+ regex: /Authorization\s*[:=]\s*Basic\s+([A-Za-z0-9+/=]{8,})/gi,
12695
+ captureIndex: 1,
12696
+ slugHint: "basic_auth"
12697
+ },
12673
12698
  {
12674
12699
  rule_id: "pem_private_key",
12675
12700
  regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
@@ -12729,7 +12754,104 @@ function shannonEntropy(s) {
12729
12754
  return h;
12730
12755
  }
12731
12756
 
12757
+ // secret-detect/inert-values.ts
12758
+ function runIsCredentialShaped(run) {
12759
+ let classes = 0;
12760
+ if (/[a-z]/.test(run))
12761
+ classes++;
12762
+ if (/[A-Z]/.test(run))
12763
+ classes++;
12764
+ if (/[0-9]/.test(run))
12765
+ classes++;
12766
+ return run.length >= (classes >= 2 ? MIXED_CLASS_RUN_MIN : SINGLE_CLASS_RUN_MIN);
12767
+ }
12768
+ function hasCredentialShapedRun(value) {
12769
+ for (const run of value.match(CREDENTIAL_RUN_RE) ?? []) {
12770
+ if (runIsCredentialShaped(run))
12771
+ return true;
12772
+ }
12773
+ return false;
12774
+ }
12775
+ function isInertValue(value) {
12776
+ if (!INERT_VALUE_RE.some((re) => re.test(value)))
12777
+ return false;
12778
+ return !hasCredentialShapedRun(value);
12779
+ }
12780
+ function stripTrailingPunctuation(value) {
12781
+ return value.replace(TRAILING_PUNCT_RE, "");
12782
+ }
12783
+ var INERT_VALUE_RE, CREDENTIAL_RUN_RE, MIXED_CLASS_RUN_MIN = 12, SINGLE_CLASS_RUN_MIN = 16, INERT_GATED_RULES, TRAILING_PUNCT_RE;
12784
+ var init_inert_values = __esm(() => {
12785
+ INERT_VALUE_RE = [
12786
+ /^\[REDACTED(?::[A-Za-z0-9_]+)?\]$/i,
12787
+ /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/,
12788
+ /^\{\{[^\n\r\u2028\u2029]*\}\}$/,
12789
+ /^<[^<>\n\r\u2028\u2029]*>$/,
12790
+ /^%[A-Za-z_][A-Za-z0-9_]*%$/,
12791
+ /^vault:[A-Za-z0-9_./-]*$/i,
12792
+ /^[*x\u2022.]+$/i,
12793
+ /^(?:process\.env|import\.meta\.env|os\.environ)(?:\.[A-Za-z_$][A-Za-z0-9_$]*|\[["']?[A-Za-z_$][A-Za-z0-9_$]*["']?\])*$/,
12794
+ /^(?:changeme|change-me|replaceme|replace-me|placeholder|todo|tbd|yourkey|your-key|yourpassword|your-password|yoursecret|your-secret|yourtoken|your-token)(?:[-_][A-Za-z0-9-]+)?$/i,
12795
+ /^[a-z]+(?: [a-z]+){2,}$/
12796
+ ];
12797
+ CREDENTIAL_RUN_RE = /[A-Za-z0-9]{12,}/g;
12798
+ INERT_GATED_RULES = new Set([
12799
+ "env_key_value",
12800
+ "json_secret_field",
12801
+ "cli_flag"
12802
+ ]);
12803
+ TRAILING_PUNCT_RE = /[.,;:!?)\]}]+$/;
12804
+ });
12805
+
12732
12806
  // secret-detect/kv-scanner.ts
12807
+ function charClassCount(value) {
12808
+ let n = 0;
12809
+ if (/[a-z]/.test(value))
12810
+ n++;
12811
+ if (/[A-Z]/.test(value))
12812
+ n++;
12813
+ if (/[0-9]/.test(value))
12814
+ n++;
12815
+ if (/[^A-Za-z0-9]/.test(value))
12816
+ n++;
12817
+ return n;
12818
+ }
12819
+ function looksLikeMemorablePassword(value) {
12820
+ if (isInertValue(value))
12821
+ return false;
12822
+ const core = stripTrailingPunctuation(value);
12823
+ if (core.length < 8 || core.length > 64)
12824
+ return false;
12825
+ if (isInertValue(core))
12826
+ return false;
12827
+ if (new Set(core).size < 4)
12828
+ return false;
12829
+ return charClassCount(core) >= MEMORABLE_PW_MIN_CLASSES;
12830
+ }
12831
+ function scanMemorablePasswords(text) {
12832
+ const hits = [];
12833
+ MEMORABLE_PW_RE.lastIndex = 0;
12834
+ let m;
12835
+ while ((m = MEMORABLE_PW_RE.exec(text)) !== null) {
12836
+ const keyName = m[1];
12837
+ const value = m[3];
12838
+ if (!value || !looksLikeMemorablePassword(value))
12839
+ continue;
12840
+ const valueOffsetInMatch = m[0].indexOf(value, keyName.length);
12841
+ if (valueOffsetInMatch < 0)
12842
+ continue;
12843
+ const start = m.index + valueOffsetInMatch;
12844
+ hits.push({
12845
+ rule_id: MEMORABLE_PW_RULE_ID,
12846
+ start,
12847
+ end: start + value.length,
12848
+ matched_text: value,
12849
+ key_name: keyName,
12850
+ confidence: "ambiguous"
12851
+ });
12852
+ }
12853
+ return hits;
12854
+ }
12733
12855
  function scanKeyValue(text) {
12734
12856
  const hits = [];
12735
12857
  KV_RE.lastIndex = 0;
@@ -12738,6 +12860,8 @@ function scanKeyValue(text) {
12738
12860
  const [, keyName, value] = m;
12739
12861
  if (!value)
12740
12862
  continue;
12863
+ if (isInertValue(value))
12864
+ continue;
12741
12865
  const h = shannonEntropy(value);
12742
12866
  if (h < KV_ENTROPY_THRESHOLD)
12743
12867
  continue;
@@ -12757,9 +12881,44 @@ function scanKeyValue(text) {
12757
12881
  }
12758
12882
  return hits;
12759
12883
  }
12760
- var KV_RE, KV_ENTROPY_THRESHOLD = 4;
12884
+ var KV_RE, KV_ENTROPY_THRESHOLD = 4, MEMORABLE_PW_RE, MEMORABLE_PW_RULE_ID = "memorable_password", MEMORABLE_PW_MIN_CLASSES = 2;
12761
12885
  var init_kv_scanner = __esm(() => {
12886
+ init_inert_values();
12762
12887
  KV_RE = /\b([A-Za-z_][A-Za-z0-9_-]*(?:password|passwd|token|secret|key|api[_-]?key))\s*[:=]\s*["']?([^\s"'\\]{8,})["']?/gi;
12888
+ MEMORABLE_PW_RE = /\b([A-Za-z0-9_-]*(?:password|passwd|passphrase|pwd))\b\s*(?:[:=]\s*|\s+is\s+)(["']?)([^\s"']{8,64})\2/gi;
12889
+ });
12890
+
12891
+ // secret-detect/db-uri.ts
12892
+ function isInertPassword(value) {
12893
+ if (value.startsWith("[REDACTED"))
12894
+ return true;
12895
+ return /^[*x]+$/i.test(value);
12896
+ }
12897
+ function scanDbUris(text) {
12898
+ const hits = [];
12899
+ DB_URI_RE.lastIndex = 0;
12900
+ let m;
12901
+ while ((m = DB_URI_RE.exec(text)) !== null) {
12902
+ const scheme = m[1];
12903
+ const user = m[2];
12904
+ const password = m[3];
12905
+ if (isInertPassword(password))
12906
+ continue;
12907
+ const start = m.index + scheme.length + 3 + user.length + 1;
12908
+ hits.push({
12909
+ rule_id: DB_URI_RULE_ID,
12910
+ start,
12911
+ end: start + password.length,
12912
+ matched_text: password,
12913
+ key_name: `${scheme}_password`,
12914
+ confidence: "ambiguous"
12915
+ });
12916
+ }
12917
+ return hits;
12918
+ }
12919
+ var DB_URI_RE, DB_URI_RULE_ID = "db_uri_password";
12920
+ var init_db_uri = __esm(() => {
12921
+ DB_URI_RE = /\b([a-zA-Z][a-zA-Z0-9+.-]+):\/\/([^\s:/@]+):([^\s/?#]+)@([^\s/@?#]+)/g;
12763
12922
  });
12764
12923
 
12765
12924
  // secret-detect/generic-entropy.ts
@@ -12953,6 +13112,8 @@ function detectSecrets(text) {
12953
13112
  const globalStart = win.offset + matchStart;
12954
13113
  const globalEnd = globalStart + cap.length;
12955
13114
  const keyName = p.rule_id === "env_key_value" ? m[1] : undefined;
13115
+ if (INERT_GATED_RULES.has(p.rule_id) && isInertValue(cap))
13116
+ continue;
12956
13117
  if (p.rule_id === "env_key_value") {
12957
13118
  const ENV_KV_MIN_LEN = 12;
12958
13119
  const ENV_KV_MIN_ENTROPY = 3.5;
@@ -12975,6 +13136,12 @@ function detectSecrets(text) {
12975
13136
  for (const h of kvHits) {
12976
13137
  raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
12977
13138
  }
13139
+ for (const h of scanDbUris(win.text)) {
13140
+ raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
13141
+ }
13142
+ for (const h of scanMemorablePasswords(win.text)) {
13143
+ raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
13144
+ }
12978
13145
  const genHits = scanGenericSecrets(win.text);
12979
13146
  for (const h of genHits) {
12980
13147
  raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
@@ -13024,6 +13191,8 @@ function dropOverlaps(hits) {
13024
13191
  var init_secret_detect = __esm(() => {
13025
13192
  init_patterns();
13026
13193
  init_kv_scanner();
13194
+ init_db_uri();
13195
+ init_inert_values();
13027
13196
  init_generic_entropy();
13028
13197
  init_chunker();
13029
13198
  init_suppressor();
@@ -71039,6 +71208,23 @@ function describeProviderCreditRemedy(provider) {
71039
71208
  }
71040
71209
 
71041
71210
  // operator-events.ts
71211
+ var OPERATOR_EVENT_KINDS = [
71212
+ "credentials-expired",
71213
+ "credentials-invalid",
71214
+ "proxy-misconfig",
71215
+ "credit-exhausted",
71216
+ "provider-credit-exhausted",
71217
+ "mcp-dependency-blocked",
71218
+ "quota-exhausted",
71219
+ "rate-limited",
71220
+ "agent-crashed",
71221
+ "agent-restarted-unexpectedly",
71222
+ "unknown-4xx",
71223
+ "unknown-5xx",
71224
+ "config-warning",
71225
+ "always-allow-persist-failed",
71226
+ "mental-model-persist-failed"
71227
+ ];
71042
71228
  function classifyClaudeError(raw) {
71043
71229
  try {
71044
71230
  return classifyInner(raw);
@@ -77648,7 +77834,12 @@ function normalizeOutboundBody(rawText, site, redact2, opts = {}) {
77648
77834
  text4 = normalizeParagraphBreaks(text4);
77649
77835
  text4 = redact2(text4, site);
77650
77836
  if (!literalText) {
77651
- let formatted = stripExcessBold(normalizePunctuation(text4));
77837
+ let formatted = stripExcessBold(normalizePunctuation(text4), (d) => {
77838
+ try {
77839
+ process.stderr.write(`telegram gateway: strip-excess-bold: fired site=${site} rule=${d.rule} ratio=${d.ratio.toFixed(3)}
77840
+ `);
77841
+ } catch {}
77842
+ });
77652
77843
  if (addSpacers)
77653
77844
  formatted = addParagraphSpacers(formatted);
77654
77845
  text4 = formatted;
@@ -79094,7 +79285,12 @@ function normalizeOutboundBody2(rawText, site, redact2, opts = {}) {
79094
79285
  text4 = normalizeParagraphBreaks(text4);
79095
79286
  text4 = redact2(text4, site);
79096
79287
  if (!literalText) {
79097
- let formatted = stripExcessBold(normalizePunctuation(text4));
79288
+ let formatted = stripExcessBold(normalizePunctuation(text4), (d) => {
79289
+ try {
79290
+ process.stderr.write(`telegram gateway: strip-excess-bold: fired site=${site} rule=${d.rule} ratio=${d.ratio.toFixed(3)}
79291
+ `);
79292
+ } catch {}
79293
+ });
79098
79294
  if (addSpacers)
79099
79295
  formatted = addParagraphSpacers(formatted);
79100
79296
  text4 = formatted;
@@ -83583,6 +83779,27 @@ var MODEL_ARG_RE2 = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/;
83583
83779
  function isValidModelArg2(arg) {
83584
83780
  return MODEL_ARG_RE2.test(arg);
83585
83781
  }
83782
+ var DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS = 180000;
83783
+ var SESSION_MODEL_RESOLUTION_POLL_MS = 250;
83784
+ function resolveSessionModelResolutionTimeoutMs(raw) {
83785
+ if (raw == null || raw.trim() === "")
83786
+ return DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS;
83787
+ const value = Number(raw);
83788
+ return Number.isFinite(value) && value >= 0 ? value : DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS;
83789
+ }
83790
+ async function waitForSessionModelResolution(input) {
83791
+ const pollMs = input.pollMs ?? SESSION_MODEL_RESOLUTION_POLL_MS;
83792
+ const sleep2 = input.sleep ?? ((ms) => new Promise((resolve7) => setTimeout(resolve7, ms)));
83793
+ const startedAt = Date.now();
83794
+ for (;; ) {
83795
+ if (input.barrierExists())
83796
+ return true;
83797
+ const remaining = input.timeoutMs - (Date.now() - startedAt);
83798
+ if (remaining <= 0)
83799
+ return false;
83800
+ await sleep2(Math.min(pollMs, remaining));
83801
+ }
83802
+ }
83586
83803
  function isSrModel2(name) {
83587
83804
  return name.startsWith("sr-");
83588
83805
  }
@@ -88847,17 +89064,7 @@ function recordWebhookEvent(rec, deps = {}) {
88847
89064
  init_format();
88848
89065
  import { renameSync as renameSync17, unlinkSync as unlinkSync20, chmodSync as chmodSync11 } from "fs";
88849
89066
  var MAX_BUFFER_SIZE = 1024 * 1024;
88850
- var VALID_OPERATOR_KINDS = new Set([
88851
- "credentials-expired",
88852
- "credentials-invalid",
88853
- "credit-exhausted",
88854
- "quota-exhausted",
88855
- "rate-limited",
88856
- "agent-crashed",
88857
- "agent-restarted-unexpectedly",
88858
- "unknown-4xx",
88859
- "unknown-5xx"
88860
- ]);
89067
+ var VALID_OPERATOR_KINDS = new Set(OPERATOR_EVENT_KINDS);
88861
89068
  var AGENT_NAME_RE3 = /^[a-z0-9][a-z0-9_-]{0,50}$/;
88862
89069
  var OPERATOR_EVENT_DETAIL_MAX = 1000;
88863
89070
  function validateClientMessage(msg) {
@@ -99461,10 +99668,10 @@ function startOutboxSweep(deps) {
99461
99668
  }
99462
99669
 
99463
99670
  // ../src/build-info.ts
99464
- var VERSION2 = "0.19.35";
99465
- var COMMIT_SHA = "76ae22ca";
99466
- var COMMIT_DATE = "2026-07-30T01:27:21Z";
99467
- var LATEST_PR = 4011;
99671
+ var VERSION2 = "0.19.36";
99672
+ var COMMIT_SHA = "ba505b15";
99673
+ var COMMIT_DATE = "2026-07-30T07:53:24Z";
99674
+ var LATEST_PR = 4019;
99468
99675
  var COMMITS_AHEAD_OF_TAG = 0;
99469
99676
 
99470
99677
  // gateway/boot-version.ts
@@ -112706,76 +112913,87 @@ async function startGateway() {
112706
112913
  try {
112707
112914
  const smAgentDir = resolveAgentDirFromEnv();
112708
112915
  if (smAgentDir) {
112709
- const activePath = join68(smAgentDir, ".active-session-model");
112710
- if (existsSync57(activePath)) {
112711
- try {
112712
- const launched = readFileSync60(activePath, "utf8").trim();
112713
- const configured = (() => {
112714
- const d = switchroomExecJson(["agent", "list"]);
112715
- const raw = d?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
112716
- return resolveMainModel(raw ?? undefined);
112717
- })();
112718
- const isApplyBoot = launched.length > 0 && launched !== configured;
112719
- sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true });
112720
- const modelBootCardDeps = {
112721
- agent: getMyAgentName(),
112722
- chat: modelSwitchMarkerChat,
112723
- log: (line) => process.stderr.write(line),
112724
- sendCard: (chatId, body, opts) => lockedBot.api.sendMessage(chatId, body, opts)
112725
- };
112726
- if (isApplyBoot) {
112727
- sessionModelSource.setDivergenceHandler(buildServedModelDivergenceHandler(modelBootCardDeps));
112728
- }
112729
- const confirmation = modelSwitchReason != null ? classifyModelSwitchConfirmation({
112730
- reason: modelSwitchReason,
112731
- launched,
112732
- configured
112733
- }) : null;
112734
- process.stderr.write(formatModelRelaunchDiagLog({
112735
- agent: getMyAgentName(),
112736
- launched,
112737
- configured,
112738
- confirmation,
112739
- isApplyBoot
112740
- }));
112741
- if (confirmation != null) {
112742
- deliverModelSwitchBootNotice({
112743
- ...modelBootCardDeps,
112916
+ const resolutionTimeoutMs = resolveSessionModelResolutionTimeoutMs(process.env.SWITCHROOM_SESSION_MODEL_RESOLUTION_TIMEOUT_MS);
112917
+ const resolved = await waitForSessionModelResolution({
112918
+ barrierExists: () => existsSync57(join68(smAgentDir, ".session-model-resolved")),
112919
+ timeoutMs: resolutionTimeoutMs
112920
+ });
112921
+ if (!resolved) {
112922
+ const target = modelSwitchReason == null ? "(none)" : parseModelSwitchTarget(modelSwitchReason) ?? "(unknown)";
112923
+ process.stderr.write(`telegram gateway: gw /model relaunch UNRESOLVED agent=${getMyAgentName()} target=${target} (barrier timeout after ${resolutionTimeoutMs}ms)
112924
+ `);
112925
+ } else {
112926
+ const activePath = join68(smAgentDir, ".active-session-model");
112927
+ if (existsSync57(activePath)) {
112928
+ try {
112929
+ const launched = readFileSync60(activePath, "utf8").trim();
112930
+ const configured = (() => {
112931
+ const d = switchroomExecJson(["agent", "list"]);
112932
+ const raw = d?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
112933
+ return resolveMainModel(raw ?? undefined);
112934
+ })();
112935
+ const isApplyBoot = launched.length > 0 && launched !== configured;
112936
+ sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true });
112937
+ const modelBootCardDeps = {
112938
+ agent: getMyAgentName(),
112939
+ chat: modelSwitchMarkerChat,
112940
+ log: (line) => process.stderr.write(line),
112941
+ sendCard: (chatId, body, opts) => lockedBot.api.sendMessage(chatId, body, opts)
112942
+ };
112943
+ if (isApplyBoot) {
112944
+ sessionModelSource.setDivergenceHandler(buildServedModelDivergenceHandler(modelBootCardDeps));
112945
+ }
112946
+ const confirmation = modelSwitchReason != null ? classifyModelSwitchConfirmation({
112947
+ reason: modelSwitchReason,
112948
+ launched,
112949
+ configured
112950
+ }) : null;
112951
+ process.stderr.write(formatModelRelaunchDiagLog({
112952
+ agent: getMyAgentName(),
112953
+ launched,
112954
+ configured,
112744
112955
  confirmation,
112745
- hasSessionModelAlert: existsSync57(join68(smAgentDir, ".session-model-alert"))
112746
- });
112747
- }
112748
- } catch {}
112749
- }
112750
- const activeEffortPath = join68(smAgentDir, ".active-session-effort");
112751
- if (existsSync57(activeEffortPath)) {
112752
- try {
112753
- const launchedEffort = readFileSync60(activeEffortPath, "utf8").trim();
112754
- const configuredEffort = getConfiguredEffortForPersist();
112755
- sessionEffortOverride = launchedEffort.length > 0 && launchedEffort !== configuredEffort ? launchedEffort : null;
112756
- } catch {}
112757
- }
112758
- const alertPath = join68(smAgentDir, ".session-model-alert");
112759
- if (existsSync57(alertPath)) {
112760
- let alertText = null;
112761
- try {
112762
- alertText = readFileSync60(alertPath, "utf8").trim();
112763
- } catch {
112764
- alertText = null;
112956
+ isApplyBoot
112957
+ }));
112958
+ if (confirmation != null) {
112959
+ deliverModelSwitchBootNotice({
112960
+ ...modelBootCardDeps,
112961
+ confirmation,
112962
+ hasSessionModelAlert: existsSync57(join68(smAgentDir, ".session-model-alert"))
112963
+ });
112964
+ }
112965
+ } catch {}
112765
112966
  }
112766
- try {
112767
- unlinkSync30(alertPath);
112768
- } catch {}
112769
- if (alertText && alertText.length > 0) {
112770
- const operators = loadAccess().allowFrom;
112771
- for (const operator of operators) {
112772
- if (!operator)
112773
- continue;
112774
- lockedBot.api.sendMessage(operator, `\u26A0\uFE0F ${alertText}`).catch((err) => process.stderr.write(`telegram gateway: session-model alert send failed for ${operator}: ${err?.message ?? String(err)}
112775
- `));
112967
+ const activeEffortPath = join68(smAgentDir, ".active-session-effort");
112968
+ if (existsSync57(activeEffortPath)) {
112969
+ try {
112970
+ const launchedEffort = readFileSync60(activeEffortPath, "utf8").trim();
112971
+ const configuredEffort = getConfiguredEffortForPersist();
112972
+ sessionEffortOverride = launchedEffort.length > 0 && launchedEffort !== configuredEffort ? launchedEffort : null;
112973
+ } catch {}
112974
+ }
112975
+ const alertPath = join68(smAgentDir, ".session-model-alert");
112976
+ if (existsSync57(alertPath)) {
112977
+ let alertText = null;
112978
+ try {
112979
+ alertText = readFileSync60(alertPath, "utf8").trim();
112980
+ } catch {
112981
+ alertText = null;
112776
112982
  }
112777
- process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop \u2014 ${alertText}
112983
+ try {
112984
+ unlinkSync30(alertPath);
112985
+ } catch {}
112986
+ if (alertText && alertText.length > 0) {
112987
+ const operators = loadAccess().allowFrom;
112988
+ for (const operator of operators) {
112989
+ if (!operator)
112990
+ continue;
112991
+ lockedBot.api.sendMessage(operator, `\u26A0\uFE0F ${alertText}`).catch((err) => process.stderr.write(`telegram gateway: session-model alert send failed for ${operator}: ${err?.message ?? String(err)}
112992
+ `));
112993
+ }
112994
+ process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop \u2014 ${alertText}
112778
112995
  `);
112996
+ }
112779
112997
  }
112780
112998
  }
112781
112999
  }
@@ -761,6 +761,35 @@ function isFullyBolded(fragment: string): boolean {
761
761
  return /^\*\*[^*]+\*\*[.,:;!?]?$/.test(fragment.trim())
762
762
  }
763
763
 
764
+ /**
765
+ * Max length (markers included) of a standalone fully-bolded line that is
766
+ * treated as a legitimate pseudo-heading (`**Section**`) rather than an
767
+ * over-bolded paragraph. Single source of truth for BOTH the per-block rule
768
+ * and the global-ratio heading exemption.
769
+ */
770
+ const PSEUDO_HEADING_MAX_CHARS = 48
771
+
772
+ /**
773
+ * True when a blank-line-delimited block is a single-line pseudo-heading: one
774
+ * non-empty line that is a single fully-bolded span of ≤PSEUDO_HEADING_MAX_CHARS
775
+ * characters (`**Summary**`, `**Next steps:**`). Multi-line blocks are never
776
+ * headings, so this can never mislabel a bolded paragraph as exempt.
777
+ */
778
+ function isPseudoHeadingBlock(block: string): boolean {
779
+ const lines = block.split('\n').filter((l) => l.trim() !== '')
780
+ if (lines.length !== 1) return false
781
+ const t = lines[0].trim()
782
+ return t.length <= PSEUDO_HEADING_MAX_CHARS && isFullyBolded(t)
783
+ }
784
+
785
+ /** Diagnostic emitted when the over-bold tripwire actually removes bold. */
786
+ export interface ExcessBoldStripDiagnostic {
787
+ /** Which rule fired: the whole-message ratio, or per-block flattening. */
788
+ rule: 'global' | 'per-block'
789
+ /** Measured bold ratio: bold chars / visible (non-code) chars. */
790
+ ratio: number
791
+ }
792
+
764
793
  /** Strip `**bold**` markers from a fragment, keeping the text. */
765
794
  function unbold(fragment: string): string {
766
795
  return fragment.replace(/\*\*([^*]+)\*\*/g, '$1')
@@ -778,16 +807,24 @@ function unbold(fragment: string): string {
778
807
  * Deliberately conservative:
779
808
  * - Messages under 100 non-code characters are exempt (a short reply whose
780
809
  * one key fact is bolded is exactly the house style).
781
- * - A single-line fully-bolded paragraph of ≤48 chars is treated as a
782
- * pseudo-heading (the "**Section**" label the fleet style encourages) and
783
- * is NOT stripped by the per-block rule (it still counts toward the
784
- * global ratio).
810
+ * - A single-line fully-bolded paragraph of ≤PSEUDO_HEADING_MAX_CHARS chars
811
+ * is treated as a pseudo-heading (the "**Section**" label the fleet style
812
+ * encourages) and is NOT stripped — neither by the per-block rule NOR by
813
+ * the global-ratio rule (it still counts toward the global ratio, so a
814
+ * genuinely over-bolded message still trips, but its section headings
815
+ * survive instead of the whole reply going plain).
785
816
  * - A list with any non-fully-bolded item is left alone.
786
817
  *
787
818
  * Code spans/fences are masked (maskCodeRegions) and never counted or
788
819
  * modified. Idempotent: stripped output has no `**` spans left to trip on.
820
+ *
821
+ * `onStrip` is an optional diagnostic sink invoked exactly once, only when
822
+ * bold is actually removed, carrying which rule fired and the measured ratio.
789
823
  */
790
- export function stripExcessBold(text: string): string {
824
+ export function stripExcessBold(
825
+ text: string,
826
+ onStrip?: (d: ExcessBoldStripDiagnostic) => void,
827
+ ): string {
791
828
  if (!text.includes('**')) return text
792
829
 
793
830
  const nonce = Math.random().toString(36).slice(2)
@@ -800,14 +837,24 @@ export function stripExcessBold(text: string): string {
800
837
 
801
838
  let boldChars = 0
802
839
  for (const m of visible.matchAll(/\*\*([^*]+)\*\*/g)) boldChars += m[1].length
840
+ const ratio = boldChars / visible.length
841
+
842
+ // Blank-line-delimited blocks of the masked text (shared by both rules).
843
+ const blocks = masked.split(/\n{2,}/)
803
844
 
804
- if (boldChars / visible.length > 0.3) {
805
- // Clearly over-bolded — strip every bold span, keep the text.
806
- return restore(unbold(masked))
845
+ if (ratio > 0.3) {
846
+ // Clearly over-bolded — strip every bold span, keeping the text, EXCEPT
847
+ // standalone short pseudo-heading blocks (`**Section**`), which stay bold
848
+ // so a bold-dense digest keeps its section headings.
849
+ const rebuilt = blocks.map((block) =>
850
+ isPseudoHeadingBlock(block) ? block : unbold(block),
851
+ )
852
+ const out = rejoinBlocks(masked, rebuilt)
853
+ if (out !== masked) onStrip?.({ rule: 'global', ratio })
854
+ return restore(out)
807
855
  }
808
856
 
809
- // Per-block check on blank-line-delimited blocks of the masked text.
810
- const blocks = masked.split(/\n{2,}/)
857
+ // Per-block check on the same blocks.
811
858
  const rebuilt = blocks.map((block) => {
812
859
  const lines = block.split('\n').filter((l) => l.trim() !== '')
813
860
  if (lines.length === 0 || !block.includes('**')) return block
@@ -824,17 +871,25 @@ export function stripExcessBold(text: string): string {
824
871
  const isProseBlock = lines.every((l) => !isMarkerLine(l, placeholder))
825
872
  if (!isProseBlock) return block
826
873
  if (!lines.every((l) => isFullyBolded(l))) return block
827
- if (lines.length === 1 && lines[0].trim().length <= 48) return block
874
+ if (isPseudoHeadingBlock(block)) return block
828
875
  return unbold(block)
829
876
  })
830
877
 
831
- // Rejoin with the original gap shapes: split() lost them, so re-split the
832
- // masked text capturing the separators and interleave.
878
+ const out = rejoinBlocks(masked, rebuilt)
879
+ if (out !== masked) onStrip?.({ rule: 'per-block', ratio })
880
+ return restore(out)
881
+ }
882
+
883
+ /**
884
+ * Rejoin blocks produced by `masked.split(/\n{2,}/)` after per-block mapping,
885
+ * restoring the ORIGINAL blank-gap shapes (split() drops the separators, so
886
+ * re-capture them from the masked source and interleave).
887
+ */
888
+ function rejoinBlocks(masked: string, rebuilt: string[]): string {
833
889
  const seps = masked.match(/\n{2,}/g) ?? []
834
890
  let out = rebuilt[0] ?? ''
835
891
  for (let i = 1; i < rebuilt.length; i++) out += (seps[i - 1] ?? '\n\n') + rebuilt[i]
836
-
837
- return restore(out)
892
+ return out
838
893
  }
839
894
 
840
895
  // ---------------------------------------------------------------------------