switchroom 0.19.25 → 0.19.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +103 -112
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. package/vendor/hindsight-memory/settings.json +1 -1
@@ -4164,15 +4164,44 @@ var init_schema = __esm(() => {
4164
4164
  recall: exports_external.object({
4165
4165
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
4166
4166
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
4167
+ hook_timeout_seconds: exports_external.number().int().min(1).optional().describe("Ceiling (seconds) Claude Code gives the UserPromptSubmit recall " + "hook before killing it. Stamped into the installed plugin's " + "hooks/hooks.json, so it survives `switchroom apply` reinstalling " + "the plugin. Default 12. Raising it lets slow banks finish at the " + "cost of pre-turn dead air; `parallel_deadline_seconds` and " + "`request_timeout_seconds` are both kept under it. A value below " + "3s is raised to 3s and reported: the fan-out deadline must be at " + "least 1s AND still leave 2s of post-deadline headroom, so a " + "lower ceiling admits no usable envelope at all."),
4168
+ parallel_deadline_seconds: exports_external.number().int().min(1).optional().describe("Shared deadline (seconds) for the whole parallel multi-bank " + "recall fan-out. Slots unfinished when it elapses are abandoned " + "and reported as timed out. Defaults to `hook_timeout_seconds` " + "minus 2s of headroom for block formatting, cache write and " + "stdout flush, so a straggler bank can never push the hook past " + "its ceiling. Set explicitly to override that derivation; a value " + "that would leave less than 2s under the hook ceiling — including " + "one set EQUAL to it — is clamped back to `hook_timeout_seconds` " + "minus 2, and the clamp is reported. Equality is not allowed: at " + "zero headroom the hook is killed mid-write and the turn loses " + "both the memories and the recall_log row explaining why."),
4167
4169
  query_max_tokens: exports_external.number().int().min(0).optional().describe("Cap on the number of DISTINCT BM25 terms the recall hook may " + "put on the wire. `recallMaxQueryChars` bounds characters, which " + "is not the cost driver: Hindsight OR-joins every query token " + "into one tsquery and Postgres native FTS ranks the entire " + "matched set before the top-60 heapsort, so cost tracks TERMS. " + "An 800-char composed query is ~96 distinct terms and matched " + "119,510 of 135,565 rows on the `overlord` bank (14.0s for the " + "3-arm " + "BM25 UNION), past the per-bank timeout — 96.8% of that agent's " + "own-bank recalls returned nothing. Plugin default is 24 " + "(measured 48,433 rows / 2.7s on the same bank). Terms are " + "chosen recency-first (the latest turn beats prior context), " + "then by selectivity. 0 disables shaping (rollback lever)."),
4168
4170
  query_stop_terms: exports_external.array(exports_external.string().min(1).regex(/^[\w./-]+$/)).optional().describe("Extra terms dropped from the BM25 recall query, on top of the " + "built-in English stopword list. For BANK-SPECIFIC " + "high-document-frequency words a generic stoplist cannot know " + "about: on `overlord`, `switchroom` matches 20% of the bank and " + "`agent` another 20%, purely because that is what the corpus is " + "about, and each such term drags tens of thousands of rows into " + "the ranking. Defaults to []."),
4169
- request_timeout_seconds: exports_external.number().int().min(1).optional().describe("Per-bank HTTP read timeout, in seconds, for one recall " + "request. Plugin default is 12, matching the UserPromptSubmit " + "hook ceiling; the shared `recallParallelDeadlineSeconds` (10) " + "is the tighter outer guard in the default configuration, so " + "this is a per-request safety net. Was a hardcoded 8 in the " + "plugin before #3757."),
4171
+ request_timeout_seconds: exports_external.number().int().min(1).optional().describe("Per-bank HTTP read timeout, in seconds, for one recall " + "request. Even parallelised, each bank carries its own deadline " + "so ONE hung bank returns empty instead of consuming the shared " + "deadline and starving its siblings. The plugin default is 12 " + "(raised from a hardcoded 8 in #3757, which fired on 96.8% of " + "one agent's own-bank recalls). Switchroom defaults it to the " + "effective `parallel_deadline_seconds` instead — 10 at the " + "shipped ceiling — because the shared fan-out deadline is " + "already the tighter outer guard, so a per-bank value above it " + "can never bind. An explicitly configured value above the " + "effective deadline is clamped down to it, and the clamp is " + "reported."),
4170
4172
  own_bank_min_slots: exports_external.number().int().min(0).optional().describe("Slots inside `max_memories` reserved as a FLOOR for the agent's " + "own bank when recall fans out to more than one bank. The merged " + "set is sorted globally by relevance and head-sliced, which is " + "winner-take-all across banks: when both banks return more " + "candidates than the cap, one bank's score distribution can fill " + "every slot and the agent gets a dossier about its operator with " + "none of its own session memory. A floor, not a quota: at most " + "this many slots, only if the own bank returned that many, and " + "only up to HALF the cap shared with `additional_bank_min_slots` " + "— the rest is always won on pure relevance, so composition still " + "moves with the scores. Fixes score-based crowd-out only; a " + "timed-out bank returns no candidates and reservation is a no-op " + "there. 0 disables (default). Switchroom-managed agents use 2 " + "against the fleet-deployed cap of 6."),
4171
4173
  additional_bank_min_slots: exports_external.number().int().min(0).optional().describe("Slots inside `max_memories` reserved as a FLOOR for the " + "additional (profile / shared / sender) banks. Symmetric with " + "`own_bank_min_slots` — same floor-not-quota semantics, and the " + "two share the same half-of-cap reservation budget. When they sum " + "above that budget the own-bank floor is honoured first. 0 " + "disables (default). Switchroom-managed agents use 1 against the " + "fleet-deployed cap of 6. Observe `injected_own_bank_count` / " + "`injected_additional_bank_count` via " + "`switchroom memory recall-log`."),
4174
+ min_score: exports_external.number().min(0).optional().describe("Absolute floor on a memory's engine relevance score " + "(`scores.final`) for it to be injected. 0 disables (default, " + "and the shipped fleet behaviour). Exists for one measured " + "failure: when the agent's own bank times out, recall still " + "injects side-bank residue under the banner 'Relevant memories " + "from past conversations' — 98.4% of degraded turns have a best " + "injected score below 0.01, against 28.4% of healthy ones. Six " + "noise memories are worse than none, because the agent cannot " + "tell them apart. Below-floor results are dropped BEFORE " + "rendering, and when the floor empties the set the turn says so " + "rather than going silent. Do NOT read this as a general " + "precision control: `scores.final` is not calibrated across " + "queries, and #3761 measured that an unconditional 0.01 floor " + "empties ~28% of HEALTHY recalls — which is why " + "`min_score_scope` defaults to degraded turns only. Observe " + "`dropped_below_min_score` via `switchroom memory recall-log`."),
4175
+ min_score_scope: exports_external.enum(["degraded", "all"]).optional().describe('Which turns `min_score` binds on. "degraded" (default) — only ' + "turns where the agent's OWN bank timed out or was unreachable, " + "the population where a below-floor score actually predicts " + "noise and where the agent already receives the degraded-recall " + 'disclosure. "all" — every turn; only for an operator who has ' + "measured their own bank's score distribution, since it " + "re-creates the empty-recall failure of #3541 at any floor " + "calibrated on degraded data. No effect while `min_score` is 0."),
4172
4176
  types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
4173
- additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
4177
+ additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with the `request_timeout_seconds` per-bank timeout " + "(defaults to the effective `parallel_deadline_seconds`, 10s at " + "the shipped ceiling) and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
4174
4178
  sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
4175
4179
  skip_trivial: exports_external.boolean().optional().describe("Skip recall on plausibly-stateless trivial turns (time/date/" + "greeting). Switchroom default true — saves the recall arm + " + "injected tokens on turns that never need memory, guarded so it " + "never skips a turn that references user/project/session state. " + "Set false to always run recall."),
4180
+ budget: exports_external.enum(["low", "mid", "high"]).optional().describe('How hard Hindsight searches. "low" (switchroom default) = ' + 'vector retrieval only, ~1-2s. "mid" adds the LLM rerank pass ' + "and measured ~5s of hook latency on real fleet turns — the " + "second-largest contributor to perceived dead air after model " + 'TTFT. "high" is thorough and slower still. Raise it for an ' + "agent whose recall quality matters more than its reply latency " + "(a research or audit role); leave it at low for chat."),
4181
+ max_tokens: exports_external.number().int().min(1).optional().describe("Token budget for the injected memory block. Default 1024. This " + "is the TOKEN bound; `max_memories` is the separate COUNT bound " + "and the tighter of the two wins. Raise it only alongside " + "`max_memories` — on its own it buys nothing once the count cap " + "binds."),
4182
+ prefer_observations: exports_external.boolean().optional().describe("Bias recall toward the synthesized `observation` tier, " + "backfilling the slots freed by superseded raw facts for denser " + "coverage inside the same budget. Default true. Set false to " + "rank raw `world`/`experience` facts on equal footing — useful " + "when auditing what the consolidation engine actually stored, or " + "if a bank's observations are stale."),
4183
+ context_turns: exports_external.number().int().min(1).optional().describe("How many recent human turns are composed into the recall query. " + 'Default 2, so a bare follow-up ("and the port?") embeds with ' + "its antecedent instead of recalling on the pronoun alone. 1 = " + "the latest turn only. Raising it costs BM25 terms, which is the " + "real recall cost driver — `query_max_tokens` still bounds the " + "result, so a large value mostly shifts which terms survive."),
4184
+ roles: exports_external.array(exports_external.string().min(1)).min(1).optional().describe("Transcript roles the multi-turn composition may draw from. " + 'Default ["user", "assistant"]. Set ["user"] to compose ' + "the query from the human's words only — worth trying when an " + "agent's own verbose replies are dominating the query terms. No " + "effect while `context_turns` is 1."),
4185
+ prompt_preamble: exports_external.string().min(1).optional().describe("The banner rendered above injected memories. The agent reads " + "this line as the instruction for how to treat the block, so it " + "is a behaviour knob, not cosmetics. Default tells the model to " + "prioritise recent memories on conflict and ignore irrelevant " + "ones. Override to tighten that framing for a specialised agent."),
4186
+ tags: exports_external.array(exports_external.string().min(1)).optional().describe("Restrict recall to memories carrying these tags. Default [] = " + "no filter (match everything). This is a HARD filter applied " + "server-side — a memory without the tags cannot surface at any " + "score — so it is for a genuinely scoped agent, not for " + "ranking. Use `tag_weights` when you want a preference rather " + "than an exclusion."),
4187
+ tags_match: exports_external.enum(["any", "all", "any_strict", "all_strict"]).optional().describe('How `tags` combine. "any" (default) = at least one; ' + '"all" = every tag. The `_strict` forms additionally require ' + "the memory to actually carry the tags rather than merely rank " + "for them. No effect while `tags` and `tag_groups` are empty."),
4188
+ tag_groups: exports_external.union([
4189
+ exports_external.array(exports_external.array(exports_external.string().min(1))),
4190
+ exports_external.record(exports_external.string(), exports_external.array(exports_external.string().min(1)))
4191
+ ]).optional().describe("Tag filtering with grouping — either an OR-of-ANDs list " + '([["a","b"],["c"]] = (a AND b) OR c) or a named ' + "{group: [tags]} map. Default unset (no grouping). Use when a " + "flat `tags` + `tags_match` cannot express the scope you need."),
4192
+ tag_weights: exports_external.record(exports_external.string(), exports_external.number().min(0)).optional().describe("Per-tag multipliers applied to `scores.final` just before the " + "final sort — a DEMOTION/PROMOTION, never a drop, so a " + "down-weighted memory still surfaces when it is the only " + "relevant hit. MERGED over switchroom's seed " + '({"sidechain": 0.8}, which ranks delegated sub-agent ' + "process-memories just under first-party ones), so setting one " + "unrelated weight does not silently undo it; pass " + "`sidechain: 1.0` to neutralise the seed. Reach for this when " + "recall_log shows one class of memory crowding the block."),
4193
+ additional_bank_filters: exports_external.record(exports_external.string(), exports_external.object({
4194
+ tags: exports_external.array(exports_external.string().min(1)).optional(),
4195
+ tags_match: exports_external.enum(["any", "all", "any_strict", "all_strict"]).optional(),
4196
+ tag_groups: exports_external.union([
4197
+ exports_external.array(exports_external.array(exports_external.string().min(1))),
4198
+ exports_external.record(exports_external.string(), exports_external.array(exports_external.string().min(1)))
4199
+ ]).optional()
4200
+ }).strict()).optional().describe("Per-bank overrides of the tag filters above, keyed by bank id " + "(applies to `additional_banks` AND to sender banks). Default " + "{} = every extra bank inherits the global filters. Use it to " + "scope a shared bank — e.g. recall only `profile`-tagged " + "memories from the operator's profile bank while leaving the " + "agent's own bank unfiltered."),
4201
+ transcript_fallback: exports_external.boolean().optional().describe("When every bank returns zero results AND no bank hit its " + "deadline, grep the current session's transcript tail for turns " + "matching the query and inject them as a clearly-labelled " + "lower-confidence block. Default true — it covers the window " + "between an abrupt kill and the next boot reconciliation, where " + "the fact layer was never told about the lost turns. Set false " + "if you never want un-consolidated transcript text in context."),
4202
+ transcript_tail_bytes: exports_external.number().int().min(0).optional().describe("Bytes of the session transcript read from the tail for the " + "multi-turn query composition. Default 262144 (256 KiB), which " + "keeps the per-turn read O(1) on a session log that can grow to " + "many MB. 0 = read the whole file (the pre-bound behaviour, and " + "the rollback lever if a composition ever needs older turns)."),
4203
+ max_query_chars: exports_external.number().int().min(1).optional().describe("Character bound on the composed recall query, applied before " + "`query_max_tokens` shapes it. Default 800. Truncation preserves " + "the latest turn and drops the oldest context first. Lower it " + "for an agent whose turns are long pasted payloads."),
4204
+ parallel: exports_external.boolean().optional().describe("Run the directives fetch and every bank recall concurrently " + "under one shared deadline, so total latency is the SLOWEST slot " + "rather than their SUM. Default true. false restores the serial " + "path — the rollback lever if the parallel path ever misbehaves; " + "expect multi-bank recall latency to add up."),
4176
4205
  topic_filter_mode: exports_external.enum(["soft-preamble", "hard-filter"]).optional().describe("Supergroup-mode cross-topic memory behaviour. Default " + "(unset) → soft-preamble: recall returns memories from all " + "topics, and a 'Current topic: …' preamble tells the model " + "to self-scope. hard-filter: drop any recalled memory whose " + "metadata.thread_id differs from the active inbound's topic. " + "Flip to hard-filter when the recall_log shows binding " + "failures (model surfacing the right memory but applying " + "it to the wrong topic).")
4177
4206
  }).optional().describe("Auto-recall tuning knobs"),
4178
4207
  retain: exports_external.object({
@@ -4385,6 +4414,7 @@ var init_schema = __esm(() => {
4385
4414
  context_window: exports_external.number().int().positive().optional().describe("Context window (tokens) of the backend serving THIS op. NOT an " + "upstream env var — switchroom derives the op's token budget " + "(consolidation batch size / max-completion caps / reflect " + "max-context cap) from it so a single call can never overflow the " + "window. Absent → inherit " + "`hindsight.llm.context_window`, else a per-provider default " + "(conservative for non-`claude-code` providers, which usually mean " + "a local llama.cpp/Ollama slot; a self-hosted `base_url` — loopback, " + "RFC1918, `.local`/`.internal` — forces the conservative default too, " + "regardless of the provider NAME, since the endpoint is where the " + "traffic actually terminates). All three lanes (`retain`, " + "`reflect`, `consolidation`) are budgeted independently.")
4386
4415
  }).describe("Per-operation LLM override. Every field optional; an unset field (or " + "an omitted op block) inherits the global `hindsight.llm.*`, which is " + "already the engine's fallback — switchroom emits only the vars set.");
4387
4416
  HindsightConfigSchema = exports_external.object({
4417
+ gpu: exports_external.boolean().optional().describe("Force GPU passthrough for the hindsight container on (`true`) or off " + "(`false`), overriding host autodetection in BOTH directions. Absent " + "(the default) → autodetect from the persisted host-capabilities " + "verdict (`~/.switchroom/host-capabilities.json`), which enables " + "`--gpus all` only when that file proves BOTH a GPU and the nvidia " + "container toolkit. Set `true` when that verdict is wrong or unreadable " + "and you know the host has a working toolkit — switchroom cannot verify " + "it for you, and `docker run --gpus all` hard-fails container create on " + "a host without one. Set `false` to pin the container to CPU on a GPU " + "host. This is also the declarative opt-out for the recreate-time GPU " + "drop guard (`switchroom memory setup --recreate` refuses to silently " + "turn a GPU container into a CPU one). `--gpu`/`--no-gpu` on `memory " + "setup` override this for a single run."),
4388
4418
  llm: exports_external.object({
4389
4419
  provider: exports_external.string().min(1).optional().describe("Hindsight LLM provider (upstream `HINDSIGHT_API_LLM_PROVIDER`). " + "Defaults to `claude-code` (subscription-honest, broker-fed OAuth). " + "Any litellm-routable provider the upstream image supports is valid. " + "Serves as the GLOBAL default for every op absent a per-op override."),
4390
4420
  model: exports_external.string().min(1).optional().describe("Hindsight LLM model (upstream `HINDSIGHT_API_LLM_MODEL`). Defaults " + "to HINDSIGHT_DEFAULT_MODEL. Any model your LiteLLM proxy can route " + "is valid, e.g. `openrouter/z-ai/glm-5.2` when routing through the " + "fleet proxy. With provider=claude-code this value is ALSO exported " + "as `ANTHROPIC_MODEL` to the claude subprocess. Serves as the GLOBAL " + "default for every op absent a per-op override."),
@@ -4529,11 +4559,40 @@ var init_schema = __esm(() => {
4529
4559
  recall: exports_external.object({
4530
4560
  max_memories: exports_external.number().int().min(0).optional(),
4531
4561
  cache_ttl_secs: exports_external.number().int().min(0).optional(),
4562
+ hook_timeout_seconds: exports_external.number().int().min(1).optional(),
4563
+ parallel_deadline_seconds: exports_external.number().int().min(1).optional(),
4532
4564
  query_max_tokens: exports_external.number().int().min(0).optional(),
4533
4565
  query_stop_terms: exports_external.array(exports_external.string().min(1).regex(/^[\w./-]+$/)).optional(),
4534
4566
  request_timeout_seconds: exports_external.number().int().min(1).optional(),
4535
4567
  own_bank_min_slots: exports_external.number().int().min(0).optional(),
4536
4568
  additional_bank_min_slots: exports_external.number().int().min(0).optional(),
4569
+ min_score: exports_external.number().min(0).optional(),
4570
+ min_score_scope: exports_external.enum(["degraded", "all"]).optional(),
4571
+ budget: exports_external.enum(["low", "mid", "high"]).optional(),
4572
+ max_tokens: exports_external.number().int().min(1).optional(),
4573
+ prefer_observations: exports_external.boolean().optional(),
4574
+ context_turns: exports_external.number().int().min(1).optional(),
4575
+ roles: exports_external.array(exports_external.string().min(1)).min(1).optional(),
4576
+ prompt_preamble: exports_external.string().min(1).optional(),
4577
+ tags: exports_external.array(exports_external.string().min(1)).optional(),
4578
+ tags_match: exports_external.enum(["any", "all", "any_strict", "all_strict"]).optional(),
4579
+ tag_groups: exports_external.union([
4580
+ exports_external.array(exports_external.array(exports_external.string().min(1))),
4581
+ exports_external.record(exports_external.string(), exports_external.array(exports_external.string().min(1)))
4582
+ ]).optional(),
4583
+ tag_weights: exports_external.record(exports_external.string(), exports_external.number().min(0)).optional(),
4584
+ additional_bank_filters: exports_external.record(exports_external.string(), exports_external.object({
4585
+ tags: exports_external.array(exports_external.string().min(1)).optional(),
4586
+ tags_match: exports_external.enum(["any", "all", "any_strict", "all_strict"]).optional(),
4587
+ tag_groups: exports_external.union([
4588
+ exports_external.array(exports_external.array(exports_external.string().min(1))),
4589
+ exports_external.record(exports_external.string(), exports_external.array(exports_external.string().min(1)))
4590
+ ]).optional()
4591
+ }).strict()).optional(),
4592
+ transcript_fallback: exports_external.boolean().optional(),
4593
+ transcript_tail_bytes: exports_external.number().int().min(0).optional(),
4594
+ max_query_chars: exports_external.number().int().min(1).optional(),
4595
+ parallel: exports_external.boolean().optional(),
4537
4596
  additional_banks: exports_external.array(exports_external.string()).optional(),
4538
4597
  sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional()
4539
4598
  }).optional()
@@ -18591,6 +18650,10 @@ function allocateAgentUid(name) {
18591
18650
  return AGENT_UID_MIN + u32 % range;
18592
18651
  }
18593
18652
 
18653
+ // src/setup/hindsight-recall-tunables.ts
18654
+ var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
18655
+ var MIN_RECALL_HOOK_TIMEOUT_SECONDS = RECALL_DEADLINE_HEADROOM_SECONDS + 1;
18656
+
18594
18657
  // src/agents/scaffold.ts
18595
18658
  init_schema();
18596
18659
 
@@ -18876,6 +18939,7 @@ function assertPositive(value, label) {
18876
18939
 
18877
18940
  // src/setup/host-capabilities.ts
18878
18941
  init_paths();
18942
+ var _warnedReads = new Set;
18879
18943
 
18880
18944
  // src/setup/hindsight-pg-defaults.ts
18881
18945
  var HINDSIGHT_PG_MEM_LIMIT_MIB_FOR_DERIVATION = 8 * 1024;
@@ -19039,7 +19103,42 @@ var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
19039
19103
  var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
19040
19104
 
19041
19105
  // src/memory/hindsight.ts
19042
- var DEFAULT_RETAIN_MISSION = "Extract durable facts that will still be true and useful weeks from now: " + "user preferences and standing rules, ongoing projects and recurring " + "commitments, technical and architectural decisions with their rationale, " + "and people/tool relationships. A preference revealed by a request is " + "durable — record the preference (what the user likes, wants, or always " + `does), not the request itself.
19106
+ var DEFAULT_RETAIN_MISSION = `Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.
19107
+ ` + `
19108
+ ` + `A TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this
19109
+ ` + `candidate a file path, a command/process/agent/session id, a temp directory, or
19110
+ ` + `the location where some output was written? If yes, drop it — it is transcript
19111
+ ` + `exhaust, not memory.
19112
+ ` + `
19113
+ ` + `NEVER extract:
19114
+ ` + `- Tool results verbatim or paraphrased. Concretely, never produce a fact whose
19115
+ ` + ` text resembles any of these: "File created successfully at /path/to/file",
19116
+ ` + ` "A background command with ID bctz4yskm is running, and its output will be
19117
+ ` + ` written to /tmp/...", "Async agent a745598ba84e71df1 was launched successfully
19118
+ ` + ` and is running in the background", "User executed a Bash command to sleep for
19119
+ ` + ` 200 seconds", "The assistant used grep to locate 'truncateSync' in src/foo.ts".
19120
+ ` + `- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.
19121
+ ` + `- Agent tool-use traces or narration of what the assistant did (e.g. "the
19122
+ ` + ` assistant used X to query Y", "ran a search", "sent the message").
19123
+ ` + `- In-flight workflow/process narration (a sub-task started, paused, or is still
19124
+ ` + ` running) — retain the outcome only once the task completes or a decision is made.
19125
+ ` + `- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.
19126
+ ` + `- Slash commands the user typed and their effects (e.g. "User issued /clear to
19127
+ ` + ` reset assistant state").
19128
+ ` + `- Hindsight's own errors, retries, backlogs, or internal state — the memory
19129
+ ` + ` system's self-reports are not memories.
19130
+ ` + `- Restatements of the user's current request or the task in progress.
19131
+ ` + `- Transient state (unread counts, build status, what is running right now) unless
19132
+ ` + ` the fact is explicitly dated, in which case record it as a dated observation.
19133
+ ` + `- Greetings, acknowledgements, and routine operational chatter.
19134
+ ` + `
19135
+ ` + `If a candidate fact matches an exclusion, drop it rather than rewording it. If
19136
+ ` + "nothing durable remains, return an empty facts list.";
19137
+ var SUPERSEDED_RETAIN_MISSIONS = [
19138
+ "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
19139
+ "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.",
19140
+ "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made.",
19141
+ "Extract durable facts that will still be true and useful weeks from now: " + "user preferences and standing rules, ongoing projects and recurring " + "commitments, technical and architectural decisions with their rationale, " + "and people/tool relationships. A preference revealed by a request is " + "durable — record the preference (what the user likes, wants, or always " + `does), not the request itself.
19043
19142
 
19044
19143
  ` + `NEVER extract:
19045
19144
  ` + "- Agent tool-use traces or narration of what the assistant did (e.g. " + `"the assistant used X to query Y", "ran a search", "sent the message").
@@ -19050,11 +19149,7 @@ var DEFAULT_RETAIN_MISSION = "Extract durable facts that will still be true and
19050
19149
  ` + "- Transient state (unread counts, build status, what is running right now) " + "unless the fact is explicitly dated, in which case record it as a dated " + `observation.
19051
19150
  ` + `- Greetings, acknowledgements, and routine operational chatter.
19052
19151
 
19053
- ` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list.";
19054
- var SUPERSEDED_RETAIN_MISSIONS = [
19055
- "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
19056
- "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.",
19057
- "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made."
19152
+ ` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list."
19058
19153
  ];
19059
19154
  var PROFILE_MEMORY_DEFAULTS = {
19060
19155
  "health-coach": {
@@ -19859,6 +19954,27 @@ var materializedDirs = new Set;
19859
19954
  // src/setup/onboarding.ts
19860
19955
  init_paths();
19861
19956
 
19957
+ // src/setup/hindsight-recall-passthrough.ts
19958
+ var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
19959
+ var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
19960
+ var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
19961
+ budget: "low",
19962
+ maxTokens: 1024,
19963
+ preferObservations: true,
19964
+ contextTurns: 2,
19965
+ roles: ["user", "assistant"],
19966
+ promptPreamble: HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT,
19967
+ tags: [],
19968
+ tagsMatch: "any",
19969
+ tagGroups: {},
19970
+ tagWeights: HINDSIGHT_RECALL_TAG_WEIGHT_SEED,
19971
+ additionalBankFilters: {},
19972
+ transcriptFallback: true,
19973
+ transcriptTailBytes: 262144,
19974
+ maxQueryChars: 800,
19975
+ parallel: true
19976
+ });
19977
+
19862
19978
  // src/repos/bare-clone.ts
19863
19979
  init_paths();
19864
19980
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.25",
4
+ "version": "0.19.27",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -28,11 +28,12 @@
28
28
  "test:vitest": "vitest run",
29
29
  "test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-scope-persist.test.ts src/vault/broker/server-tokenless-scope.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/self-approval-bypass.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs",
31
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-agent-attribution-trailers.mjs",
32
32
  "lint:tsc": "tsc --noEmit",
33
33
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
34
34
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
35
35
  "lint:bun-test-imports": "node scripts/check-bun-test-imports.mjs",
36
+ "lint:test-runner-coverage": "node scripts/check-test-runner-coverage.mjs",
36
37
  "lint:bun-module-mock-scope": "node scripts/check-bun-module-mock-scope.mjs",
37
38
  "lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
38
39
  "lint:auth-test-hermeticity": "node scripts/check-auth-test-hermeticity.mjs",
@@ -40,9 +41,12 @@
40
41
  "lint:web-subscription-honest": "node scripts/check-web-subscription-honest.mjs",
41
42
  "lint:no-broadcast-delivery": "node scripts/check-no-broadcast-delivery.mjs",
42
43
  "lint:gateway-line-ratchet": "node scripts/check-gateway-line-ratchet.mjs",
44
+ "lint:retry-flood-hooks": "node scripts/check-retry-flood-hooks.mjs",
45
+ "lint:status-pin-single-path": "node scripts/check-status-pin-single-path.mjs",
43
46
  "lint:mcp-instructions-budget": "node scripts/check-mcp-instructions-budget.mjs",
44
47
  "lint:litellm-config-guard": "node scripts/check-litellm-config-guard.mjs",
45
48
  "lint:release-asset-contract": "node scripts/check-release-asset-names.mjs",
49
+ "lint:agent-attribution-trailers": "node scripts/check-agent-attribution-trailers.mjs",
46
50
  "prepublishOnly": "npm run build && npm run lint && npm test"
47
51
  },
48
52
  "dependencies": {
@@ -74,6 +74,12 @@ export CLAUDE_CONFIG_DIR="$CRON_CONFIG_DIR"
74
74
  # (honouring any value already in the env). See start.sh.hbs for rationale.
75
75
  export CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH="${CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH:-2}"
76
76
 
77
+ # Pin sub-agent fan-out width below the CLI's own default of 20 — the
78
+ # deterministic half of the dev-protocol's "max 15 parallel sub-agents".
79
+ # Normally inherited from start.sh's env; set here as a belt-and-braces default
80
+ # (honouring any value already in the env). See start.sh.hbs for rationale.
81
+ export CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS="${CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS:-15}"
82
+
77
83
  # Defer the (now full) MCP schema set via tool-search so the cron session keeps
78
84
  # its low-context cost — the heavy servers load on demand; switchroom-telegram
79
85
  # + hindsight stay alwaysLoad. Normally inherited from start.sh's env; set here
@@ -171,6 +177,14 @@ fi
171
177
  # claude in a detached pane that registers fine; a human can still
172
178
  # `tmux -L "$CRON_SOCKET" attach -t "$CRON_NAME"` later. The `-x/-y` give the
173
179
  # pane a size since there's no TTY to derive one from.
180
+ #
181
+ # Commit-attribution carrier for the model half of the trailer pair. The Tier-1
182
+ # cron session runs a DIFFERENT model from the main session, so it exports its
183
+ # own value — otherwise a commit made during a cron fire would be stamped with
184
+ # the main session's model. Mirrors the export in start.sh; consumed by
185
+ # bin/git-agent-attribution-hook.sh. ({{{cronModelQ}}} is already
186
+ # shell-single-quoted by the scaffold, so it is assigned BARE.)
187
+ export SWITCHROOM_SESSION_MODEL={{{cronModelQ}}}
174
188
  tmux -L "$CRON_SOCKET" \
175
189
  new-session -d -s "$CRON_NAME" -x 400 -y 50 \
176
190
  claude \
@@ -594,6 +594,30 @@ if [ ! -e "$HOME/.switchroom-config" ] || [ -L "$HOME/.switchroom-config" ]; the
594
594
  fi
595
595
  {{/if}}
596
596
 
597
+ # --- Commit attribution: global git hooks path ---------------------------
598
+ #
599
+ # Every commit an agent makes is AUTHORED by the operator's GitHub account
600
+ # (deliberate, and staying that way), which leaves `git log` unable to say
601
+ # WHICH agent and WHICH model produced a change. The image ships a
602
+ # `prepare-commit-msg` hook that stamps `Switchroom-Agent:` /
603
+ # `Switchroom-Model:` trailers from $SWITCHROOM_AGENT_NAME and the launched
604
+ # model — see bin/git-agent-attribution-hook.sh for the full rationale
605
+ # (including why prepare-commit-msg, which `--no-verify` does NOT bypass).
606
+ #
607
+ # Set GLOBALLY, not per-repo: an agent clones repos on demand and a per-repo
608
+ # install would mean every new clone starts out unattributed until someone
609
+ # remembered to run a setup step. Repo-local `core.hooksPath` (husky et al)
610
+ # still wins, and the hook chains to any displaced `.git/hooks` copy.
611
+ #
612
+ # Idempotent, and non-fatal: attribution must never be the reason a boot
613
+ # fails. `git config --global` writes $HOME/.gitconfig, which lives on the
614
+ # /state/agent bind mount and so persists — re-asserting it each boot is what
615
+ # repairs an agent whose config was hand-edited.
616
+ if command -v git >/dev/null 2>&1 && [ -x /opt/switchroom/git-hooks/prepare-commit-msg ]; then
617
+ git config --global core.hooksPath /opt/switchroom/git-hooks 2>/dev/null \
618
+ || echo "attribution: WARNING — could not set core.hooksPath; commits from this agent will not carry Switchroom-Agent/Switchroom-Model trailers and will fail the repo's attribution lint" >&2
619
+ fi
620
+
597
621
  export NVM_DIR="$HOME/.nvm"
598
622
  [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
599
623
  export PATH="$HOME/.bun/bin:$PATH"
@@ -685,10 +709,19 @@ unset CLAUDE_CODE_OAUTH_TOKEN
685
709
  # out its own sub-agents), so we restore the pre-2.1.217 behaviour. Depth 2
686
710
  # = main → sub-agent → grandchild, which covers the one extra level the
687
711
  # fleet actually uses; there is no evidence of deeper nesting, so we don't
688
- # raise it further. (The companion 2.1.217 change — CLAUDE_CODE_MAX_
689
- # CONCURRENT_SUBAGENTS defaulting to 20 — is already compatible: our fleet
690
- # self-caps at 15 parallel sub-agents, so the 20 default is left untouched.)
712
+ # raise it further.
691
713
  export CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH="${CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH:-2}"
714
+ # Fan-out width. The CLI's own default is 20 (2.1.217's companion change);
715
+ # switchroom deliberately pins it LOWER, at 15, because the dev-protocol
716
+ # fragment every agent boots with ("max 15 parallel sub-agents",
717
+ # profiles/_shared/dev-protocol.md.hbs) is prompt text — a model can ignore
718
+ # it, and nothing else caps the width. This export is the deterministic
719
+ # mechanism behind that sentence: past 15 the CLI refuses the launch instead
720
+ # of relying on the agent to count. 15 rather than 20 keeps a fully fanned-out
721
+ # worker inside the per-container CPU/RAM envelope and leaves headroom for the
722
+ # nested level that SPAWN_DEPTH=2 above permits. Override-able for a one-off
723
+ # wide job; raise both this and the fragment together or they disagree.
724
+ export CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS="${CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS:-15}"
692
725
  export TELEGRAM_STATE_DIR="{{agentDir}}/telegram"
693
726
  # SWITCHROOM_AGENT_NAME is the canonical "which agent am I" identifier the
694
727
  # telegram-plugin reads to detect self-restart commands. We can't rely on
@@ -737,6 +770,33 @@ export HINDSIGHT_RECALL_MAX_MEMORIES={{hindsightRecallMaxMemories}}
737
770
  {{#if (isNumber hindsightRecallCacheTtlSecs)}}
738
771
  export HINDSIGHT_RECALL_CACHE_TTL_SECS={{hindsightRecallCacheTtlSecs}}
739
772
  {{/if}}
773
+ # Recall latency envelope (memory.recall.parallel_deadline_seconds /
774
+ # .request_timeout_seconds cascade). Exported UNCONDITIONALLY, unlike the
775
+ # override-only knobs above, and that is deliberate.
776
+ #
777
+ # The plugin's config load order is: built-in defaults -> plugin settings.json
778
+ # -> ~/.hindsight/claude-code.json -> environment. Switchroom stamps these two
779
+ # into settings.json as well, but claude-code.json sits BETWEEN the two and
780
+ # SURVIVES `switchroom apply`. A hand-written value left in that file (the
781
+ # documented pre-promotion workaround — the live fleet had exactly this,
782
+ # pinning recallParallelDeadlineSeconds to a stale 16) would therefore silently
783
+ # defeat a settings.json-only stamp. Env is the top of the chain, so exporting
784
+ # the resolved value unconditionally is what actually makes switchroom.yaml
785
+ # authoritative. Both values come from the same resolver
786
+ # (src/setup/hindsight-recall-tunables.ts) fed the same CASCADED agent config —
787
+ # scaffold/reconcile thread their resolved `agentConfig` into
788
+ # installHindsightPlugin instead of letting it re-derive — so the two channels
789
+ # cannot diverge. Sharing a resolver was NOT enough on its own: the stamp used
790
+ # to re-derive from `agents[name] ?? {}`, and `{}` carries no `extends`, so it
791
+ # silently dropped the profile tier on one side only.
792
+ # Pinned by tests/scaffold.recall-envelope-carrier.test.ts, which asserts on the
793
+ # RENDERED start.sh — a typo'd handlebars variable renders empty rather than
794
+ # failing, and an empty export hands authority straight back to claude-code.json.
795
+ #
796
+ # The third knob in this envelope — the UserPromptSubmit hook ceiling — has no
797
+ # env channel at all (Claude Code does not expand env in hooks.json `timeout`),
798
+ # so it is stamped into the deployed hooks/hooks.json instead.
799
+ export HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS={{hindsightRecallParallelDeadlineSeconds}}
740
800
  # BM25 term budget for the recall query (#3757). The hook composes a query
741
801
  # from the last 2 turns and Hindsight OR-joins EVERY token into one
742
802
  # to_tsquery; Postgres native FTS cannot top-k from the GIN index, so it
@@ -789,6 +849,78 @@ export HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS={{hindsightRecallRequestTimeoutS
789
849
  # Observe `injected_own_bank_count` via `switchroom memory recall-log <agent>`.
790
850
  export HINDSIGHT_RECALL_OWN_BANK_MIN_SLOTS={{hindsightRecallOwnBankMinSlots}}
791
851
  export HINDSIGHT_RECALL_ADDITIONAL_BANK_MIN_SLOTS={{hindsightRecallAdditionalBankMinSlots}}
852
+ # Absolute relevance floor on an injected memory's engine score (#3837).
853
+ # 0 = OFF, and 0 is what switchroom ships: at 0 recall.py drops nothing and the
854
+ # injected set is exactly what it was before #3837.
855
+ # It exists for the measured failure where the agent's own bank times out and
856
+ # recall injects side-bank residue under the "relevant memories" banner —
857
+ # 98.4% of degraded turns carry a best injected score below 0.01 against 28.4%
858
+ # of healthy ones, and the agent cannot tell noise from recall. SCOPE decides
859
+ # which turns a non-zero floor binds on: `degraded` (default) only when the own
860
+ # bank timed out / was unreachable, `all` on every turn. `all` is NOT a
861
+ # recommended fleet default — an unconditional 0.01 floor empties ~28% of
862
+ # HEALTHY recalls (#3761), because `scores.final` is not calibrated across
863
+ # queries. EXPORTED UNCONDITIONALLY for the #3774 reason above: a stale
864
+ # hand-edited ~/.hindsight/claude-code.json must not be able to switch a recall
865
+ # filter ON that switchroom ships OFF.
866
+ # Observe `dropped_below_min_score` via `switchroom memory recall-log <agent>`.
867
+ export HINDSIGHT_RECALL_MIN_SCORE={{hindsightRecallMinScore}}
868
+ export HINDSIGHT_RECALL_MIN_SCORE_SCOPE={{hindsightRecallMinScoreScope}}
869
+ # ── The remaining recall knobs (#3841) ───────────────────────────────────────
870
+ # Everything else `recall.py` reads out of the plugin config, resolved from
871
+ # `memory.recall.*` in switchroom.yaml and exported UNCONDITIONALLY for the
872
+ # #3774 reason spelled out above: `~/.hindsight/claude-code.json` loads AFTER
873
+ # the plugin's settings.json and survives an apply, so a knob switchroom does
874
+ # not export is a knob a stale hand-edit owns. Every default below is the value
875
+ # the fleet already ran before this block existed — an operator who sets none of
876
+ # these gets byte-identical recall — and each is pinned against the vendored
877
+ # plugin by tests/scaffold.recall-passthrough.test.ts so the two cannot drift.
878
+ # Structural values are single-quoted JSON; `_cast_env` parses them and, unlike
879
+ # an empty export, actually ASSIGNS (an empty one is skipped and hands authority
880
+ # straight back to claude-code.json).
881
+ #
882
+ # How hard Hindsight searches: low (vector only, ~1-2s) | mid (+LLM rerank,
883
+ # ~5s measured) | high. Switchroom ships `low` — mid was the second-largest
884
+ # contributor to perceived dead air after model TTFT.
885
+ export HINDSIGHT_RECALL_BUDGET={{hindsightRecallPass.budget}}
886
+ # Token budget for the injected block. The count cap (…MAX_MEMORIES) is the
887
+ # other bound and the tighter of the two wins.
888
+ export HINDSIGHT_RECALL_MAX_TOKENS={{hindsightRecallPass.maxTokens}}
889
+ # Bias toward the synthesized `observation` tier, backfilling slots freed by
890
+ # superseded raw facts. On by default (memory.recall.prefer_observations).
891
+ export HINDSIGHT_RECALL_PREFER_OBSERVATIONS={{hindsightRecallPass.preferObservations}}
892
+ # Recent human turns composed into the query, and the transcript roles they may
893
+ # be drawn from. 2 turns so a bare follow-up ("and the port?") embeds with its
894
+ # antecedent instead of recalling on the pronoun alone.
895
+ export HINDSIGHT_RECALL_CONTEXT_TURNS={{hindsightRecallPass.contextTurns}}
896
+ export HINDSIGHT_RECALL_ROLES={{{hindsightRecallPass.rolesQ}}}
897
+ # Character bound on the composed query, applied BEFORE …QUERY_MAX_TOKENS
898
+ # shapes it; truncation keeps the latest turn and drops oldest context first.
899
+ export HINDSIGHT_RECALL_MAX_QUERY_CHARS={{hindsightRecallPass.maxQueryChars}}
900
+ # Tail bytes read from the session transcript for that composition — the read
901
+ # stays O(1) on a session log that can grow to many MB. 0 = whole file.
902
+ export HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES={{hindsightRecallPass.transcriptTailBytes}}
903
+ # The banner above injected memories. The agent reads this line as the
904
+ # instruction for how to treat the block, so it is behaviour, not cosmetics.
905
+ export HINDSIGHT_RECALL_PROMPT_PREAMBLE={{{hindsightRecallPass.promptPreambleQ}}}
906
+ # Tag filters (upstream 962140eef port). Dormant by default and exported at
907
+ # their no-op values — `[]` / `{}` fold to None in recall.py, so nothing is
908
+ # filtered unless an operator sets memory.recall.tags / .tag_groups.
909
+ export HINDSIGHT_RECALL_TAGS={{{hindsightRecallPass.tagsQ}}}
910
+ export HINDSIGHT_RECALL_TAGS_MATCH={{hindsightRecallPass.tagsMatch}}
911
+ export HINDSIGHT_RECALL_TAG_GROUPS={{{hindsightRecallPass.tagGroupsQ}}}
912
+ export HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS={{{hindsightRecallPass.additionalBankFiltersQ}}}
913
+ # Per-tag score multipliers — a DOWN-RANK, never a drop. Carries switchroom's
914
+ # `sidechain: 0.8` seed (delegated sub-agent memories rank just under
915
+ # first-party ones), merged with memory.recall.tag_weights.
916
+ export HINDSIGHT_RECALL_TAG_WEIGHTS={{{hindsightRecallPass.tagWeightsQ}}}
917
+ # Bounded transcript-grep fallback: only when EVERY bank returned zero AND no
918
+ # bank hit its deadline. Covers the window between an abrupt kill and the next
919
+ # boot reconciliation, where the fact layer never heard about the lost turns.
920
+ export HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK={{hindsightRecallPass.transcriptFallback}}
921
+ # Parallel multi-bank fan-out: total latency is the SLOWEST slot, not the SUM.
922
+ # false is the serial rollback lever.
923
+ export HINDSIGHT_RECALL_PARALLEL={{hindsightRecallPass.parallel}}
792
924
  # Recall fact types (memory.recall.types cascade). Switchroom default is
793
925
  # world,experience,observation (the synthesized `observation` tier is ON
794
926
  # by default, set in the plugin settings.json). Export only when the
@@ -1772,7 +1904,7 @@ esac
1772
1904
  # tells the operator once.
1773
1905
  #
1774
1906
  # NB: the CONFIGURED default resolution is untouched (#3186 constraint) —
1775
- # the fleet `thinking_effort: low` pin (see #1978 /
1907
+ # the configured `thinking_effort` (see #1978 /
1776
1908
  # src/config/thinking-effort-risk.ts) resolves exactly as before.
1777
1909
  _EFFECTIVE_EFFORT={{#if thinkingEffort}}'{{thinkingEffort}}'{{else}}''{{/if}}
1778
1910
  if [ -f "{{agentDir}}/.session-effort" ]; then
@@ -1857,6 +1989,15 @@ esac
1857
1989
  # welcome card honest. Overwrite (not consumed) — the gateway reads it at boot.
1858
1990
  printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.active-session-model" 2>/dev/null || true
1859
1991
 
1992
+ # Commit-attribution carrier for the model half of the trailer pair. Exported
1993
+ # here, from the SAME $_EFFECTIVE_MODEL the session is about to launch on, so a
1994
+ # `/model` override is reflected in the trailer rather than the configured
1995
+ # default. `exec claude` below inherits it, and so does every shell claude
1996
+ # spawns — which is where `git commit` actually runs.
1997
+ # Consumer: bin/git-agent-attribution-hook.sh (falls back to the static
1998
+ # SWITCHROOM_AGENT_MODEL that compose.ts writes, then to "unknown").
1999
+ export SWITCHROOM_SESSION_MODEL="$_EFFECTIVE_MODEL"
2000
+
1860
2001
  # --- Routing-mode observability (.routing-mode, 2026-07-17 boot-race incident) ---
1861
2002
  #
1862
2003
  # Deterministic record of WHERE this boot actually landed, written immediately