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.
- package/dist/auth-broker/index.js +7 -1
- package/dist/cli/skill-validate-pretool.mjs +15 -2
- package/dist/cli/switchroom.js +1005 -482
- package/dist/host-control/main.js +156 -4
- package/dist/vault/approvals/kernel-server.js +7 -1
- package/dist/vault/broker/server.js +7 -1
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/dist/gateway/gateway.js +476 -116
- package/telegram-plugin/format.ts +70 -15
- package/telegram-plugin/gateway/gateway.ts +27 -31
- package/telegram-plugin/gateway/ipc-server.ts +18 -15
- package/telegram-plugin/gateway/model-command.ts +279 -23
- package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
- package/telegram-plugin/operator-events.ts +40 -16
- package/telegram-plugin/secret-detect/db-uri.ts +90 -0
- package/telegram-plugin/secret-detect/index.ts +24 -1
- package/telegram-plugin/secret-detect/inert-values.ts +147 -0
- package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
- package/telegram-plugin/secret-detect/patterns.ts +24 -4
- package/telegram-plugin/tests/format-consistency.test.ts +93 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
- package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
- package/telegram-plugin/tests/model-command.test.ts +415 -4
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
- package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
- package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
- package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
- package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
- 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 ≤
|
|
782
|
-
* pseudo-heading (the "**Section**" label the fleet style
|
|
783
|
-
* is NOT stripped by the per-block rule
|
|
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(
|
|
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 (
|
|
805
|
-
// Clearly over-bolded — strip every bold span,
|
|
806
|
-
|
|
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
|
|
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 (
|
|
874
|
+
if (isPseudoHeadingBlock(block)) return block
|
|
828
875
|
return unbold(block)
|
|
829
876
|
})
|
|
830
877
|
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
200
|
-
* the
|
|
201
|
-
*
|
|
202
|
-
* taxonomy
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|