switchroom 0.19.26 → 0.19.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -475,11 +475,25 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
|
|
|
475
475
|
# mechanism — it runs because the container is up, needs no LLM turn of
|
|
476
476
|
# its own, and cannot be prompted away.
|
|
477
477
|
#
|
|
478
|
-
# SERIALISED
|
|
479
|
-
#
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
#
|
|
478
|
+
# SERIALISED, BUT NOT BY THIS FILE. `drain_pending.py` takes an
|
|
479
|
+
# exclusive `fcntl.flock` on `$HOME/.hindsight/drain-pending.lock` for
|
|
480
|
+
# the whole of `drain()` and does nothing at all if another drain holds
|
|
481
|
+
# it. That is where the guarantee has to live: the queue has three
|
|
482
|
+
# independent drain paths — the SessionStart hook (which imports
|
|
483
|
+
# `drain` in-process, and boots while this loop may be mid-run), this
|
|
484
|
+
# sidecar, and the `docker exec … drain_pending.py --backlog` that
|
|
485
|
+
# `switchroom doctor` documents for operators — and a lock wrapped
|
|
486
|
+
# around only ONE of them serialises nothing. Two drains on one queue
|
|
487
|
+
# means the same entry POSTed twice, i.e. ~168s of a 4-slot fleet-wide
|
|
488
|
+
# lane spent twice on one memory.
|
|
489
|
+
#
|
|
490
|
+
# DO NOT WRAP THE CALL BELOW IN `flock`. It would take the very lock
|
|
491
|
+
# the python then asks for, and the drain would skip every tick,
|
|
492
|
+
# silently, forever. (This is also why the lock file is NOT the
|
|
493
|
+
# `drain.lock` the interim host cron wraps its `docker exec` in — with
|
|
494
|
+
# distinct names that wrapper stays merely redundant instead of
|
|
495
|
+
# starving the drain it launched.) `tests/hindsight-drain-sidecar.test.ts`
|
|
496
|
+
# fails if a `flock` reappears here.
|
|
483
497
|
#
|
|
484
498
|
# LANE-RESPECTING. Retain extraction is served by a small fixed pool of
|
|
485
499
|
# LLM lanes (4 on this fleet, 2 boxes x 2 slots) SHARED with live
|
|
@@ -516,6 +530,22 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
|
|
|
516
530
|
# already queued ABOVE the current bound are recovered by re-splitting
|
|
517
531
|
# them, not by waiting longer. An operator who still wants a longer
|
|
518
532
|
# deadline moves HINDSIGHT_RETAIN_CLIENT_DEADLINE_S, which moves both.
|
|
533
|
+
# THE ROWS OUTLIVE THE STOPGAP THAT PRODUCED THEM; the serialisation in
|
|
534
|
+
# it does not. That stopgap is the interim host cron named above, whose
|
|
535
|
+
# outer `flock` on `drain.lock` serialised only that cron against
|
|
536
|
+
# itself, while the SessionStart hook's in-process drain and a bare
|
|
537
|
+
# `docker exec` walked straight past it — the widening to all three is
|
|
538
|
+
# the whole point of moving the lock inside `drain_pending.py`. What
|
|
539
|
+
# the rows measure is one drain completing (or not) inside a deadline,
|
|
540
|
+
# which is a property of the drain and not of who serialised it, so
|
|
541
|
+
# they still stand. They are not a licence to reinstate the wrapper.
|
|
542
|
+
#
|
|
543
|
+
# UNATTENDED, SO BOUNDED PER ENTRY. `drain_pending` parks an entry past
|
|
544
|
+
# its attempt ceiling (default 20) instead of retrying it forever —
|
|
545
|
+
# without that, one permanently-failing entry on a 3600s backlog budget
|
|
546
|
+
# would burn most of this agent's wall-clock, and a share of the 4
|
|
547
|
+
# lanes, indefinitely and with nobody watching. Parked entries stay
|
|
548
|
+
# queued and are still reconciled for free; `--force` replays them.
|
|
519
549
|
#
|
|
520
550
|
# Failure is never terminal: the drain exits non-zero only when it
|
|
521
551
|
# promoted entries to `.dead`, and the loop below ignores the status
|
|
@@ -524,13 +554,24 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
|
|
|
524
554
|
# (those are exported later, in the inner pass) — only $HOME, to find
|
|
525
555
|
# the queue.
|
|
526
556
|
#
|
|
557
|
+
# DOCKER RUNTIME ONLY. This whole block is inside the
|
|
558
|
+
# `[ "$SWITCHROOM_RUNTIME" = "docker" ]` branch that ends at the `exec
|
|
559
|
+
# tmux` below, so an agent on the legacy systemd path gets NO drain
|
|
560
|
+
# sidecar and keeps the boot-only SessionStart behaviour. The fleet is
|
|
561
|
+
# entirely docker; if systemd ever comes back, this needs an equivalent
|
|
562
|
+
# timer, and until then the honest statement is that it is unsupported
|
|
563
|
+
# there rather than silently absent.
|
|
564
|
+
#
|
|
527
565
|
# Kill switch: SWITCHROOM_HINDSIGHT_DRAIN=0 at the container level.
|
|
528
|
-
# Interval: SWITCHROOM_HINDSIGHT_DRAIN_INTERVAL_S (default 900s).
|
|
566
|
+
# Interval: SWITCHROOM_HINDSIGHT_DRAIN_INTERVAL_S (default 900s). That
|
|
567
|
+
# is a COOLDOWN BETWEEN RUNS, not a fixed period — the loop is strictly
|
|
568
|
+
# sequential, so the true tick-to-tick spacing is one drain's duration
|
|
569
|
+
# (up to the 3600s backlog budget) plus the interval. Deliberate: a
|
|
570
|
+
# fixed period would give a long, lane-heavy drain no cooldown at all.
|
|
529
571
|
_hs_drain_script="{{agentDir}}/.claude/plugins/hindsight-memory/scripts/drain_pending.py"
|
|
530
|
-
if [ "$SWITCHROOM_HINDSIGHT_DRAIN" != "0" ] \
|
|
572
|
+
if [ "${SWITCHROOM_HINDSIGHT_DRAIN:-1}" != "0" ] \
|
|
531
573
|
&& [ -f "$_hs_drain_script" ] \
|
|
532
|
-
&& command -v python3 >/dev/null 2>&1
|
|
533
|
-
&& command -v flock >/dev/null 2>&1; then
|
|
574
|
+
&& command -v python3 >/dev/null 2>&1; then
|
|
534
575
|
_switchroom_hindsight_drain_loop() {
|
|
535
576
|
local _script="$1"
|
|
536
577
|
local _interval="${SWITCHROOM_HINDSIGHT_DRAIN_INTERVAL_S:-900}"
|
|
@@ -538,24 +579,38 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
|
|
|
538
579
|
# against the LLM lane. 60s is the floor.
|
|
539
580
|
case "$_interval" in ''|*[!0-9]*) _interval=900 ;; esac
|
|
540
581
|
[ "$_interval" -lt 60 ] && _interval=60
|
|
541
|
-
local _lock="$HOME/.hindsight/drain.lock"
|
|
542
|
-
mkdir -p "$HOME/.hindsight" 2>/dev/null || true
|
|
543
582
|
# Deterministic per-agent phase offset (see the note above).
|
|
544
583
|
local _offset=0
|
|
545
584
|
if command -v cksum >/dev/null 2>&1; then
|
|
546
585
|
_offset=$(( $(printf '%s' "{{name}}" | cksum | cut -d' ' -f1) % _interval ))
|
|
547
586
|
fi
|
|
548
|
-
echo "[hindsight-drain] starting: interval=${_interval}s offset=${_offset}s
|
|
587
|
+
echo "[hindsight-drain] starting: interval=${_interval}s (cooldown between runs, not a fixed period) offset=${_offset}s serialised-by=drain_pending.py"
|
|
549
588
|
sleep "$_offset"
|
|
550
589
|
while true; do
|
|
551
|
-
#
|
|
552
|
-
#
|
|
553
|
-
|
|
590
|
+
# No `flock` here, by design — drain_pending.py locks itself, and a
|
|
591
|
+
# wrapper here would hold the lock it needs. See the note above.
|
|
592
|
+
python3 "$_script" --backlog || true
|
|
554
593
|
sleep "$_interval"
|
|
555
594
|
done
|
|
556
595
|
}
|
|
557
596
|
_switchroom_supervise hindsight-drain /var/log/switchroom/hindsight-drain.log \
|
|
558
597
|
_switchroom_hindsight_drain_loop "$_hs_drain_script" &
|
|
598
|
+
else
|
|
599
|
+
# NEVER FAIL SILENTLY. Without this branch a failed precondition left no
|
|
600
|
+
# log line and no logfile at all, so "is the drain running?" was
|
|
601
|
+
# unanswerable from the logs — indistinguishable from a drain that was
|
|
602
|
+
# running and finding nothing to do.
|
|
603
|
+
if [ "${SWITCHROOM_HINDSIGHT_DRAIN:-1}" = "0" ]; then
|
|
604
|
+
_hs_drain_why="disabled by SWITCHROOM_HINDSIGHT_DRAIN=0"
|
|
605
|
+
elif [ ! -f "$_hs_drain_script" ]; then
|
|
606
|
+
_hs_drain_why="no drain script at $_hs_drain_script (hindsight plugin not installed, or an interrupted \`switchroom apply\`)"
|
|
607
|
+
else
|
|
608
|
+
_hs_drain_why="python3 is not on PATH in this image"
|
|
609
|
+
fi
|
|
610
|
+
_hs_drain_msg="[hindsight-drain] NOT STARTED: ${_hs_drain_why}. The memory queue will only be drained at session boot, which cannot clear a backlog."
|
|
611
|
+
echo "$_hs_drain_msg"
|
|
612
|
+
mkdir -p /var/log/switchroom 2>/dev/null || true
|
|
613
|
+
echo "$_hs_drain_msg" >> /var/log/switchroom/hindsight-drain.log 2>/dev/null || true
|
|
559
614
|
fi
|
|
560
615
|
{{/if}}
|
|
561
616
|
|
|
@@ -594,6 +649,30 @@ if [ ! -e "$HOME/.switchroom-config" ] || [ -L "$HOME/.switchroom-config" ]; the
|
|
|
594
649
|
fi
|
|
595
650
|
{{/if}}
|
|
596
651
|
|
|
652
|
+
# --- Commit attribution: global git hooks path ---------------------------
|
|
653
|
+
#
|
|
654
|
+
# Every commit an agent makes is AUTHORED by the operator's GitHub account
|
|
655
|
+
# (deliberate, and staying that way), which leaves `git log` unable to say
|
|
656
|
+
# WHICH agent and WHICH model produced a change. The image ships a
|
|
657
|
+
# `prepare-commit-msg` hook that stamps `Switchroom-Agent:` /
|
|
658
|
+
# `Switchroom-Model:` trailers from $SWITCHROOM_AGENT_NAME and the launched
|
|
659
|
+
# model — see bin/git-agent-attribution-hook.sh for the full rationale
|
|
660
|
+
# (including why prepare-commit-msg, which `--no-verify` does NOT bypass).
|
|
661
|
+
#
|
|
662
|
+
# Set GLOBALLY, not per-repo: an agent clones repos on demand and a per-repo
|
|
663
|
+
# install would mean every new clone starts out unattributed until someone
|
|
664
|
+
# remembered to run a setup step. Repo-local `core.hooksPath` (husky et al)
|
|
665
|
+
# still wins, and the hook chains to any displaced `.git/hooks` copy.
|
|
666
|
+
#
|
|
667
|
+
# Idempotent, and non-fatal: attribution must never be the reason a boot
|
|
668
|
+
# fails. `git config --global` writes $HOME/.gitconfig, which lives on the
|
|
669
|
+
# /state/agent bind mount and so persists — re-asserting it each boot is what
|
|
670
|
+
# repairs an agent whose config was hand-edited.
|
|
671
|
+
if command -v git >/dev/null 2>&1 && [ -x /opt/switchroom/git-hooks/prepare-commit-msg ]; then
|
|
672
|
+
git config --global core.hooksPath /opt/switchroom/git-hooks 2>/dev/null \
|
|
673
|
+
|| 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
|
|
674
|
+
fi
|
|
675
|
+
|
|
597
676
|
export NVM_DIR="$HOME/.nvm"
|
|
598
677
|
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
|
599
678
|
export PATH="$HOME/.bun/bin:$PATH"
|
|
@@ -825,6 +904,78 @@ export HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS={{hindsightRecallRequestTimeoutS
|
|
|
825
904
|
# Observe `injected_own_bank_count` via `switchroom memory recall-log <agent>`.
|
|
826
905
|
export HINDSIGHT_RECALL_OWN_BANK_MIN_SLOTS={{hindsightRecallOwnBankMinSlots}}
|
|
827
906
|
export HINDSIGHT_RECALL_ADDITIONAL_BANK_MIN_SLOTS={{hindsightRecallAdditionalBankMinSlots}}
|
|
907
|
+
# Absolute relevance floor on an injected memory's engine score (#3837).
|
|
908
|
+
# 0 = OFF, and 0 is what switchroom ships: at 0 recall.py drops nothing and the
|
|
909
|
+
# injected set is exactly what it was before #3837.
|
|
910
|
+
# It exists for the measured failure where the agent's own bank times out and
|
|
911
|
+
# recall injects side-bank residue under the "relevant memories" banner —
|
|
912
|
+
# 98.4% of degraded turns carry a best injected score below 0.01 against 28.4%
|
|
913
|
+
# of healthy ones, and the agent cannot tell noise from recall. SCOPE decides
|
|
914
|
+
# which turns a non-zero floor binds on: `degraded` (default) only when the own
|
|
915
|
+
# bank timed out / was unreachable, `all` on every turn. `all` is NOT a
|
|
916
|
+
# recommended fleet default — an unconditional 0.01 floor empties ~28% of
|
|
917
|
+
# HEALTHY recalls (#3761), because `scores.final` is not calibrated across
|
|
918
|
+
# queries. EXPORTED UNCONDITIONALLY for the #3774 reason above: a stale
|
|
919
|
+
# hand-edited ~/.hindsight/claude-code.json must not be able to switch a recall
|
|
920
|
+
# filter ON that switchroom ships OFF.
|
|
921
|
+
# Observe `dropped_below_min_score` via `switchroom memory recall-log <agent>`.
|
|
922
|
+
export HINDSIGHT_RECALL_MIN_SCORE={{hindsightRecallMinScore}}
|
|
923
|
+
export HINDSIGHT_RECALL_MIN_SCORE_SCOPE={{hindsightRecallMinScoreScope}}
|
|
924
|
+
# ── The remaining recall knobs (#3841) ───────────────────────────────────────
|
|
925
|
+
# Everything else `recall.py` reads out of the plugin config, resolved from
|
|
926
|
+
# `memory.recall.*` in switchroom.yaml and exported UNCONDITIONALLY for the
|
|
927
|
+
# #3774 reason spelled out above: `~/.hindsight/claude-code.json` loads AFTER
|
|
928
|
+
# the plugin's settings.json and survives an apply, so a knob switchroom does
|
|
929
|
+
# not export is a knob a stale hand-edit owns. Every default below is the value
|
|
930
|
+
# the fleet already ran before this block existed — an operator who sets none of
|
|
931
|
+
# these gets byte-identical recall — and each is pinned against the vendored
|
|
932
|
+
# plugin by tests/scaffold.recall-passthrough.test.ts so the two cannot drift.
|
|
933
|
+
# Structural values are single-quoted JSON; `_cast_env` parses them and, unlike
|
|
934
|
+
# an empty export, actually ASSIGNS (an empty one is skipped and hands authority
|
|
935
|
+
# straight back to claude-code.json).
|
|
936
|
+
#
|
|
937
|
+
# How hard Hindsight searches: low (vector only, ~1-2s) | mid (+LLM rerank,
|
|
938
|
+
# ~5s measured) | high. Switchroom ships `low` — mid was the second-largest
|
|
939
|
+
# contributor to perceived dead air after model TTFT.
|
|
940
|
+
export HINDSIGHT_RECALL_BUDGET={{hindsightRecallPass.budget}}
|
|
941
|
+
# Token budget for the injected block. The count cap (…MAX_MEMORIES) is the
|
|
942
|
+
# other bound and the tighter of the two wins.
|
|
943
|
+
export HINDSIGHT_RECALL_MAX_TOKENS={{hindsightRecallPass.maxTokens}}
|
|
944
|
+
# Bias toward the synthesized `observation` tier, backfilling slots freed by
|
|
945
|
+
# superseded raw facts. On by default (memory.recall.prefer_observations).
|
|
946
|
+
export HINDSIGHT_RECALL_PREFER_OBSERVATIONS={{hindsightRecallPass.preferObservations}}
|
|
947
|
+
# Recent human turns composed into the query, and the transcript roles they may
|
|
948
|
+
# be drawn from. 2 turns so a bare follow-up ("and the port?") embeds with its
|
|
949
|
+
# antecedent instead of recalling on the pronoun alone.
|
|
950
|
+
export HINDSIGHT_RECALL_CONTEXT_TURNS={{hindsightRecallPass.contextTurns}}
|
|
951
|
+
export HINDSIGHT_RECALL_ROLES={{{hindsightRecallPass.rolesQ}}}
|
|
952
|
+
# Character bound on the composed query, applied BEFORE …QUERY_MAX_TOKENS
|
|
953
|
+
# shapes it; truncation keeps the latest turn and drops oldest context first.
|
|
954
|
+
export HINDSIGHT_RECALL_MAX_QUERY_CHARS={{hindsightRecallPass.maxQueryChars}}
|
|
955
|
+
# Tail bytes read from the session transcript for that composition — the read
|
|
956
|
+
# stays O(1) on a session log that can grow to many MB. 0 = whole file.
|
|
957
|
+
export HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES={{hindsightRecallPass.transcriptTailBytes}}
|
|
958
|
+
# The banner above injected memories. The agent reads this line as the
|
|
959
|
+
# instruction for how to treat the block, so it is behaviour, not cosmetics.
|
|
960
|
+
export HINDSIGHT_RECALL_PROMPT_PREAMBLE={{{hindsightRecallPass.promptPreambleQ}}}
|
|
961
|
+
# Tag filters (upstream 962140eef port). Dormant by default and exported at
|
|
962
|
+
# their no-op values — `[]` / `{}` fold to None in recall.py, so nothing is
|
|
963
|
+
# filtered unless an operator sets memory.recall.tags / .tag_groups.
|
|
964
|
+
export HINDSIGHT_RECALL_TAGS={{{hindsightRecallPass.tagsQ}}}
|
|
965
|
+
export HINDSIGHT_RECALL_TAGS_MATCH={{hindsightRecallPass.tagsMatch}}
|
|
966
|
+
export HINDSIGHT_RECALL_TAG_GROUPS={{{hindsightRecallPass.tagGroupsQ}}}
|
|
967
|
+
export HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS={{{hindsightRecallPass.additionalBankFiltersQ}}}
|
|
968
|
+
# Per-tag score multipliers — a DOWN-RANK, never a drop. Carries switchroom's
|
|
969
|
+
# `sidechain: 0.8` seed (delegated sub-agent memories rank just under
|
|
970
|
+
# first-party ones), merged with memory.recall.tag_weights.
|
|
971
|
+
export HINDSIGHT_RECALL_TAG_WEIGHTS={{{hindsightRecallPass.tagWeightsQ}}}
|
|
972
|
+
# Bounded transcript-grep fallback: only when EVERY bank returned zero AND no
|
|
973
|
+
# bank hit its deadline. Covers the window between an abrupt kill and the next
|
|
974
|
+
# boot reconciliation, where the fact layer never heard about the lost turns.
|
|
975
|
+
export HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK={{hindsightRecallPass.transcriptFallback}}
|
|
976
|
+
# Parallel multi-bank fan-out: total latency is the SLOWEST slot, not the SUM.
|
|
977
|
+
# false is the serial rollback lever.
|
|
978
|
+
export HINDSIGHT_RECALL_PARALLEL={{hindsightRecallPass.parallel}}
|
|
828
979
|
# Recall fact types (memory.recall.types cascade). Switchroom default is
|
|
829
980
|
# world,experience,observation (the synthesized `observation` tier is ON
|
|
830
981
|
# by default, set in the plugin settings.json). Export only when the
|
|
@@ -1893,6 +2044,15 @@ esac
|
|
|
1893
2044
|
# welcome card honest. Overwrite (not consumed) — the gateway reads it at boot.
|
|
1894
2045
|
printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.active-session-model" 2>/dev/null || true
|
|
1895
2046
|
|
|
2047
|
+
# Commit-attribution carrier for the model half of the trailer pair. Exported
|
|
2048
|
+
# here, from the SAME $_EFFECTIVE_MODEL the session is about to launch on, so a
|
|
2049
|
+
# `/model` override is reflected in the trailer rather than the configured
|
|
2050
|
+
# default. `exec claude` below inherits it, and so does every shell claude
|
|
2051
|
+
# spawns — which is where `git commit` actually runs.
|
|
2052
|
+
# Consumer: bin/git-agent-attribution-hook.sh (falls back to the static
|
|
2053
|
+
# SWITCHROOM_AGENT_MODEL that compose.ts writes, then to "unknown").
|
|
2054
|
+
export SWITCHROOM_SESSION_MODEL="$_EFFECTIVE_MODEL"
|
|
2055
|
+
|
|
1896
2056
|
# --- Routing-mode observability (.routing-mode, 2026-07-17 boot-race incident) ---
|
|
1897
2057
|
#
|
|
1898
2058
|
# Deterministic record of WHERE this boot actually landed, written immediately
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card layout core — the ONE place a status/progress card is composed.
|
|
3
|
+
*
|
|
4
|
+
* Every card the gateway paints (🤖 agent, 🛠 single worker, 🛠 WORKERS
|
|
5
|
+
* combined, and their overflow/terminal variants) is expressed as a
|
|
6
|
+
* {@link CardSpec}: fixed `chrome` lines, N `sections` (each an optional
|
|
7
|
+
* header plus a windowed `✓`/`→` step trail), and fixed `footer` lines. The
|
|
8
|
+
* renderers in `tool-activity-summary.ts` / `worker-activity-feed.ts` are
|
|
9
|
+
* CONFIGURATIONS of this module, not parallel implementations.
|
|
10
|
+
*
|
|
11
|
+
* Two things used to exist twice and agreed only by hand (#3844):
|
|
12
|
+
*
|
|
13
|
+
* 1. **Header composition.** The combined card built its own row header
|
|
14
|
+
* instead of going through `renderActivityHeader`, so the metrics run
|
|
15
|
+
* (`{elapsed} · {n} tools · {t} tok · {model}`) was written out in two
|
|
16
|
+
* places. It is now composed exactly once, by {@link metricsRun}, which
|
|
17
|
+
* the agent header, the worker header, the combined row header and the
|
|
18
|
+
* combined glance line all read.
|
|
19
|
+
* 2. **Char-budget enforcement.** The combined card carried its own
|
|
20
|
+
* row-shrinking loop instead of the status card's `fitCardToBudget`.
|
|
21
|
+
* There is now exactly one budget enforcer — {@link fitCardToBudget} —
|
|
22
|
+
* a generic "render at progressively deeper shrink levels, take the
|
|
23
|
+
* first that fits" loop. Each card supplies what a shrink level MEANS
|
|
24
|
+
* (drop the oldest bullet / drop the newest row); nothing else compares
|
|
25
|
+
* a rendered card against `STATUS_CARD_CHAR_BUDGET`.
|
|
26
|
+
*
|
|
27
|
+
* Nothing in here knows about workers, turns, or Telegram transport — it is
|
|
28
|
+
* pure string composition, so a card variant can be rendered (and asserted)
|
|
29
|
+
* without any of the feed machinery.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
|
|
33
|
+
import { formatModelLabel } from './model-label.js'
|
|
34
|
+
import { STATUS_CARD_CHAR_BUDGET, STATUS_LINE_MAX, STATUS_ROLLING_LINES } from './status-no-truncate.js'
|
|
35
|
+
|
|
36
|
+
// ─── Metrics composition (the single header vocabulary) ─────────────────────
|
|
37
|
+
|
|
38
|
+
/** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
|
|
39
|
+
export function formatFeedElapsed(ms: number): string {
|
|
40
|
+
const s = Math.floor(ms / 1000)
|
|
41
|
+
if (s < 60) return `${s}s`
|
|
42
|
+
const m = Math.floor(s / 60)
|
|
43
|
+
return `${m}m${(s % 60).toString().padStart(2, '0')}s`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compact token-count formatter for the activity card's metrics line:
|
|
48
|
+
* <1000 → raw ("940")
|
|
49
|
+
* ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
|
|
50
|
+
* ≥1e6 → one-decimal M ("1.2M")
|
|
51
|
+
* Negative / non-finite inputs clamp to "0". The caller appends " tok".
|
|
52
|
+
*/
|
|
53
|
+
export function formatTokenCount(n: number): string {
|
|
54
|
+
if (!Number.isFinite(n) || n <= 0) return '0'
|
|
55
|
+
if (n < 1000) return String(Math.floor(n))
|
|
56
|
+
if (n < 1_000_000) {
|
|
57
|
+
// Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
|
|
58
|
+
// round to "1000.0k", which must promote into the M branch rather than
|
|
59
|
+
// render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
|
|
60
|
+
const k = Number((n / 1000).toFixed(1))
|
|
61
|
+
if (k < 1000) return `${k.toFixed(1)}k`
|
|
62
|
+
}
|
|
63
|
+
return `${(n / 1_000_000).toFixed(1)}M`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
|
|
68
|
+
* A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
|
|
69
|
+
* transcript) OMITS the segment entirely so the line stays clean; every card
|
|
70
|
+
* that shows tokens reads this one predicate so they never diverge.
|
|
71
|
+
*/
|
|
72
|
+
export function tokenSegment(totalTokens: number | undefined): string {
|
|
73
|
+
if (totalTokens == null || totalTokens <= 0) return ''
|
|
74
|
+
return ` · ${formatTokenCount(totalTokens)} tok`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `tool` / `tools`, pluralised once for every surface that prints a tool count. */
|
|
78
|
+
export function toolWord(n: number): string {
|
|
79
|
+
return n === 1 ? 'tool' : 'tools'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Terminal/running state a card (or a row on it) can report. */
|
|
83
|
+
export type CardState = 'running' | 'done' | 'failed' | 'incomplete'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The metrics RUN — the dot-separated `{elapsed} · {n} tools · {t} tok · {model}`
|
|
87
|
+
* text that every card header carries, WITHOUT the italic wrapper (callers own
|
|
88
|
+
* the wrapper because they place the leading separator differently).
|
|
89
|
+
*
|
|
90
|
+
* This is the single composition point the audit (#3844) found forked: the
|
|
91
|
+
* combined card's row header spelled the same run out by hand, so a change to
|
|
92
|
+
* (say) token placement had to be made twice and agreed only by inspection.
|
|
93
|
+
*
|
|
94
|
+
* `running` puts elapsed FIRST (it is the live fact); a terminal state leads
|
|
95
|
+
* with the state word and moves elapsed to the tail, so a failed or reaped
|
|
96
|
+
* worker never reads as done.
|
|
97
|
+
*/
|
|
98
|
+
export function metricsRun(
|
|
99
|
+
elapsedMs: number,
|
|
100
|
+
toolCount: number,
|
|
101
|
+
state: CardState,
|
|
102
|
+
totalTokens?: number,
|
|
103
|
+
model?: string,
|
|
104
|
+
): string {
|
|
105
|
+
const elapsed = formatFeedElapsed(elapsedMs)
|
|
106
|
+
const tokPart = tokenSegment(totalTokens)
|
|
107
|
+
const modelLabel = formatModelLabel(model)
|
|
108
|
+
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
109
|
+
return state === 'running'
|
|
110
|
+
? `${elapsed} · ${toolCount} ${toolWord(toolCount)}${tokPart}${modelPart}`
|
|
111
|
+
: `${state} · ${toolCount} ${toolWord(toolCount)}${tokPart} · ${elapsed}${modelPart}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Line 1 of a card: `<emoji> <b>LABEL</b> · <i>description</i>` (description
|
|
116
|
+
* optional). Shared by the agent card, the worker card and the static
|
|
117
|
+
* superseded notice, so the card's type chrome is composed in one place.
|
|
118
|
+
*/
|
|
119
|
+
export function renderCardTitleLine(emoji: string, label: string, description: string): string {
|
|
120
|
+
const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
|
|
121
|
+
return `${emoji} **${escapeMarkdown(label)}**${descPart}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render a two-line header for an activity card.
|
|
126
|
+
*
|
|
127
|
+
* Line 1: `<emoji> <b>Label</b> · <i>description</i>` (description optional)
|
|
128
|
+
* Line 2: the metrics run, italicised.
|
|
129
|
+
*
|
|
130
|
+
* Returns a two-element array of ready Telegram markdown lines (no trailing
|
|
131
|
+
* newline).
|
|
132
|
+
*/
|
|
133
|
+
export function renderActivityHeader(
|
|
134
|
+
emoji: string,
|
|
135
|
+
label: string,
|
|
136
|
+
description: string,
|
|
137
|
+
elapsedMs: number,
|
|
138
|
+
toolCount: number,
|
|
139
|
+
state: CardState,
|
|
140
|
+
model?: string,
|
|
141
|
+
totalTokens?: number,
|
|
142
|
+
): [string, string] {
|
|
143
|
+
return [
|
|
144
|
+
renderCardTitleLine(emoji, label, description),
|
|
145
|
+
`_${metricsRun(elapsedMs, toolCount, state, totalTokens, model)}_`,
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Per-line truncation pipeline ───────────────────────────────────────────
|
|
150
|
+
//
|
|
151
|
+
// Per RAW line, in this EXACT order:
|
|
152
|
+
// 1. stripMarkdown(raw)
|
|
153
|
+
// 2. .replace(/\s+/g, ' ').trim()
|
|
154
|
+
// 3. truncate(_, STATUS_LINE_MAX)
|
|
155
|
+
// 4. escapeMarkdown(_) ← escape is ALWAYS the last per-line op.
|
|
156
|
+
// Escaping last is load-bearing: clipping an already-escaped string can split
|
|
157
|
+
// a markdown escape (\* → \), which renders wrong.
|
|
158
|
+
|
|
159
|
+
/** Clean + clip + escape a single raw step line. Returns ready-to-wrap markdown. */
|
|
160
|
+
export function escapeStepLine(raw: string): string {
|
|
161
|
+
const cleaned = stripMarkdown(raw).replace(/\s+/g, ' ').trim()
|
|
162
|
+
return escapeMarkdown(truncate(cleaned, STATUS_LINE_MAX))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The cleaned (unescaped) form of a raw line — the input to `truncate`. */
|
|
166
|
+
export function cleanStepLine(raw: string): string {
|
|
167
|
+
return stripMarkdown(raw).replace(/\s+/g, ' ').trim()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Card spec ──────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* How a FINISHED step bullet is drawn.
|
|
174
|
+
* 'struck' — `~~_✓ step_~~`, the normal card body.
|
|
175
|
+
* 'plain' — `_step_`, used by the char-budget shrink levels where the
|
|
176
|
+
* strike/✓ chrome is spent bytes on a card already over the wire
|
|
177
|
+
* limit.
|
|
178
|
+
*/
|
|
179
|
+
export type DoneStyle = 'struck' | 'plain'
|
|
180
|
+
|
|
181
|
+
/** One section of a card: an optional header plus a windowed step trail. */
|
|
182
|
+
export interface CardSection {
|
|
183
|
+
/** Ready lines emitted above this section's steps (the combined row header). */
|
|
184
|
+
header?: string[]
|
|
185
|
+
/** ALREADY-ESCAPED step lines, oldest → newest. */
|
|
186
|
+
steps: string[]
|
|
187
|
+
/** Trailing steps shown; the remainder collapses to a `+N earlier…` marker. */
|
|
188
|
+
window: number
|
|
189
|
+
/** When true every step renders done — no live `→` bullet. */
|
|
190
|
+
allDone?: boolean
|
|
191
|
+
/** Heartbeat suffix rendered INSIDE the newest in-progress bullet. */
|
|
192
|
+
liveSuffix?: string
|
|
193
|
+
/**
|
|
194
|
+
* Literal prefix on EVERY line this section emits (marker, bullets,
|
|
195
|
+
* placeholder), OUTSIDE the markdown spans so it never lands inside an
|
|
196
|
+
* emphasis run.
|
|
197
|
+
*/
|
|
198
|
+
indent?: string
|
|
199
|
+
/**
|
|
200
|
+
* Token before a done step and inside the overflow marker. `'✓ '` on a normal
|
|
201
|
+
* body; `''` for the `↳` nested-child block, whose `↳` glyph already marks it.
|
|
202
|
+
*/
|
|
203
|
+
doneMark?: string
|
|
204
|
+
/** See {@link DoneStyle}. Default 'struck'. */
|
|
205
|
+
doneStyle?: DoneStyle
|
|
206
|
+
/** Emitted (with `indent`) when this section has no steps at all. */
|
|
207
|
+
placeholder?: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A whole card, as fixed chrome + sections + fixed footer. */
|
|
211
|
+
export interface CardSpec {
|
|
212
|
+
/** Lines above every section — the card header, or the combined glance line. */
|
|
213
|
+
chrome?: string[]
|
|
214
|
+
sections: CardSection[]
|
|
215
|
+
/** Lines below every section — `✓ N steps`, the result block, the spill line. */
|
|
216
|
+
footer?: string[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Emit one section's lines into `out`. This is the single `✓`/`→` bullet
|
|
221
|
+
* emitter: every step line on every card comes through here.
|
|
222
|
+
*/
|
|
223
|
+
export function emitSection(out: string[], section: CardSection): void {
|
|
224
|
+
const indent = section.indent ?? ''
|
|
225
|
+
const doneMark = section.doneMark ?? '✓ '
|
|
226
|
+
const doneStyle = section.doneStyle ?? 'struck'
|
|
227
|
+
const allDone = section.allDone === true
|
|
228
|
+
const liveSuffix = section.liveSuffix ?? ''
|
|
229
|
+
if (section.header != null) out.push(...section.header)
|
|
230
|
+
if (section.steps.length === 0) {
|
|
231
|
+
if (section.placeholder != null) out.push(`${indent}${section.placeholder}`)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
const shown = section.steps.slice(-Math.max(1, section.window))
|
|
235
|
+
const hidden = section.steps.length - shown.length
|
|
236
|
+
if (hidden > 0) out.push(`${indent}_${doneMark}+${hidden} earlier…_`)
|
|
237
|
+
const lastIdx = shown.length - 1
|
|
238
|
+
shown.forEach((s, i) => {
|
|
239
|
+
if (!allDone && i === lastIdx) {
|
|
240
|
+
out.push(`${indent}**→ ${s}${liveSuffix}**`)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
out.push(doneStyle === 'plain' ? `${indent}_${s}_` : `${indent}~~_${doneMark}${s}_~~`)
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Shared step-feed emitter (back-compat signature). Appends `✓`/`→` bullet
|
|
249
|
+
* lines to `out` for the given ALREADY-ESCAPED step strings. Thin wrapper over
|
|
250
|
+
* {@link emitSection}.
|
|
251
|
+
*/
|
|
252
|
+
export function renderStepFeed(
|
|
253
|
+
out: string[],
|
|
254
|
+
steps: string[],
|
|
255
|
+
allDone: boolean,
|
|
256
|
+
liveSuffix = '',
|
|
257
|
+
window: number = STATUS_ROLLING_LINES,
|
|
258
|
+
indent = '',
|
|
259
|
+
): void {
|
|
260
|
+
emitSection(out, { steps, window, allDone, liveSuffix, indent })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Flatten a spec to its ordered card lines. */
|
|
264
|
+
export function cardSpecLines(spec: CardSpec): string[] {
|
|
265
|
+
const out: string[] = [...(spec.chrome ?? [])]
|
|
266
|
+
for (const section of spec.sections) emitSection(out, section)
|
|
267
|
+
out.push(...(spec.footer ?? []))
|
|
268
|
+
return out
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Join card lines with GFM hard breaks so the styled prose lines don't collapse
|
|
273
|
+
* onto one visual line in the rich-message renderer — see `stackCardLines`.
|
|
274
|
+
*
|
|
275
|
+
* `collapseSafe` is unconditional here: every card this module renders is a
|
|
276
|
+
* PINNED surface, and Telegram's pinned bar shows a pinned message collapsed to
|
|
277
|
+
* one line with the newlines dropped and nothing substituted (#3666).
|
|
278
|
+
*/
|
|
279
|
+
export function stackCard(lines: string[]): string {
|
|
280
|
+
return stackCardLines(lines, { collapseSafe: true })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Render a spec straight to wire text, with no budget enforcement. */
|
|
284
|
+
export function renderCardSpec(spec: CardSpec): string {
|
|
285
|
+
return stackCard(cardSpecLines(spec))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── The one char-budget enforcer ───────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/** One shrink level's rendered candidate. */
|
|
291
|
+
export interface CardCandidate {
|
|
292
|
+
spec: CardSpec
|
|
293
|
+
/**
|
|
294
|
+
* An additional card-specific ceiling this candidate violates (the combined
|
|
295
|
+
* card's total body-line budget). A candidate that fits the char budget but
|
|
296
|
+
* sets this is still rejected, so both ceilings are enforced by one loop.
|
|
297
|
+
*/
|
|
298
|
+
overflow?: boolean
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* THE char-budget backstop — the only place a rendered card is measured against
|
|
303
|
+
* `STATUS_CARD_CHAR_BUDGET`.
|
|
304
|
+
*
|
|
305
|
+
* `build(level)` renders the card at progressively deeper shrink levels
|
|
306
|
+
* (level 0 = the full card). The first level whose rendered text fits the
|
|
307
|
+
* budget and clears its own `overflow` flag wins; if even `maxLevel` does not
|
|
308
|
+
* fit, that deepest render is returned — a card is always emitted, never
|
|
309
|
+
* dropped.
|
|
310
|
+
*
|
|
311
|
+
* What a level MEANS is the caller's business and is genuinely card-specific:
|
|
312
|
+
* the status card drops its oldest bullet (and, at its deepest level, truncates
|
|
313
|
+
* an oversized newest bullet), while the combined card drops its newest worker
|
|
314
|
+
* row into `+M more working…`. The MECHANISM — render, measure, accept or go
|
|
315
|
+
* deeper — exists only here.
|
|
316
|
+
*/
|
|
317
|
+
export function fitCardToBudget(
|
|
318
|
+
build: (level: number) => CardCandidate,
|
|
319
|
+
maxLevel: number,
|
|
320
|
+
): string {
|
|
321
|
+
let text = ''
|
|
322
|
+
for (let level = 0; level <= maxLevel; level++) {
|
|
323
|
+
const candidate = build(level)
|
|
324
|
+
text = renderCardSpec(candidate.spec)
|
|
325
|
+
if (candidate.overflow !== true && text.length <= STATUS_CARD_CHAR_BUDGET) return text
|
|
326
|
+
}
|
|
327
|
+
return text
|
|
328
|
+
}
|