switchroom 0.20.9 → 0.20.11
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/bin/handoff-briefing.sh +57 -5
- package/bin/working-state-reload-hook.sh +262 -0
- package/dist/agent-scheduler/index.js +65 -2
- package/dist/auth-broker/index.js +204 -24
- package/dist/cli/notion-write-pretool.mjs +65 -2
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +3699 -2110
- package/dist/host-control/main.js +67 -4
- package/dist/vault/approvals/kernel-server.js +66 -3
- package/dist/vault/broker/server.js +66 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +49 -0
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/dist/gateway/gateway.js +1414 -918
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +85 -90
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/vendor/hindsight-memory/CHANGELOG.md +31 -0
- package/vendor/hindsight-memory/hooks/hooks.json +2 -1
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/session_start.py +35 -8
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session privacy state — the gateway side of the `/private` `/public`
|
|
3
|
+
* feature (switchroom private-mode).
|
|
4
|
+
*
|
|
5
|
+
* The operator can pause Hindsight auto-retain for a stretch of a session with
|
|
6
|
+
* `/private`, then resume it with `/public`. The pause is recorded here as a
|
|
7
|
+
* list of half-open time intervals in a small JSON state file that the Python
|
|
8
|
+
* retain side (`vendor/hindsight-memory`) reads to EXCLUDE any turn whose
|
|
9
|
+
* timestamp falls inside an interval from memory.
|
|
10
|
+
*
|
|
11
|
+
* ── The state-file contract (MUST match the Python reader exactly) ──────────
|
|
12
|
+
* Path: `${TELEGRAM_STATE_DIR}/privacy-state.json` (dir resolved the same way
|
|
13
|
+
* `src/cli/self-improve-stop.ts:resolveStateDir()` does). Schema:
|
|
14
|
+
*
|
|
15
|
+
* { "version": 1,
|
|
16
|
+
* "intervals": [
|
|
17
|
+
* { "start": "2026-08-06T02:00:25.558Z", "end": "2026-08-06T02:05:10.100Z" },
|
|
18
|
+
* { "start": "2026-08-06T02:10:00.000Z", "end": null }
|
|
19
|
+
* ] }
|
|
20
|
+
*
|
|
21
|
+
* `end: null` = an OPEN interval = "private right now". At most one is open at
|
|
22
|
+
* a time. A missing file (or `{"intervals":[]}`) means public — the default.
|
|
23
|
+
* Timestamps are ISO-8601 via `new Date().toISOString()`.
|
|
24
|
+
*
|
|
25
|
+
* ── Invariants ──────────────────────────────────────────────────────────────
|
|
26
|
+
* - All writes are ATOMIC (tmp + fsync + rename via `atomicWriteFileSync`),
|
|
27
|
+
* so a crash mid-write can never leave the Python reader a torn file.
|
|
28
|
+
* - All reads are BEST-EFFORT and never throw: a missing, unreadable, or
|
|
29
|
+
* corrupt file resolves to the public default. Losing this state is
|
|
30
|
+
* fail-safe (memory records rather than drops), so — unlike the security
|
|
31
|
+
* stores — a corrupt file here is tolerated silently rather than
|
|
32
|
+
* quarantined.
|
|
33
|
+
* - `openPrivateInterval` / `closePrivateInterval` are IDEMPOTENT: a second
|
|
34
|
+
* `/private` while already private is a no-op, and `/public` while already
|
|
35
|
+
* public is a no-op.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync, mkdirSync } from 'node:fs'
|
|
39
|
+
import { homedir } from 'node:os'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
|
|
42
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
43
|
+
|
|
44
|
+
/** One half-open privacy interval. `end: null` = still open ("private now"). */
|
|
45
|
+
export interface PrivacyInterval {
|
|
46
|
+
start: string
|
|
47
|
+
end: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The on-disk shape of `privacy-state.json`. */
|
|
51
|
+
export interface PrivacyState {
|
|
52
|
+
version: 1
|
|
53
|
+
intervals: PrivacyInterval[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The public (default) state — no private intervals. */
|
|
57
|
+
export function emptyPrivacyState(): PrivacyState {
|
|
58
|
+
return { version: 1, intervals: [] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Loud, verbatim operator-facing strings ──────────────────────────────────
|
|
62
|
+
// Exported so the gateway command handlers and the boot alert use the exact
|
|
63
|
+
// wording the spec pins (and so tests assert against a single source).
|
|
64
|
+
|
|
65
|
+
/** Reply to `/private`. */
|
|
66
|
+
export const PRIVATE_ON_REPLY =
|
|
67
|
+
'🔒 Private mode ON — memory writing paused. Nothing said until /public is stored.'
|
|
68
|
+
|
|
69
|
+
/** Reply to `/public`. */
|
|
70
|
+
export const PUBLIC_REPLY =
|
|
71
|
+
'🔓 Public mode — memory writing resumed. The private stretch was excluded from memory.'
|
|
72
|
+
|
|
73
|
+
/** Loud alert posted when a genuine session start reset a leftover open interval. */
|
|
74
|
+
export const SESSION_RESET_ALERT =
|
|
75
|
+
'🔓 New session — memory writing is ON by default. Private mode from the previous session was reset.'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Agent state dir — set by start.sh; resolved identically to
|
|
79
|
+
* `self-improve-stop.ts:resolveStateDir()` so the gateway writer and the
|
|
80
|
+
* Python reader agree on where `privacy-state.json` lives.
|
|
81
|
+
*/
|
|
82
|
+
export function resolvePrivacyStateDir(): string {
|
|
83
|
+
return (
|
|
84
|
+
process.env.TELEGRAM_STATE_DIR ??
|
|
85
|
+
join(homedir(), '.claude', 'channels', 'telegram')
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Absolute path to the shared state file. */
|
|
90
|
+
export function privacyStatePath(stateDir: string = resolvePrivacyStateDir()): string {
|
|
91
|
+
return join(stateDir, 'privacy-state.json')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True iff `v` is a well-formed interval object. */
|
|
95
|
+
function isInterval(v: unknown): v is PrivacyInterval {
|
|
96
|
+
if (v === null || typeof v !== 'object') return false
|
|
97
|
+
const o = v as Record<string, unknown>
|
|
98
|
+
if (typeof o.start !== 'string') return false
|
|
99
|
+
return o.end === null || typeof o.end === 'string'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the current privacy state. BEST-EFFORT: a missing / unreadable /
|
|
104
|
+
* corrupt file resolves to the public default and NEVER throws. Only
|
|
105
|
+
* well-formed intervals survive; a partially-corrupt array is filtered down to
|
|
106
|
+
* its valid members rather than discarded wholesale.
|
|
107
|
+
*/
|
|
108
|
+
export function readPrivacyState(stateDir: string = resolvePrivacyStateDir()): PrivacyState {
|
|
109
|
+
try {
|
|
110
|
+
const raw = readFileSync(privacyStatePath(stateDir), 'utf8')
|
|
111
|
+
const parsed: unknown = JSON.parse(raw)
|
|
112
|
+
if (parsed === null || typeof parsed !== 'object') return emptyPrivacyState()
|
|
113
|
+
const rawIntervals = (parsed as Record<string, unknown>).intervals
|
|
114
|
+
if (!Array.isArray(rawIntervals)) return emptyPrivacyState()
|
|
115
|
+
return { version: 1, intervals: rawIntervals.filter(isInterval) }
|
|
116
|
+
} catch {
|
|
117
|
+
return emptyPrivacyState()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Atomically persist `state`. BEST-EFFORT: a write failure is logged to stderr
|
|
123
|
+
* and swallowed so a transient fs error can never crash a command handler or
|
|
124
|
+
* the boot path. (Losing the write is fail-safe — memory records the turn.)
|
|
125
|
+
*/
|
|
126
|
+
function writePrivacyState(state: PrivacyState, stateDir: string): void {
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(stateDir, { recursive: true })
|
|
129
|
+
} catch {
|
|
130
|
+
/* dir may already exist / be unwritable — the write below reports */
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
atomicWriteFileSync(privacyStatePath(stateDir), JSON.stringify(state), 0o600)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
`telegram gateway: privacy-state write failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** True iff an interval is currently open ("private right now"). */
|
|
142
|
+
export function isPrivate(state: PrivacyState): boolean {
|
|
143
|
+
return state.intervals.some(i => i.end === null)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Start a private stretch. IDEMPOTENT: if an interval is already open this is a
|
|
148
|
+
* no-op (a second `/private` doesn't stack).
|
|
149
|
+
*/
|
|
150
|
+
export function openPrivateInterval(
|
|
151
|
+
now: Date = new Date(),
|
|
152
|
+
stateDir: string = resolvePrivacyStateDir(),
|
|
153
|
+
): void {
|
|
154
|
+
const state = readPrivacyState(stateDir)
|
|
155
|
+
if (isPrivate(state)) return
|
|
156
|
+
state.intervals.push({ start: now.toISOString(), end: null })
|
|
157
|
+
writePrivacyState(state, stateDir)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* End the current private stretch. IDEMPOTENT: if no interval is open (already
|
|
162
|
+
* public) this is a no-op.
|
|
163
|
+
*/
|
|
164
|
+
export function closePrivateInterval(
|
|
165
|
+
now: Date = new Date(),
|
|
166
|
+
stateDir: string = resolvePrivacyStateDir(),
|
|
167
|
+
): void {
|
|
168
|
+
const state = readPrivacyState(stateDir)
|
|
169
|
+
const open = state.intervals.find(i => i.end === null)
|
|
170
|
+
if (!open) return
|
|
171
|
+
open.end = now.toISOString()
|
|
172
|
+
writePrivacyState(state, stateDir)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Truncate the state file back to the public default. */
|
|
176
|
+
export function resetToPublic(stateDir: string = resolvePrivacyStateDir()): void {
|
|
177
|
+
writePrivacyState(emptyPrivacyState(), stateDir)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Outcome of a session-start reset. */
|
|
181
|
+
export interface SessionResetResult {
|
|
182
|
+
/** True iff an OPEN interval existed and was reset (a private→public transition). */
|
|
183
|
+
hadOpenInterval: boolean
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Reset privacy to public at a GENUINE session start (cold boot / crash /
|
|
188
|
+
* planned restart / `/clear`). Always truncates the state file. If — and only
|
|
189
|
+
* if — an OPEN interval existed (the previous session ended still private),
|
|
190
|
+
* `onOpenIntervalReset` is invoked so the caller can post the loud alert. When
|
|
191
|
+
* the previous session was already public there is no transition, so no alert
|
|
192
|
+
* fires (silent reset).
|
|
193
|
+
*
|
|
194
|
+
* The alert is delegated to a callback rather than sent here so this module
|
|
195
|
+
* stays free of gateway/bot dependencies and unit-testable in isolation.
|
|
196
|
+
*/
|
|
197
|
+
export function resetPrivacyOnGenuineSessionStart(opts: {
|
|
198
|
+
stateDir?: string
|
|
199
|
+
onOpenIntervalReset?: () => void
|
|
200
|
+
} = {}): SessionResetResult {
|
|
201
|
+
const stateDir = opts.stateDir ?? resolvePrivacyStateDir()
|
|
202
|
+
const hadOpenInterval = isPrivate(readPrivacyState(stateDir))
|
|
203
|
+
resetToPublic(stateDir)
|
|
204
|
+
if (hadOpenInterval) opts.onOpenIntervalReset?.()
|
|
205
|
+
return { hadOpenInterval }
|
|
206
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-improvement proposal IPC handlers, lifted out of gateway.ts.
|
|
3
|
+
*
|
|
4
|
+
* Both `post_skill_proposal` (#2670 one-tap self-improvement) and
|
|
5
|
+
* `post_eval_case_proposal` (RFC amendment §"corrections as eval cases")
|
|
6
|
+
* arrive over the per-agent gateway socket, are chat-fenced via
|
|
7
|
+
* `assertAllowedChat`, persisted to their respective store, and rendered as
|
|
8
|
+
* an Approve/Dismiss card. The store transition + apply on Approve are owned
|
|
9
|
+
* by the callback handlers (so a gateway restart between post and tap still
|
|
10
|
+
* resolves), NOT here.
|
|
11
|
+
*
|
|
12
|
+
* These live in a module (not inline in gateway.ts) so the gateway anti-
|
|
13
|
+
* inflation line ratchet stays flat and the handler bodies are unit-testable
|
|
14
|
+
* against a stubbed `bot` / `swallowingApiCall`. gateway.ts keeps only a thin
|
|
15
|
+
* delegate per handler.
|
|
16
|
+
*
|
|
17
|
+
* The `bot.api.sendMessage` calls are wrapped in the injected
|
|
18
|
+
* `swallowingApiCall` exactly as they were in gateway.ts — the bot-api-wrapping
|
|
19
|
+
* lint recognises the wrapper by name in the surrounding context.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Bot, Context } from 'grammy'
|
|
23
|
+
import type { RetryCallOpts } from '../retry-api-call.js'
|
|
24
|
+
import type { PostSkillProposalMessage, PostEvalCaseProposalMessage } from './ipc-protocol.js'
|
|
25
|
+
import { renderSkillProposalCard, skillProposalKeyboard } from './skill-proposal-card.js'
|
|
26
|
+
import { renderEvalCaseProposalCard, evalCaseProposalKeyboard } from './eval-case-proposal-card.js'
|
|
27
|
+
import {
|
|
28
|
+
enqueueProposal as enqueueSkillProposal,
|
|
29
|
+
isSuppressed as isSkillProposalSuppressed,
|
|
30
|
+
} from '../../src/self-improve/skill-proposals.js'
|
|
31
|
+
import { enqueueEvalCaseProposal } from '../../src/self-improve/eval-case-proposals.js'
|
|
32
|
+
|
|
33
|
+
/** Collaborators the gateway injects into each handler. */
|
|
34
|
+
export interface ProposalWiringDeps {
|
|
35
|
+
bot: Bot<Context>
|
|
36
|
+
assertAllowedChat: (chatId: string) => void
|
|
37
|
+
swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* #2670 one-tap self-improvement — persist a skill-improvement proposal and
|
|
42
|
+
* post its Approve/Dismiss card. Dedups against still-live rejection
|
|
43
|
+
* fingerprints so a dismissed proposal never re-surfaces.
|
|
44
|
+
*/
|
|
45
|
+
export function handlePostSkillProposal(
|
|
46
|
+
msg: PostSkillProposalMessage,
|
|
47
|
+
deps: ProposalWiringDeps,
|
|
48
|
+
): void {
|
|
49
|
+
const { bot, assertAllowedChat, swallowingApiCall } = deps
|
|
50
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
51
|
+
if (self && msg.agentName !== self) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`telegram gateway: post_skill_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
54
|
+
)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
assertAllowedChat(msg.chatId)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
|
|
62
|
+
)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
66
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
67
|
+
process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
// Dedup against still-live rejection fingerprints — never re-surface a
|
|
71
|
+
// proposal the operator already dismissed.
|
|
72
|
+
if (isSkillProposalSuppressed(stateDir, {
|
|
73
|
+
lesson: msg.lesson,
|
|
74
|
+
draft: msg.draft,
|
|
75
|
+
skill_slug: msg.skillSlug,
|
|
76
|
+
})) {
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const proposal = enqueueSkillProposal(stateDir, {
|
|
83
|
+
skill_slug: msg.skillSlug,
|
|
84
|
+
is_new: msg.isNew,
|
|
85
|
+
lesson: msg.lesson,
|
|
86
|
+
draft: msg.draft,
|
|
87
|
+
evidence: msg.evidence,
|
|
88
|
+
chat_id: Number(msg.chatId),
|
|
89
|
+
// Provenance — absent ⇒ the store's back-compat default (skill-synthesis).
|
|
90
|
+
...(msg.origin != null ? { origin: msg.origin } : {}),
|
|
91
|
+
})
|
|
92
|
+
const cardText = renderSkillProposalCard({
|
|
93
|
+
id: proposal.id,
|
|
94
|
+
skill_slug: proposal.skill_slug,
|
|
95
|
+
is_new: proposal.is_new,
|
|
96
|
+
lesson: proposal.lesson,
|
|
97
|
+
evidence: proposal.evidence,
|
|
98
|
+
skill_md: proposal.draft['SKILL.md'],
|
|
99
|
+
})
|
|
100
|
+
const threadId = msg.threadId
|
|
101
|
+
void swallowingApiCall(
|
|
102
|
+
() =>
|
|
103
|
+
bot.api.sendMessage(msg.chatId, cardText, {
|
|
104
|
+
parse_mode: 'HTML',
|
|
105
|
+
reply_markup: skillProposalKeyboard(proposal.id),
|
|
106
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
107
|
+
}),
|
|
108
|
+
{ chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
|
|
109
|
+
)
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
`telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
|
|
112
|
+
`proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* RFC amendment §"corrections as eval cases" — persist an eval-case proposal
|
|
118
|
+
* and post its Approve/Dismiss card. On Approve the callback runs the
|
|
119
|
+
* DETERMINISTIC applier (handleEvalCaseProposalCallback), NOT a model turn, so
|
|
120
|
+
* the case lands byte-exact. Same per-agent-socket / chat-fenced trust model
|
|
121
|
+
* as handlePostSkillProposal.
|
|
122
|
+
*/
|
|
123
|
+
export function handlePostEvalCaseProposal(
|
|
124
|
+
msg: PostEvalCaseProposalMessage,
|
|
125
|
+
deps: ProposalWiringDeps,
|
|
126
|
+
): void {
|
|
127
|
+
const { bot, assertAllowedChat, swallowingApiCall } = deps
|
|
128
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
129
|
+
if (self && msg.agentName !== self) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`telegram gateway: post_eval_case_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
assertAllowedChat(msg.chatId)
|
|
137
|
+
} catch (err) {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`telegram gateway: post_eval_case_proposal rejected — ${(err as Error).message}\n`,
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
144
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
145
|
+
process.stderr.write(`telegram gateway: post_eval_case_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const proposal = enqueueEvalCaseProposal(stateDir, {
|
|
149
|
+
skill_slug: msg.skillSlug,
|
|
150
|
+
skill_dir: msg.skillDir,
|
|
151
|
+
case: msg.case,
|
|
152
|
+
fingerprint: msg.fingerprint,
|
|
153
|
+
held_out: msg.heldOut === true,
|
|
154
|
+
chat_id: Number(msg.chatId),
|
|
155
|
+
})
|
|
156
|
+
const cardText = renderEvalCaseProposalCard({
|
|
157
|
+
id: proposal.id,
|
|
158
|
+
skill_slug: proposal.skill_slug,
|
|
159
|
+
held_out: proposal.held_out,
|
|
160
|
+
case: proposal.case,
|
|
161
|
+
})
|
|
162
|
+
const threadId = msg.threadId
|
|
163
|
+
void swallowingApiCall(
|
|
164
|
+
() =>
|
|
165
|
+
bot.api.sendMessage(msg.chatId, cardText, {
|
|
166
|
+
parse_mode: 'HTML',
|
|
167
|
+
reply_markup: evalCaseProposalKeyboard(proposal.id),
|
|
168
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
169
|
+
}),
|
|
170
|
+
{ chat_id: msg.chatId, verb: 'eval-case-proposal-card', ...(threadId != null ? { threadId } : {}) },
|
|
171
|
+
)
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
`telegram gateway: post_eval_case_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
|
|
174
|
+
`proposal=${proposal.id} slug=${proposal.skill_slug} held_out=${proposal.held_out}\n`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
@@ -51,9 +51,7 @@ export interface StalePinSweepBotSeam {
|
|
|
51
51
|
) => Promise<unknown>
|
|
52
52
|
unpinChatMessage: (chatId: string, messageId: number) => Promise<unknown>
|
|
53
53
|
unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
|
|
54
|
-
getChatMember: (chatId: string, userId: number) => Promise<unknown>
|
|
55
54
|
}
|
|
56
|
-
botInfo?: { id?: number }
|
|
57
55
|
}
|
|
58
56
|
/** The gateway's retry/telemetry envelope (`robustApiCall`). */
|
|
59
57
|
call: <T>(fn: () => Promise<T>, meta: { chat_id: string; verb: string }) => Promise<T>
|
|
@@ -68,6 +66,14 @@ export interface StalePinSweepWiring {
|
|
|
68
66
|
loadPinRows: () => PersistedStatusPin[]
|
|
69
67
|
/** Mutex-won AND bot-constructed. False ⇒ the drain must not write. */
|
|
70
68
|
eligible: () => boolean
|
|
69
|
+
/**
|
|
70
|
+
* The SHARED per-process pin-rights negative cache (status-pin.ts
|
|
71
|
+
* `PinRightsCache`), unifying the sweep and the live status-pin path on which
|
|
72
|
+
* chats are rights-less. Only `isBlocked`/`block` are used here; the live
|
|
73
|
+
* path owns `clear` (on a successful explicit pin) and boot owns the reset.
|
|
74
|
+
* Optional — undefined ⇒ the sweep relies purely on its reactive classifier.
|
|
75
|
+
*/
|
|
76
|
+
rightsCache?: { isBlocked: (chatId: string) => boolean; block: (chatId: string) => boolean }
|
|
71
77
|
store: { path: string; fs: SweepStoreFsSeam }
|
|
72
78
|
/** Per-deployment override of `UNPIN_ALL_FORUM_TOPIC_ENABLED`; undefined =
|
|
73
79
|
* take the standing policy (the wholesale topic drain stays OFF). */
|
|
@@ -168,18 +174,16 @@ export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSw
|
|
|
168
174
|
chat_id: chatId,
|
|
169
175
|
verb: 'stale-pin-sweep.unpin-all-topic',
|
|
170
176
|
}),
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return member?.status === 'administrator' && member.can_pin_messages === true
|
|
182
|
-
},
|
|
177
|
+
// No proactive rights precheck. The old `getChatMember` precheck read
|
|
178
|
+
// `bot().botInfo?.id`, but the sweep runs against the chat-lock-wrapped bot
|
|
179
|
+
// (`wrapBot({ api: bot.api })`), which carries only `.api` — `.botInfo` was
|
|
180
|
+
// always undefined, so `self` was always null and every group precheck
|
|
181
|
+
// returned false, forfeiting every group cursor without a single unpin. The
|
|
182
|
+
// sweep now relies SOLELY on its reactive classifier: it attempts the unpin
|
|
183
|
+
// and classifies Telegram's honest `400 "not enough rights"` via
|
|
184
|
+
// `isPinRightsError`. Telegram is authoritative about pin rights; a precheck
|
|
185
|
+
// could only ever be redundant with (and, wired against the wrong bot,
|
|
186
|
+
// wrong about) that answer.
|
|
183
187
|
recordedPinIds: (chatId, threadId) => {
|
|
184
188
|
try {
|
|
185
189
|
return recordedPinIdsFor(w.loadPinRows(), chatId, threadId)
|
|
@@ -200,6 +204,12 @@ export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSw
|
|
|
200
204
|
log,
|
|
201
205
|
}),
|
|
202
206
|
eligible: w.eligible,
|
|
207
|
+
rightsBlocked: w.rightsCache ? (chatId) => w.rightsCache!.isBlocked(chatId) : undefined,
|
|
208
|
+
recordRightsBlock: w.rightsCache
|
|
209
|
+
? (chatId) => {
|
|
210
|
+
w.rightsCache!.block(chatId)
|
|
211
|
+
}
|
|
212
|
+
: undefined,
|
|
203
213
|
sleep: w.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
204
214
|
now,
|
|
205
215
|
store: w.store,
|