switchroom 0.19.34 → 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 (35) hide show
  1. package/dist/auth-broker/index.js +7 -1
  2. package/dist/cli/skill-validate-pretool.mjs +15 -2
  3. package/dist/cli/switchroom.js +1005 -482
  4. package/dist/host-control/main.js +156 -4
  5. package/dist/vault/approvals/kernel-server.js +7 -1
  6. package/dist/vault/broker/server.js +7 -1
  7. package/package.json +4 -2
  8. package/profiles/_base/start.sh.hbs +37 -12
  9. package/telegram-plugin/dist/gateway/gateway.js +476 -116
  10. package/telegram-plugin/format.ts +70 -15
  11. package/telegram-plugin/gateway/gateway.ts +27 -31
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +279 -23
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  17. package/telegram-plugin/secret-detect/index.ts +24 -1
  18. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  19. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  20. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  21. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  22. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  23. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  24. package/telegram-plugin/tests/model-command.test.ts +415 -4
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  26. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  27. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  29. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  30. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  31. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  33. package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +377 -0
@@ -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
  // ---------------------------------------------------------------------------
@@ -550,6 +550,9 @@ import {
550
550
  handleModelCommand,
551
551
  classifyModelSwitchConfirmation,
552
552
  formatModelRelaunchDiagLog,
553
+ parseModelSwitchTarget,
554
+ resolveSessionModelResolutionTimeoutMs,
555
+ waitForSessionModelResolution,
553
556
  servedModelMatchesRequested,
554
557
  buildServedModelDivergenceHandler,
555
558
  deliverModelSwitchBootNotice,
@@ -564,7 +567,7 @@ import {
564
567
  MODEL_CALLBACK_PAGE_EXTERNAL,
565
568
  MODEL_CALLBACK_PAGE_MAIN,
566
569
  srFriendlyLabel,
567
- expandSrAlias,
570
+ expandModelAlias,
568
571
  isSrModel,
569
572
  isBusyRefusalText,
570
573
  isOfflineTrustedModelToken,
@@ -16961,6 +16964,9 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
16961
16964
  const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
16962
16965
  return data?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
16963
16966
  },
16967
+ // Feeds the switch-time #1978 assessment (thinkingEffortCaveat); see the
16968
+ // ModelCommandDeps.getConfiguredEffort doc for why doctor can't make it.
16969
+ getConfiguredEffort: () => getConfiguredEffortForPersist(),
16964
16970
  escapeHtml: escapeHtmlForTg,
16965
16971
  preBlock,
16966
16972
  /**
@@ -17259,7 +17265,7 @@ function persistQueuedCommandForRestart(action: ShutdownResolutionAction): strin
17259
17265
  const configured =
17260
17266
  readConfiguredDefaultModel(agentDir) ?? resolveMainModel(undefined)
17261
17267
  // Consume-once carrier: applied by the next boot, then reverts.
17262
- writeSessionModelFile(agentDir, expandSrAlias(action.arg), configured)
17268
+ writeSessionModelFile(agentDir, expandModelAlias(action.arg), configured)
17263
17269
  break
17264
17270
  }
17265
17271
  case 'clear-model':
@@ -23662,23 +23668,26 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23662
23668
  }
23663
23669
  } catch {}
23664
23670
 
23665
- // ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
23666
- //
23667
- // start.sh writes the EFFECTIVE launched model to `.active-session-model`
23668
- // on every boot (the model actually passed to `claude --model`). Re-hydrate
23669
- // the in-memory session-model override from it so `/status` and the welcome
23670
- // card stay honest after a session-relaunch restart. Only treat it as an
23671
- // override when it differs from the configured/default model — a plain boot
23672
- // on the configured model leaves the override null.
23673
- //
23674
- // Also consume the `.session-model-alert` sentinel: start.sh drops it when
23675
- // it had to DROP an sr-* override because LiteLLM was unreachable at boot
23676
- // (booting on the configured default instead of 4xx-ing against Anthropic).
23677
- // We turn it into a loud Telegram message to the operator, then delete it.
23671
+ // Rehydrate only after start.sh publishes this boot's resolved outputs.
23678
23672
  try {
23679
23673
  const smAgentDir = resolveAgentDirFromEnv()
23680
23674
  if (smAgentDir) {
23681
- const activePath = join(smAgentDir, '.active-session-model')
23675
+ const resolutionTimeoutMs = resolveSessionModelResolutionTimeoutMs(
23676
+ process.env.SWITCHROOM_SESSION_MODEL_RESOLUTION_TIMEOUT_MS,
23677
+ )
23678
+ const resolved = await waitForSessionModelResolution({
23679
+ barrierExists: () => existsSync(join(smAgentDir, '.session-model-resolved')),
23680
+ timeoutMs: resolutionTimeoutMs,
23681
+ })
23682
+ if (!resolved) {
23683
+ const target = modelSwitchReason == null
23684
+ ? '(none)'
23685
+ : (parseModelSwitchTarget(modelSwitchReason) ?? '(unknown)')
23686
+ process.stderr.write(
23687
+ `telegram gateway: gw /model relaunch UNRESOLVED agent=${getMyAgentName()} target=${target} (barrier timeout after ${resolutionTimeoutMs}ms)\n`,
23688
+ )
23689
+ } else {
23690
+ const activePath = join(smAgentDir, '.active-session-model')
23682
23691
  if (existsSync(activePath)) {
23683
23692
  try {
23684
23693
  const launched = readFileSync(activePath, 'utf8').trim()
@@ -23694,21 +23703,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23694
23703
  // a phantom session override.
23695
23704
  return resolveMainModel(raw ?? undefined)
23696
23705
  })()
23697
- // `launched !== configured` is the DETERMINISTIC "a model switch
23698
- // landed" signal (F1): the carrier is consume-once, so a launched
23699
- // model that differs from the configured default only ever
23700
- // happens on a genuine apply-boot. Seed the in-memory override
23701
- // from it — this is the ONLY success reporting, sourced from the
23702
- // real post-boot signal (`.active-session-model`), never from a
23703
- // scraped pane or an optimistic record.
23706
+ // A launched model differing from configured is a genuine apply boot.
23704
23707
  const isApplyBoot = launched.length > 0 && launched !== configured
23705
- // { verify: true } (#3427 item 4 / H1): ONLY this site arms the
23706
- // requested-vs-served tripwire — `launched` IS the token of the
23707
- // session now serving. Command-time setOverride never arms.
23708
23708
  sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })
23709
- // Boot /model cards (#3427): the divergence tripwire warn and
23710
- // the switch confirmation share one deps surface; the card
23711
- // logic lives in model-command.ts (#2996 ratchet).
23712
23709
  const modelBootCardDeps: ModelBootCardDeps = {
23713
23710
  agent: getMyAgentName(),
23714
23711
  chat: modelSwitchMarkerChat,
@@ -23719,8 +23716,6 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23719
23716
  if (isApplyBoot) {
23720
23717
  sessionModelSource.setDivergenceHandler(buildServedModelDivergenceHandler(modelBootCardDeps))
23721
23718
  }
23722
- // F1/N4: classify + log + one confirmation card. Formatters live
23723
- // in model-command.ts so this file does not inflate (#2996 ratchet).
23724
23719
  const confirmation = modelSwitchReason != null
23725
23720
  ? classifyModelSwitchConfirmation({
23726
23721
  reason: modelSwitchReason,
@@ -23790,6 +23785,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23790
23785
  process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
23791
23786
  }
23792
23787
  }
23788
+ }
23793
23789
  }
23794
23790
  } catch (err) {
23795
23791
  process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
@@ -25,6 +25,7 @@ import type {
25
25
  ToolCallResult,
26
26
  } from "./ipc-protocol.js";
27
27
  import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
28
+ import { OPERATOR_EVENT_KINDS } from "../operator-events.js";
28
29
 
29
30
  export interface IpcServerOptions {
30
31
  socketPath: string;
@@ -196,21 +197,23 @@ type SocketData = { clientId: string; buffer: string };
196
197
  * data without newline delimiters, which would cause unbounded memory growth. */
197
198
  const MAX_BUFFER_SIZE = 1024 * 1024;
198
199
 
199
- /** Allowlist of OperatorEventKind values that can arrive over IPC. Mirrors
200
- * the union in `telegram-plugin/operator-events.ts` — kept as a literal Set
201
- * here so the validator has zero cross-package type dependencies. If the
202
- * taxonomy grows, update both places. */
203
- const VALID_OPERATOR_KINDS = new Set([
204
- "credentials-expired",
205
- "credentials-invalid",
206
- "credit-exhausted",
207
- "quota-exhausted",
208
- "rate-limited",
209
- "agent-crashed",
210
- "agent-restarted-unexpectedly",
211
- "unknown-4xx",
212
- "unknown-5xx",
213
- ]);
200
+ /** Allowlist of OperatorEventKind values that can arrive over IPC. DERIVED
201
+ * from the canonical `OPERATOR_EVENT_KINDS` array in
202
+ * `telegram-plugin/operator-events.ts` — the single source of truth for the
203
+ * taxonomy — so the validator can NEVER drift out of sync with it again.
204
+ *
205
+ * History: this used to be a hand-maintained literal Set that missed the
206
+ * `provider-credit-exhausted` / `mcp-dependency-blocked` / `proxy-misconfig`
207
+ * kinds after they were added to the union. The bridge forwarded a
208
+ * `provider-credit-exhausted` operator_event; this validator rejected it as an
209
+ * "invalid IPC message shape" and dropped it, so an OpenRouter/LiteLLM 402
210
+ * credit wall produced NO loud Telegram card (the 2026-07-30 incident).
211
+ * Deriving from the canonical array closes that drift class structurally.
212
+ *
213
+ * `operator-events.ts` is a pure module (its only imports are the pure
214
+ * `format` / `raw-error-scrub` / `model-unavailable` / `provider-credit`
215
+ * leaves), so importing it here introduces no cycle back into the gateway. */
216
+ const VALID_OPERATOR_KINDS = new Set<string>(OPERATOR_EVENT_KINDS);
214
217
 
215
218
  /** Same regex as `assertSafeAgentName` and the op:* callback handler in
216
219
  * gateway.ts — keeps every entry-point that touches an agent name on the