switchroom 0.20.10 → 0.20.12

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +68 -3
  2. package/dist/auth-broker/index.js +211 -46
  3. package/dist/cli/notion-write-pretool.mjs +68 -3
  4. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  5. package/dist/cli/self-improve-stop.mjs +889 -7
  6. package/dist/cli/skill-validate-pretool.mjs +82 -3
  7. package/dist/cli/switchroom.js +5063 -2961
  8. package/dist/host-control/main.js +93 -27
  9. package/dist/vault/approvals/kernel-server.js +92 -26
  10. package/dist/vault/broker/server.js +92 -26
  11. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  14. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  15. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  17. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  18. package/profiles/default/CLAUDE.md.hbs +3 -5
  19. package/skills/switchroom-architecture/telegram.md +0 -1
  20. package/skills/switchroom-cli/SKILL.md +0 -1
  21. package/telegram-plugin/README.md +2 -11
  22. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  23. package/telegram-plugin/bridge/bridge.ts +0 -12
  24. package/telegram-plugin/chat-lock.ts +1 -1
  25. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +1454 -976
  27. package/telegram-plugin/dist/server.js +0 -12
  28. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  29. package/telegram-plugin/gateway/auth-command.ts +11 -1
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  31. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  32. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  33. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  34. package/telegram-plugin/gateway/gateway.ts +109 -149
  35. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  36. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  37. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  38. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  39. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  40. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  41. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  42. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  43. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  44. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  45. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  46. package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
  47. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  48. package/telegram-plugin/gateway/stream-render.ts +4 -4
  49. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  50. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  51. package/telegram-plugin/hooks/hooks.json +13 -12
  52. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  53. package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
  54. package/telegram-plugin/slot-banner-driver.ts +42 -5
  55. package/telegram-plugin/status-pin.ts +2 -5
  56. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  57. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  58. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  59. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  60. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  61. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  62. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  63. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  64. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  65. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  66. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  68. package/telegram-plugin/throttle-tier.ts +12 -3
  69. package/telegram-plugin/turn-flush-safety.ts +97 -0
  70. package/telegram-plugin/worker-activity-feed.ts +1 -1
  71. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  72. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  73. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  74. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
@@ -24962,18 +24962,6 @@ var init_bridge = __esm(async () => {
24962
24962
  required: ["chat_id"]
24963
24963
  }
24964
24964
  },
24965
- {
24966
- name: "pin_message",
24967
- description: "Pin a message in a Telegram chat. Useful for important outputs the user wants to find later. Requires admin rights in groups.",
24968
- inputSchema: {
24969
- type: "object",
24970
- properties: {
24971
- chat_id: { type: "string" },
24972
- message_id: { type: "string" }
24973
- },
24974
- required: ["chat_id", "message_id"]
24975
- }
24976
- },
24977
24965
  {
24978
24966
  name: "delete_message",
24979
24967
  description: "Delete a message the bot previously sent. Prefer edit_message if you just want to update text \u2014 delete_message is for true removal.",
@@ -103,6 +103,7 @@ export type FallbackDeliveryOutcomeKind =
103
103
  | 'all-blocked'
104
104
  | 'no-old-active'
105
105
  | 'no-eligible-target'
106
+ | 'strict-pinned'
106
107
  | 'error';
107
108
 
108
109
  /**
@@ -328,7 +328,15 @@ export interface AuthBrokerClient {
328
328
  * `setActive` this needs no admin — the account is derived from the caller's
329
329
  * identity — so auto-fallback works from any agent.
330
330
  */
331
- markExhausted(until?: number): Promise<{ account: string; rolled: string[]; rolledTo?: string | null }>
331
+ markExhausted(until?: number): Promise<{
332
+ account: string
333
+ rolled: string[]
334
+ rolledTo?: string | null
335
+ /** True when the caller is a strict-pinned agent (`auth.strict`): its
336
+ * null `rolledTo` means "riding out the wall", not fleet all-blocked.
337
+ * Absent on pre-flag brokers. */
338
+ caller_pinned_strict?: boolean
339
+ }>
332
340
  /**
333
341
  * 429 throttle tier (broker `mark-throttled`). Records a transient
334
342
  * per-account rate limit on the CALLER's own account — `throttled_until`
@@ -344,6 +352,8 @@ export interface AuthBrokerClient {
344
352
  throttled_until: number
345
353
  escalated: boolean
346
354
  rolledTo?: string | null
355
+ /** See markExhausted.caller_pinned_strict. */
356
+ caller_pinned_strict?: boolean
347
357
  }>
