switchroom 0.19.19 → 0.19.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-broker/index.js +53 -0
- package/dist/cli/switchroom.js +2444 -1264
- package/dist/host-control/main.js +54 -1
- package/dist/vault/approvals/kernel-server.js +53 -0
- package/dist/vault/broker/server.js +53 -0
- package/package.json +4 -2
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +769 -172
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
- package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
- package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
|
@@ -21120,7 +21120,7 @@ function allocateAgentUid(name) {
|
|
|
21120
21120
|
}
|
|
21121
21121
|
|
|
21122
21122
|
// src/build-info.ts
|
|
21123
|
-
var VERSION = "0.19.
|
|
21123
|
+
var VERSION = "0.19.22";
|
|
21124
21124
|
|
|
21125
21125
|
// src/cli/agent-config.ts
|
|
21126
21126
|
import { join as join2 } from "node:path";
|
|
@@ -21172,6 +21172,47 @@ for (const name of SHARED_FRAGMENTS) {
|
|
|
21172
21172
|
}
|
|
21173
21173
|
}
|
|
21174
21174
|
|
|
21175
|
+
// src/litellm/timeout-budget.ts
|
|
21176
|
+
var LITELLM_ROUTER_MARGIN_S = 10;
|
|
21177
|
+
var LITELLM_TIMEOUT_TIERS = {
|
|
21178
|
+
interactive: {
|
|
21179
|
+
group: "gpt-oss-20b",
|
|
21180
|
+
localTimeoutS: 90,
|
|
21181
|
+
fallbackGroup: "gpt-oss-20b-openrouter",
|
|
21182
|
+
fallbackTimeoutS: 60
|
|
21183
|
+
},
|
|
21184
|
+
retain: {
|
|
21185
|
+
group: "gpt-oss-20b-retain",
|
|
21186
|
+
localTimeoutS: 200,
|
|
21187
|
+
fallbackGroup: "gpt-oss-20b-retain-openrouter",
|
|
21188
|
+
fallbackTimeoutS: 90
|
|
21189
|
+
},
|
|
21190
|
+
consolidation: {
|
|
21191
|
+
group: "gpt-oss-20b-consolidation",
|
|
21192
|
+
localTimeoutS: 200,
|
|
21193
|
+
fallbackGroup: "gpt-oss-20b-consolidation-openrouter",
|
|
21194
|
+
fallbackTimeoutS: 90
|
|
21195
|
+
}
|
|
21196
|
+
};
|
|
21197
|
+
function litellmChainSeconds(tier) {
|
|
21198
|
+
assertPositive(tier.localTimeoutS, `${tier.group} localTimeoutS`);
|
|
21199
|
+
if (tier.fallbackGroup === null)
|
|
21200
|
+
return tier.localTimeoutS;
|
|
21201
|
+
assertPositive(tier.fallbackTimeoutS, `${tier.fallbackGroup} fallbackTimeoutS`);
|
|
21202
|
+
return tier.localTimeoutS + tier.fallbackTimeoutS;
|
|
21203
|
+
}
|
|
21204
|
+
function minimumClientBudgetSeconds(tier, marginS = LITELLM_ROUTER_MARGIN_S) {
|
|
21205
|
+
return litellmChainSeconds(tier) + marginS;
|
|
21206
|
+
}
|
|
21207
|
+
function clientBudgetSeconds(tier, floorS = 0, marginS = LITELLM_ROUTER_MARGIN_S) {
|
|
21208
|
+
return Math.max(Math.ceil(floorS), minimumClientBudgetSeconds(tier, marginS));
|
|
21209
|
+
}
|
|
21210
|
+
function assertPositive(value, label) {
|
|
21211
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
21212
|
+
throw new Error(`litellm timeout budget: ${label} must be a positive number, got ${value}`);
|
|
21213
|
+
}
|
|
21214
|
+
}
|
|
21215
|
+
|
|
21175
21216
|
// src/setup/hindsight.ts
|
|
21176
21217
|
var HINDSIGHT_DEFAULT_API_PORT = 18888;
|
|
21177
21218
|
var HINDSIGHT_DEFAULT_MCP_URL = `http://127.0.0.1:${HINDSIGHT_DEFAULT_API_PORT}/mcp/`;
|
|
@@ -21181,6 +21222,18 @@ var HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight";
|
|
|
21181
21222
|
var HINDSIGHT_IMAGE = `${HINDSIGHT_IMAGE_REPO}:latest`;
|
|
21182
21223
|
var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`;
|
|
21183
21224
|
var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
|
|
21225
|
+
var HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 16384;
|
|
21226
|
+
var HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND = 80.6;
|
|
21227
|
+
function hindsightRetainLlmTimeoutSeconds(maxCompletionTokens = HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS, tokensPerSecond = HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND) {
|
|
21228
|
+
if (!(maxCompletionTokens > 0) || !(tokensPerSecond > 0)) {
|
|
21229
|
+
throw new Error(`hindsight retain budget: maxCompletionTokens (${maxCompletionTokens}) and ` + `tokensPerSecond (${tokensPerSecond}) must both be positive`);
|
|
21230
|
+
}
|
|
21231
|
+
return Math.ceil(maxCompletionTokens / tokensPerSecond);
|
|
21232
|
+
}
|
|
21233
|
+
function hindsightRetainClientTimeoutSeconds() {
|
|
21234
|
+
return clientBudgetSeconds(LITELLM_TIMEOUT_TIERS.retain, hindsightRetainLlmTimeoutSeconds());
|
|
21235
|
+
}
|
|
21236
|
+
var HINDSIGHT_RETAIN_CLIENT_DEADLINE_S = hindsightRetainClientTimeoutSeconds() + LITELLM_ROUTER_MARGIN_S;
|
|
21184
21237
|
var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
|
|
21185
21238
|
var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
|
|
21186
21239
|
var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
|
|
@@ -19147,6 +19147,47 @@ for (const name of SHARED_FRAGMENTS) {
|
|
|
19147
19147
|
}
|
|
19148
19148
|
}
|
|
19149
19149
|
|
|
19150
|
+
// src/litellm/timeout-budget.ts
|
|
19151
|
+
var LITELLM_ROUTER_MARGIN_S = 10;
|
|
19152
|
+
var LITELLM_TIMEOUT_TIERS = {
|
|
19153
|
+
interactive: {
|
|
19154
|
+
group: "gpt-oss-20b",
|
|
19155
|
+
localTimeoutS: 90,
|
|
19156
|
+
fallbackGroup: "gpt-oss-20b-openrouter",
|
|
19157
|
+
fallbackTimeoutS: 60
|
|
19158
|
+
},
|
|
19159
|
+
retain: {
|
|
19160
|
+
group: "gpt-oss-20b-retain",
|
|
19161
|
+
localTimeoutS: 200,
|
|
19162
|
+
fallbackGroup: "gpt-oss-20b-retain-openrouter",
|
|
19163
|
+
fallbackTimeoutS: 90
|
|
19164
|
+
},
|
|
19165
|
+
consolidation: {
|
|
19166
|
+
group: "gpt-oss-20b-consolidation",
|
|
19167
|
+
localTimeoutS: 200,
|
|
19168
|
+
fallbackGroup: "gpt-oss-20b-consolidation-openrouter",
|
|
19169
|
+
fallbackTimeoutS: 90
|
|
19170
|
+
}
|
|
19171
|
+
};
|
|
19172
|
+
function litellmChainSeconds(tier) {
|
|
19173
|
+
assertPositive(tier.localTimeoutS, `${tier.group} localTimeoutS`);
|
|
19174
|
+
if (tier.fallbackGroup === null)
|
|
19175
|
+
return tier.localTimeoutS;
|
|
19176
|
+
assertPositive(tier.fallbackTimeoutS, `${tier.fallbackGroup} fallbackTimeoutS`);
|
|
19177
|
+
return tier.localTimeoutS + tier.fallbackTimeoutS;
|
|
19178
|
+
}
|
|
19179
|
+
function minimumClientBudgetSeconds(tier, marginS = LITELLM_ROUTER_MARGIN_S) {
|
|
19180
|
+
return litellmChainSeconds(tier) + marginS;
|
|
19181
|
+
}
|
|
19182
|
+
function clientBudgetSeconds(tier, floorS = 0, marginS = LITELLM_ROUTER_MARGIN_S) {
|
|
19183
|
+
return Math.max(Math.ceil(floorS), minimumClientBudgetSeconds(tier, marginS));
|
|
19184
|
+
}
|
|
19185
|
+
function assertPositive(value, label) {
|
|
19186
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
19187
|
+
throw new Error(`litellm timeout budget: ${label} must be a positive number, got ${value}`);
|
|
19188
|
+
}
|
|
19189
|
+
}
|
|
19190
|
+
|
|
19150
19191
|
// src/setup/hindsight.ts
|
|
19151
19192
|
var HINDSIGHT_DEFAULT_API_PORT = 18888;
|
|
19152
19193
|
var HINDSIGHT_DEFAULT_MCP_URL = `http://127.0.0.1:${HINDSIGHT_DEFAULT_API_PORT}/mcp/`;
|
|
@@ -19156,6 +19197,18 @@ var HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight";
|
|
|
19156
19197
|
var HINDSIGHT_IMAGE = `${HINDSIGHT_IMAGE_REPO}:latest`;
|
|
19157
19198
|
var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`;
|
|
19158
19199
|
var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
|
|
19200
|
+
var HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 16384;
|
|
19201
|
+
var HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND = 80.6;
|
|
19202
|
+
function hindsightRetainLlmTimeoutSeconds(maxCompletionTokens = HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS, tokensPerSecond = HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND) {
|
|
19203
|
+
if (!(maxCompletionTokens > 0) || !(tokensPerSecond > 0)) {
|
|
19204
|
+
throw new Error(`hindsight retain budget: maxCompletionTokens (${maxCompletionTokens}) and ` + `tokensPerSecond (${tokensPerSecond}) must both be positive`);
|
|
19205
|
+
}
|
|
19206
|
+
return Math.ceil(maxCompletionTokens / tokensPerSecond);
|
|
19207
|
+
}
|
|
19208
|
+
function hindsightRetainClientTimeoutSeconds() {
|
|
19209
|
+
return clientBudgetSeconds(LITELLM_TIMEOUT_TIERS.retain, hindsightRetainLlmTimeoutSeconds());
|
|
19210
|
+
}
|
|
19211
|
+
var HINDSIGHT_RETAIN_CLIENT_DEADLINE_S = hindsightRetainClientTimeoutSeconds() + LITELLM_ROUTER_MARGIN_S;
|
|
19159
19212
|
var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
|
|
19160
19213
|
var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
|
|
19161
19214
|
var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
|
|
@@ -18820,6 +18820,47 @@ for (const name of SHARED_FRAGMENTS) {
|
|
|
18820
18820
|
}
|
|
18821
18821
|
}
|
|
18822
18822
|
|
|
18823
|
+
// src/litellm/timeout-budget.ts
|
|
18824
|
+
var LITELLM_ROUTER_MARGIN_S = 10;
|
|
18825
|
+
var LITELLM_TIMEOUT_TIERS = {
|
|
18826
|
+
interactive: {
|
|
18827
|
+
group: "gpt-oss-20b",
|
|
18828
|
+
localTimeoutS: 90,
|
|
18829
|
+
fallbackGroup: "gpt-oss-20b-openrouter",
|
|
18830
|
+
fallbackTimeoutS: 60
|
|
18831
|
+
},
|
|
18832
|
+
retain: {
|
|
18833
|
+
group: "gpt-oss-20b-retain",
|
|
18834
|
+
localTimeoutS: 200,
|
|
18835
|
+
fallbackGroup: "gpt-oss-20b-retain-openrouter",
|
|
18836
|
+
fallbackTimeoutS: 90
|
|
18837
|
+
},
|
|
18838
|
+
consolidation: {
|
|
18839
|
+
group: "gpt-oss-20b-consolidation",
|
|
18840
|
+
localTimeoutS: 200,
|
|
18841
|
+
fallbackGroup: "gpt-oss-20b-consolidation-openrouter",
|
|
18842
|
+
fallbackTimeoutS: 90
|
|
18843
|
+
}
|
|
18844
|
+
};
|
|
18845
|
+
function litellmChainSeconds(tier) {
|
|
18846
|
+
assertPositive(tier.localTimeoutS, `${tier.group} localTimeoutS`);
|
|
18847
|
+
if (tier.fallbackGroup === null)
|
|
18848
|
+
return tier.localTimeoutS;
|
|
18849
|
+
assertPositive(tier.fallbackTimeoutS, `${tier.fallbackGroup} fallbackTimeoutS`);
|
|
18850
|
+
return tier.localTimeoutS + tier.fallbackTimeoutS;
|
|
18851
|
+
}
|
|
18852
|
+
function minimumClientBudgetSeconds(tier, marginS = LITELLM_ROUTER_MARGIN_S) {
|
|
18853
|
+
return litellmChainSeconds(tier) + marginS;
|
|
18854
|
+
}
|
|
18855
|
+
function clientBudgetSeconds(tier, floorS = 0, marginS = LITELLM_ROUTER_MARGIN_S) {
|
|
18856
|
+
return Math.max(Math.ceil(floorS), minimumClientBudgetSeconds(tier, marginS));
|
|
18857
|
+
}
|
|
18858
|
+
function assertPositive(value, label) {
|
|
18859
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
18860
|
+
throw new Error(`litellm timeout budget: ${label} must be a positive number, got ${value}`);
|
|
18861
|
+
}
|
|
18862
|
+
}
|
|
18863
|
+
|
|
18823
18864
|
// src/setup/hindsight.ts
|
|
18824
18865
|
var HINDSIGHT_DEFAULT_API_PORT = 18888;
|
|
18825
18866
|
var HINDSIGHT_DEFAULT_MCP_URL = `http://127.0.0.1:${HINDSIGHT_DEFAULT_API_PORT}/mcp/`;
|
|
@@ -18829,6 +18870,18 @@ var HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight";
|
|
|
18829
18870
|
var HINDSIGHT_IMAGE = `${HINDSIGHT_IMAGE_REPO}:latest`;
|
|
18830
18871
|
var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`;
|
|
18831
18872
|
var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
|
|
18873
|
+
var HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 16384;
|
|
18874
|
+
var HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND = 80.6;
|
|
18875
|
+
function hindsightRetainLlmTimeoutSeconds(maxCompletionTokens = HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS, tokensPerSecond = HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND) {
|
|
18876
|
+
if (!(maxCompletionTokens > 0) || !(tokensPerSecond > 0)) {
|
|
18877
|
+
throw new Error(`hindsight retain budget: maxCompletionTokens (${maxCompletionTokens}) and ` + `tokensPerSecond (${tokensPerSecond}) must both be positive`);
|
|
18878
|
+
}
|
|
18879
|
+
return Math.ceil(maxCompletionTokens / tokensPerSecond);
|
|
18880
|
+
}
|
|
18881
|
+
function hindsightRetainClientTimeoutSeconds() {
|
|
18882
|
+
return clientBudgetSeconds(LITELLM_TIMEOUT_TIERS.retain, hindsightRetainLlmTimeoutSeconds());
|
|
18883
|
+
}
|
|
18884
|
+
var HINDSIGHT_RETAIN_CLIENT_DEADLINE_S = hindsightRetainClientTimeoutSeconds() + LITELLM_ROUTER_MARGIN_S;
|
|
18832
18885
|
var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
|
|
18833
18886
|
var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
|
|
18834
18887
|
var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
|
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.
|
|
4
|
+
"version": "0.19.22",
|
|
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/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-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-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",
|
|
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-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",
|
|
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:bun-module-mock-scope": "node scripts/check-bun-module-mock-scope.mjs",
|
|
36
37
|
"lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
|
|
37
38
|
"lint:auth-test-hermeticity": "node scripts/check-auth-test-hermeticity.mjs",
|
|
38
39
|
"lint:web-subscription-honest": "node scripts/check-web-subscription-honest.mjs",
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
"lint:gateway-line-ratchet": "node scripts/check-gateway-line-ratchet.mjs",
|
|
41
42
|
"lint:mcp-instructions-budget": "node scripts/check-mcp-instructions-budget.mjs",
|
|
42
43
|
"lint:litellm-config-guard": "node scripts/check-litellm-config-guard.mjs",
|
|
44
|
+
"lint:release-asset-contract": "node scripts/check-release-asset-names.mjs",
|
|
43
45
|
"prepublishOnly": "npm run build && npm run lint && npm test"
|
|
44
46
|
},
|
|
45
47
|
"dependencies": {
|
|
@@ -12,7 +12,9 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
|
|
|
12
12
|
|
|
13
13
|
- A release = a `vX.Y.Z` **git tag** on `main` (the merge commit of the CHANGELOG PR).
|
|
14
14
|
- The tag is the version source of truth (`scripts/build.mjs:resolveVersion()`). `package.json` `version` is a **stale placeholder by design** — never bump it in a commit (the `//version` comment + #2733 discipline). The uncommitted pack-time bump happens in CI now, not by hand.
|
|
15
|
-
- Cutting the tag fires
|
|
15
|
+
- Cutting the tag fires **two** workflows: `docker-images` (builds + pushes the 6 ghcr images) and `release` (the **orchestrator** — builds the four static binaries, attaches them, waits for `docker-images`, then calls `npm-publish`, then takes the GitHub Release out of draft).
|
|
16
|
+
- **`npm-publish.yml` no longer has a tag trigger (#3654).** It is reachable only via `workflow_call` from `release.yml` and via `workflow_dispatch`. npm is the one leg that cannot be undone, so it runs last and only once everything else is green. It also re-proves both preconditions from inside its own run, so a hand dispatch cannot bypass the ordering either.
|
|
17
|
+
- **The GitHub Release is created as a DRAFT and stays one until every leg is green.** `install.sh` resolves the version to install from `/releases/latest`, and that endpoint excludes drafts — so a half-finished release is invisible to `curl | sh` users and the previous complete release keeps serving them. This is not theoretical: v0.19.19 shipped published-with-zero-assets and broke the installer on every platform.
|
|
16
18
|
|
|
17
19
|
## Before you start — pre-flight (verify, don't assume)
|
|
18
20
|
|
|
@@ -28,30 +30,97 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
|
|
|
28
30
|
- The release commit touches **CHANGELOG.md only**. Do NOT bump `package.json` (placeholder discipline).
|
|
29
31
|
- Branch protection blocks direct push to `main`, so: create a `release/vX.Y.Z` branch, push it, open a `chore: release vX.Y.Z` PR (base `main`), arm auto-merge (squash, delete-branch) on green CI.
|
|
30
32
|
|
|
31
|
-
## Step 2 —
|
|
33
|
+
## Step 2 — Create the DRAFT release on a PINNED SHA, then push the tag
|
|
32
34
|
|
|
33
|
-
Once the changelog PR is merged
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
Once the changelog PR is merged. **Order matters, and so does the pin.**
|
|
36
|
+
|
|
37
|
+
### 2a — Resolve and PIN the commit
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git fetch origin
|
|
41
|
+
SHA="$(git rev-parse origin/main)"
|
|
42
|
+
git --no-pager log -1 --oneline "$SHA" # show the operator exactly what is being released
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Never pass `--target main`.** `main` is resolved server-side at the moment the API call lands, and agents merge PRs in parallel here — a PR that merged in the seconds between your pre-flight check and your `gh release create` would be silently swallowed into the release. Resolve `$SHA` once, confirm it is the commit you inspected in pre-flight, and use that literal SHA everywhere below.
|
|
46
|
+
|
|
47
|
+
### 2b — Create the release as a DRAFT
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
gh release create "vX.Y.Z" -R switchroom/switchroom \
|
|
51
|
+
--draft \
|
|
52
|
+
--target "$SHA" \
|
|
53
|
+
--title 'vX.Y.Z — <summary>' \
|
|
54
|
+
--notes-file <notes-file>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **`--draft` is mandatory.** A published release with no assets immediately becomes `/releases/latest` and 404s every `curl | sh` install for the entire ~25-minute build window. `release.yml` will forcibly re-draft an incomplete published release within about a minute, but do not rely on the safety net — it exists for the case where this step was done wrong.
|
|
58
|
+
- Creating the release object *before* the tag is what removes the race: `release.yml`'s `guard` job needs it to exist within ~150s of the tag push.
|
|
37
59
|
- **Notes extraction gotcha (historical):** the naive `awk '/^## vX/,/^## v/' CHANGELOG` range collapses to a single line. Use a start-flag awk: `awk 'f{print} /^## vX\.Y\.Z/{print; f=1} f && /^## v/ && !/^## vX\.Y\.Z/{exit}'` — or extract the section to a temp file by line range.
|
|
38
|
-
- **`gh release create` has been silently dropped in past runs.**
|
|
60
|
+
- **`gh release create` has been silently dropped in past runs.** Verify it exists and is a draft:
|
|
61
|
+
```bash
|
|
62
|
+
gh release view vX.Y.Z -R switchroom/switchroom --json tagName,isDraft
|
|
63
|
+
```
|
|
64
|
+
`gh` resolves drafts by tag name (it falls back to scanning the release list). Note the raw REST `GET /releases/tags/{tag}` does **not** — it 404s on a draft. That difference is why the workflow scripts use the list endpoint; don't "fix" them to use the by-tag endpoint.
|
|
39
65
|
|
|
40
|
-
|
|
66
|
+
### 2c — Push the tag at that same pinned SHA
|
|
41
67
|
|
|
42
|
-
|
|
68
|
+
GitHub does not create the tag ref for a *draft* release (it creates it on publish), so the tag push below is what actually starts the pipeline. Confirm that before pushing, because if the ref already existed at `$SHA` the push would be a silent no-op and **no workflow would fire**:
|
|
43
69
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
```bash
|
|
71
|
+
git ls-remote --tags origin "refs/tags/vX.Y.Z" # expect NO output
|
|
72
|
+
git push origin "$SHA:refs/tags/vX.Y.Z"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Pushing the SHA-to-ref form rather than `git tag && git push --tags` guarantees the tag lands on the commit you pinned in 2a, not on whatever your local `main` happens to be. (If the ref *does* already exist at a different commit, the push is rejected — that is the safe direction. Do not force it; work out why first.)
|
|
76
|
+
|
|
77
|
+
Then confirm both workflows actually started before you walk away:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
gh run list -R switchroom/switchroom --branch vX.Y.Z --limit 5
|
|
81
|
+
```
|
|
48
82
|
|
|
49
|
-
|
|
83
|
+
You should see a `docker-images` run and a `release` run. If you see neither, the tag ref already existed and nothing fired.
|
|
84
|
+
|
|
85
|
+
## Step 3 — Wait for the pipeline (two workflows, one of them orchestrated)
|
|
86
|
+
|
|
87
|
+
The tag push triggers `docker-images` and `release`. `release` internally waits for `docker-images`, then publishes to npm, then un-drafts the GitHub Release. **Do not proceed to rollout until both are green AND verified.**
|
|
88
|
+
|
|
89
|
+
### Gate A — docker images (`docker-images.yml`)
|
|
50
90
|
- `gh run list --workflow=docker-images.yml --limit 1` — wait for `completed` / `success`.
|
|
51
|
-
- Verify all 6 images are published
|
|
52
|
-
|
|
91
|
+
- Verify all 6 images are published. The repository name is `switchroom-<name>`, **not** `<name>` — `.github/workflows/docker-images.yml` builds `${REGISTRY}/${IMAGE_NAMESPACE}/switchroom-${{ matrix.image.name }}`, so `ghcr.io/switchroom/agent` does not exist and returns `manifest unknown`. Check each of:
|
|
92
|
+
```bash
|
|
93
|
+
for n in agent auth-broker kernel broker web hostd; do
|
|
94
|
+
docker manifest inspect "ghcr.io/switchroom/switchroom-$n:vX.Y.Z" >/dev/null \
|
|
95
|
+
&& echo "OK switchroom-$n" || echo "MISS switchroom-$n"
|
|
96
|
+
done
|
|
97
|
+
```
|
|
98
|
+
Each must resolve.
|
|
99
|
+
- If any image is missing: do NOT roll — the rollout canary version-assert fails on an unpublished tag. Wait + re-check. `release` will block on this by itself, so a red image build means npm never publishes and the release never leaves draft. That is the design.
|
|
100
|
+
|
|
101
|
+
### Gate B — the release pipeline (`release.yml`)
|
|
102
|
+
- `gh run list --workflow=release.yml --limit 1` — wait for `completed` / `success`. Expect ~25-30 minutes: four native build legs plus the wait on `docker-images`.
|
|
103
|
+
- Its jobs, in order: `guard` (release exists + held out of `latest`) → `build` ×4 → `bundle` → `publish` (attach) → `images-gate` (wait on docker-images) → `npm` → `finalize` (un-draft). A red job anywhere leaves the release a **draft** and npm **unpublished** — which is the correct, recoverable state.
|
|
104
|
+
- Verify the release page actually has assets **and is no longer a draft**:
|
|
105
|
+
```bash
|
|
106
|
+
gh release view vX.Y.Z -R switchroom/switchroom --json isDraft,assets \
|
|
107
|
+
--jq '{isDraft, assets: [.assets[].name]}'
|
|
108
|
+
```
|
|
109
|
+
`isDraft` must be `false`, and `assets` must list all four binaries (`switchroom-{linux,macos}-{amd64,arm64}`) **and** `switchroom-checksums.txt`. This is the gate that did not exist through v0.19.19 — every release up to then shipped **zero** assets and the advertised `curl | sh` installer (`install.sh`) was dead on every platform (#3633).
|
|
110
|
+
- Confirm the installer's own resolution path agrees:
|
|
111
|
+
```bash
|
|
112
|
+
gh api repos/switchroom/switchroom/releases/latest --jq '{tag_name, assets: [.assets[].name]}'
|
|
113
|
+
```
|
|
114
|
+
This is literally what `install.sh` calls. If it still reports the previous version, `finalize` did not run.
|
|
115
|
+
|
|
116
|
+
### Gate C — npm publish
|
|
117
|
+
- npm is published by the `npm` job **inside** the `release` run, not by a separate workflow run. `gh run list --workflow=npm-publish.yml` will show nothing new for a normal release — that is expected, not a failure.
|
|
118
|
+
- Verify the publish is live: `npm view switchroom version` must return `X.Y.Z` (not the old version). Retry a few times — npm registry propagation can lag a few seconds.
|
|
119
|
+
|
|
120
|
+
**Only when Gates A, B AND C are green + verified** do you proceed.
|
|
53
121
|
|
|
54
|
-
|
|
122
|
+
### Rehearsing a workflow change without releasing anything
|
|
123
|
+
`gh workflow run release.yml --ref <branch>` — `dry_run` defaults to `true`, so it builds, checksums and verifies the full bundle and attaches it as a workflow artifact, touching no GitHub Release, no npm, and no image tags. This is the only supported way to prove a change to the release pipeline before a real tag.
|
|
55
124
|
|
|
56
125
|
## Step 4 — Fleet rollout (operator-gated, canary-first)
|
|
57
126
|
|
|
@@ -63,14 +132,28 @@ The tag push triggers `docker-images` AND `npm-publish` in parallel. **Do not pr
|
|
|
63
132
|
|
|
64
133
|
- **Never bump `package.json` `version` in a commit.** It's a stale placeholder; the tag is the source of truth and `npm-publish.yml` does the uncommitted pack-time bump.
|
|
65
134
|
- **Never run `npm publish` by hand from the agent container.** You can't reach the operator's npm auth, and the workflow is the reliable path. If the workflow is broken, fix the workflow — don't side-step it.
|
|
66
|
-
- **Never
|
|
135
|
+
- **Never create the GitHub Release without `--draft`,** and **never `gh release create --target main`.** Pin the SHA (step 2a).
|
|
136
|
+
- **Never take the release out of draft by hand** while the pipeline is still running. `finalize` is the only thing that should publish it; un-drafting early puts an incomplete release on `/releases/latest` and breaks every installer.
|
|
137
|
+
- **Never roll the fleet before Gates A, B AND C are green + verified.** A release that's on the fleet but not on npm is the exact regression this skill exists to prevent.
|
|
67
138
|
- **Never push directly to `main`.** The CHANGELOG PR goes through auto-merge on green.
|
|
68
139
|
- **Never force-push `main` or bypass hooks (`--no-verify`).**
|
|
69
140
|
|
|
70
141
|
## If something goes wrong
|
|
71
142
|
|
|
72
|
-
|
|
73
|
-
|
|
143
|
+
**The single recovery command for almost everything is a re-dispatch of the orchestrator:**
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
gh workflow run release.yml -R switchroom/switchroom --ref vX.Y.Z -f dry_run=false
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
It re-runs every leg — including `finalize`, which is what actually takes the release out of draft. Re-running one leg on its own generally leaves the release stuck as a draft.
|
|
150
|
+
|
|
151
|
+
**But a re-dispatch cannot fix a defect that lives in the tag's own tree.** `workflow_dispatch` executes the workflow YAML **at the dispatched ref**, so `--ref vX.Y.Z` re-runs the same broken file and fails identically, every time. This is not theoretical — it is how v0.19.20 died (#3691). Before reaching for the re-dispatch, root-cause the failure in the workflow source **at that tag** (`git show vX.Y.Z:.github/workflows/release.yml`) and compare it to `main`. If the bug is in the tag's tree, the only path is a fix merged to `main` plus a **fresh tag**; abandon the old tag as a permanent draft (do not delete it, do not hand-publish it) and burn a patch version. Re-dispatch is for *transient* failures — a flaky runner, an npm 5xx, a `docker-images` run that has since gone green.
|
|
152
|
+
|
|
153
|
+
- **`release` failed at `guard` ("no GitHub Release exists"):** step 2b was skipped or the tag name is misspelled. Create the draft release, then re-dispatch as above.
|
|
154
|
+
- **`release` failed at `images-gate`:** `docker-images` was not green for this tag+commit. Fix it, `gh workflow run docker-images.yml --ref vX.Y.Z`, wait for green, then re-dispatch `release.yml`. Nothing was published to npm and the release is still a draft — nothing to undo.
|
|
155
|
+
- **`release` failed at `npm` (e.g. a transient npm 5xx):** re-dispatch `release.yml` as above. `npm-publish` treats "already published" as success, so a re-run is safe and idempotent. Dispatching `npm-publish.yml --ref vX.Y.Z` directly also works and its own gates still apply, but it will NOT un-draft the release, so you would then have to re-dispatch `release.yml` anyway.
|
|
156
|
+
- **Everything is green but the release is still a draft:** `finalize` did not run. Check `gh run view <run-id>` for a skipped job, then re-dispatch. Do not hand-publish — `finalize` re-verifies the asset set immediately before flipping the flag.
|
|
74
157
|
- **Rollout started before publish verified (the old bug):** abort the rollout, publish, then re-roll. Do not let a half-published release sit on the fleet.
|
|
75
158
|
|
|
76
159
|
## Operator one-time setup (tell them once, not every release)
|
|
@@ -38,6 +38,69 @@ import { normalizeDashes } from './text-voice-scrub.js'
|
|
|
38
38
|
*/
|
|
39
39
|
export { escapeMarkdown, codeSpanSafe }
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Separator appended to every card line that is followed by a hard break when
|
|
43
|
+
* `stackCardLines` runs in `collapseSafe` mode (#3666).
|
|
44
|
+
*
|
|
45
|
+
* It is a NO-BREAK SPACE (U+00A0), chosen deliberately:
|
|
46
|
+
*
|
|
47
|
+
* - It is a real character in the message text, so it survives Telegram's
|
|
48
|
+
* pinned-bar collapse (which drops the `\n` and substitutes nothing) and
|
|
49
|
+
* keeps the two lines' glyphs apart in the one-line preview.
|
|
50
|
+
* - It is NOT ASCII whitespace, so neither this module's own
|
|
51
|
+
* trailing-whitespace strip nor the GFM parser's hard-break handling
|
|
52
|
+
* (which consumes only the ASCII spaces immediately before the newline)
|
|
53
|
+
* can eat it.
|
|
54
|
+
* - It is invisible where the card is normally read — the chat feed — since
|
|
55
|
+
* it lands at end-of-line. That is the whole reason a separator with
|
|
56
|
+
* visible ink (`' ·'`, `' —'`) was rejected: it would put dangling
|
|
57
|
+
* punctuation on every line of every pinned card to fix a defect that only
|
|
58
|
+
* manifests on the pin bar.
|
|
59
|
+
*
|
|
60
|
+
* If a real-pin eyeball shows a single space is too weak a break, this constant
|
|
61
|
+
* is the one place to strengthen it (e.g. `' ·'`) — the seam is deliberate.
|
|
62
|
+
*/
|
|
63
|
+
export const COLLAPSE_SAFE_SEPARATOR = '\u00A0'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Normalise a line's tail before `stackCardLines` re-terminates it.
|
|
67
|
+
*
|
|
68
|
+
* Two strips, both there for the SAME reason: this function must be idempotent,
|
|
69
|
+
* so re-stacking an already-stacked body can never accumulate terminator
|
|
70
|
+
* characters.
|
|
71
|
+
*
|
|
72
|
+
* - trailing ASCII whitespace, so exactly one ` \n` hard break is emitted;
|
|
73
|
+
* - in `collapseSafe` mode, any `COLLAPSE_SAFE_SEPARATOR` run the line
|
|
74
|
+
* already carries (interleaved with ASCII whitespace, since a caller may
|
|
75
|
+
* have split the body on `\n` rather than on ` \n`), so exactly one
|
|
76
|
+
* separator is emitted.
|
|
77
|
+
*
|
|
78
|
+
* The separator strip is written against the CONSTANT, not against a hardcoded
|
|
79
|
+
* U+00A0, so it stays correct if the separator is ever strengthened to visible
|
|
80
|
+
* ink. That one-constant seam is what COLLAPSE_SAFE_SEPARATOR's own doc comment
|
|
81
|
+
* promises, and it would be a lie if this strip did not follow it.
|
|
82
|
+
*/
|
|
83
|
+
function normalizeLineTail(line: string, collapseSafe: boolean): string {
|
|
84
|
+
let out = line.replace(/[ \t\r]+$/, '')
|
|
85
|
+
// The length guard is not defensive noise: `''.endsWith('')` is true and
|
|
86
|
+
// `''.slice(0, -0)` is `''`, so an empty separator would spin this loop
|
|
87
|
+
// forever — a hang in the gateway's render path. The doc comment above the
|
|
88
|
+
// constant invites future edits to it, so make that edit unable to hang.
|
|
89
|
+
if (!collapseSafe || COLLAPSE_SAFE_SEPARATOR.length === 0) return out
|
|
90
|
+
while (out.endsWith(COLLAPSE_SAFE_SEPARATOR)) {
|
|
91
|
+
out = out.slice(0, -COLLAPSE_SAFE_SEPARATOR.length).replace(/[ \t\r]+$/, '')
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Options for `stackCardLines`. */
|
|
97
|
+
export interface StackCardLinesOpts {
|
|
98
|
+
/** Append `COLLAPSE_SAFE_SEPARATOR` to each hard-broken line so the stack
|
|
99
|
+
* stays readable on a surface that collapses it to one line (#3666).
|
|
100
|
+
* Opt-in: only pinned cards pay the (tiny) extra character per line. */
|
|
101
|
+
collapseSafe?: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
41
104
|
/**
|
|
42
105
|
* Join a card's pre-rendered, single-line entries so they STACK in the
|
|
43
106
|
* Bot API 10.1 rich-message renderer (#2669) — the same visual result the
|
|
@@ -63,8 +126,19 @@ export { escapeMarkdown, codeSpanSafe }
|
|
|
63
126
|
* This is the card-surface analogue of the reply path's
|
|
64
127
|
* `normalizeParagraphBreaks`: it guarantees a card authored as stacked
|
|
65
128
|
* bullet/step lines renders identically to a normal reply.
|
|
129
|
+
*
|
|
130
|
+
* `opts.collapseSafe` (#3666) additionally makes the stack survive being
|
|
131
|
+
* rendered on a surface that COLLAPSES the message to one line — Telegram's
|
|
132
|
+
* pinned-message bar drops the newlines and substitutes nothing, mashing the
|
|
133
|
+
* last glyph of each line into the first glyph of the next
|
|
134
|
+
* (`… · opus 5✓ Reading gateway.ts→ Running search …`). Opt-in, because it is
|
|
135
|
+
* only correct for cards that are actually pinned; see COLLAPSE_SAFE_SEPARATOR.
|
|
66
136
|
*/
|
|
67
|
-
export function stackCardLines(
|
|
137
|
+
export function stackCardLines(
|
|
138
|
+
lines: string[],
|
|
139
|
+
opts?: StackCardLinesOpts,
|
|
140
|
+
): string {
|
|
141
|
+
const collapseSafe = opts?.collapseSafe === true
|
|
68
142
|
const pieces: string[] = []
|
|
69
143
|
for (let i = 0; i < lines.length; i++) {
|
|
70
144
|
const line = lines[i]
|
|
@@ -75,12 +149,27 @@ export function stackCardLines(lines: string[]): string {
|
|
|
75
149
|
// A blank current or next line is a genuine `\n\n` paragraph gap — leave
|
|
76
150
|
// the separator a plain newline so the blank entry reconstructs the gap.
|
|
77
151
|
if (cur === '' || next === '') {
|
|
152
|
+
// The gap still needs a collapse separator when THIS line has content:
|
|
153
|
+
// a collapsing surface drops the `\n\n` with no substitute exactly as it
|
|
154
|
+
// drops a ` \n`, and the blank entry is the empty string, so it
|
|
155
|
+
// contributes NO character of its own to hold the two sides apart. Only
|
|
156
|
+
// when `cur` is blank is the separator skipped — appending it to a blank
|
|
157
|
+
// entry would turn the gap into a visible U+00A0 paragraph in the FEED.
|
|
158
|
+
if (collapseSafe && cur !== '') {
|
|
159
|
+
pieces[pieces.length - 1] = normalizeLineTail(line, true) + COLLAPSE_SAFE_SEPARATOR
|
|
160
|
+
}
|
|
78
161
|
pieces.push('\n')
|
|
79
162
|
continue
|
|
80
163
|
}
|
|
81
164
|
// Strip any trailing whitespace the line already carried so we emit
|
|
82
|
-
// exactly one ` \n` hard break (never accumulate spaces on a re-run)
|
|
83
|
-
|
|
165
|
+
// exactly one ` \n` hard break (never accumulate spaces on a re-run) and,
|
|
166
|
+
// under collapseSafe, exactly one separator — see normalizeLineTail.
|
|
167
|
+
// The collapse separator is appended AFTER the strip (it is not ASCII
|
|
168
|
+
// whitespace, so it survives both this strip and the markdown parser's
|
|
169
|
+
// own trailing-whitespace handling) and BEFORE the two hard-break spaces,
|
|
170
|
+
// which stay immediately adjacent to the `\n` so the break still parses.
|
|
171
|
+
pieces[pieces.length - 1] =
|
|
172
|
+
normalizeLineTail(line, collapseSafe) + (collapseSafe ? COLLAPSE_SAFE_SEPARATOR : '')
|
|
84
173
|
pieces.push(' \n')
|
|
85
174
|
}
|
|
86
175
|
return pieces.join('')
|