switchroom 0.17.6 ā 0.17.10
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/agent-scheduler/index.js +38 -4
- package/dist/auth-broker/index.js +302 -203
- package/dist/cli/notion-write-pretool.mjs +35 -2
- package/dist/cli/switchroom.js +1178 -576
- package/dist/host-control/main.js +148 -14
- package/dist/vault/approvals/kernel-server.js +140 -55
- package/dist/vault/broker/server.js +142 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2565 -610
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +293 -0
- package/telegram-plugin/gateway/gateway.ts +1376 -82
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +60 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +153 -4
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { Bot, GrammyError, InlineKeyboard, InputFile, type Context, type Api } from 'grammy'
|
|
13
13
|
import { run, type RunnerHandle } from '@grammyjs/runner'
|
|
14
14
|
import type { ReactionTypeEmoji } from 'grammy/types'
|
|
15
|
-
import { randomBytes } from 'crypto'
|
|
15
|
+
import { randomBytes, createHash } from 'crypto'
|
|
16
16
|
import { execFileSync, execSync, spawn } from 'child_process'
|
|
17
17
|
import {
|
|
18
18
|
readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync,
|
|
@@ -93,6 +93,20 @@ import {
|
|
|
93
93
|
import { StatusReactionController } from '../status-reactions.js'
|
|
94
94
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
95
95
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
96
|
+
import {
|
|
97
|
+
detectMemoryLegibilityEvent,
|
|
98
|
+
isMemoryLegibilityEnabled,
|
|
99
|
+
renderMemoryLegibilityLine,
|
|
100
|
+
MemoryLegibilityStager,
|
|
101
|
+
} from '../memory-legibility.js'
|
|
102
|
+
import {
|
|
103
|
+
ConsolidationRateLimiter,
|
|
104
|
+
consolidationSignature,
|
|
105
|
+
detectConsolidationEvent,
|
|
106
|
+
isConsolidationLegibilityEnabled,
|
|
107
|
+
renderConsolidationLine,
|
|
108
|
+
} from '../consolidation-legibility.js'
|
|
109
|
+
import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
|
|
96
110
|
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
97
111
|
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
98
112
|
import { decidePinAction } from '../status-pin.js'
|
|
@@ -111,10 +125,25 @@ import {
|
|
|
111
125
|
type PermissionCardRef,
|
|
112
126
|
} from './permission-timeout.js'
|
|
113
127
|
import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
114
|
-
import { createPermissionCardStore } from './permission-card-store.js'
|
|
128
|
+
import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
|
|
129
|
+
import {
|
|
130
|
+
isPermissionRearmEnabled,
|
|
131
|
+
permissionRearmGraceMs,
|
|
132
|
+
classifyPermissionRequest,
|
|
133
|
+
computeBootSweepStripTargets,
|
|
134
|
+
distinctRequestIds,
|
|
135
|
+
} from './permission-rearm.js'
|
|
136
|
+
import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
|
|
137
|
+
import {
|
|
138
|
+
renderMissedApprovalsDigest,
|
|
139
|
+
missedApprovalsKeyboard,
|
|
140
|
+
parseMissedApprovalCallback,
|
|
141
|
+
buildMissedApprovalRetryInbound,
|
|
142
|
+
} from './missed-approvals-card.js'
|
|
115
143
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
116
144
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
117
145
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
146
|
+
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
118
147
|
import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
119
148
|
import { toolLabel } from '../tool-labels.js'
|
|
120
149
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
@@ -144,7 +173,7 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
|
144
173
|
import { classifyInbound } from '../inbound-classifier.js'
|
|
145
174
|
import * as silencePoke from '../silence-poke.js'
|
|
146
175
|
import * as pendingProgress from '../pending-work-progress.js'
|
|
147
|
-
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
176
|
+
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd, silentEndFallbackText, type SilentEndDeps } from '../silent-end.js'
|
|
148
177
|
import { isFinalAnswerReply, isSubstantiveFinalReply, FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
149
178
|
import { deriveTurnRole, decideTerminalReason, parsePostAnswerLivenessMs, evaluatePostAnswerLiveness, type LoopRole } from '../turn-liveness-floor.js'
|
|
150
179
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
@@ -224,15 +253,9 @@ import { validateStringArray } from './access-validator.js'
|
|
|
224
253
|
*/
|
|
225
254
|
const REPLY_TO_TEXT_MAX = 200
|
|
226
255
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* has already been exhausted. Without this the user only sees the
|
|
231
|
-
* progress card vanish; silence must never be the failure mode.
|
|
232
|
-
*/
|
|
233
|
-
const SILENT_END_FALLBACK_TEXT =
|
|
234
|
-
'ā ļø The agent finished working but didnāt send a reply ā your last ' +
|
|
235
|
-
'message may not have been answered. Please try asking again.'
|
|
256
|
+
// #1161 silent-end fallback text now lives in ../silent-end.ts
|
|
257
|
+
// (`silentEndFallbackText`, imported above) so the transport-boundary
|
|
258
|
+
// tests exercise the real string ā see PR #2892.
|
|
236
259
|
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
237
260
|
import { richMessage } from '../rich-send.js'
|
|
238
261
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
@@ -392,6 +415,13 @@ import {
|
|
|
392
415
|
type StatusPinPersistOp,
|
|
393
416
|
type TrackedStatusPin,
|
|
394
417
|
} from './status-pin-store.js'
|
|
418
|
+
import {
|
|
419
|
+
writeActivityCardRecord,
|
|
420
|
+
clearActivityCardRecord,
|
|
421
|
+
runActivityCardBootReaper,
|
|
422
|
+
restartOrphanCardFinalizeText,
|
|
423
|
+
type ActivityCardStoreFsSeam,
|
|
424
|
+
} from './activity-card-store.js'
|
|
395
425
|
import { driveEscalation } from './escalation-drive.js'
|
|
396
426
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
397
427
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
@@ -448,6 +478,12 @@ import {
|
|
|
448
478
|
buildVaultSaveFailedInbound,
|
|
449
479
|
buildVaultSaveDiscardedInbound,
|
|
450
480
|
} from './vault-grant-inbound-builders.js'
|
|
481
|
+
import { renderMentalModelProposeCard } from './mental-model-propose-card.js'
|
|
482
|
+
import {
|
|
483
|
+
resolveMentalModelProposal,
|
|
484
|
+
type MentalModelPendingProposal,
|
|
485
|
+
} from './mental-model-propose-resolve.js'
|
|
486
|
+
import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
|
|
451
487
|
import {
|
|
452
488
|
parseSkillProposalCallback,
|
|
453
489
|
buildSkillProposalApplyInbound,
|
|
@@ -531,6 +567,8 @@ import {
|
|
|
531
567
|
startSubagentWatcher,
|
|
532
568
|
type SubagentWatcherHandle,
|
|
533
569
|
} from '../subagent-watcher.js'
|
|
570
|
+
import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
|
|
571
|
+
import { ownedWorktreeCwds } from '../worktree-watch-cwds.js'
|
|
534
572
|
import {
|
|
535
573
|
startBootCard,
|
|
536
574
|
resolvePersonaName,
|
|
@@ -551,7 +589,9 @@ import {
|
|
|
551
589
|
recordScopedGrant,
|
|
552
590
|
lookupScopedGrant,
|
|
553
591
|
sweepScopedGrants,
|
|
592
|
+
countScopedGrants,
|
|
554
593
|
} from '../scoped-approval.js'
|
|
594
|
+
import { createScopedGrantStore } from './scoped-grant-store.js'
|
|
555
595
|
import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
|
|
556
596
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
557
597
|
import {
|
|
@@ -666,6 +706,12 @@ process.on('beforeExit', () => {
|
|
|
666
706
|
// āāā Env + state dir āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
667
707
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
668
708
|
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
709
|
+
// #2862 ā missed-approvals re-offer. Persisted list of approvals that
|
|
710
|
+
// TTL-expired while the operator was away; a digest card is posted on the
|
|
711
|
+
// operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
|
|
712
|
+
const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
|
|
713
|
+
const MISSED_APPROVAL_REOFFER_ENABLED =
|
|
714
|
+
process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
|
|
669
715
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
670
716
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
671
717
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
@@ -4579,6 +4625,131 @@ function clearPermissionTimeoutSuppression(reason: string): void {
|
|
|
4579
4625
|
`telegram gateway: permission no-repeat suppression cleared (${n} sig(s)) ā ${reason}\n`,
|
|
4580
4626
|
)
|
|
4581
4627
|
}
|
|
4628
|
+
|
|
4629
|
+
// #2862 ā missed-approvals re-offer digest. Called from every operator-activity
|
|
4630
|
+
// path that clears no-repeat suppression (inbound, /approve|/deny, card verdict):
|
|
4631
|
+
// on the FIRST activity after ā„1 approval TTL-expired while the operator was
|
|
4632
|
+
// away, post ONE compact digest card per origin surface listing the misses,
|
|
4633
|
+
// then promote those pending entries into a delivered-digest record (the posted
|
|
4634
|
+
// card is now the durable record in chat). Best-effort + fire-and-forget: never
|
|
4635
|
+
// blocks or throws into the hot inbound/callback path. Kill switch (=0) makes
|
|
4636
|
+
// this a no-op AND the auto-deny append below a no-op, so the whole feature is off.
|
|
4637
|
+
function maybePostMissedApprovalDigest(reason: string): void {
|
|
4638
|
+
if (!MISSED_APPROVAL_REOFFER_ENABLED) return
|
|
4639
|
+
const pending = missedApprovalsStore.listPending()
|
|
4640
|
+
if (pending.length === 0) return
|
|
4641
|
+
// Group by origin surface so each card lands where its cards were posted.
|
|
4642
|
+
const groups = new Map<string, MissedApproval[]>()
|
|
4643
|
+
for (const e of pending) {
|
|
4644
|
+
const key = `${e.chatId}::${e.threadId ?? ''}`
|
|
4645
|
+
const g = groups.get(key)
|
|
4646
|
+
if (g) g.push(e)
|
|
4647
|
+
else groups.set(key, [e])
|
|
4648
|
+
}
|
|
4649
|
+
for (const entries of groups.values()) {
|
|
4650
|
+
const first = entries[0]
|
|
4651
|
+
const chatId = first.chatId
|
|
4652
|
+
const threadId = first.threadId ?? undefined
|
|
4653
|
+
const digestId = randomBytes(4).toString('hex')
|
|
4654
|
+
const text = renderMissedApprovalsDigest(entries, {
|
|
4655
|
+
agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
|
|
4656
|
+
})
|
|
4657
|
+
void swallowingApiCall(
|
|
4658
|
+
() =>
|
|
4659
|
+
// allow-raw-bot-api: routed through swallowingApiCall retry policy; thread-aware digest card
|
|
4660
|
+
bot.api.sendMessage(chatId, text, {
|
|
4661
|
+
parse_mode: 'HTML',
|
|
4662
|
+
reply_markup: missedApprovalsKeyboard(digestId),
|
|
4663
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
4664
|
+
}),
|
|
4665
|
+
{ chat_id: chatId, verb: 'missed-approval-digest', ...(threadId != null ? { threadId } : {}) },
|
|
4666
|
+
)
|
|
4667
|
+
// Promote synchronously (before the async send resolves) so a second
|
|
4668
|
+
// operator-activity event racing in sees an empty pending list and can't
|
|
4669
|
+
// double-post ā "one card per accumulated batch" holds.
|
|
4670
|
+
missedApprovalsStore.promoteToDigest({
|
|
4671
|
+
digestId,
|
|
4672
|
+
chatId,
|
|
4673
|
+
threadId: first.threadId ?? null,
|
|
4674
|
+
entries,
|
|
4675
|
+
deliveredAt: Date.now(),
|
|
4676
|
+
})
|
|
4677
|
+
}
|
|
4678
|
+
process.stderr.write(
|
|
4679
|
+
`telegram gateway: missed-approval digest posted (${pending.length} entr(y/ies), ` +
|
|
4680
|
+
`${groups.size} surface(s)) ā ${reason}\n`,
|
|
4681
|
+
)
|
|
4682
|
+
}
|
|
4683
|
+
|
|
4684
|
+
// #2862 ā handle a Retry / Dismiss tap on a missed-approvals digest card.
|
|
4685
|
+
// missre:retry:<id> ā inject a synthetic inbound asking the agent to
|
|
4686
|
+
// re-attempt the actions (claude-native; re-raises a
|
|
4687
|
+
// fresh approval card). NEVER a verdict / re-execution.
|
|
4688
|
+
// missre:dismiss:<id> ā clear the record + edit the card closed.
|
|
4689
|
+
async function handleMissedApprovalCallback(ctx: Context, data: string): Promise<void> {
|
|
4690
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
4691
|
+
const access = loadAccess()
|
|
4692
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
4693
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
4694
|
+
return
|
|
4695
|
+
}
|
|
4696
|
+
const parsed = parseMissedApprovalCallback(data)
|
|
4697
|
+
if (parsed == null) {
|
|
4698
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
4699
|
+
return
|
|
4700
|
+
}
|
|
4701
|
+
const digest = missedApprovalsStore.getDigest(parsed.digestId)
|
|
4702
|
+
if (digest == null) {
|
|
4703
|
+
await ctx.answerCallbackQuery({ text: 'This digest already actioned or expired.' }).catch(() => {})
|
|
4704
|
+
if (ctx.callbackQuery?.message) {
|
|
4705
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
4706
|
+
}
|
|
4707
|
+
return
|
|
4708
|
+
}
|
|
4709
|
+
|
|
4710
|
+
if (parsed.action === 'dismiss') {
|
|
4711
|
+
missedApprovalsStore.removeDigest(parsed.digestId)
|
|
4712
|
+
await ctx.answerCallbackQuery({ text: 'š« Dismissed ā cleared.' }).catch(() => {})
|
|
4713
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
4714
|
+
await ctx
|
|
4715
|
+
.editMessageText(
|
|
4716
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\nš« <i>Dismissed.</i>`,
|
|
4717
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
4718
|
+
)
|
|
4719
|
+
.catch(() => {})
|
|
4720
|
+
}
|
|
4721
|
+
return
|
|
4722
|
+
}
|
|
4723
|
+
|
|
4724
|
+
// Retry ā consume the record, then inject a synthetic inbound to the ORIGIN
|
|
4725
|
+
// surface asking the agent to re-attempt. This rides the same synthesized-
|
|
4726
|
+
// inbound path cron uses; the agent naturally re-raises a fresh approval card.
|
|
4727
|
+
missedApprovalsStore.removeDigest(parsed.digestId)
|
|
4728
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4729
|
+
const synthetic = buildMissedApprovalRetryInbound({
|
|
4730
|
+
ctx: {
|
|
4731
|
+
agent,
|
|
4732
|
+
chat_id: digest.chatId,
|
|
4733
|
+
...(digest.threadId != null ? { threadId: digest.threadId } : {}),
|
|
4734
|
+
},
|
|
4735
|
+
actions: digest.entries.map(e => e.action),
|
|
4736
|
+
operatorId: senderId,
|
|
4737
|
+
})
|
|
4738
|
+
const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
|
|
4739
|
+
await ctx.answerCallbackQuery({ text: 'š Asking the agent to retryā¦' }).catch(() => {})
|
|
4740
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
4741
|
+
await ctx
|
|
4742
|
+
.editMessageText(
|
|
4743
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\nš <i>Retrying ā asked the agent.</i>`,
|
|
4744
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
4745
|
+
)
|
|
4746
|
+
.catch(() => {})
|
|
4747
|
+
}
|
|
4748
|
+
process.stderr.write(
|
|
4749
|
+
`telegram gateway: missed-approval retry injection agent=${agent} ` +
|
|
4750
|
+
`digest=${parsed.digestId} actions=${digest.entries.length} delivered=${delivered}\n`,
|
|
4751
|
+
)
|
|
4752
|
+
}
|
|
4582
4753
|
// Permission/approval-card origin recovery (marko Rentals-budget, 2026-06-17).
|
|
4583
4754
|
// When `currentTurn` was force-closed by the orphaned-reply backstop but the
|
|
4584
4755
|
// claude session kept running into a permission-gated tool, recover the card's
|
|
@@ -4612,13 +4783,54 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
|
4612
4783
|
}
|
|
4613
4784
|
}
|
|
4614
4785
|
|
|
4786
|
+
// Sibling of pendingAlwaysAllowCorrelations for the agent-proposes ā
|
|
4787
|
+
// human-approves MENTAL MODEL flow (hindsight Phase 5). When the operator
|
|
4788
|
+
// taps Approve on a mental-model PROPOSAL card, the gateway dispatches a
|
|
4789
|
+
// `config_propose_edit` appending the model to memory.mental_models[]; hostd
|
|
4790
|
+
// then calls back for operator approval. We pre-register the exact diff here
|
|
4791
|
+
// so that callback auto-approves WITHOUT a second card (the operator already
|
|
4792
|
+
// approved on the proposal card). Forge-resistance: the auto-resolve match is
|
|
4793
|
+
// an EXACT byte-match of the inbound diff against the diff the gateway itself
|
|
4794
|
+
// synthesized and queued ā an agent-forged edit finds no entry and falls
|
|
4795
|
+
// through to a real operator card. Single-shot + a dedicated TTL sweep.
|
|
4796
|
+
//
|
|
4797
|
+
// TTL: this correlation must outlive the WHOLE config-edit approval budget,
|
|
4798
|
+
// NOT the 30s "always allow" window. The mental-model resolve dispatches
|
|
4799
|
+
// `config_propose_edit` to hostd with a 720s timeout (see the 720_000 dispatch
|
|
4800
|
+
// budgets below); hostd's approval callback can legitimately arrive any time
|
|
4801
|
+
// within that window if the operator taps slowly. Reusing the 30s
|
|
4802
|
+
// ALWAYS_ALLOW_CORRELATION_TTL_MS would sweep the correlation out from under a
|
|
4803
|
+
// slow-but-valid tap, dropping the auto-approve and surfacing a SECOND card
|
|
4804
|
+
// for an edit the operator already approved. Size it to the hostd budget.
|
|
4805
|
+
const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
|
|
4806
|
+
const pendingMentalModelCorrelations = new Map<string, { agentName: string; unifiedDiff: string; createdAt: number }>()
|
|
4807
|
+
function mentalModelCorrelationKey(agentName: string, unifiedDiff: string): string {
|
|
4808
|
+
return `${agentName}::${createHash('sha256').update(unifiedDiff).digest('hex')}`
|
|
4809
|
+
}
|
|
4810
|
+
function sweepStaleMentalModelCorrelations(now = Date.now()): void {
|
|
4811
|
+
for (const [key, entry] of pendingMentalModelCorrelations) {
|
|
4812
|
+
if (now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS) {
|
|
4813
|
+
pendingMentalModelCorrelations.delete(key)
|
|
4814
|
+
}
|
|
4815
|
+
}
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4615
4818
|
// Scoped-approval store: the 30-min window that backs the "ā
Allow" tap for
|
|
4616
4819
|
// narrow non-destructive scopes (not a separate button ā it IS what Allow
|
|
4617
4820
|
// means for those). Operator-tapped, gateway-side ONLY (never pushed to the
|
|
4618
4821
|
// bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
|
|
4619
4822
|
// agent name for per-agent isolation. All policy lives in
|
|
4620
4823
|
// ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
|
|
4621
|
-
|
|
4824
|
+
//
|
|
4825
|
+
// Persisted across gateway restarts (#2863): a bounce (fleet roll / config
|
|
4826
|
+
// apply / failover probe) used to wipe every window, re-carding an action the
|
|
4827
|
+
// operator allowed minutes ago. The store mirrors to a tiny STATE_DIR JSON
|
|
4828
|
+
// file; reload drops entries already past their ABSOLUTE expiry (a restart
|
|
4829
|
+
// never extends a window). Kill switch SWITCHROOM_SCOPED_GRANT_PERSIST=0 boots
|
|
4830
|
+
// empty and never writes. Fail-closed lookup semantics are unchanged ā a
|
|
4831
|
+
// reloaded grant runs the same lookupScopedGrant gate as an in-memory one.
|
|
4832
|
+
const scopedGrantStore = createScopedGrantStore(STATE_DIR)
|
|
4833
|
+
const scopedGrants: ScopedGrantStore = scopedGrantStore.load(Date.now())
|
|
4622
4834
|
const selfAgentName = (): string => process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4623
4835
|
|
|
4624
4836
|
// `ask_user` MCP tool ā open prompts awaiting a user button-tap.
|
|
@@ -4944,6 +5156,81 @@ function sweepPendingVaultRequestAccesses(): void {
|
|
|
4944
5156
|
}
|
|
4945
5157
|
}
|
|
4946
5158
|
|
|
5159
|
+
/**
|
|
5160
|
+
* Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
|
|
5161
|
+
* calls `mental_model_propose`; the operator taps Approve/Deny on the card.
|
|
5162
|
+
* Mirrors PendingVaultRequestAccess ā no memory content is staged here, only
|
|
5163
|
+
* the proposed DECLARATION (name + source_query + optional knobs). On Approve
|
|
5164
|
+
* the model becomes a first-class declared model in memory.mental_models[] via
|
|
5165
|
+
* the operator-approved config-edit path; on Deny nothing is written.
|
|
5166
|
+
*/
|
|
5167
|
+
interface PendingMentalModelPropose {
|
|
5168
|
+
agent: string
|
|
5169
|
+
chat_id: string
|
|
5170
|
+
card_message_id?: number
|
|
5171
|
+
threadId?: number
|
|
5172
|
+
/** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
|
|
5173
|
+
spec: {
|
|
5174
|
+
name: string
|
|
5175
|
+
source_query: string
|
|
5176
|
+
refresh_after_consolidation?: boolean
|
|
5177
|
+
max_tokens?: number
|
|
5178
|
+
}
|
|
5179
|
+
reason?: string
|
|
5180
|
+
staged_at: number
|
|
5181
|
+
}
|
|
5182
|
+
const pendingMentalModelProposes = new Map<string, PendingMentalModelPropose>()
|
|
5183
|
+
const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
|
|
5184
|
+
// Sweep expired pending proposals. For any entry past its TTL we ALSO edit the
|
|
5185
|
+
// posted card's keyboard away, so a stale card left in the chat can't be tapped
|
|
5186
|
+
// into a "Card expired" answer ā the operator sees the ā expiry inline instead.
|
|
5187
|
+
// Best-effort: card edits are fire-and-forget (the entry is removed regardless).
|
|
5188
|
+
function sweepPendingMentalModelProposes(): void {
|
|
5189
|
+
const cutoff = Date.now() - MENTAL_MODEL_PROPOSE_TTL_MS
|
|
5190
|
+
for (const [k, v] of pendingMentalModelProposes) {
|
|
5191
|
+
if (v.staged_at < cutoff) {
|
|
5192
|
+
pendingMentalModelProposes.delete(k)
|
|
5193
|
+
if (v.card_message_id != null) {
|
|
5194
|
+
void lockedBot.api
|
|
5195
|
+
.editMessageText(
|
|
5196
|
+
v.chat_id,
|
|
5197
|
+
v.card_message_id,
|
|
5198
|
+
richMessage('ā _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
|
|
5199
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
5200
|
+
)
|
|
5201
|
+
.catch(() => {})
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
}
|
|
5206
|
+
|
|
5207
|
+
// Sliding-window rate limit for mental-model proposals: at most
|
|
5208
|
+
// MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW cards per MENTAL_MODEL_PROPOSE_WINDOW_MS.
|
|
5209
|
+
// The window is per-gateway-process, and since each agent runs its own gateway
|
|
5210
|
+
// process (keyed by $SWITCHROOM_AGENT_NAME), one process == one agent ā so this
|
|
5211
|
+
// throttle is effectively per-agent. A candidate model is a deliberate, rare
|
|
5212
|
+
// curation act ā an agent that re-proposes in a loop should be throttled so the
|
|
5213
|
+
// operator is never spammed.
|
|
5214
|
+
const mentalModelProposeTimes: number[] = []
|
|
5215
|
+
const MENTAL_MODEL_PROPOSE_WINDOW_MS = 60 * 60 * 1000
|
|
5216
|
+
const MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW = 5
|
|
5217
|
+
// Mirror the memory.mental_models[] schema caps (src/config/schema.ts): a
|
|
5218
|
+
// source_query capped at 2000 chars and max_tokens capped at 8192. Enforced
|
|
5219
|
+
// up-front in executeMentalModelPropose so a proposal that would fail hostd
|
|
5220
|
+
// config validation never reaches an approval card.
|
|
5221
|
+
const MENTAL_MODEL_SOURCE_QUERY_MAX = 2000
|
|
5222
|
+
const MENTAL_MODEL_MAX_TOKENS_CAP = 8192
|
|
5223
|
+
function checkMentalModelProposeRate(now = Date.now()): { ok: true } | { ok: false; retryAtMs: number } {
|
|
5224
|
+
const cutoff = now - MENTAL_MODEL_PROPOSE_WINDOW_MS
|
|
5225
|
+
while (mentalModelProposeTimes.length > 0 && mentalModelProposeTimes[0]! < cutoff) {
|
|
5226
|
+
mentalModelProposeTimes.shift()
|
|
5227
|
+
}
|
|
5228
|
+
if (mentalModelProposeTimes.length >= MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW) {
|
|
5229
|
+
return { ok: false, retryAtMs: mentalModelProposeTimes[0]! + MENTAL_MODEL_PROPOSE_WINDOW_MS }
|
|
5230
|
+
}
|
|
5231
|
+
return { ok: true }
|
|
5232
|
+
}
|
|
5233
|
+
|
|
4947
5234
|
/**
|
|
4948
5235
|
* Mint an approval-kernel decision row for a deferred-secret card
|
|
4949
5236
|
* (MIGRATION.md §1). Best-effort: if the kernel/broker is unreachable, we
|
|
@@ -5238,6 +5525,23 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5238
5525
|
now,
|
|
5239
5526
|
)
|
|
5240
5527
|
}
|
|
5528
|
+
// #2862 ā record the miss so it can be re-offered when the operator
|
|
5529
|
+
// returns. Anchor to the card's own origin surface (where the operator
|
|
5530
|
+
// would have tapped), so the digest lands in the same topic ā not a
|
|
5531
|
+
// fanned-out DM. Skip if the card was never posted anywhere.
|
|
5532
|
+
if (MISSED_APPROVAL_REOFFER_ENABLED) {
|
|
5533
|
+
const origin = v.cards[0]
|
|
5534
|
+
if (origin != null) {
|
|
5535
|
+
missedApprovalsStore.add({
|
|
5536
|
+
requestId: k,
|
|
5537
|
+
toolName: v.tool_name,
|
|
5538
|
+
action: naturalAction(v.tool_name, v.input_preview),
|
|
5539
|
+
chatId: origin.chatId,
|
|
5540
|
+
threadId: origin.threadId ?? null,
|
|
5541
|
+
timedOutAt: now,
|
|
5542
|
+
})
|
|
5543
|
+
}
|
|
5544
|
+
}
|
|
5241
5545
|
process.stderr.write(
|
|
5242
5546
|
`telegram gateway: permission TTL expired ā auto-deny request=${k} ` +
|
|
5243
5547
|
`tool=${v.tool_name} (no operator response in ` +
|
|
@@ -5256,8 +5560,14 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5256
5560
|
if (now > v.expiresAt) vaultPassphraseCache.delete(k)
|
|
5257
5561
|
}
|
|
5258
5562
|
// Drop expired "ā± 30 min" scoped grants. (Lookup already fails closed on
|
|
5259
|
-
// expiry; this just keeps the map from accumulating dead entries.)
|
|
5563
|
+
// expiry; this just keeps the map from accumulating dead entries.) Persist
|
|
5564
|
+
// the removal so a restart between sweeps can't resurrect a swept grant ā
|
|
5565
|
+
// only write when the sweep actually changed something (sweeps only remove).
|
|
5566
|
+
const scopedGrantsBefore = countScopedGrants(scopedGrants)
|
|
5260
5567
|
sweepScopedGrants(scopedGrants, now)
|
|
5568
|
+
if (countScopedGrants(scopedGrants) !== scopedGrantsBefore) {
|
|
5569
|
+
scopedGrantStore.save(scopedGrants)
|
|
5570
|
+
}
|
|
5261
5571
|
for (const [k, v] of deferredSecrets) {
|
|
5262
5572
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
5263
5573
|
}
|
|
@@ -5754,6 +6064,22 @@ const statusPinStoreFs = {
|
|
|
5754
6064
|
}
|
|
5755
6065
|
const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
|
|
5756
6066
|
|
|
6067
|
+
// Durable card-handle snapshot for the mid-turn activity card (Known Gap 1,
|
|
6068
|
+
// `reference/rfcs/deterministic-turn-liveness.md`). Persisted on card OPEN,
|
|
6069
|
+
// cleared on normal close/finalize; a boot-time reaper (wired alongside
|
|
6070
|
+
// `statusPinBootCleanup`, same startup-mutex ordering constraint) finalizes
|
|
6071
|
+
// any leftover record with one honest edit so a gateway restart mid-turn can
|
|
6072
|
+
// never leave the card frozen forever. STATIC mode skips disk (dry-run, no
|
|
6073
|
+
// durable volume) ā same gate as the status-pin store.
|
|
6074
|
+
const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
|
|
6075
|
+
const activityCardStoreFs: ActivityCardStoreFsSeam = {
|
|
6076
|
+
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
6077
|
+
writeFileSync: (p: string, d: string) => writeFileSync(p, d),
|
|
6078
|
+
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
6079
|
+
existsSync: (p: string) => existsSync(p),
|
|
6080
|
+
}
|
|
6081
|
+
const activityCardPersistEnabled = !STATIC
|
|
6082
|
+
|
|
5757
6083
|
// Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
|
|
5758
6084
|
// in the owner chat when the agent is on a non-default OAuth slot. Rather than a
|
|
5759
6085
|
// parallel store + second boot hook, its pin is persisted in the SAME
|
|
@@ -5835,6 +6161,75 @@ async function statusPinBootCleanup(): Promise<void> {
|
|
|
5835
6161
|
)
|
|
5836
6162
|
}
|
|
5837
6163
|
}
|
|
6164
|
+
/**
|
|
6165
|
+
* Boot-time orphan-card reaper (Known Gap 1,
|
|
6166
|
+
* `reference/rfcs/deterministic-turn-liveness.md`). Thin gateway wrapper over
|
|
6167
|
+
* the pure `runActivityCardBootReaper` ā binds the live fs seam, a real
|
|
6168
|
+
* Telegram edit, and the gateway logger. Finalizes ANY card left open by a
|
|
6169
|
+
* prior (crashed/restarted) session with ONE honest edit ā never a new
|
|
6170
|
+
* message, so no ping. Guarantee is AT-MOST-ONCE: the pure routine deletes each
|
|
6171
|
+
* record before attempting its edit and does not retry, so a failed edit
|
|
6172
|
+
* forfeits that orphan rather than risk a double-finalize on a later boot (see
|
|
6173
|
+
* the module doc in activity-card-store.ts for the full tradeoff). A benign-400
|
|
6174
|
+
* (card already gone) is reported as `vanished`, not `finalized`.
|
|
6175
|
+
*
|
|
6176
|
+
* MUST run ONLY after this gateway wins the startup mutex (the store is a
|
|
6177
|
+
* shared per-agent file; a losing double-boot would finalize a card the
|
|
6178
|
+
* still-alive holder's in-flight turn still legitimately owns) ā identical
|
|
6179
|
+
* ordering constraint to `statusPinBootCleanup`.
|
|
6180
|
+
*
|
|
6181
|
+
* Deliberately does NOT attempt to resume climbing the old card: the resumed
|
|
6182
|
+
* turn (if any) opens its own fresh card. Honest finalization, not
|
|
6183
|
+
* resumption, is the goal (see the module doc in activity-card-store.ts).
|
|
6184
|
+
*
|
|
6185
|
+
* After the finalizing edit, also unpins the card, but ONLY for a record whose
|
|
6186
|
+
* persisted `pinned` flag is true ā i.e. the card was actually pin-eligible on
|
|
6187
|
+
* open (`PIN_STATUS_WHILE_WORKING`). This unpin is DEFENSE-IN-DEPTH, not the
|
|
6188
|
+
* primary unpin path: `statusPinBootCleanup` already unpins orphaned status
|
|
6189
|
+
* pins from its own durable store on boot. The reaper's unpin is belt-and-
|
|
6190
|
+
* braces for the case where the two stores disagree; the record's `pinned`
|
|
6191
|
+
* flag must therefore reflect the ACTUAL pin outcome (see the open path), not
|
|
6192
|
+
* an unconditional `true`, or the reaper would attempt an unpin on a card that
|
|
6193
|
+
* was never pinned.
|
|
6194
|
+
*/
|
|
6195
|
+
async function activityCardBootReaper(): Promise<void> {
|
|
6196
|
+
if (!activityCardPersistEnabled) return
|
|
6197
|
+
const { finalized, vanished, unpinned, total } = await runActivityCardBootReaper({
|
|
6198
|
+
path: ACTIVITY_CARD_STORE_PATH,
|
|
6199
|
+
fs: activityCardStoreFs,
|
|
6200
|
+
finalizeCard: (record) =>
|
|
6201
|
+
robustApiCall(
|
|
6202
|
+
() =>
|
|
6203
|
+
lockedBot.api.editMessageText(
|
|
6204
|
+
record.chatId,
|
|
6205
|
+
record.activityMessageId,
|
|
6206
|
+
richMessage(restartOrphanCardFinalizeText(record.startedAt)),
|
|
6207
|
+
{},
|
|
6208
|
+
),
|
|
6209
|
+
{
|
|
6210
|
+
chat_id: record.chatId,
|
|
6211
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6212
|
+
verb: 'activity-card.boot-reap-finalize',
|
|
6213
|
+
},
|
|
6214
|
+
),
|
|
6215
|
+
unpinCard: (record) =>
|
|
6216
|
+
robustApiCall(
|
|
6217
|
+
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
6218
|
+
{
|
|
6219
|
+
chat_id: record.chatId,
|
|
6220
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6221
|
+
verb: 'activity-card.boot-reap-unpin',
|
|
6222
|
+
},
|
|
6223
|
+
),
|
|
6224
|
+
})
|
|
6225
|
+
if (total > 0) {
|
|
6226
|
+
process.stderr.write(
|
|
6227
|
+
`telegram gateway: activity-card: finalized ${finalized}/${total} ` +
|
|
6228
|
+
`(vanished ${vanished}/${total}), unpinned ${unpinned}/${total} ` +
|
|
6229
|
+
`orphaned card(s) from a prior session (at-most-once)\n`,
|
|
6230
|
+
)
|
|
6231
|
+
}
|
|
6232
|
+
}
|
|
5838
6233
|
// NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
|
|
5839
6234
|
// The status-pin store is a SHARED per-agent file, and cleanup issues real
|
|
5840
6235
|
// unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
|
|
@@ -6140,6 +6535,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
6140
6535
|
// LOSING double-boot never unpins the live holder's legitimate pins.
|
|
6141
6536
|
// Fire-and-forget: cleanup is best-effort and must not block boot.
|
|
6142
6537
|
void statusPinBootCleanup()
|
|
6538
|
+
void activityCardBootReaper()
|
|
6143
6539
|
} catch (err) {
|
|
6144
6540
|
process.stderr.write(
|
|
6145
6541
|
`telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
|
|
@@ -6156,6 +6552,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
6156
6552
|
// successful writePidFile here means no live holder was detected, so
|
|
6157
6553
|
// running orphan cleanup is consistent with the pre-mutex behaviour.
|
|
6158
6554
|
void statusPinBootCleanup()
|
|
6555
|
+
void activityCardBootReaper()
|
|
6159
6556
|
} catch (writeErr) {
|
|
6160
6557
|
process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
|
|
6161
6558
|
}
|
|
@@ -6201,11 +6598,15 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
|
|
|
6201
6598
|
}
|
|
6202
6599
|
const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
|
|
6203
6600
|
const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
|
|
6204
|
-
// #2527 ā mid-turn liveness floor threshold (default 45s).
|
|
6205
|
-
//
|
|
6206
|
-
//
|
|
6207
|
-
//
|
|
6208
|
-
//
|
|
6601
|
+
// #2527 ā mid-turn liveness floor threshold (default 45s). Still gates the
|
|
6602
|
+
// decision (`decideMidTurnFloor`, role + delivery + fire-once + timing) for a
|
|
6603
|
+
// `user` turn working silently this long without a substantive answer.
|
|
6604
|
+
// Phase 3 (deterministic-turn-liveness.md): the busy-but-silent case no
|
|
6605
|
+
// longer sends TEXT at this threshold ā the climbing card (Phase 1) covers
|
|
6606
|
+
// it. The ONE surviving delivery gated by this threshold is the
|
|
6607
|
+
// approval-blocked re-ping (see `onMidTurnFloor` below). Strictly below
|
|
6608
|
+
// SILENCE_FALLBACK_MS (the loud 300s unwedge). Whole floor is
|
|
6609
|
+
// kill-switchable via SWITCHROOM_TG_LIVENESS_FLOOR=0.
|
|
6209
6610
|
const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_000)
|
|
6210
6611
|
// #2527 ā role-aware terminal reaction honesty (the "thumbs-up false done"
|
|
6211
6612
|
// fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-š.
|
|
@@ -6222,6 +6623,25 @@ const SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIG
|
|
|
6222
6623
|
// and null currentTurn mid-work. Default ON; SWITCHROOM_SILENCE_LIVENESS_PRODUCTION=0
|
|
6223
6624
|
// restores the legacy "only a real reply resets the clock" behaviour.
|
|
6224
6625
|
const SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== '0'
|
|
6626
|
+
// Sparse chat-legible memory (#2849, hindsight Phase 4). Surface ONE terse
|
|
6627
|
+
// line in the originating chat/topic when the interactive session materially
|
|
6628
|
+
// changes what it remembers (create_directive / invalidate / demote) ā never
|
|
6629
|
+
// on ordinary recall or routine consolidation. Default ON;
|
|
6630
|
+
// SWITCHROOM_MEMORY_LEGIBILITY=0 disables it.
|
|
6631
|
+
const MEMORY_LEGIBILITY_ENABLED = isMemoryLegibilityEnabled(process.env.SWITCHROOM_MEMORY_LEGIBILITY)
|
|
6632
|
+
// Consolidation-driven legibility (#2849 follow-up, hindsight Phase 4). The
|
|
6633
|
+
// "updated what I know about Y" side, driven by the background
|
|
6634
|
+
// `consolidation.completed` webhook rather than a foreground tool call.
|
|
6635
|
+
// OPT-IN (default OFF), unlike MEMORY_LEGIBILITY_ENABLED above: this path is
|
|
6636
|
+
// fed by an unbounded background engine + a webhook the pinned hindsight image
|
|
6637
|
+
// does not yet emit, so the RFC keeps it "sparse ⦠clearly gated".
|
|
6638
|
+
const CONSOLIDATION_LEGIBILITY_ENABLED = isConsolidationLegibilityEnabled(
|
|
6639
|
+
process.env.SWITCHROOM_CONSOLIDATION_LEGIBILITY,
|
|
6640
|
+
)
|
|
6641
|
+
// One long-lived limiter across all consolidation events ā collapses a burst
|
|
6642
|
+
// of material consolidations to at most one line per interval, and suppresses
|
|
6643
|
+
// an identical "updated about X" line inside the dedup window.
|
|
6644
|
+
const consolidationRateLimiter = new ConsolidationRateLimiter()
|
|
6225
6645
|
|
|
6226
6646
|
/**
|
|
6227
6647
|
* Feed-survival predicate ā the single source of truth for "is this turn
|
|
@@ -6290,10 +6710,19 @@ silencePoke.startTimer({
|
|
|
6290
6710
|
if (statusKey(turn.sessionChatId, turn.sessionThreadId) !== key) return null
|
|
6291
6711
|
return { role: turn.role, finalAnswerDelivered: turn.finalAnswerDelivered }
|
|
6292
6712
|
},
|
|
6293
|
-
//
|
|
6294
|
-
//
|
|
6295
|
-
//
|
|
6296
|
-
//
|
|
6713
|
+
// Phase 3 (reference/rfcs/deterministic-turn-liveness.md): the busy-but-
|
|
6714
|
+
// silent TEXT floor is retired. The climbing card (Phase 1 ā the 0-label
|
|
6715
|
+
// branch of `feedHeartbeatTick` / `runSilentTurnHeartbeatTick` above) IS
|
|
6716
|
+
// the mid-turn floor now: it edits the already-open activity card's
|
|
6717
|
+
// elapsed clock every `FEED_HEARTBEAT_MIN_STALE_MS`, model-independent, no
|
|
6718
|
+
// ping. This handler still decides/fires via `decideMidTurnFloor` (the
|
|
6719
|
+
// timing + role + fire-once machinery in `silence-poke.ts` /
|
|
6720
|
+
// `turn-liveness-floor.ts` is unchanged and still load-bearing ā see
|
|
6721
|
+
// below), but its ONLY surviving delivery is the approval-blocked re-ping.
|
|
6722
|
+
// Do not re-add a generic "still workingā¦" text send here believing this
|
|
6723
|
+
// path covers the silent-tool case; it does not, and re-adding one would
|
|
6724
|
+
// reintroduce the exact banned cadence ping `conversational-pacing.md`
|
|
6725
|
+
// retired in #2667.
|
|
6297
6726
|
onMidTurnFloor: async (ctx) => {
|
|
6298
6727
|
// Late-fire guard, mirroring the fallback: a clean turn-end can race the
|
|
6299
6728
|
// tick. If the turn is gone, stay silent.
|
|
@@ -6301,19 +6730,19 @@ silencePoke.startTimer({
|
|
|
6301
6730
|
const blockedOnApproval = activeStatusReactions
|
|
6302
6731
|
.get(statusKey(ctx.chatId, ctx.threadId))
|
|
6303
6732
|
?.isAwaiting() ?? false
|
|
6733
|
+
// The one surviving text case: the turn is parked on an approval card
|
|
6734
|
+
// waiting for YOUR tap. That's not a stall ā it names the real blocker ā
|
|
6735
|
+
// so it keeps this quiet re-ping (`conversational-pacing.md` § Silence-
|
|
6736
|
+
// poke fallback names this one of the two surviving honest cases). The
|
|
6737
|
+
// busy-but-silent (non-approval) case sends nothing here; the card climb
|
|
6738
|
+
// (Phase 1) is its only signal now.
|
|
6739
|
+
if (!blockedOnApproval) return
|
|
6304
6740
|
const text = silencePoke.formatFrameworkFallbackText(
|
|
6305
6741
|
'working',
|
|
6306
6742
|
ctx.silenceMs,
|
|
6307
6743
|
ctx.inFlightTools,
|
|
6308
6744
|
blockedOnApproval,
|
|
6309
6745
|
)
|
|
6310
|
-
// The stall-notice stop-gap is retired: `formatFrameworkFallbackText`
|
|
6311
|
-
// returns null for the pure-liveness "still working / running <Tool>"
|
|
6312
|
-
// beat (the only string it still emits is the approval-blocked re-ping).
|
|
6313
|
-
// The early quiet floor beat existed ONLY to surface that stall notice, so
|
|
6314
|
-
// with the notice gone there is nothing to send here unless the turn is
|
|
6315
|
-
// parked on an approval card. Skip silently otherwise; the live draft +
|
|
6316
|
-
// the model's own pacing beats carry progress.
|
|
6317
6746
|
if (text == null) return
|
|
6318
6747
|
try {
|
|
6319
6748
|
await robustApiCall(
|
|
@@ -7283,6 +7712,81 @@ function dispatchPermissionVerdict(ev: PermissionEvent): void {
|
|
|
7283
7712
|
}
|
|
7284
7713
|
}
|
|
7285
7714
|
|
|
7715
|
+
/**
|
|
7716
|
+
* #2861 re-arm: a `permission_request` re-sent by the bridge after a gateway
|
|
7717
|
+
* restart, for a request_id that's NO LONGER in `pendingPermissions` but is
|
|
7718
|
+
* still in the persisted card store. Restore the in-memory pending entry with
|
|
7719
|
+
* the ORIGINAL `startedAt` (so the existing TTL clock keeps running from the
|
|
7720
|
+
* original ask ā an already-expired re-arm drops straight into the TTL
|
|
7721
|
+
* auto-deny sweep, which correctly unwedges claude) and re-attach the inline
|
|
7722
|
+
* keyboard on the EXISTING message(s) via editMessageText ā never a new card.
|
|
7723
|
+
*
|
|
7724
|
+
* NEVER answers the card: only the question is restored (no-self-escalation).
|
|
7725
|
+
*/
|
|
7726
|
+
function rearmPermissionFromStore(
|
|
7727
|
+
msg: PermissionRequestForward,
|
|
7728
|
+
persisted: PersistedPermCard[],
|
|
7729
|
+
): void {
|
|
7730
|
+
const { requestId, toolName, description, inputPreview } = msg
|
|
7731
|
+
// Original clock: take the earliest persisted startedAt (all entries for one
|
|
7732
|
+
// request share it, but be defensive).
|
|
7733
|
+
const startedAt = persisted.reduce(
|
|
7734
|
+
(min, c) => (c.startedAt < min ? c.startedAt : min),
|
|
7735
|
+
persisted[0].startedAt,
|
|
7736
|
+
)
|
|
7737
|
+
const cardText = persisted[0].cardText
|
|
7738
|
+
const cards = persisted.map(c => ({ chatId: c.chatId, messageId: c.messageId }))
|
|
7739
|
+
pendingPermissions.set(requestId, {
|
|
7740
|
+
tool_name: toolName,
|
|
7741
|
+
description,
|
|
7742
|
+
input_preview: inputPreview,
|
|
7743
|
+
startedAt,
|
|
7744
|
+
card_text: cardText,
|
|
7745
|
+
cards,
|
|
7746
|
+
})
|
|
7747
|
+
process.stderr.write(
|
|
7748
|
+
`telegram gateway: re-arming permission card(s) for request=${requestId} ` +
|
|
7749
|
+
`tool=${toolName} (${cards.length} card(s), original startedAt preserved)\n`,
|
|
7750
|
+
)
|
|
7751
|
+
// Re-attach the keyboard on each surviving card message. Message-id-targeted
|
|
7752
|
+
// edit ā no thread needed, so THREAD_NOT_FOUND is not in the blast radius.
|
|
7753
|
+
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
|
|
7754
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways)
|
|
7755
|
+
for (const c of cards) {
|
|
7756
|
+
void swallowingApiCall(
|
|
7757
|
+
// allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Re-attaches the Allow/Deny keyboard on the persisted card after a gateway restart.
|
|
7758
|
+
() => bot.api.editMessageText(c.chatId, c.messageId, richMessage(cardText), { reply_markup: keyboard }),
|
|
7759
|
+
{ chat_id: c.chatId, verb: 'permission_request.rearm' },
|
|
7760
|
+
)
|
|
7761
|
+
}
|
|
7762
|
+
}
|
|
7763
|
+
|
|
7764
|
+
/**
|
|
7765
|
+
* Strip a single stale permission card: drop its keyboard and show the
|
|
7766
|
+
* "gateway restarted" notice. Shared by the boot-sweep (immediate legacy path
|
|
7767
|
+
* and the #2861 grace-period path). Best-effort ā a deleted/inaccessible card
|
|
7768
|
+
* is swallowed, never fatal.
|
|
7769
|
+
*/
|
|
7770
|
+
async function stripStalePermissionCard(card: PersistedPermCard): Promise<void> {
|
|
7771
|
+
const toolLabel = card.toolName ?? 'unknown tool'
|
|
7772
|
+
const notice = `š **${toolLabel}**\n\nā ļø *Gateway restarted ā this request is no longer active. Ask your agent to try again if needed.*`
|
|
7773
|
+
try {
|
|
7774
|
+
// allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
|
|
7775
|
+
await bot.api.editMessageText(
|
|
7776
|
+
card.chatId,
|
|
7777
|
+
card.messageId,
|
|
7778
|
+
richMessage(notice),
|
|
7779
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
7780
|
+
)
|
|
7781
|
+
} catch (err) {
|
|
7782
|
+
// Card may already be deleted, edited, or in an inaccessible chat ā benign
|
|
7783
|
+
process.stderr.write(
|
|
7784
|
+
`telegram gateway: boot-sweep: stale-card strip failed ` +
|
|
7785
|
+
`${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
|
|
7786
|
+
)
|
|
7787
|
+
}
|
|
7788
|
+
}
|
|
7789
|
+
|
|
7286
7790
|
const ipcServer: IpcServer = createIpcServer({
|
|
7287
7791
|
socketPath: SOCKET_PATH,
|
|
7288
7792
|
|
|
@@ -7652,6 +8156,38 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7652
8156
|
|
|
7653
8157
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
7654
8158
|
const { requestId, toolName, description, inputPreview } = msg
|
|
8159
|
+
// #2861 idempotent re-send handling. The bridge re-sends every outstanding
|
|
8160
|
+
// permission_request on each IPC (re)connect, so the same request_id can
|
|
8161
|
+
// arrive more than once. Disposition:
|
|
8162
|
+
// duplicate ā already live in pendingPermissions; ignore (no 2nd card).
|
|
8163
|
+
// rearm ā gone from memory (gateway restarted) but the persisted card
|
|
8164
|
+
// store still has it; restore + re-attach keyboard in place.
|
|
8165
|
+
// fresh ā new ask; fall through to the normal card-posting path.
|
|
8166
|
+
// Either re-claim marks the id so the grace-period boot-sweep won't strip
|
|
8167
|
+
// it. Kill switch SWITCHROOM_PERMISSION_REARM=0 skips this entirely (legacy
|
|
8168
|
+
// behavior: a re-send would post a duplicate card, as before this fix).
|
|
8169
|
+
if (isPermissionRearmEnabled()) {
|
|
8170
|
+
const persisted = pendingPermissions.has(requestId)
|
|
8171
|
+
? []
|
|
8172
|
+
: permCardStore.loadAll().filter(e => e.requestId === requestId)
|
|
8173
|
+
const disposition = classifyPermissionRequest({
|
|
8174
|
+
hasPending: pendingPermissions.has(requestId),
|
|
8175
|
+
persistedCount: persisted.length,
|
|
8176
|
+
})
|
|
8177
|
+
if (disposition === 'duplicate') {
|
|
8178
|
+
process.stderr.write(
|
|
8179
|
+
`telegram gateway: permission_request duplicate re-send ignored ` +
|
|
8180
|
+
`request=${requestId} (already pending)\n`,
|
|
8181
|
+
)
|
|
8182
|
+
return
|
|
8183
|
+
}
|
|
8184
|
+
if (disposition === 'rearm') {
|
|
8185
|
+
// Re-arm restores the pending entry; the grace-period boot-sweep
|
|
8186
|
+
// preserves anything live in pendingPermissions, so it won't strip it.
|
|
8187
|
+
rearmPermissionFromStore(msg, persisted)
|
|
8188
|
+
return
|
|
8189
|
+
}
|
|
8190
|
+
}
|
|
7655
8191
|
// "ā± 30 min" short-circuit: if the operator tapped a live scoped grant
|
|
7656
8192
|
// covering this exact request, auto-allow without posting a card. CRITICAL:
|
|
7657
8193
|
// dispatch WITHOUT a `rule` so the bridge does NOT cache it untimed
|
|
@@ -8180,11 +8716,24 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8180
8716
|
// field, or any other byte difference) won't match ā falls
|
|
8181
8717
|
// through to a real operator approval card.
|
|
8182
8718
|
const added = extractAddedAllowRule(msg.unifiedDiff)
|
|
8183
|
-
if (
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8719
|
+
if (added) {
|
|
8720
|
+
const key = `${msg.agentName}::${added}`
|
|
8721
|
+
const entry = pendingAlwaysAllowCorrelations.get(key)
|
|
8722
|
+
if (entry && entry.unifiedDiff === msg.unifiedDiff) {
|
|
8723
|
+
pendingAlwaysAllowCorrelations.delete(key)
|
|
8724
|
+
return 'approve'
|
|
8725
|
+
}
|
|
8726
|
+
}
|
|
8727
|
+
// hindsight Phase 5: a mental-model PROPOSAL the operator already
|
|
8728
|
+
// approved on the proposal card. The gateway pre-registered the EXACT
|
|
8729
|
+
// diff; auto-approve on a byte-exact match (the security gate), so no
|
|
8730
|
+
// second card is posted. Any forged/other edit finds no entry and
|
|
8731
|
+
// falls through to a real operator card.
|
|
8732
|
+
sweepStaleMentalModelCorrelations()
|
|
8733
|
+
const mmKey = mentalModelCorrelationKey(msg.agentName, msg.unifiedDiff)
|
|
8734
|
+
const mmEntry = pendingMentalModelCorrelations.get(mmKey)
|
|
8735
|
+
if (mmEntry && mmEntry.unifiedDiff === msg.unifiedDiff) {
|
|
8736
|
+
pendingMentalModelCorrelations.delete(mmKey)
|
|
8188
8737
|
return 'approve'
|
|
8189
8738
|
}
|
|
8190
8739
|
return null
|
|
@@ -8317,11 +8866,45 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8317
8866
|
// frequent cron, or a crashed cron session) falls back to the MAIN agent
|
|
8318
8867
|
// bridge so the fire lands now; it routes cheap again once the session is
|
|
8319
8868
|
// up. See deliverInjectWithFallback.
|
|
8869
|
+
// #2793 part B ā durable cron boot-replay. A fire the scheduler is
|
|
8870
|
+
// REPLAYING across a restart carries `meta.replay_fire_ms`; route it
|
|
8871
|
+
// through the durable inbound spool so accept and consume are ledgered
|
|
8872
|
+
// separately (mirrors the real-inbound path). The scheduler's own
|
|
8873
|
+
// scheduler.jsonl records the fire at socket-accept, which stops
|
|
8874
|
+
// findMissedFires from re-firing it ā but a socket accept is NOT proof
|
|
8875
|
+
// the session consumed it. Spooling here means: if the session isn't up
|
|
8876
|
+
// (accepted-but-not-consumed), the entry stays un-acked and the spool's
|
|
8877
|
+
// boot-replay re-delivers it on the next gateway boot (closes the
|
|
8878
|
+
// silent-loss window); and the stable `spoolId` (keyed on the replayed
|
|
8879
|
+
// fire) dedups a re-replay so it lands at most once (closes the
|
|
8880
|
+
// double-fire window). Live cron ticks never set replay_fire_ms, so they
|
|
8881
|
+
// keep the fire-and-forget path unchanged. STATIC mode has no spool ā
|
|
8882
|
+
// fall through to the legacy path.
|
|
8883
|
+
const isDurableReplay =
|
|
8884
|
+
inboundSpool != null &&
|
|
8885
|
+
typeof msg.inbound.meta?.replay_fire_ms === 'string' &&
|
|
8886
|
+
msg.inbound.meta.replay_fire_ms.length > 0
|
|
8887
|
+
if (isDurableReplay && inboundSpool != null) {
|
|
8888
|
+
// Durable ACCEPT: put before delivery so a crash between accept and
|
|
8889
|
+
// consume leaves the entry recoverable (boot-replay re-delivers).
|
|
8890
|
+
// Idempotent by stable spoolId ā a re-replay of the same missed fire
|
|
8891
|
+
// is a no-op while the prior entry is still un-acked.
|
|
8892
|
+
inboundSpool.put(msg.agentName, msg.inbound as unknown as InboundMessage)
|
|
8893
|
+
}
|
|
8320
8894
|
const { target, delivered, fellBackToMain } = deliverInjectWithFallback(
|
|
8321
8895
|
msg.agentName,
|
|
8322
8896
|
msg.inbound.meta,
|
|
8323
8897
|
(t) => ipcServer.sendToAgent(t, msg.inbound),
|
|
8324
8898
|
)
|
|
8899
|
+
if (isDurableReplay && inboundSpool != null && delivered) {
|
|
8900
|
+
// Durable CONSUME: the fire reached a live bridge, so tombstone the
|
|
8901
|
+
// spool entry (same "delivered to a live registered bridge" ack
|
|
8902
|
+
// semantics the inbound spool uses ā see its v1 scope note). On a
|
|
8903
|
+
// miss we leave it un-acked: the pendingInboundBuffer.push below
|
|
8904
|
+
// buffers it for the reconnect drain, and the durable spool copy is
|
|
8905
|
+
// re-delivered on the next boot if this process dies first.
|
|
8906
|
+
inboundSpool.ack(msg.inbound as unknown as InboundMessage)
|
|
8907
|
+
}
|
|
8325
8908
|
if (fellBackToMain) {
|
|
8326
8909
|
process.stderr.write(
|
|
8327
8910
|
`telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
|
|
@@ -8560,6 +9143,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8560
9143
|
{
|
|
8561
9144
|
inject: webhookInject,
|
|
8562
9145
|
log: (s) => process.stderr.write(`telegram gateway: ${s}`),
|
|
9146
|
+
onConsolidation: surfaceConsolidationLegibility,
|
|
8563
9147
|
},
|
|
8564
9148
|
),
|
|
8565
9149
|
})
|
|
@@ -8681,6 +9265,7 @@ const ALLOWED_TOOLS = new Set([
|
|
|
8681
9265
|
'vault_request_save',
|
|
8682
9266
|
'vault_request_access',
|
|
8683
9267
|
'request_secret',
|
|
9268
|
+
'mental_model_propose',
|
|
8684
9269
|
'linear_agent_activity',
|
|
8685
9270
|
'linear_create_issue',
|
|
8686
9271
|
'linear_agent_setup',
|
|
@@ -8727,6 +9312,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
8727
9312
|
return executeVaultRequestAccess(args)
|
|
8728
9313
|
case 'request_secret':
|
|
8729
9314
|
return executeRequestSecret(args)
|
|
9315
|
+
case 'mental_model_propose':
|
|
9316
|
+
return executeMentalModelPropose(args)
|
|
8730
9317
|
case 'linear_agent_activity':
|
|
8731
9318
|
return executeLinearAgentActivity(args)
|
|
8732
9319
|
case 'linear_create_issue':
|
|
@@ -11355,6 +11942,182 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
11355
11942
|
}
|
|
11356
11943
|
}
|
|
11357
11944
|
|
|
11945
|
+
/** Read the live switchroom.yaml bytes for diff/dup-check. Mirrors the
|
|
11946
|
+
* always-allow persistence path's config read. */
|
|
11947
|
+
function readLiveSwitchroomConfigText(): string {
|
|
11948
|
+
const cfgPath = process.env.SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
|
|
11949
|
+
return readFileSync(cfgPath, 'utf8')
|
|
11950
|
+
}
|
|
11951
|
+
|
|
11952
|
+
const MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/
|
|
11953
|
+
|
|
11954
|
+
function buildMentalModelProposeKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
|
|
11955
|
+
return {
|
|
11956
|
+
inline_keyboard: [
|
|
11957
|
+
[
|
|
11958
|
+
{ text: 'ā
Approve', callback_data: `mmp:approve:${stageId}` },
|
|
11959
|
+
{ text: 'š« Deny', callback_data: `mmp:deny:${stageId}` },
|
|
11960
|
+
],
|
|
11961
|
+
],
|
|
11962
|
+
}
|
|
11963
|
+
}
|
|
11964
|
+
|
|
11965
|
+
/**
|
|
11966
|
+
* `mental_model_propose` tool (hindsight Phase 5) ā the agent surfaces a
|
|
11967
|
+
* candidate mental model for the operator to approve. Mirrors the
|
|
11968
|
+
* `vault_request_access` shape: the agent can only PROPOSE; the [Approve]/[Deny]
|
|
11969
|
+
* tap is operator-gated (handleMentalModelProposeCallback), so an agent can
|
|
11970
|
+
* never self-approve. On Approve the proposal is DECLARED ā appended to the
|
|
11971
|
+
* agent's memory.mental_models[] via the operator-approved config-edit path
|
|
11972
|
+
* (reusing config_propose_edit apply+reconcile) ā and ensured in the bank. On
|
|
11973
|
+
* Deny nothing is written. Guardrails enforced here BEFORE any card:
|
|
11974
|
+
* duplicate-name rejection against the agent's already-declared models, and a
|
|
11975
|
+
* per-agent rate limit so proposals stay non-spammy.
|
|
11976
|
+
*/
|
|
11977
|
+
async function executeMentalModelPropose(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
11978
|
+
const chat_id = String(args.chat_id ?? '')
|
|
11979
|
+
if (!chat_id) throw new Error('mental_model_propose: chat_id is required')
|
|
11980
|
+
const name = typeof args.name === 'string' ? args.name.trim() : ''
|
|
11981
|
+
if (!name) throw new Error('mental_model_propose: name is required')
|
|
11982
|
+
if (!MENTAL_MODEL_NAME_REGEX.test(name)) {
|
|
11983
|
+
throw new Error('mental_model_propose: name must be a slug (letters/digits/_/-, ā¤64 chars, e.g. `training-plan-state`)')
|
|
11984
|
+
}
|
|
11985
|
+
const source_query = typeof args.source_query === 'string' ? args.source_query.trim() : ''
|
|
11986
|
+
if (!source_query) throw new Error('mental_model_propose: source_query is required')
|
|
11987
|
+
// Enforce the memory.mental_models[] schema cap (src/config/schema.ts) up-front
|
|
11988
|
+
// so the operator never approves a card that then fails hostd config validation.
|
|
11989
|
+
// The 2000-char ceiling also keeps the rendered card under Telegram's 4096-char
|
|
11990
|
+
// message limit.
|
|
11991
|
+
if (source_query.length > MENTAL_MODEL_SOURCE_QUERY_MAX) {
|
|
11992
|
+
throw new Error(
|
|
11993
|
+
`mental_model_propose: source_query is ${source_query.length} chars; the schema caps it at ${MENTAL_MODEL_SOURCE_QUERY_MAX} (a standing reflection query, not a document). Shorten it.`,
|
|
11994
|
+
)
|
|
11995
|
+
}
|
|
11996
|
+
// Accept `why` as an alias for `reason` (mirrors the vault tools).
|
|
11997
|
+
const reason =
|
|
11998
|
+
typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
|
|
11999
|
+
let refresh_after_consolidation: boolean | undefined
|
|
12000
|
+
if (args.refresh_after_consolidation !== undefined) {
|
|
12001
|
+
if (typeof args.refresh_after_consolidation !== 'boolean') {
|
|
12002
|
+
throw new Error('mental_model_propose: refresh_after_consolidation must be a boolean')
|
|
12003
|
+
}
|
|
12004
|
+
refresh_after_consolidation = args.refresh_after_consolidation
|
|
12005
|
+
}
|
|
12006
|
+
let max_tokens: number | undefined
|
|
12007
|
+
if (args.max_tokens !== undefined) {
|
|
12008
|
+
const n = Number(args.max_tokens)
|
|
12009
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
12010
|
+
throw new Error('mental_model_propose: max_tokens must be a positive integer')
|
|
12011
|
+
}
|
|
12012
|
+
// Enforce the schema ceiling (src/config/schema.ts) here so the card can't be
|
|
12013
|
+
// approved into a config-validation failure downstream.
|
|
12014
|
+
if (n > MENTAL_MODEL_MAX_TOKENS_CAP) {
|
|
12015
|
+
throw new Error(
|
|
12016
|
+
`mental_model_propose: max_tokens ${n} exceeds the schema cap of ${MENTAL_MODEL_MAX_TOKENS_CAP} (a mental model is a standing summary, not a corpus).`,
|
|
12017
|
+
)
|
|
12018
|
+
}
|
|
12019
|
+
max_tokens = n
|
|
12020
|
+
}
|
|
12021
|
+
assertAllowedChat(chat_id)
|
|
12022
|
+
|
|
12023
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
12024
|
+
|
|
12025
|
+
// Rate limit: a proposal is a rare, deliberate curation act ā throttle so a
|
|
12026
|
+
// looping agent can never spam the operator with cards.
|
|
12027
|
+
const rate = checkMentalModelProposeRate()
|
|
12028
|
+
if (!rate.ok) {
|
|
12029
|
+
const retryAtIso = new Date(rate.retryAtMs).toISOString()
|
|
12030
|
+
return {
|
|
12031
|
+
content: [
|
|
12032
|
+
{
|
|
12033
|
+
type: 'text',
|
|
12034
|
+
text:
|
|
12035
|
+
`mental_model_propose: RATE-LIMITED (max ${MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW} proposals/hour). ` +
|
|
12036
|
+
`No card was posted. Next slot opens at ${retryAtIso}. Proposing mental models is meant to be ` +
|
|
12037
|
+
`rare ā batch or wait rather than re-firing.`,
|
|
12038
|
+
},
|
|
12039
|
+
],
|
|
12040
|
+
}
|
|
12041
|
+
}
|
|
12042
|
+
|
|
12043
|
+
// Duplicate-name guard: reject a proposal for a model already DECLARED for
|
|
12044
|
+
// this agent, BEFORE posting a card (the name is the idempotent-ensure key).
|
|
12045
|
+
try {
|
|
12046
|
+
const configText = readLiveSwitchroomConfigText()
|
|
12047
|
+
const declared = readDeclaredMentalModelNames(configText, agentSlug)
|
|
12048
|
+
if (declared.includes(name)) {
|
|
12049
|
+
return {
|
|
12050
|
+
content: [
|
|
12051
|
+
{
|
|
12052
|
+
type: 'text',
|
|
12053
|
+
text:
|
|
12054
|
+
`mental_model_propose: '${name}' is ALREADY a declared mental model for ${agentSlug} ` +
|
|
12055
|
+
`(memory.mental_models[]). No card was posted ā it already exists and is ensured in your ` +
|
|
12056
|
+
`bank. Pick a different name if you meant a NEW model, or just use the existing one.`,
|
|
12057
|
+
},
|
|
12058
|
+
],
|
|
12059
|
+
}
|
|
12060
|
+
}
|
|
12061
|
+
} catch (err) {
|
|
12062
|
+
// Config read failed (transient) ā fall through to the card. The approve
|
|
12063
|
+
// path re-reads and re-checks (dupe guard is defense-in-depth), so a
|
|
12064
|
+
// redundant card is harmless; suppressing a needed card is not.
|
|
12065
|
+
process.stderr.write(`telegram gateway: mental_model_propose dup pre-check read failed: ${(err as Error).message}\n`)
|
|
12066
|
+
}
|
|
12067
|
+
|
|
12068
|
+
const stageId = randomBytes(4).toString('hex')
|
|
12069
|
+
const pending: PendingMentalModelPropose = {
|
|
12070
|
+
agent: agentSlug,
|
|
12071
|
+
chat_id,
|
|
12072
|
+
spec: {
|
|
12073
|
+
name,
|
|
12074
|
+
source_query,
|
|
12075
|
+
...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
|
|
12076
|
+
...(max_tokens !== undefined ? { max_tokens } : {}),
|
|
12077
|
+
},
|
|
12078
|
+
...(reason ? { reason } : {}),
|
|
12079
|
+
staged_at: Date.now(),
|
|
12080
|
+
}
|
|
12081
|
+
pendingMentalModelProposes.set(stageId, pending)
|
|
12082
|
+
sweepPendingMentalModelProposes()
|
|
12083
|
+
|
|
12084
|
+
const text = renderMentalModelProposeCard({
|
|
12085
|
+
agent: agentSlug,
|
|
12086
|
+
name,
|
|
12087
|
+
source_query,
|
|
12088
|
+
...(reason ? { reason } : {}),
|
|
12089
|
+
...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
|
|
12090
|
+
})
|
|
12091
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
12092
|
+
if (threadId != null) pending.threadId = threadId
|
|
12093
|
+
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
12094
|
+
robustApiCall,
|
|
12095
|
+
(tid) =>
|
|
12096
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
12097
|
+
reply_markup: buildMentalModelProposeKeyboard(stageId),
|
|
12098
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
12099
|
+
}),
|
|
12100
|
+
{ threadId, chat_id, verb: 'mental_model_propose.card' },
|
|
12101
|
+
)
|
|
12102
|
+
pending.card_message_id = sent.message_id
|
|
12103
|
+
// Only count a proposal against the rate budget once its card actually
|
|
12104
|
+
// posted (validation errors / dupes don't consume the budget).
|
|
12105
|
+
mentalModelProposeTimes.push(Date.now())
|
|
12106
|
+
|
|
12107
|
+
return {
|
|
12108
|
+
content: [
|
|
12109
|
+
{
|
|
12110
|
+
type: 'text',
|
|
12111
|
+
text:
|
|
12112
|
+
`mental_model_propose: card sent (stage_id=${stageId}, name=${name}). Wait for the operator to tap ` +
|
|
12113
|
+
`Approve or Deny ā END YOUR TURN cleanly. A fresh inbound arrives with the outcome ` +
|
|
12114
|
+
`(source=mental_model_proposal_applied / mental_model_proposal_denied). Do NOT re-propose this ` +
|
|
12115
|
+
`model while the card is open.`,
|
|
12116
|
+
},
|
|
12117
|
+
],
|
|
12118
|
+
}
|
|
12119
|
+
}
|
|
12120
|
+
|
|
11358
12121
|
async function executeReact(args: Record<string, unknown>): Promise<unknown> {
|
|
11359
12122
|
if (!args.chat_id) throw new Error('react: chat_id is required')
|
|
11360
12123
|
if (!args.message_id) throw new Error('react: message_id is required')
|
|
@@ -11924,6 +12687,31 @@ async function drainActivitySummary(
|
|
|
11924
12687
|
)
|
|
11925
12688
|
turn.activityMessageId = sent.message_id
|
|
11926
12689
|
turn.activityEverOpened = true
|
|
12690
|
+
// Known Gap 1 (deterministic-turn-liveness.md) ā persist the
|
|
12691
|
+
// minimal card handle the moment it opens, so a gateway restart
|
|
12692
|
+
// mid-turn has something to finalize on next boot instead of
|
|
12693
|
+
// leaving this card frozen forever. Fire-and-forget/best-effort:
|
|
12694
|
+
// a failed persist degrades to the pre-fix (in-memory-only)
|
|
12695
|
+
// behaviour, never blocks the card opening.
|
|
12696
|
+
if (activityCardPersistEnabled) {
|
|
12697
|
+
writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
|
|
12698
|
+
turnKey: statusKey(chat, thread),
|
|
12699
|
+
chatId: chat,
|
|
12700
|
+
threadId: thread ?? null,
|
|
12701
|
+
activityMessageId: sent.message_id,
|
|
12702
|
+
startedAt: turn.startedAt,
|
|
12703
|
+
// Mirror the ACTUAL pin decision, not an unconditional `true`:
|
|
12704
|
+
// the OPEN below silently-pins the fresh card only when
|
|
12705
|
+
// `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
|
|
12706
|
+
// when it's off, and can also fail on missing supergroup
|
|
12707
|
+
// rights). Persisting `pinned: true` regardless would make the
|
|
12708
|
+
// boot reaper attempt an unpin on a card that was never pinned.
|
|
12709
|
+
// The reaper's unpin is defense-in-depth anyway
|
|
12710
|
+
// (`statusPinBootCleanup` owns the primary unpin), so tracking
|
|
12711
|
+
// the flag honestly is what matters here.
|
|
12712
|
+
pinned: PIN_STATUS_WHILE_WORKING,
|
|
12713
|
+
})
|
|
12714
|
+
}
|
|
11927
12715
|
// Status-pin: the per-turn status message just opened ā it's the
|
|
11928
12716
|
// in-flight "what it's doing" surface. Silently pin it so the turn
|
|
11929
12717
|
// stays in view when the feed scrolls past. Keyed to the same
|
|
@@ -11968,11 +12756,15 @@ async function drainActivitySummary(
|
|
|
11968
12756
|
}
|
|
11969
12757
|
|
|
11970
12758
|
/**
|
|
11971
|
-
*
|
|
11972
|
-
*
|
|
11973
|
-
*
|
|
11974
|
-
*
|
|
11975
|
-
*
|
|
12759
|
+
* OPEN the minimal "Workingā¦" liveness card for a 0-label turn once it has been
|
|
12760
|
+
* alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card may OPEN ā both
|
|
12761
|
+
* the enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and the 6 s
|
|
12762
|
+
* heartbeat call through here, so a card opened by one caller is a clean no-op
|
|
12763
|
+
* for the other. This function OPENS only; it does NOT climb an already-open card
|
|
12764
|
+
* (its WHEN-gate `shouldEarlyOpenLiveness` returns false once `activityMessageId`
|
|
12765
|
+
* is set). The 0-label CLIMB of an already-open card lives at the heartbeat call
|
|
12766
|
+
* site via `silentTurnClimbRender` (deterministic-turn-liveness.md Phase 1) ā so
|
|
12767
|
+
* BOTH the labelled and the 0-label branches now keep the card visibly climbing.
|
|
11976
12768
|
* - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
|
|
11977
12769
|
* once it is set, so a second call after an open just maintains the card;
|
|
11978
12770
|
* - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
|
|
@@ -12157,13 +12949,45 @@ function feedHeartbeatTick(): void {
|
|
|
12157
12949
|
// sends (opens) when activityMessageId is null and edits (maintains) once set
|
|
12158
12950
|
// ā so this one branch handles both the open and the climb.
|
|
12159
12951
|
//
|
|
12160
|
-
// The
|
|
12952
|
+
// The OPEN logic lives in ONE place (`openLivenessFeedIfDue`) so the
|
|
12161
12953
|
// enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
|
|
12162
12954
|
// heartbeat both reach the same drain ā there is exactly one path that can
|
|
12163
12955
|
// OPEN the liveness card, so the two callers can never double-open or race.
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
12956
|
+
//
|
|
12957
|
+
// Phase 1 climb (deterministic-turn-liveness.md): once the card IS open, the
|
|
12958
|
+
// OPEN path no-ops (its WHEN-gate `shouldEarlyOpenLiveness` returns false for
|
|
12959
|
+
// an already-open card) ā which is exactly how the 0-label card used to FREEZE
|
|
12960
|
+
// during a long silent tool. So split the two cases: OPEN when no card exists,
|
|
12961
|
+
// and otherwise re-render the "Workingā¦" card with a fresh wall-clock elapsed
|
|
12962
|
+
// through the SAME cardDrainGate / mayDrain / liveness EDIT path the labelled
|
|
12963
|
+
// branch below uses. Model-independent (reads only `now - startedAt`), so a
|
|
12964
|
+
// blocked tool call can't starve it; edit-only, so it never push-notifies.
|
|
12965
|
+
//
|
|
12966
|
+
// The tick BODY lives in feed-heartbeat-climb.ts (`runSilentTurnHeartbeatTick`)
|
|
12967
|
+
// so the shipped decision logic is directly under the outcome-based regression
|
|
12968
|
+
// test (tests/silent-turn-climb-transport.test.ts) ā this gateway IIFE cannot
|
|
12969
|
+
// be imported in-process. This call site only wires the REAL deps; the wiring
|
|
12970
|
+
// shape is pinned structurally by tests/feed-heartbeat-liveness-open.test.ts.
|
|
12971
|
+
{
|
|
12972
|
+
const ea = emissionAuthorityFor(turn)
|
|
12973
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
12974
|
+
{
|
|
12975
|
+
mirrorLineCount: turn.mirrorLines.length,
|
|
12976
|
+
activityMessageId: turn.activityMessageId,
|
|
12977
|
+
labeledToolCount: turn.labeledToolCount,
|
|
12978
|
+
ageMs: Date.now() - turn.startedAt,
|
|
12979
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
12980
|
+
},
|
|
12981
|
+
{
|
|
12982
|
+
openLivenessFeedIfDue: () => openLivenessFeedIfDue(turn),
|
|
12983
|
+
setPendingRender: (rendered) => { turn.activityPendingRender = rendered },
|
|
12984
|
+
cardDrainGate: (run) => cardDrainGate(turn, ea, run),
|
|
12985
|
+
mayDrain: () => ea.mayDrain(turn),
|
|
12986
|
+
openOrEditCard: (apply) => ea.openOrEditCard('liveness', apply),
|
|
12987
|
+
drain: () => { turn.activityInFlight = drainActivitySummary(turn, 'liveness') },
|
|
12988
|
+
},
|
|
12989
|
+
)
|
|
12990
|
+
if (handled) return
|
|
12167
12991
|
}
|
|
12168
12992
|
|
|
12169
12993
|
// Labelled-feed heartbeat: keep a stale in-progress step visibly advancing.
|
|
@@ -12228,6 +13052,21 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
12228
13052
|
if (turn.activityMessageId == null) return
|
|
12229
13053
|
const id = turn.activityMessageId
|
|
12230
13054
|
turn.activityMessageId = null
|
|
13055
|
+
// Known Gap 1 (deterministic-turn-liveness.md) ā the card is closing
|
|
13056
|
+
// normally (about to be deleted or finalized below), so drop its durable
|
|
13057
|
+
// handle too: a normal close must never leave a stale record for the
|
|
13058
|
+
// boot reaper to "finalize" a message that's already been handled.
|
|
13059
|
+
if (activityCardPersistEnabled) {
|
|
13060
|
+
clearActivityCardRecord(
|
|
13061
|
+
ACTIVITY_CARD_STORE_PATH,
|
|
13062
|
+
activityCardStoreFs,
|
|
13063
|
+
statusKey(chat, thread),
|
|
13064
|
+
// Scope to this card's exact id (reap-race guard): only drop the row
|
|
13065
|
+
// for the card THIS turn is closing, never a fresher card another
|
|
13066
|
+
// turn may have already upserted under the same topic key.
|
|
13067
|
+
id,
|
|
13068
|
+
)
|
|
13069
|
+
}
|
|
12231
13070
|
if (CLEAR_STATUS_ON_COMPLETION) {
|
|
12232
13071
|
try {
|
|
12233
13072
|
await robustApiCall(
|
|
@@ -12269,6 +13108,175 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
12269
13108
|
})
|
|
12270
13109
|
}
|
|
12271
13110
|
|
|
13111
|
+
/**
|
|
13112
|
+
* #2849 hindsight Phase 4 ā sparse chat-legible memory.
|
|
13113
|
+
*
|
|
13114
|
+
* Fire-and-forget: if this main-agent tool call is a material memory
|
|
13115
|
+
* operation (create_directive ā š remembered; invalidate/demote ā āļø
|
|
13116
|
+
* forgot), send ONE terse real message into the turn's originating
|
|
13117
|
+
* chat/topic. Routes on `turn.sessionChatId` / `turn.sessionThreadId`
|
|
13118
|
+
* (the originating topic, never the operator DM), through
|
|
13119
|
+
* `retryWithThreadFallback` so a deleted forum topic can't crash the
|
|
13120
|
+
* gateway. Non-material tool calls return silently ā no line on ordinary
|
|
13121
|
+
* recall or routine consolidation. Kill-switch: SWITCHROOM_MEMORY_LEGIBILITY=0.
|
|
13122
|
+
*/
|
|
13123
|
+
/**
|
|
13124
|
+
* Fix 1.3 (#2903): a memory-legibility line must render only AFTER the write
|
|
13125
|
+
* is confirmed. We stage the detected event on `tool_use` (keyed by
|
|
13126
|
+
* `toolUseId`) and flush the š/āļø line on the matching SUCCESSFUL
|
|
13127
|
+
* `tool_result` ā a failed write (engine down / isError envelope) drops the
|
|
13128
|
+
* staged line, so chat never claims "remembered" for a write that errored.
|
|
13129
|
+
*
|
|
13130
|
+
* A hindsight `tools/call` returns HTTP 200 + `is_error:true` on failure, and
|
|
13131
|
+
* Claude Code surfaces that on the transcript `tool_result` block, which
|
|
13132
|
+
* `session-tail` projects onto `ev.isError`. That is the confirmed-success
|
|
13133
|
+
* signal we gate on.
|
|
13134
|
+
*/
|
|
13135
|
+
interface MemoryLegibilityRoute {
|
|
13136
|
+
chatId: string
|
|
13137
|
+
threadId: number | undefined
|
|
13138
|
+
toolName: string
|
|
13139
|
+
}
|
|
13140
|
+
const memoryLegibilityStager = new MemoryLegibilityStager<MemoryLegibilityRoute>()
|
|
13141
|
+
|
|
13142
|
+
function sendMemoryLegibilityLine(
|
|
13143
|
+
event: NonNullable<ReturnType<typeof detectMemoryLegibilityEvent>>,
|
|
13144
|
+
chatId: string,
|
|
13145
|
+
threadId: number | undefined,
|
|
13146
|
+
): void {
|
|
13147
|
+
const line = renderMemoryLegibilityLine(event)
|
|
13148
|
+
void retryWithThreadFallback(
|
|
13149
|
+
robustApiCall,
|
|
13150
|
+
(tid) =>
|
|
13151
|
+
bot.api.sendMessage(chatId, line, {
|
|
13152
|
+
parse_mode: 'HTML',
|
|
13153
|
+
// Status surface, not the user's answer ā never ping the device.
|
|
13154
|
+
disable_notification: true,
|
|
13155
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
13156
|
+
}),
|
|
13157
|
+
{ threadId, chat_id: chatId, verb: 'memory-legibility.sendMessage' },
|
|
13158
|
+
).catch((err) => {
|
|
13159
|
+
process.stderr.write(
|
|
13160
|
+
`telegram gateway: memory-legibility send failed: ${
|
|
13161
|
+
err instanceof Error ? err.message : String(err)
|
|
13162
|
+
}\n`,
|
|
13163
|
+
)
|
|
13164
|
+
})
|
|
13165
|
+
}
|
|
13166
|
+
|
|
13167
|
+
/** Stage a material memory event observed on `tool_use`. Nothing is sent yet;
|
|
13168
|
+
* the line is flushed by `confirmMemoryLegibility` on a successful result. */
|
|
13169
|
+
function surfaceMemoryLegibility(
|
|
13170
|
+
turn: CurrentTurn,
|
|
13171
|
+
toolName: string,
|
|
13172
|
+
toolUseId: string | null | undefined,
|
|
13173
|
+
input: Record<string, unknown> | undefined,
|
|
13174
|
+
): void {
|
|
13175
|
+
if (!MEMORY_LEGIBILITY_ENABLED) return
|
|
13176
|
+
const event = detectMemoryLegibilityEvent(toolName, input)
|
|
13177
|
+
if (event == null) return
|
|
13178
|
+
const chatId = turn.sessionChatId
|
|
13179
|
+
const threadId = turn.sessionThreadId
|
|
13180
|
+
// Without a toolUseId we cannot correlate a result to confirm success. This
|
|
13181
|
+
// effectively never happens (Claude Code always stamps a tool_use id), so
|
|
13182
|
+
// rather than claim an unconfirmed "remembered", we skip and log.
|
|
13183
|
+
if (toolUseId == null || toolUseId.length === 0) {
|
|
13184
|
+
process.stderr.write(
|
|
13185
|
+
`telegram gateway: memory-legibility ${event.kind} SKIPPED (no toolUseId) ` +
|
|
13186
|
+
`chat=${chatId} tool=${toolName}\n`,
|
|
13187
|
+
)
|
|
13188
|
+
return
|
|
13189
|
+
}
|
|
13190
|
+
memoryLegibilityStager.stage(toolUseId, event, { chatId, threadId, toolName })
|
|
13191
|
+
process.stderr.write(
|
|
13192
|
+
`telegram gateway: memory-legibility ${event.kind} STAGED chat=${chatId} ` +
|
|
13193
|
+
`thread=${threadId ?? '-'} tool=${toolName} id=${toolUseId}\n`,
|
|
13194
|
+
)
|
|
13195
|
+
}
|
|
13196
|
+
|
|
13197
|
+
/** Flush (or drop) a staged memory-legibility line on its matching
|
|
13198
|
+
* `tool_result`. Sends only on confirmed success; a failed write is dropped
|
|
13199
|
+
* so chat never claims "remembered" for a write that errored. */
|
|
13200
|
+
function confirmMemoryLegibility(
|
|
13201
|
+
toolUseId: string | null | undefined,
|
|
13202
|
+
isError: boolean | undefined,
|
|
13203
|
+
): void {
|
|
13204
|
+
const resolved = memoryLegibilityStager.confirm(toolUseId, isError)
|
|
13205
|
+
if (resolved == null) {
|
|
13206
|
+
if (isError === true) {
|
|
13207
|
+
process.stderr.write(
|
|
13208
|
+
`telegram gateway: memory-legibility DROPPED (write errored) id=${toolUseId}\n`,
|
|
13209
|
+
)
|
|
13210
|
+
}
|
|
13211
|
+
return
|
|
13212
|
+
}
|
|
13213
|
+
const { event, meta } = resolved
|
|
13214
|
+
process.stderr.write(
|
|
13215
|
+
`telegram gateway: memory-legibility ${event.kind} CONFIRMED chat=${meta.chatId} ` +
|
|
13216
|
+
`thread=${meta.threadId ?? '-'} tool=${meta.toolName} id=${toolUseId}\n`,
|
|
13217
|
+
)
|
|
13218
|
+
sendMemoryLegibilityLine(event, meta.chatId, meta.threadId)
|
|
13219
|
+
}
|
|
13220
|
+
|
|
13221
|
+
/**
|
|
13222
|
+
* #2849 hindsight Phase 4 (follow-up) ā consolidation-driven legibility.
|
|
13223
|
+
*
|
|
13224
|
+
* The gateway-side sink for a verified `hindsight` / `consolidation.completed`
|
|
13225
|
+
* webhook. `recordWebhookEvent` has already logged the event for audit and
|
|
13226
|
+
* resolved the agent's channel target; this decides whether it is MATERIAL
|
|
13227
|
+
* (a genuine store/correct ā else nothing), rate-limits it hard, and ā only
|
|
13228
|
+
* if it clears both gates ā sends ONE terse "š§ updated what I know about Y"
|
|
13229
|
+
* real message with notifications suppressed. Never injects a model turn.
|
|
13230
|
+
* OPT-IN via SWITCHROOM_CONSOLIDATION_LEGIBILITY.
|
|
13231
|
+
*/
|
|
13232
|
+
function surfaceConsolidationLegibility(
|
|
13233
|
+
rec: WebhookGatewayRecord,
|
|
13234
|
+
target: { chatId: string; threadId?: number },
|
|
13235
|
+
): void {
|
|
13236
|
+
if (!CONSOLIDATION_LEGIBILITY_ENABLED) return
|
|
13237
|
+
const event = detectConsolidationEvent(rec.payload)
|
|
13238
|
+
if (event == null) return // routine no-op consolidation ā surface nothing
|
|
13239
|
+
const agent = rec.agent
|
|
13240
|
+
// Rate-limiter clock MUST be wall-clock (Date.now()), never rec.ts: rec.ts
|
|
13241
|
+
// can be derived from the (attacker-influenceable) webhook payload, and a
|
|
13242
|
+
// spoofed far-past / far-future timestamp would poison the per-agent
|
|
13243
|
+
// min-interval gate ā either permanently opening it (stale ts always older
|
|
13244
|
+
// than the window) or jamming it shut. The limiter only cares about "how
|
|
13245
|
+
// long since the last surface on THIS gateway", which is a local wall-clock
|
|
13246
|
+
// question.
|
|
13247
|
+
if (!consolidationRateLimiter.allow(agent, consolidationSignature(event), Date.now())) {
|
|
13248
|
+
process.stderr.write(
|
|
13249
|
+
`telegram gateway: consolidation-legibility ${event.kind} rate-limited ` +
|
|
13250
|
+
`agent=${agent} topic='${event.topic}'\n`,
|
|
13251
|
+
)
|
|
13252
|
+
return
|
|
13253
|
+
}
|
|
13254
|
+
const chatId = target.chatId
|
|
13255
|
+
const threadId = target.threadId
|
|
13256
|
+
const line = renderConsolidationLine(event)
|
|
13257
|
+
process.stderr.write(
|
|
13258
|
+
`telegram gateway: consolidation-legibility ${event.kind} chat=${chatId} ` +
|
|
13259
|
+
`thread=${threadId ?? '-'} agent=${agent}\n`,
|
|
13260
|
+
)
|
|
13261
|
+
void retryWithThreadFallback(
|
|
13262
|
+
robustApiCall,
|
|
13263
|
+
(tid) =>
|
|
13264
|
+
bot.api.sendMessage(chatId, line, {
|
|
13265
|
+
parse_mode: 'HTML',
|
|
13266
|
+
// Status surface, not the user's answer ā never ping the device.
|
|
13267
|
+
disable_notification: true,
|
|
13268
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
13269
|
+
}),
|
|
13270
|
+
{ threadId, chat_id: chatId, verb: 'consolidation-legibility.sendMessage' },
|
|
13271
|
+
).catch((err) => {
|
|
13272
|
+
process.stderr.write(
|
|
13273
|
+
`telegram gateway: consolidation-legibility send failed: ${
|
|
13274
|
+
err instanceof Error ? err.message : String(err)
|
|
13275
|
+
}\n`,
|
|
13276
|
+
)
|
|
13277
|
+
})
|
|
13278
|
+
}
|
|
13279
|
+
|
|
12272
13280
|
function handleSessionEvent(ev: SessionEvent): void {
|
|
12273
13281
|
switch (ev.kind) {
|
|
12274
13282
|
case 'enqueue': {
|
|
@@ -12567,6 +13575,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12567
13575
|
// of dropping. The answer-stream's own dedup handles overlap
|
|
12568
13576
|
// with the reply tool's payload.
|
|
12569
13577
|
preambleSuppressor.onTool({ isReplyTool: isTelegramSurfaceTool(ev.toolName) })
|
|
13578
|
+
// #2849 Phase 4 ā sparse chat-legible memory. Surface ONE terse line in
|
|
13579
|
+
// the originating chat/topic when this tool call materially changes what
|
|
13580
|
+
// the agent remembers (create_directive / invalidate / demote). Fires
|
|
13581
|
+
// BEFORE the `if (!ctrl) return` status-reaction gate below so it works
|
|
13582
|
+
// on turns with no active status-reaction controller. Deterministic
|
|
13583
|
+
// tool-call observation ā no model call, no polling; ordinary recall and
|
|
13584
|
+
// routine consolidation never reach here (they aren't material tools).
|
|
13585
|
+
surfaceMemoryLegibility(turn, ev.toolName, ev.toolUseId, ev.input)
|
|
12570
13586
|
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
12571
13587
|
const name = ev.toolName
|
|
12572
13588
|
// Phase tracking removed in #553 PR 5 ā phases only fed the
|
|
@@ -12997,6 +14013,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12997
14013
|
}
|
|
12998
14014
|
case 'tool_result': {
|
|
12999
14015
|
if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
|
|
14016
|
+
// Fix 1.3 (#2903): flush a staged š/āļø memory-legibility line only on a
|
|
14017
|
+
// CONFIRMED-successful write; a failed write (ev.isError) drops it.
|
|
14018
|
+
confirmMemoryLegibility(ev.toolUseId, ev.isError)
|
|
13000
14019
|
return
|
|
13001
14020
|
}
|
|
13002
14021
|
case 'sub_agent_tool_use': {
|
|
@@ -13753,11 +14772,28 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13753
14772
|
// this path. The turn-flush 'flush' branch also returns earlier
|
|
13754
14773
|
// (and sets finalAnswerDelivered=true defensively).
|
|
13755
14774
|
if (turn.finalAnswerDelivered === false) {
|
|
13756
|
-
|
|
13757
|
-
|
|
13758
|
-
|
|
13759
|
-
|
|
13760
|
-
|
|
14775
|
+
// PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
|
|
14776
|
+
// wire the represent-guard-style staleness
|
|
14777
|
+
// check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
|
|
14778
|
+
// an exhausted-looking record left over from a PRIOR, already-
|
|
14779
|
+
// answered turn on this same chat/thread (statusKey is not a
|
|
14780
|
+
// per-turn nonce) can never be misread as this turn's spent
|
|
14781
|
+
// re-prompt budget. Falls back to the pre-existing turnKey/
|
|
14782
|
+
// retryCount-only check when history is unavailable.
|
|
14783
|
+
const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
|
|
14784
|
+
? {
|
|
14785
|
+
hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
|
|
14786
|
+
hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
|
|
14787
|
+
}
|
|
14788
|
+
: undefined
|
|
14789
|
+
const silentEnd = recordUndeliveredTurnEnd(
|
|
14790
|
+
{
|
|
14791
|
+
chatId,
|
|
14792
|
+
threadId: threadId ?? null,
|
|
14793
|
+
turnKey: tKey,
|
|
14794
|
+
},
|
|
14795
|
+
silentEndDeps,
|
|
14796
|
+
)
|
|
13761
14797
|
if (silentEnd.exhausted) {
|
|
13762
14798
|
process.stderr.write(
|
|
13763
14799
|
`telegram gateway: WARN silent-end fallback ā agent stayed ` +
|
|
@@ -13769,7 +14805,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13769
14805
|
(tid) =>
|
|
13770
14806
|
bot.api.sendMessage(
|
|
13771
14807
|
chatId,
|
|
13772
|
-
|
|
14808
|
+
silentEndFallbackText(turnDurationMs),
|
|
13773
14809
|
tid != null ? { message_thread_id: tid } : {},
|
|
13774
14810
|
),
|
|
13775
14811
|
{ threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
|
|
@@ -14289,6 +15325,8 @@ async function handleInbound(
|
|
|
14289
15325
|
// present, so reset any no-repeat suppression: the next time the agent asks
|
|
14290
15326
|
// for something that timed out earlier, they should see a fresh card.
|
|
14291
15327
|
clearPermissionTimeoutSuppression('operator inbound')
|
|
15328
|
+
// #2862 ā operator is back; re-offer any approvals that timed out meanwhile.
|
|
15329
|
+
maybePostMissedApprovalDigest('operator inbound')
|
|
14292
15330
|
|
|
14293
15331
|
// Capture wall-clock receive time for inbound_ack metric (#203).
|
|
14294
15332
|
// Must be after gate() so early-exit paths (drop/pair) don't skew the delta.
|
|
@@ -18041,6 +19079,8 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
18041
19079
|
}
|
|
18042
19080
|
// Operator answered via slash ā present; reset no-repeat suppression.
|
|
18043
19081
|
clearPermissionTimeoutSuppression('operator answered via /approve|/deny')
|
|
19082
|
+
// #2862 ā operator is present; re-offer any approvals that timed out meanwhile.
|
|
19083
|
+
maybePostMissedApprovalDigest('operator answered via /approve|/deny')
|
|
18044
19084
|
// Forward to connected bridges ā same IPC the button handler uses.
|
|
18045
19085
|
dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
|
|
18046
19086
|
resumeReactionAfterVerdict()
|
|
@@ -19898,6 +20938,185 @@ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<
|
|
|
19898
20938
|
)
|
|
19899
20939
|
}
|
|
19900
20940
|
|
|
20941
|
+
/**
|
|
20942
|
+
* hindsight Phase 5 ā handle a tap on the mental-model PROPOSAL card.
|
|
20943
|
+
* mmp:approve:<stageId> ā declare the model: append it to the agent's
|
|
20944
|
+
* memory.mental_models[] via the operator-approved
|
|
20945
|
+
* config-edit path (reused config_propose_edit
|
|
20946
|
+
* apply+reconcile; reconcile ensures it), then wake
|
|
20947
|
+
* the agent with an "applied" inbound.
|
|
20948
|
+
* mmp:deny:<stageId> ā drop the proposal; NOTHING is written; wake the
|
|
20949
|
+
* agent with a "denied" inbound.
|
|
20950
|
+
*
|
|
20951
|
+
* Authorization: the tapper MUST be on the gateway's allowFrom list ā an agent
|
|
20952
|
+
* can PROPOSE but can never self-approve (identical gate to the vault flow).
|
|
20953
|
+
*/
|
|
20954
|
+
async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
|
|
20955
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
20956
|
+
const access = loadAccess()
|
|
20957
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
20958
|
+
// Self-approve is impossible: only an allow-listed operator can resolve
|
|
20959
|
+
// the card. A tap from anyone else (incl. a compromised agent identity) is
|
|
20960
|
+
// refused here.
|
|
20961
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
20962
|
+
return
|
|
20963
|
+
}
|
|
20964
|
+
const parts = data.split(':')
|
|
20965
|
+
if (parts.length < 3) {
|
|
20966
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
20967
|
+
return
|
|
20968
|
+
}
|
|
20969
|
+
const action = parts[1]
|
|
20970
|
+
const stageId = parts.slice(2).join(':')
|
|
20971
|
+
const pending = pendingMentalModelProposes.get(stageId)
|
|
20972
|
+
if (!pending) {
|
|
20973
|
+
await ctx.answerCallbackQuery({ text: 'Card expired ā ask the agent to re-propose.' }).catch(() => {})
|
|
20974
|
+
if (ctx.callbackQuery?.message) {
|
|
20975
|
+
await ctx.api
|
|
20976
|
+
.editMessageText(
|
|
20977
|
+
ctx.callbackQuery.message.chat.id,
|
|
20978
|
+
ctx.callbackQuery.message.message_id,
|
|
20979
|
+
richMessage('ā _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
20980
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
20981
|
+
)
|
|
20982
|
+
.catch(() => {})
|
|
20983
|
+
}
|
|
20984
|
+
return
|
|
20985
|
+
}
|
|
20986
|
+
if (action !== 'approve' && action !== 'deny') {
|
|
20987
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
20988
|
+
return
|
|
20989
|
+
}
|
|
20990
|
+
// Enforce the TTL at TAP time, not just on the next propose's sweep. Without
|
|
20991
|
+
// this, a card left untapped past its TTL is still resolvable if no fresh
|
|
20992
|
+
// proposal has run the sweep ā an operator could approve a stale proposal.
|
|
20993
|
+
if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
|
|
20994
|
+
pendingMentalModelProposes.delete(stageId)
|
|
20995
|
+
await ctx.answerCallbackQuery({ text: 'Card expired ā ask the agent to re-propose.' }).catch(() => {})
|
|
20996
|
+
if (pending.card_message_id != null) {
|
|
20997
|
+
await ctx.api
|
|
20998
|
+
.editMessageText(
|
|
20999
|
+
pending.chat_id,
|
|
21000
|
+
pending.card_message_id,
|
|
21001
|
+
richMessage('ā _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
21002
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
21003
|
+
)
|
|
21004
|
+
.catch(() => {})
|
|
21005
|
+
}
|
|
21006
|
+
return
|
|
21007
|
+
}
|
|
21008
|
+
// Single-shot: remove the pending entry immediately so a double-tap can't
|
|
21009
|
+
// resolve twice.
|
|
21010
|
+
pendingMentalModelProposes.delete(stageId)
|
|
21011
|
+
|
|
21012
|
+
const proposal: MentalModelPendingProposal = {
|
|
21013
|
+
agent: pending.agent,
|
|
21014
|
+
chat_id: pending.chat_id,
|
|
21015
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
21016
|
+
spec: pending.spec,
|
|
21017
|
+
...(pending.reason ? { reason: pending.reason } : {}),
|
|
21018
|
+
}
|
|
21019
|
+
|
|
21020
|
+
const resolveDeps = {
|
|
21021
|
+
readConfigText: () => readLiveSwitchroomConfigText(),
|
|
21022
|
+
registerPreApproval: (agent: string, diff: string) => {
|
|
21023
|
+
pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
|
|
21024
|
+
agentName: agent,
|
|
21025
|
+
unifiedDiff: diff,
|
|
21026
|
+
createdAt: Date.now(),
|
|
21027
|
+
})
|
|
21028
|
+
},
|
|
21029
|
+
clearPreApproval: (agent: string, diff: string) => {
|
|
21030
|
+
pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
|
|
21031
|
+
},
|
|
21032
|
+
dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
|
|
21033
|
+
const req: HostdRequest = {
|
|
21034
|
+
v: 1,
|
|
21035
|
+
op: 'config_propose_edit',
|
|
21036
|
+
request_id: hostdRequestId('gw-mental-model'),
|
|
21037
|
+
args: {
|
|
21038
|
+
unified_diff: a.diff,
|
|
21039
|
+
reason: a.reason,
|
|
21040
|
+
target_path: '/state/config/switchroom.yaml',
|
|
21041
|
+
},
|
|
21042
|
+
}
|
|
21043
|
+
// config_propose_edit blocks on validateāapproveāapplyāreconcile
|
|
21044
|
+
// (5-10 min on a busy host) ā allow 12 min. The operator already
|
|
21045
|
+
// approved on the proposal card, so hostd's config-approval callback
|
|
21046
|
+
// auto-resolves via the pre-registered correlation (no second card).
|
|
21047
|
+
const resp = await tryHostdDispatch(a.agent, req, 720_000)
|
|
21048
|
+
if (resp === 'not-configured') {
|
|
21049
|
+
return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
|
|
21050
|
+
}
|
|
21051
|
+
if (resp.result === 'completed') return { state: 'applied' as const }
|
|
21052
|
+
if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
|
|
21053
|
+
return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
|
|
21054
|
+
},
|
|
21055
|
+
// Ensure is delegated to reconcile: config_propose_edit's apply triggers a
|
|
21056
|
+
// reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
|
|
21057
|
+
// declared model ā the authoritative, correctly-scoped ensure. We
|
|
21058
|
+
// deliberately do NOT add a redundant gateway-side ensure (it would need
|
|
21059
|
+
// the agent's bank id + a reachable Hindsight endpoint from the gateway).
|
|
21060
|
+
injectInbound: (inbound: InboundMessage) => {
|
|
21061
|
+
deliverResumeSyntheticOrBuffer(pending.agent, inbound)
|
|
21062
|
+
},
|
|
21063
|
+
log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
21064
|
+
}
|
|
21065
|
+
|
|
21066
|
+
if (action === 'deny') {
|
|
21067
|
+
await ctx.answerCallbackQuery({ text: 'š« Denied' }).catch(() => {})
|
|
21068
|
+
await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
|
|
21069
|
+
if (pending.card_message_id != null) {
|
|
21070
|
+
await ctx.api
|
|
21071
|
+
.editMessageText(
|
|
21072
|
+
pending.chat_id,
|
|
21073
|
+
pending.card_message_id,
|
|
21074
|
+
richMessage(`š« _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
|
|
21075
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
21076
|
+
)
|
|
21077
|
+
.catch(() => {})
|
|
21078
|
+
}
|
|
21079
|
+
return
|
|
21080
|
+
}
|
|
21081
|
+
|
|
21082
|
+
// Approve. Ack immediately + show an interim state, then persist in the
|
|
21083
|
+
// background (config_propose_edit can take minutes), then edit the card with
|
|
21084
|
+
// the real outcome. The turn resumes via the synthetic inbound injected by
|
|
21085
|
+
// resolveMentalModelProposal ā not by this card edit.
|
|
21086
|
+
await ctx.answerCallbackQuery({ text: 'ā
Declaring the modelā¦' }).catch(() => {})
|
|
21087
|
+
if (pending.card_message_id != null) {
|
|
21088
|
+
await ctx.api
|
|
21089
|
+
.editMessageText(
|
|
21090
|
+
pending.chat_id,
|
|
21091
|
+
pending.card_message_id,
|
|
21092
|
+
richMessage(`ā³ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` ā appending to config + ensuringā¦_`),
|
|
21093
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
21094
|
+
)
|
|
21095
|
+
.catch(() => {})
|
|
21096
|
+
}
|
|
21097
|
+
void (async () => {
|
|
21098
|
+
let result
|
|
21099
|
+
try {
|
|
21100
|
+
result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
|
|
21101
|
+
} catch (err) {
|
|
21102
|
+
process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
|
|
21103
|
+
result = { outcome: 'failed' as const, reason: (err as Error).message }
|
|
21104
|
+
}
|
|
21105
|
+
if (pending.card_message_id != null) {
|
|
21106
|
+
const label =
|
|
21107
|
+
result.outcome === 'applied'
|
|
21108
|
+
? `ā
**Declared** ${escapeHtmlForTg(pending.agent)}'s mental model \`${pending.spec.name}\` ā appended to \`memory.mental_models[]\` and ensured. Restart the agent to load it if it isn't picked up automatically.`
|
|
21109
|
+
: `ā ļø **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` ā ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
|
|
21110
|
+
await ctx.api
|
|
21111
|
+
.editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
|
|
21112
|
+
reply_markup: { inline_keyboard: [] },
|
|
21113
|
+
link_preview_options: { is_disabled: true },
|
|
21114
|
+
})
|
|
21115
|
+
.catch(() => {})
|
|
21116
|
+
}
|
|
21117
|
+
})()
|
|
21118
|
+
}
|
|
21119
|
+
|
|
19901
21120
|
async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
|
|
19902
21121
|
const senderId = String(ctx.from?.id ?? '')
|
|
19903
21122
|
const access = loadAccess()
|
|
@@ -22439,6 +23658,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22439
23658
|
return
|
|
22440
23659
|
}
|
|
22441
23660
|
|
|
23661
|
+
// hindsight Phase 5: agent-proposes ā human-approves mental-model card.
|
|
23662
|
+
// mmp:approve:<stageId> ā declare the model (append memory.mental_models[]
|
|
23663
|
+
// via operator-approved config edit) + ensure it
|
|
23664
|
+
// mmp:deny:<stageId> ā drop the proposal; NOTHING is written
|
|
23665
|
+
if (data.startsWith('mmp:')) {
|
|
23666
|
+
await handleMentalModelProposeCallback(ctx, data)
|
|
23667
|
+
return
|
|
23668
|
+
}
|
|
23669
|
+
|
|
22442
23670
|
// #2670: one-tap skill-improvement proposal card.
|
|
22443
23671
|
// skprop:approve:<id> ā apply the stored draft via the personal-skill
|
|
22444
23672
|
// write pipeline (inject a turn; agent writes it)
|
|
@@ -22449,6 +23677,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22449
23677
|
return
|
|
22450
23678
|
}
|
|
22451
23679
|
|
|
23680
|
+
// #2862: missed-approvals re-offer digest.
|
|
23681
|
+
// missre:retry:<id> ā inject a synthetic inbound asking the agent to
|
|
23682
|
+
// re-attempt (re-raises a fresh approval card)
|
|
23683
|
+
// missre:dismiss:<id> ā clear the record + edit the card closed
|
|
23684
|
+
if (data.startsWith('missre:')) {
|
|
23685
|
+
await handleMissedApprovalCallback(ctx, data)
|
|
23686
|
+
return
|
|
23687
|
+
}
|
|
23688
|
+
|
|
22452
23689
|
// Issue #969 P2b: vault recent-denial one-tap approval.
|
|
22453
23690
|
// vrd:<agent>:<key> ā mint a 30-day read-grant for the agent + key
|
|
22454
23691
|
// Posted by /vault audit <agent> in the "Recent denials" section.
|
|
@@ -23095,6 +24332,8 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23095
24332
|
// Operator tapped a verdict ā they are present; reset no-repeat suppression
|
|
23096
24333
|
// so a later identical ask is shown fresh rather than silently short-circuited.
|
|
23097
24334
|
clearPermissionTimeoutSuppression('operator answered a permission card')
|
|
24335
|
+
// #2862 ā operator is present; re-offer any approvals that timed out meanwhile.
|
|
24336
|
+
maybePostMissedApprovalDigest('operator answered a permission card')
|
|
23098
24337
|
const pd = pendingPermissions.get(request_id)
|
|
23099
24338
|
const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : ''
|
|
23100
24339
|
const scopedTtl = scopedApprovalTtlMs()
|
|
@@ -23106,6 +24345,9 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23106
24345
|
permCardStore.remove(request_id)
|
|
23107
24346
|
if (timeBox && grantAgent) {
|
|
23108
24347
|
recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
|
|
24348
|
+
// Write-through so the window survives a gateway restart (#2863). Absolute
|
|
24349
|
+
// expiry is baked into the entry, so a reload can't extend it.
|
|
24350
|
+
scopedGrantStore.save(scopedGrants)
|
|
23109
24351
|
process.stderr.write(
|
|
23110
24352
|
`telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" ` +
|
|
23111
24353
|
`agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})\n`,
|
|
@@ -24874,39 +26116,68 @@ void (async () => {
|
|
|
24874
26116
|
// tracks the live turn from there.
|
|
24875
26117
|
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
24876
26118
|
|
|
24877
|
-
//
|
|
24878
|
-
// still in the store was never resolved (gateway died before the
|
|
24879
|
-
// operator tapped or the reaper ran).
|
|
24880
|
-
//
|
|
24881
|
-
//
|
|
24882
|
-
//
|
|
24883
|
-
|
|
24884
|
-
|
|
24885
|
-
|
|
24886
|
-
|
|
24887
|
-
|
|
24888
|
-
|
|
24889
|
-
|
|
24890
|
-
|
|
24891
|
-
|
|
24892
|
-
|
|
24893
|
-
|
|
24894
|
-
|
|
24895
|
-
|
|
24896
|
-
|
|
24897
|
-
|
|
24898
|
-
|
|
24899
|
-
|
|
24900
|
-
|
|
24901
|
-
|
|
24902
|
-
|
|
24903
|
-
|
|
24904
|
-
|
|
24905
|
-
|
|
24906
|
-
|
|
26119
|
+
// Boot-sweep for permission cards from a prior gateway session. Any
|
|
26120
|
+
// entry still in the store was never resolved (gateway died before the
|
|
26121
|
+
// operator tapped or the reaper ran).
|
|
26122
|
+
//
|
|
26123
|
+
// #2861: DON'T strip immediately. A still-live claude session's bridge
|
|
26124
|
+
// re-sends its outstanding permission_requests on IPC reconnect within
|
|
26125
|
+
// a few seconds ā re-arming the persisted card instead of losing the
|
|
26126
|
+
// suspended turn. So we wait a grace window (~90 s) and only strip
|
|
26127
|
+
// entries that were NOT re-claimed by a bridge re-send in the meantime.
|
|
26128
|
+
// A genuinely dead session (container recreate ā new bridge, empty
|
|
26129
|
+
// ledger) never re-sends, so those still get the "restarted" notice.
|
|
26130
|
+
//
|
|
26131
|
+
// Kill switch SWITCHROOM_PERMISSION_REARM=0 reverts to the legacy
|
|
26132
|
+
// immediate strip-everything sweep.
|
|
26133
|
+
if (!isPermissionRearmEnabled()) {
|
|
26134
|
+
void (async () => {
|
|
26135
|
+
const stale = permCardStore.loadAll()
|
|
26136
|
+
if (stale.length === 0) return
|
|
26137
|
+
process.stderr.write(
|
|
26138
|
+
`telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
|
|
26139
|
+
)
|
|
26140
|
+
for (const card of stale) await stripStalePermissionCard(card)
|
|
26141
|
+
permCardStore.clear()
|
|
26142
|
+
})()
|
|
26143
|
+
} else {
|
|
26144
|
+
const bootStale = permCardStore.loadAll()
|
|
26145
|
+
if (bootStale.length > 0) {
|
|
26146
|
+
const graceMs = permissionRearmGraceMs()
|
|
26147
|
+
process.stderr.write(
|
|
26148
|
+
`telegram gateway: boot-sweep: ${bootStale.length} pending permission card(s) from prior ` +
|
|
26149
|
+
`session; deferring strip ${graceMs}ms to allow bridge re-arm (#2861)\n`,
|
|
26150
|
+
)
|
|
26151
|
+
setTimeout(() => {
|
|
26152
|
+
void (async () => {
|
|
26153
|
+
// Reload ā some entries may have been resolved (tap/TTL) during
|
|
26154
|
+
// the grace window, which removes them from the store.
|
|
26155
|
+
const remaining = permCardStore.loadAll()
|
|
26156
|
+
// Preserve anything currently LIVE in pendingPermissions: that
|
|
26157
|
+
// covers both re-armed cards (a bridge re-send restored them)
|
|
26158
|
+
// AND fresh cards born during the grace window (a new approval
|
|
26159
|
+
// posted after boot). Only cards with no live pending entry ā
|
|
26160
|
+
// a genuinely dead session that never reconnected ā are stripped.
|
|
26161
|
+
const liveRequestIds = new Set(pendingPermissions.keys())
|
|
26162
|
+
const toStrip = computeBootSweepStripTargets(remaining, liveRequestIds)
|
|
26163
|
+
if (toStrip.length === 0) {
|
|
26164
|
+
process.stderr.write(
|
|
26165
|
+
`telegram gateway: boot-sweep: all pending permission cards re-armed or still live ā nothing to strip\n`,
|
|
26166
|
+
)
|
|
26167
|
+
return
|
|
26168
|
+
}
|
|
26169
|
+
process.stderr.write(
|
|
26170
|
+
`telegram gateway: boot-sweep: stripping ${toStrip.length} dead-session permission card(s) ` +
|
|
26171
|
+
`after ${graceMs}ms grace (of ${remaining.length} still persisted, ${liveRequestIds.size} live)\n`,
|
|
26172
|
+
)
|
|
26173
|
+
for (const card of toStrip) await stripStalePermissionCard(card)
|
|
26174
|
+
// Remove ONLY the stripped request_ids ā re-armed entries stay
|
|
26175
|
+
// persisted so a subsequent restart re-arms them again.
|
|
26176
|
+
for (const id of distinctRequestIds(toStrip)) permCardStore.remove(id)
|
|
26177
|
+
})()
|
|
26178
|
+
}, graceMs).unref?.()
|
|
24907
26179
|
}
|
|
24908
|
-
|
|
24909
|
-
})()
|
|
26180
|
+
}
|
|
24910
26181
|
|
|
24911
26182
|
// Boot-time pin sweep
|
|
24912
26183
|
try {
|
|
@@ -25300,6 +26571,29 @@ void (async () => {
|
|
|
25300
26571
|
// accident no longer pollute the watcher with phantom
|
|
25301
26572
|
// registrations + ENOENT log spam + false stalls.
|
|
25302
26573
|
agentCwd: watcherAgentDir,
|
|
26574
|
+
// Gap 2 (deterministic-turn-liveness.md "Known gaps"): a
|
|
26575
|
+
// sub-agent dispatched into a `switchroom worktree claim`
|
|
26576
|
+
// cwd runs under a different project-dir slug than
|
|
26577
|
+
// `agentCwd` above, so the #1116 foreign-slug filter would
|
|
26578
|
+
// otherwise skip it forever ā no activity stamp, no `š
|
|
26579
|
+
// Worker` feed for the entire run. Re-derive, fresh on every
|
|
26580
|
+
// rescan tick, the set of worktree paths this agent itself
|
|
26581
|
+
// currently owns (registry records are the deterministic
|
|
26582
|
+
// source of truth for "cwds this agent's sub-agents may run
|
|
26583
|
+
// in") and let the watcher also watch those slugs.
|
|
26584
|
+
// Best-effort: a registry read failure (e.g. no worktree dir
|
|
26585
|
+
// on an agent that never claims one) must not affect the
|
|
26586
|
+
// primary agentCwd watch.
|
|
26587
|
+
extraWatchCwdsProvider: () =>
|
|
26588
|
+
// Fail-CLOSED ownership filter (unset identity ā nothing;
|
|
26589
|
+
// ownerless records excluded; registry throw ā []). Extracted
|
|
26590
|
+
// to telegram-plugin/worktree-watch-cwds.ts so the #1116 /
|
|
26591
|
+
// Gap-2 ownership predicate is under direct unit test ā see
|
|
26592
|
+
// telegram-plugin/tests/worktree-watch-cwds.test.ts.
|
|
26593
|
+
ownedWorktreeCwds({
|
|
26594
|
+
self: process.env.SWITCHROOM_AGENT_NAME,
|
|
26595
|
+
listRecords: listWorktreeRecords,
|
|
26596
|
+
}),
|
|
25303
26597
|
// Bug 0 fix: previously omitted, leaving the watcher unable to
|
|
25304
26598
|
// write liveness/stall/turn_end updates to the registry DB.
|
|
25305
26599
|
// Liveness writes are now persisted across the gateway lifetime.
|