348
358
  rmAccount(label: string): Promise<{ label: string }>
349
359
  refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
@@ -70,6 +70,11 @@ import {
70
70
  getProposal as getSkillProposal,
71
71
  setProposalStatus as setSkillProposalStatus,
72
72
  } from '../../src/self-improve/skill-proposals.js'
73
+ import { parseEvalCaseProposalCallback } from './eval-case-proposal-card.js'
74
+ import {
75
+ getEvalCaseProposal,
76
+ setEvalCaseProposalStatus,
77
+ } from '../../src/self-improve/eval-case-proposals.js'
73
78
  import { maskToken } from '../secret-detect/mask.js'
74
79
  import {
75
80
  defaultVaultWrite,
@@ -1328,6 +1333,100 @@ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<
1328
1333
  )
1329
1334
  }
1330
1335
 
1336
+ /**
1337
+ * Eval-case proposal callback (RFC amendment §"corrections as eval cases").
1338
+ *
1339
+ * On Approve this runs the DETERMINISTIC `apply-eval-case` applier via
1340
+ * execFileSync — NOT a model turn — so the case lands byte-exact as approved
1341
+ * (precedent: the `switchroom vault set` on-tap execFileSync in this file).
1342
+ * The operator's tap IS the authorization; the gateway sets the proposal
1343
+ * `approved` BEFORE invoking the applier (whose own status check is
1344
+ * defense-in-depth, MJ2, since the store is agent-writable).
1345
+ */
1346
+ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promise<void> {
1347
+ const senderId = String(ctx.from?.id ?? '')
1348
+ const access = loadAccess()
1349
+ if (!access.allowFrom.includes(senderId)) {
1350
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1351
+ return
1352
+ }
1353
+ const parsed = parseEvalCaseProposalCallback(data)
1354
+ if (parsed == null) {
1355
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
1356
+ return
1357
+ }
1358
+ const stateDir = process.env.TELEGRAM_STATE_DIR
1359
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1360
+ if (stateDir == null || stateDir.length === 0) {
1361
+ await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
1362
+ return
1363
+ }
1364
+ const proposal = getEvalCaseProposal(stateDir, parsed.id)
1365
+ if (proposal == null) {
1366
+ await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
1367
+ if (ctx.callbackQuery?.message) {
1368
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
1369
+ }
1370
+ return
1371
+ }
1372
+ if (proposal.status !== 'pending') {
1373
+ await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
1374
+ if (ctx.callbackQuery?.message) {
1375
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
1376
+ }
1377
+ return
1378
+ }
1379
+
1380
+ if (parsed.action === 'deny') {
1381
+ setEvalCaseProposalStatus(stateDir, parsed.id, 'rejected')
1382
+ await ctx.answerCallbackQuery({ text: '🚫 Dismissed.' }).catch(() => {})
1383
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
1384
+ await ctx
1385
+ .editMessageText(
1386
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
1387
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
1388
+ )
1389
+ .catch(() => {})
1390
+ }
1391
+ return
1392
+ }
1393
+
1394
+ // Approve — authorize, then run the deterministic applier.
1395
+ setEvalCaseProposalStatus(stateDir, parsed.id, 'approved')
1396
+ await ctx.answerCallbackQuery({ text: '✅ Adding the eval case…' }).catch(() => {})
1397
+
1398
+ const cli = process.env.SWITCHROOM_CLI_PATH ?? 'switchroom'
1399
+ let applyOk = true
1400
+ let applyOut = ''
1401
+ try {
1402
+ applyOut = execFileSync(
1403
+ cli,
1404
+ ['self-improve', 'apply-eval-case', '--id', parsed.id],
1405
+ { encoding: 'utf8', timeout: 15000, env: process.env },
1406
+ ).trim()
1407
+ } catch (err) {
1408
+ applyOk = false
1409
+ const e = err as { stdout?: string; stderr?: string; message?: string }
1410
+ applyOut = [e.stdout, e.stderr, e.message].filter(Boolean).join('\n').trim()
1411
+ }
1412
+
1413
+ const footer = applyOk
1414
+ ? '✅ <i>Added as a regression test.</i>'
1415
+ : `⚠️ <i>Apply failed:</i> ${escapeHtmlForTg(applyOut.slice(0, 200))}`
1416
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
1417
+ await ctx
1418
+ .editMessageText(
1419
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n${footer}`,
1420
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
1421
+ )
1422
+ .catch(() => {})
1423
+ }
1424
+ process.stderr.write(
1425
+ `telegram gateway: eval_case_apply agent=${agent} proposal=${proposal.id} ` +
1426
+ `slug=${proposal.skill_slug} ok=${applyOk}\n`,
1427
+ )
1428
+ }
1429
+
1331
1430
  /**
1332
1431
  * hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
1333
1432
  * mmp:approve:<stageId> — declare the model: append it to the agent's
@@ -3233,6 +3332,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
3233
3332
  performVaultAccessApproval,
3234
3333
  resolveAccessApprovalPassphraseMismatch,
3235
3334
  handleSkillProposalCallback,
3335
+ handleEvalCaseProposalCallback,
3236
3336
  handleMentalModelProposeCallback,
3237
3337
  handleVaultRequestAccessCallback,
3238
3338
  handleVaultRequestSaveCallback,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * One-tap eval-case proposal card (RFC amendment §"corrections as eval
3
+ * cases").
4
+ *
5
+ * `switchroom self-improve add-eval-case` sends a `post_eval_case_proposal`
6
+ * IPC; the gateway persists it (eval-case-proposals store) and renders THIS
7
+ * card. Unlike the skill-proposal card, tapping Approve does NOT inject a
8
+ * model turn — the gateway callback runs the DETERMINISTIC `apply-eval-case`
9
+ * applier via execFileSync, so the case lands byte-exact as approved.
10
+ *
11
+ * Pure builders, kept out of gateway.ts so the card text + callback shape are
12
+ * pinned by tests independent of the bot plumbing.
13
+ *
14
+ * callback_data shape (must fit Telegram's 64-byte limit):
15
+ * evcase:approve:<id>
16
+ * evcase:deny:<id>
17
+ */
18
+
19
+ export const EVAL_CASE_PROPOSAL_CALLBACK_PREFIX = 'evcase:'
20
+
21
+ /** Narrow view of a stored eval-case proposal the card needs. */
22
+ export interface EvalCaseProposalView {
23
+ id: string
24
+ skill_slug: string
25
+ held_out: boolean
26
+ case: { prompt: string; expectations?: string[] }
27
+ }
28
+
29
+ function escapeHtml(s: string): string {
30
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
31
+ }
32
+
33
+ /** Truncate a single-line preview of the prompt for the card. */
34
+ function previewPrompt(prompt: string, max = 240): string {
35
+ const oneLine = prompt.replace(/\s+/g, ' ').trim()
36
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine
37
+ }
38
+
39
+ /** Render the eval-case proposal card text (HTML parse mode). */
40
+ export function renderEvalCaseProposalCard(p: EvalCaseProposalView): string {
41
+ const lines: string[] = []
42
+ lines.push(`🧪 <b>Eval case proposed</b>`)
43
+ lines.push('')
44
+ lines.push(`<b>Skill:</b> <code>${escapeHtml(p.skill_slug)}</code>`)
45
+ if (p.held_out) lines.push(`<i>held-out (won't be added to evals.json)</i>`)
46
+ lines.push(`<b>Test prompt:</b> ${escapeHtml(previewPrompt(p.case.prompt))}`)
47
+ const exps = p.case.expectations ?? []
48
+ if (exps.length > 0) {
49
+ lines.push('<b>Checks:</b>')
50
+ for (const e of exps.slice(0, 5)) lines.push(`• ${escapeHtml(e.slice(0, 120))}`)
51
+ }
52
+ lines.push('')
53
+ lines.push(
54
+ '<i>Tap Add to append it as a regression test (re-scanned for ' +
55
+ 'secrets/PII, written byte-exact — no model turn). Tap Dismiss to drop it.</i>',
56
+ )
57
+ return lines.join('\n')
58
+ }
59
+
60
+ /** Inline keyboard for the card. */
61
+ export function evalCaseProposalKeyboard(id: string): {
62
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
63
+ } {
64
+ return {
65
+ inline_keyboard: [
66
+ [
67
+ { text: '✅ Add case', callback_data: `${EVAL_CASE_PROPOSAL_CALLBACK_PREFIX}approve:${id}` },
68
+ { text: '🚫 Dismiss', callback_data: `${EVAL_CASE_PROPOSAL_CALLBACK_PREFIX}deny:${id}` },
69
+ ],
70
+ ],
71
+ }
72
+ }
73
+
74
+ /** Parse an `evcase:` callback into { action, id }, or null if not ours. */
75
+ export function parseEvalCaseProposalCallback(
76
+ data: string,
77
+ ): { action: 'approve' | 'deny'; id: string } | null {
78
+ if (!data.startsWith(EVAL_CASE_PROPOSAL_CALLBACK_PREFIX)) return null
79
+ const rest = data.slice(EVAL_CASE_PROPOSAL_CALLBACK_PREFIX.length)
80
+ const idx = rest.indexOf(':')
81
+ if (idx < 0) return null
82
+ const action = rest.slice(0, idx)
83
+ const id = rest.slice(idx + 1)
84
+ if ((action !== 'approve' && action !== 'deny') || id.length === 0) return null
85
+ return { action, id }
86
+ }
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ resetFleetFallbackNoticeCooldowns,
4
+ shouldSendAllBlockedNotice,
5
+ shouldSendStrictPinnedNotice,
6
+ } from './fleet-fallback-notice-cooldown.js'
7
+
8
+ const COOLDOWN_MS = 30 * 60_000
9
+ // Realistic wall-clock base: the initial state is { lastSentAtMs: 0 }, so the
10
+ // first call only "sends" when `now` is at least COOLDOWN_MS past the epoch —
11
+ // always true for a real Date.now(), so anchor the fakes there too.
12
+ const BASE = 1_700_000_000_000
13
+
14
+ function captureStderr(): { lines: string[]; restore: () => void } {
15
+ const lines: string[] = []
16
+ const spy = vi
17
+ .spyOn(process.stderr, 'write')
18
+ .mockImplementation((chunk: string | Uint8Array): boolean => {
19
+ lines.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
20
+ return true
21
+ })
22
+ return { lines, restore: () => spy.mockRestore() }
23
+ }
24
+
25
+ afterEach(() => {
26
+ // Reset the module-scope windows so each test starts from a clean gate.
27
+ resetFleetFallbackNoticeCooldowns()
28
+ vi.restoreAllMocks()
29
+ })
30
+
31
+ describe('fleet-fallback notice cooldown gates', () => {
32
+ it('sends the first all-blocked card and suppresses a repeat inside the window', () => {
33
+ const { lines, restore } = captureStderr()
34
+ const t0 = BASE
35
+ expect(shouldSendAllBlockedNotice('agentA', t0)).toBe(true)
36
+ // Re-fire well inside the 30-min window: suppressed, with the exact log text.
37
+ expect(shouldSendAllBlockedNotice('agentA', t0 + 60_000)).toBe(false)
38
+ expect(lines).toEqual([
39
+ 'telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=agentA\n',
40
+ ])
41
+ // After the window elapses, it sends again.
42
+ expect(shouldSendAllBlockedNotice('agentA', t0 + COOLDOWN_MS)).toBe(true)
43
+ restore()
44
+ })
45
+
46
+ it('sends the first strict-pinned card and suppresses a repeat inside the window', () => {
47
+ const { lines, restore } = captureStderr()
48
+ const t0 = BASE + 100_000_000
49
+ expect(shouldSendStrictPinnedNotice('agentB', t0)).toBe(true)
50
+ expect(shouldSendStrictPinnedNotice('agentB', t0 + 60_000)).toBe(false)
51
+ expect(lines).toEqual([
52
+ 'telegram gateway: [fleet-fallback] strict-pinned card suppressed (cooldown) agent=agentB\n',
53
+ ])
54
+ restore()
55
+ })
56
+
57
+ it('keeps the two windows independent — a strict card never suppresses an all-blocked', () => {
58
+ const t0 = BASE + 200_000_000
59
+ expect(shouldSendStrictPinnedNotice('agentC', t0)).toBe(true)
60
+ // All-blocked has its own window and is unaffected by the strict send above.
61
+ expect(shouldSendAllBlockedNotice('agentC', t0 + 1000)).toBe(true)
62
+ })
63
+
64
+ it('reset re-arms both windows so a post-recovery transition emits promptly', () => {
65
+ const t0 = BASE + 300_000_000
66
+ expect(shouldSendAllBlockedNotice('agentD', t0)).toBe(true)
67
+ expect(shouldSendStrictPinnedNotice('agentD', t0)).toBe(true)
68
+ // A successful swap resets both windows.
69
+ resetFleetFallbackNoticeCooldowns()
70
+ // Immediately after reset, both send again despite being inside the window.
71
+ expect(shouldSendAllBlockedNotice('agentD', t0 + 1000)).toBe(true)
72
+ expect(shouldSendStrictPinnedNotice('agentD', t0 + 1000)).toBe(true)
73
+ })
74
+ })
@@ -0,0 +1,71 @@
1
+ /**
2
+ * fleet-fallback-notice-cooldown.ts — per-gateway cooldown gates for the two
3
+ * NO-OP fleet-fallback outcomes: "all accounts blocked" and "strict-pinned".
4
+ *
5
+ * Both outcomes make `doFireFleetAutoFallback` return false WITHOUT swapping the
6
+ * active account, so the `fleetFallbackGate` dedup window never arms for them and
7
+ * the ~60s `quota_wall_detected` re-trigger would otherwise re-broadcast the
8
+ * identical card every minute for the life of the wall. Each outcome gets its own
9
+ * per-gateway cooldown window (separate state so a strict-pinned card never
10
+ * suppresses a genuine later all-blocked, or vice versa). A successful swap resets
11
+ * both windows so a fresh transition after recovery is not stale-suppressed.
12
+ *
13
+ * Extracted verbatim from gateway.ts (switchroom#4442) to keep the inline notice
14
+ * logic out of the file the line-ratchet guards. Behaviour is byte-identical to
15
+ * the previous inline gates: same 30-min cooldown window (via
16
+ * `evaluateAllBlockedNotice`), same stderr suppress-log text, same
17
+ * return-false-on-suppress contract.
18
+ */
19
+
20
+ import {
21
+ evaluateAllBlockedNotice,
22
+ type FallbackAllBlockedNoticeState,
23
+ } from '../auto-fallback-fleet.js'
24
+
25
+ let allBlockedState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
26
+ let strictPinnedState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
27
+
28
+ /** Reset both cooldown windows — called on a successful account swap. */
29
+ export function resetFleetFallbackNoticeCooldowns(): void {
30
+ allBlockedState = { lastSentAtMs: 0 }
31
+ strictPinnedState = { lastSentAtMs: 0 }
32
+ }
33
+
34
+ /**
35
+ * Gate the "all accounts blocked" card. Returns true if it should be sent now
36
+ * (advancing the cooldown window); false if suppressed by the window, writing the
37
+ * same stderr suppress log the gateway used inline.
38
+ */
39
+ export function shouldSendAllBlockedNotice(
40
+ triggerAgent: string,
41
+ now: number = Date.now(),
42
+ ): boolean {
43
+ const verdict = evaluateAllBlockedNotice(allBlockedState, now)
44
+ if (!verdict.send) {
45
+ process.stderr.write(
46
+ `telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=${triggerAgent}\n`,
47
+ )
48
+ return false
49
+ }
50
+ allBlockedState = verdict.next
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * Gate the "strict-pinned" card. Same contract as `shouldSendAllBlockedNotice`,
56
+ * against the separate strict-pinned cooldown window.
57
+ */
58
+ export function shouldSendStrictPinnedNotice(
59
+ triggerAgent: string,
60
+ now: number = Date.now(),
61
+ ): boolean {
62
+ const verdict = evaluateAllBlockedNotice(strictPinnedState, now)
63
+ if (!verdict.send) {
64
+ process.stderr.write(
65
+ `telegram gateway: [fleet-fallback] strict-pinned card suppressed (cooldown) agent=${triggerAgent}\n`,
66
+ )
67
+ return false
68
+ }
69
+ strictPinnedState = verdict.next
70
+ return true
71
+ }