switchroom 0.17.6 → 0.18.3
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/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// IR -> Telegram rich-markdown renderer for the Telegram HTML render engine
|
|
2
|
+
// (name kept for continuity with render/ir.ts + render/parse.ts; the actual
|
|
3
|
+
// wire format is GFM markdown, NOT HTML — see the note below).
|
|
4
|
+
//
|
|
5
|
+
// Increment 2 of the render pipeline: takes the typed IR produced by
|
|
6
|
+
// `parse.ts` (per `ir.ts`) and emits a string suitable for the `markdown`
|
|
7
|
+
// field of `InputRichMessageMarkdown` (`telegram-plugin/rich-send.ts`).
|
|
8
|
+
//
|
|
9
|
+
// IMPORTANT — why this emits markdown, not HTML:
|
|
10
|
+
// `ir.ts`'s block-type doc comment (written during Increment 1) sketches an
|
|
11
|
+
// HTML tag mapping "for the next increment." That comment predates the Bot
|
|
12
|
+
// API 10.1 migration (#2669, `telegram-plugin/rich-send.ts`) becoming the
|
|
13
|
+
// live send path: `sendRichMessage` / `editMessageText` are called with
|
|
14
|
+
// `{ markdown: string }` — raw GFM markdown — not with `{ text, parse_mode:
|
|
15
|
+
// "HTML" }`. There is no HTML anywhere on the current outbound path (see
|
|
16
|
+
// `reference/telegram-formatting-guide.md`). This renderer therefore targets
|
|
17
|
+
// the ACTUAL contract: GFM markdown with the Bot API 10.1 extensions
|
|
18
|
+
// documented in the formatting guide (expandable blockquote via `**> `,
|
|
19
|
+
// spoiler via `||…||`, GFM pipe tables, etc). This module is NOT wired into
|
|
20
|
+
// the live send path yet — that is a later increment, per the RFC's phased
|
|
21
|
+
// rollout (rich rendering stays gated off by default until then).
|
|
22
|
+
//
|
|
23
|
+
// Round-trip note: `parse.ts` folds inline text (`PlainNode.text`,
|
|
24
|
+
// `CodeNode.text`, `code-block` `text`, link `href`) into DECODED strings —
|
|
25
|
+
// entity references and escapes are already resolved by mdast. Rendering
|
|
26
|
+
// back to markdown therefore re-escapes GFM-special characters in prose
|
|
27
|
+
// context with `escapeMarkdown` (same helper `format.ts` uses for
|
|
28
|
+
// dynamic-content interpolation) and re-defuses embedded backticks in code
|
|
29
|
+
// spans with `codeSpanSafe`, so the emitted markdown parses back to
|
|
30
|
+
// equivalent entities rather than accidentally re-triggering formatting or
|
|
31
|
+
// breaking out of a code span.
|
|
32
|
+
|
|
33
|
+
import { escapeMarkdown, codeSpanSafe, RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
34
|
+
import type {
|
|
35
|
+
Block,
|
|
36
|
+
BlockquoteNode,
|
|
37
|
+
CodeBlockNode,
|
|
38
|
+
Document,
|
|
39
|
+
Inline,
|
|
40
|
+
ListItem,
|
|
41
|
+
ListNode,
|
|
42
|
+
TableNode,
|
|
43
|
+
TableRow,
|
|
44
|
+
} from "./ir.js";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Inline rendering
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function renderInline(node: Inline): string {
|
|
51
|
+
switch (node.type) {
|
|
52
|
+
case "plain":
|
|
53
|
+
return escapeMarkdown(node.text);
|
|
54
|
+
case "bold":
|
|
55
|
+
return `**${renderInlineChildren(node.children)}**`;
|
|
56
|
+
case "italic":
|
|
57
|
+
return `*${renderInlineChildren(node.children)}*`;
|
|
58
|
+
case "underline":
|
|
59
|
+
return `__${renderInlineChildren(node.children)}__`;
|
|
60
|
+
case "strike":
|
|
61
|
+
return `~~${renderInlineChildren(node.children)}~~`;
|
|
62
|
+
case "spoiler":
|
|
63
|
+
return `||${renderInlineChildren(node.children)}||`;
|
|
64
|
+
case "highlight":
|
|
65
|
+
return `==${renderInlineChildren(node.children)}==`;
|
|
66
|
+
case "code":
|
|
67
|
+
return `\`${codeSpanSafe(node.text)}\``;
|
|
68
|
+
case "link":
|
|
69
|
+
return `[${renderInlineChildren(node.children)}](${node.href})`;
|
|
70
|
+
default: {
|
|
71
|
+
// Exhaustiveness guard — the IR union is closed; a new variant must be
|
|
72
|
+
// handled above rather than silently dropped.
|
|
73
|
+
const _exhaustive: never = node;
|
|
74
|
+
return escapeMarkdown((_exhaustive as Inline & { text?: string }).text ?? "");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderInlineChildren(children: Inline[]): string {
|
|
80
|
+
return children.map(renderInline).join("");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Block rendering
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Prefix every line of `text` with `prefix` (used for blockquote nesting). */
|
|
88
|
+
function prefixLines(text: string, prefix: string): string {
|
|
89
|
+
return text
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((line) => (line.length > 0 ? `${prefix}${line}` : prefix.trimEnd()))
|
|
92
|
+
.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderBlockquote(node: BlockquoteNode): string {
|
|
96
|
+
const inner = renderBlocks(node.children);
|
|
97
|
+
// Bot API 10.1 expandable blockquote: `**> ` on the first quoted line.
|
|
98
|
+
// Plain blockquote: `> ` on every line.
|
|
99
|
+
if (node.expandable) {
|
|
100
|
+
const lines = inner.split("\n");
|
|
101
|
+
return lines
|
|
102
|
+
.map((line, i) => {
|
|
103
|
+
const marker = i === 0 ? "**> " : "> ";
|
|
104
|
+
return line.length > 0 ? `${marker}${line}` : marker.trimEnd();
|
|
105
|
+
})
|
|
106
|
+
.join("\n");
|
|
107
|
+
}
|
|
108
|
+
return prefixLines(inner, "> ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderCodeBlock(node: CodeBlockNode): string {
|
|
112
|
+
const lang = node.language ?? "";
|
|
113
|
+
// Fenced code content is verbatim per the formatting guide; the only
|
|
114
|
+
// hazard is an embedded ``` closing the fence early. Widen the fence to a
|
|
115
|
+
// run of backticks one longer than the longest run already present in the
|
|
116
|
+
// content, mirroring the guide's `preBlock` defusal strategy.
|
|
117
|
+
const longestRun = Math.max(
|
|
118
|
+
0,
|
|
119
|
+
...(node.text.match(/`+/g) ?? []).map((run) => run.length),
|
|
120
|
+
);
|
|
121
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
122
|
+
return `${fence}${lang}\n${node.text}\n${fence}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderListItem(item: ListItem, ordered: boolean, index: number): string {
|
|
126
|
+
const checkbox =
|
|
127
|
+
item.checked === null ? "" : item.checked ? "[x] " : "[ ] ";
|
|
128
|
+
const marker = ordered ? `${index}. ` : "- ";
|
|
129
|
+
// Tight item (spread=false): join the item's block children with a single
|
|
130
|
+
// newline so a paragraph and its nested sub-list stay adjacent — no spurious
|
|
131
|
+
// blank line injected before the sub-list. Loose item (spread=true): keep the
|
|
132
|
+
// blank-line separation the source author intended.
|
|
133
|
+
const body = renderBlocksJoined(item.children, item.spread ? "\n\n" : "\n");
|
|
134
|
+
const lines = body.split("\n");
|
|
135
|
+
const first = `${marker}${checkbox}${lines[0] ?? ""}`;
|
|
136
|
+
const contIndent = " ".repeat(marker.length);
|
|
137
|
+
const rest = lines
|
|
138
|
+
.slice(1)
|
|
139
|
+
.map((line) => (line.length > 0 ? `${contIndent}${line}` : line));
|
|
140
|
+
return [first, ...rest].join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderList(node: ListNode): string {
|
|
144
|
+
const start = node.startNumber ?? 1;
|
|
145
|
+
// Loose list (spread=true): a blank line between top-level items, as the
|
|
146
|
+
// source author wrote them. Tight list (spread=false, the common case and
|
|
147
|
+
// all nested sub-lists): siblings on adjacent lines, single newline.
|
|
148
|
+
const sep = node.spread ? "\n\n" : "\n";
|
|
149
|
+
return node.items
|
|
150
|
+
.map((item, i) => renderListItem(item, node.ordered, start + i))
|
|
151
|
+
.join(sep);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Render a single cell's inline content for a table (no line breaks — GFM
|
|
155
|
+
* table cells can't contain them; pipes are escaped defensively). */
|
|
156
|
+
function renderTableCell(cells: TableRow["cells"][number]): string {
|
|
157
|
+
return renderInlineChildren(cells.children).replace(/\n+/g, " ");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function alignSeparator(align: TableNode["align"][number]): string {
|
|
161
|
+
switch (align) {
|
|
162
|
+
case "left":
|
|
163
|
+
return ":---";
|
|
164
|
+
case "right":
|
|
165
|
+
return "---:";
|
|
166
|
+
case "center":
|
|
167
|
+
return ":---:";
|
|
168
|
+
default:
|
|
169
|
+
return "---";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderTable(node: TableNode): string {
|
|
174
|
+
const headerCells = node.header.cells.map(renderTableCell);
|
|
175
|
+
const colCount = headerCells.length;
|
|
176
|
+
const align = node.align.length > 0 ? node.align : new Array(colCount).fill(null);
|
|
177
|
+
const headerLine = `| ${headerCells.join(" | ")} |`;
|
|
178
|
+
const sepLine = `| ${align
|
|
179
|
+
.slice(0, colCount)
|
|
180
|
+
.map(alignSeparator)
|
|
181
|
+
.join(" | ")} |`;
|
|
182
|
+
const bodyLines = node.rows.map(
|
|
183
|
+
(row) => `| ${row.cells.map(renderTableCell).join(" | ")} |`,
|
|
184
|
+
);
|
|
185
|
+
return [headerLine, sepLine, ...bodyLines].join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderBlock(node: Block): string {
|
|
189
|
+
switch (node.type) {
|
|
190
|
+
case "paragraph":
|
|
191
|
+
return renderInlineChildren(node.children);
|
|
192
|
+
case "heading":
|
|
193
|
+
return `${"#".repeat(node.level)} ${renderInlineChildren(node.children)}`;
|
|
194
|
+
case "blockquote":
|
|
195
|
+
return renderBlockquote(node);
|
|
196
|
+
case "code-block":
|
|
197
|
+
return renderCodeBlock(node);
|
|
198
|
+
case "list":
|
|
199
|
+
return renderList(node);
|
|
200
|
+
case "thematic-break":
|
|
201
|
+
return "---";
|
|
202
|
+
case "table":
|
|
203
|
+
return renderTable(node);
|
|
204
|
+
default: {
|
|
205
|
+
const _exhaustive: never = node;
|
|
206
|
+
return escapeMarkdown((_exhaustive as Block & { text?: string }).text ?? "");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Join a run of blocks with an explicit separator. Blockquote children and
|
|
212
|
+
* the top-level document use a blank line (`\n\n`); tight list items pass a
|
|
213
|
+
* single newline so a nested sub-list stays adjacent to its item's paragraph
|
|
214
|
+
* (see `renderListItem`). */
|
|
215
|
+
function renderBlocksJoined(blocks: Block[], sep: string): string {
|
|
216
|
+
return blocks.map(renderBlock).join(sep);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderBlocks(blocks: Block[]): string {
|
|
220
|
+
return renderBlocksJoined(blocks, "\n\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Render a full IR `Document` to Bot API 10.1 rich GFM markdown. */
|
|
224
|
+
export function render(doc: Document): string {
|
|
225
|
+
return renderBlocks(doc.blocks);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Construct allowlist (gap #2: escape/degrade anything unsupported)
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
// The IR node types this renderer emits NATIVE Telegram markup for. The IR
|
|
233
|
+
// union is closed and TypeScript's exhaustiveness `default` branches in
|
|
234
|
+
// `renderInline` / `renderBlock` already escape any unknown variant to literal
|
|
235
|
+
// text rather than emitting a broken tag — so the allowlist is enforced at
|
|
236
|
+
// COMPILE time. These runtime constants make the allowlist inspectable (and
|
|
237
|
+
// unit-testable: a test asserts every IR `type` appears here, so adding an IR
|
|
238
|
+
// node without teaching the renderer to render it fails the build), which is
|
|
239
|
+
// the concrete "construct-allowlist validator" that pairs with the expandable-
|
|
240
|
+
// blockquote / spoiler / underline additions.
|
|
241
|
+
export const SUPPORTED_INLINE = [
|
|
242
|
+
"plain",
|
|
243
|
+
"bold",
|
|
244
|
+
"italic",
|
|
245
|
+
"underline",
|
|
246
|
+
"strike",
|
|
247
|
+
"spoiler",
|
|
248
|
+
"highlight",
|
|
249
|
+
"code",
|
|
250
|
+
"link",
|
|
251
|
+
] as const;
|
|
252
|
+
|
|
253
|
+
export const SUPPORTED_BLOCK = [
|
|
254
|
+
"paragraph",
|
|
255
|
+
"heading",
|
|
256
|
+
"blockquote",
|
|
257
|
+
"code-block",
|
|
258
|
+
"list",
|
|
259
|
+
"thematic-break",
|
|
260
|
+
"table",
|
|
261
|
+
] as const;
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Oversized / malformed-content safe fallback
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/** Which construct degraded. `document` = the whole message fell back to
|
|
268
|
+
* plain text. */
|
|
269
|
+
export type DegradationConstruct = "table" | "code-block" | "blockquote" | "document";
|
|
270
|
+
|
|
271
|
+
/** Why a construct could not be emitted as a proper rich construct.
|
|
272
|
+
* - `table-malformed` : the table's structure is not renderable as a rich
|
|
273
|
+
* GFM table (e.g. no columns, ragged rows); emitted
|
|
274
|
+
* as a preformatted code fence instead.
|
|
275
|
+
* - `oversize` : a single atomic block alone exceeds the wire cap;
|
|
276
|
+
* emitted as plain source text.
|
|
277
|
+
* - `document-oversize` : the whole document still exceeds the cap after
|
|
278
|
+
* per-block substitution; sent as plain text with no
|
|
279
|
+
* rich wrapper. */
|
|
280
|
+
export type DegradationReason = "table-malformed" | "oversize" | "document-oversize";
|
|
281
|
+
|
|
282
|
+
/** A tracked degradation — WHY a construct fell back from its native rich form
|
|
283
|
+
* to a plain/code rendering. Returned on `RenderResult` so the caller can log
|
|
284
|
+
* / surface it instead of the fallback happening silently. */
|
|
285
|
+
export interface Degradation {
|
|
286
|
+
construct: DegradationConstruct;
|
|
287
|
+
reason: DegradationReason;
|
|
288
|
+
/** Human-readable detail for logs / telemetry. */
|
|
289
|
+
detail: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface RenderResult {
|
|
293
|
+
/** The rendered text to send. */
|
|
294
|
+
text: string;
|
|
295
|
+
/** "markdown": send as rich `{ markdown }`. "plain": send as a literal
|
|
296
|
+
* string with no rich wrapper (structure stripped, content preserved). */
|
|
297
|
+
mode: "markdown" | "plain";
|
|
298
|
+
/** Constructs that could not be emitted in their native rich form, each with
|
|
299
|
+
* a tracked reason. Empty when everything rendered natively. Never silently
|
|
300
|
+
* degrade — a caller can log/telemeter these. */
|
|
301
|
+
degradations: Degradation[];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Classify a table that cannot render as a proper rich GFM table. Returns a
|
|
306
|
+
* human-readable reason string, or null when the table is well-formed.
|
|
307
|
+
*
|
|
308
|
+
* mdast normally normalises tables, but a table folded from degraded/adversarial
|
|
309
|
+
* source (or a future IR producer) can still be structurally unrenderable — no
|
|
310
|
+
* header columns, or body rows whose cell count doesn't match the header. Such a
|
|
311
|
+
* table would emit pipes that Telegram renders as broken/literal text, so we
|
|
312
|
+
* catch it here and fall back to a preformatted code fence (content preserved,
|
|
313
|
+
* degradation tracked) rather than shipping a broken row.
|
|
314
|
+
*/
|
|
315
|
+
function tableDegradationReason(node: TableNode): string | null {
|
|
316
|
+
const cols = node.header.cells.length;
|
|
317
|
+
if (cols === 0) return "table has no header columns";
|
|
318
|
+
for (let i = 0; i < node.rows.length; i++) {
|
|
319
|
+
const n = node.rows[i].cells.length;
|
|
320
|
+
if (n !== cols) {
|
|
321
|
+
return `row ${i + 1} has ${n} cell(s), expected ${cols} to match the header`;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Wrap a block's raw source text in a widened preformatted code fence — the
|
|
328
|
+
* degraded rendering for a malformed table (content verbatim, no broken
|
|
329
|
+
* markup). Fence-widening mirrors `renderCodeBlock`. */
|
|
330
|
+
function degradeToCodeFence(source: string, node: Block): string {
|
|
331
|
+
const raw = source.slice(node.start, node.end);
|
|
332
|
+
const longestRun = Math.max(0, ...(raw.match(/`+/g) ?? []).map((run) => run.length));
|
|
333
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
334
|
+
return `${fence}\n${raw}\n${fence}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A block is "atomic" for chunking purposes when splitting it mid-construct
|
|
339
|
+
* would break the wire format — an unterminated fence, a half table row, or
|
|
340
|
+
* a blockquote whose closing marker got separated from its opener. These are
|
|
341
|
+
* exactly the constructs `splitMarkdownChunks` (format.ts) already knows how
|
|
342
|
+
* to avoid bisecting for a MULTI-block body, but a SINGLE such block that by
|
|
343
|
+
* itself exceeds the cap has nowhere left to cut without breaking mid-tag —
|
|
344
|
+
* that's the case this module must catch before send.
|
|
345
|
+
*/
|
|
346
|
+
function isAtomicBlock(node: Block): boolean {
|
|
347
|
+
return (
|
|
348
|
+
node.type === "code-block" ||
|
|
349
|
+
node.type === "table" ||
|
|
350
|
+
node.type === "blockquote"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Render `node`'s raw source text as a plain literal (escaped so none of
|
|
355
|
+
* the source's own markdown-special characters accidentally re-trigger
|
|
356
|
+
* formatting when sent WITHOUT the rich wrapper — a plain send has no
|
|
357
|
+
* parser, but downstream literal display should still show the author's
|
|
358
|
+
* intended characters, not stray backslashes, so we use the raw slice
|
|
359
|
+
* as-is: a plain send does not interpret markdown at all). */
|
|
360
|
+
function plainSlice(source: string, node: Block): string {
|
|
361
|
+
return source.slice(node.start, node.end);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Render `doc` to Bot API 10.1 rich markdown, honoring the wire cap
|
|
366
|
+
* (`RICH_MESSAGE_MAX_CHARS` by default). Behaviour:
|
|
367
|
+
*
|
|
368
|
+
* 1. If the full rendered markdown fits under `maxLen`, return it as-is
|
|
369
|
+
* (`mode: "markdown"`).
|
|
370
|
+
* 2. Otherwise, any individual ATOMIC block (table / code-block /
|
|
371
|
+
* blockquote — constructs that cannot be safely bisected without
|
|
372
|
+
* producing a broken tag/fence/row) whose OWN rendered form alone
|
|
373
|
+
* exceeds `maxLen` is replaced by its raw plain-text source slice
|
|
374
|
+
* instead of being emitted as rich markdown that a later chunker would
|
|
375
|
+
* have to cut mid-construct.
|
|
376
|
+
* 3. If the document *still* exceeds `maxLen` after that per-block
|
|
377
|
+
* substitution (i.e. the oversized content isn't isolated to one
|
|
378
|
+
* swappable block, or the whole document is one giant atomic
|
|
379
|
+
* construct), the ENTIRE document falls back to plain text — the raw
|
|
380
|
+
* source, verbatim, sent with no rich wrapper at all. This never
|
|
381
|
+
* truncates: the caller's ordinary length-based chunker
|
|
382
|
+
* (`splitMarkdownChunks` and `hardSliceToCap`) is what applies size
|
|
383
|
+
* limits to plain text before send; this function's only job is to
|
|
384
|
+
* guarantee it is never handed a rich-markdown body with a mid-tag cut.
|
|
385
|
+
*/
|
|
386
|
+
export function renderSafe(
|
|
387
|
+
doc: Document,
|
|
388
|
+
source: string,
|
|
389
|
+
maxLen: number = RICH_MESSAGE_MAX_CHARS,
|
|
390
|
+
): RenderResult {
|
|
391
|
+
const degradations: Degradation[] = [];
|
|
392
|
+
|
|
393
|
+
// Per-block render, degrading a MALFORMED table (any size) to a preformatted
|
|
394
|
+
// code fence up front so a structurally-unrenderable table never ships as
|
|
395
|
+
// broken pipes — and the reason is recorded, not swallowed.
|
|
396
|
+
const rendered = doc.blocks.map((block) => {
|
|
397
|
+
if (block.type === "table") {
|
|
398
|
+
const reason = tableDegradationReason(block);
|
|
399
|
+
if (reason != null) {
|
|
400
|
+
degradations.push({ construct: "table", reason: "table-malformed", detail: reason });
|
|
401
|
+
return { block, text: degradeToCodeFence(source, block) };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { block, text: renderBlock(block) };
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const full = rendered.map((r) => r.text).join("\n\n");
|
|
408
|
+
if (full.length <= maxLen) {
|
|
409
|
+
return { text: full, mode: "markdown", degradations };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Oversized: swap any atomic block whose OWN rendered form alone exceeds the
|
|
413
|
+
// cap for its raw plain-text source (a construct that can't be safely
|
|
414
|
+
// bisected), recording an `oversize` degradation for each.
|
|
415
|
+
const swapped = rendered.map(({ block, text }) => {
|
|
416
|
+
if (isAtomicBlock(block) && text.length > maxLen) {
|
|
417
|
+
degradations.push({
|
|
418
|
+
construct: block.type as DegradationConstruct,
|
|
419
|
+
reason: "oversize",
|
|
420
|
+
detail: `${block.type} of ${text.length} chars exceeds the ${maxLen}-char cap; sent as plain source text`,
|
|
421
|
+
});
|
|
422
|
+
return escapeMarkdown(plainSlice(source, block));
|
|
423
|
+
}
|
|
424
|
+
return text;
|
|
425
|
+
});
|
|
426
|
+
const patched = swapped.join("\n\n");
|
|
427
|
+
if (patched.length <= maxLen) {
|
|
428
|
+
return { text: patched, mode: "markdown", degradations };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Still oversized (or a giant atomic block never fit even as plain text) —
|
|
432
|
+
// fall all the way back to plain text for the whole document. Never
|
|
433
|
+
// truncate here; that is the length-based chunker's job downstream.
|
|
434
|
+
degradations.push({
|
|
435
|
+
construct: "document",
|
|
436
|
+
reason: "document-oversize",
|
|
437
|
+
detail: `rendered document of ${patched.length} chars exceeds the ${maxLen}-char cap; sent as plain text without the rich wrapper`,
|
|
438
|
+
});
|
|
439
|
+
return { text: source, mode: "plain", degradations };
|
|
440
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Flag-gated wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts)
|
|
2
|
+
// into the live outbound send path.
|
|
3
|
+
//
|
|
4
|
+
// The renderer (`render/render.ts`, PR #2930) and parser (`render/parse.ts`)
|
|
5
|
+
// have full unit coverage but were, until this module, wired into NOTHING —
|
|
6
|
+
// no outbound message ever flowed through them. This module is the single,
|
|
7
|
+
// feature-flagged bridge: the gateway's rich send path (stream-controller.ts)
|
|
8
|
+
// runs the assistant's raw markdown through `parse → renderSafe` before it
|
|
9
|
+
// reaches `sendRichMessage`, but ONLY when the flag is on.
|
|
10
|
+
//
|
|
11
|
+
// Feature flag — `SWITCHROOM_RICH_RENDER`, default OFF:
|
|
12
|
+
// Mirrors the `SWITCHROOM_VISIBLE_ANSWER_STREAM` convention
|
|
13
|
+
// (`answer-stream-flag.ts`): an env var read at runtime, default off, opted
|
|
14
|
+
// in PER AGENT via the `env:` block in `switchroom.yaml` (propagated into
|
|
15
|
+
// the container `environment:` by `src/agents/compose.ts`). When off,
|
|
16
|
+
// `maybeRenderOutbound` returns the input untouched, so the live send path
|
|
17
|
+
// is byte-for-byte unchanged — no agent's behaviour moves until an operator
|
|
18
|
+
// explicitly flips the flag for a specific agent. Accepts `1/true/on/yes`.
|
|
19
|
+
//
|
|
20
|
+
// Why route through `renderSafe` and not bare `render`:
|
|
21
|
+
// `renderSafe` guarantees the returned body is never a rich-markdown string
|
|
22
|
+
// with a mid-construct cut, and degrades a document it can't safely fit to
|
|
23
|
+
// plain-text mode (structure stripped, content preserved). The send path
|
|
24
|
+
// honours that `mode` — a `plain` result is sent WITHOUT the rich wrapper.
|
|
25
|
+
|
|
26
|
+
import { parse } from "./parse.js";
|
|
27
|
+
import { renderSafe, type RenderResult } from "./render.js";
|
|
28
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
29
|
+
|
|
30
|
+
/** Parse the `SWITCHROOM_RICH_RENDER` flag value. Default OFF; accepts the
|
|
31
|
+
* same truthy tokens as the other switchroom env flags. Pure so the default
|
|
32
|
+
* + parsing are unit-testable. */
|
|
33
|
+
export function parseRichRenderEnabled(raw: string | undefined): boolean {
|
|
34
|
+
if (raw == null) return false;
|
|
35
|
+
const v = raw.trim().toLowerCase();
|
|
36
|
+
return v === "1" || v === "true" || v === "on" || v === "yes";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Is the rich renderer enabled in this process? Reads the env flag live so a
|
|
40
|
+
* test can set/unset it per-case; defaults OFF. */
|
|
41
|
+
export function richRenderEnabled(
|
|
42
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
43
|
+
): boolean {
|
|
44
|
+
return parseRichRenderEnabled(env.SWITCHROOM_RICH_RENDER);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Unconditionally run `text` through `parse → renderSafe` (ignores the flag).
|
|
48
|
+
* Exposed for tests and callers that have already gated on the flag. */
|
|
49
|
+
export function renderOutbound(
|
|
50
|
+
text: string,
|
|
51
|
+
maxLen: number = RICH_MESSAGE_MAX_CHARS,
|
|
52
|
+
): RenderResult {
|
|
53
|
+
return renderSafe(parse(text), text, maxLen);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Flag-gated transform for the live send path.
|
|
58
|
+
*
|
|
59
|
+
* - flag OFF (default): returns the input untouched, `mode: "markdown"` —
|
|
60
|
+
* identical to the pre-existing behaviour (raw transcript markdown sent
|
|
61
|
+
* straight to `sendRichMessage`). No behavioural change for any agent.
|
|
62
|
+
* - flag ON: returns `parse → renderSafe` output. `mode: "plain"` signals
|
|
63
|
+
* the caller to send WITHOUT the rich wrapper (oversized/unsafe content).
|
|
64
|
+
*/
|
|
65
|
+
export function maybeRenderOutbound(
|
|
66
|
+
text: string,
|
|
67
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
68
|
+
maxLen: number = RICH_MESSAGE_MAX_CHARS,
|
|
69
|
+
): RenderResult {
|
|
70
|
+
if (!richRenderEnabled(env)) return { text, mode: "markdown", degradations: [] };
|
|
71
|
+
return renderOutbound(text, maxLen);
|
|
72
|
+
}
|
|
@@ -214,6 +214,65 @@ export function sweepScopedGrants(store: ScopedGrantStore, now: number): void {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/** Total number of live-or-not grant entries across all agents. Cheap change
|
|
218
|
+
* signal for "did a sweep remove anything?" (sweeps only ever remove). */
|
|
219
|
+
export function countScopedGrants(store: ScopedGrantStore): number {
|
|
220
|
+
let n = 0;
|
|
221
|
+
for (const list of store.values()) n += list.length;
|
|
222
|
+
return n;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A single grant flattened for on-disk persistence. Carries the agent it
|
|
227
|
+
* belongs to (the store is keyed by agent), the narrow rule/signature, and
|
|
228
|
+
* the ABSOLUTE wall-clock expiry — never a relative TTL, so a reload can
|
|
229
|
+
* never extend a window (the restart-doesn't-extend invariant). No grant
|
|
230
|
+
* time is stored because nothing needs it: expiry is the only gate.
|
|
231
|
+
*/
|
|
232
|
+
export interface SerializedScopedGrant {
|
|
233
|
+
readonly agent: string;
|
|
234
|
+
readonly rule: string;
|
|
235
|
+
readonly expiresAt: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Flatten the per-agent store to a plain array for JSON persistence. */
|
|
239
|
+
export function serializeScopedGrants(store: ScopedGrantStore): SerializedScopedGrant[] {
|
|
240
|
+
const out: SerializedScopedGrant[] = [];
|
|
241
|
+
for (const [agent, list] of store) {
|
|
242
|
+
for (const g of list) out.push({ agent, rule: g.rule, expiresAt: g.expiresAt });
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Rebuild the store from persisted entries at gateway boot. Entries already
|
|
249
|
+
* at/past `now` are DROPPED (expiry is absolute — a restart never revives or
|
|
250
|
+
* extends a window). Malformed rows are skipped defensively (fail-closed:
|
|
251
|
+
* an unparseable grant simply doesn't exist → the action re-cards). A
|
|
252
|
+
* duplicate rule for one agent collapses to the latest expiry, mirroring
|
|
253
|
+
* `recordScopedGrant`'s replace-not-accumulate behaviour.
|
|
254
|
+
*/
|
|
255
|
+
export function deserializeScopedGrants(
|
|
256
|
+
data: readonly unknown[],
|
|
257
|
+
now: number,
|
|
258
|
+
): ScopedGrantStore {
|
|
259
|
+
const store: ScopedGrantStore = new Map();
|
|
260
|
+
if (!Array.isArray(data)) return store;
|
|
261
|
+
for (const raw of data) {
|
|
262
|
+
if (!raw || typeof raw !== "object") continue;
|
|
263
|
+
const entry = raw as Partial<SerializedScopedGrant>;
|
|
264
|
+
if (typeof entry.agent !== "string" || entry.agent.length === 0) continue;
|
|
265
|
+
if (typeof entry.rule !== "string" || entry.rule.length === 0) continue;
|
|
266
|
+
if (typeof entry.expiresAt !== "number" || !Number.isFinite(entry.expiresAt)) continue;
|
|
267
|
+
if (entry.expiresAt <= now) continue; // absolute expiry already elapsed → drop
|
|
268
|
+
const list = store.get(entry.agent) ?? [];
|
|
269
|
+
const others = list.filter((g) => g.rule !== entry.rule);
|
|
270
|
+
others.push({ rule: entry.rule, expiresAt: entry.expiresAt });
|
|
271
|
+
store.set(entry.agent, others);
|
|
272
|
+
}
|
|
273
|
+
return store;
|
|
274
|
+
}
|
|
275
|
+
|
|
217
276
|
/**
|
|
218
277
|
* Heuristic destructive/irreversible-command detector. FAIL-CLOSED: when
|
|
219
278
|
* a command can't be read or looks risky, return `true` so it is never
|
|
@@ -49,6 +49,17 @@ export interface SilentEndDeps {
|
|
|
49
49
|
stateDir?: string
|
|
50
50
|
/** stderr writer (defaults to `process.stderr.write`). */
|
|
51
51
|
log?: (line: string) => void
|
|
52
|
+
/**
|
|
53
|
+
* Has a genuine assistant reply been delivered to this chat (optionally
|
|
54
|
+
* scoped to thread) at or after `sinceMs`? Same predicate shape as
|
|
55
|
+
* `history.hasOutboundDeliveredSince` / the represent-guard's dep
|
|
56
|
+
* (`gateway/represent-guard.ts`) — injected here so the exhaustion check
|
|
57
|
+
* below is a pure, testable decision. When omitted (history unavailable,
|
|
58
|
+
* or callers that don't wire it), the check is skipped and the guard
|
|
59
|
+
* falls back to the pre-existing turnKey/retryCount bookkeeping only —
|
|
60
|
+
* never suppress on doubt.
|
|
61
|
+
*/
|
|
62
|
+
hasOutboundDeliveredSince?: (chatId: string, sinceMs: number, threadId?: number | null) => boolean
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
/**
|
|
@@ -69,6 +80,35 @@ export interface SilentEndDeps {
|
|
|
69
80
|
*/
|
|
70
81
|
export const SILENT_END_MAX_RETRIES = 2
|
|
71
82
|
|
|
83
|
+
/**
|
|
84
|
+
* User-facing fallback text delivered when a user-message turn ends with no
|
|
85
|
+
* final answer AND the deterministic Stop-hook re-prompt has already been
|
|
86
|
+
* exhausted (#1161). Without this the user only sees the progress card
|
|
87
|
+
* vanish; silence must never be the failure mode.
|
|
88
|
+
*
|
|
89
|
+
* PR #2892 (reference/rfcs/deterministic-turn-liveness.md Phase 2
|
|
90
|
+
* hardening): include the turn's elapsed so the fallback is honest about
|
|
91
|
+
* how long the user actually waited, instead of a generic apology with no
|
|
92
|
+
* timing. A degenerate/unknown duration (missing, non-finite, or <= 0 —
|
|
93
|
+
* e.g. a turn whose `startedAt` was never stamped) omits the waited clause
|
|
94
|
+
* rather than printing a nonsensical "(waited 0s)".
|
|
95
|
+
*
|
|
96
|
+
* Lives here (not gateway.ts) so the transport-boundary tests exercise the
|
|
97
|
+
* REAL string (tests/silent-end-transport.test.ts), not a hand-maintained
|
|
98
|
+
* copy — gateway.ts is not importable in tests.
|
|
99
|
+
*/
|
|
100
|
+
export function silentEndFallbackText(turnDurationMs: number | undefined): string {
|
|
101
|
+
const elapsed =
|
|
102
|
+
typeof turnDurationMs === 'number' && Number.isFinite(turnDurationMs) && turnDurationMs > 0
|
|
103
|
+
? ` (waited ${Math.round(turnDurationMs / 1000)}s)`
|
|
104
|
+
: ''
|
|
105
|
+
return (
|
|
106
|
+
'⚠️ The agent finished working but didn’t send a reply' +
|
|
107
|
+
elapsed +
|
|
108
|
+
' — your last message may not have been answered. Please try asking again.'
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
72
112
|
function resolveStateDir(deps?: SilentEndDeps): string {
|
|
73
113
|
if (deps?.stateDir != null) return deps.stateDir
|
|
74
114
|
const env = process.env.TELEGRAM_STATE_DIR
|
|
@@ -255,6 +295,44 @@ export function recordSilentTurnEnd(
|
|
|
255
295
|
prev.turnKey === args.turnKey &&
|
|
256
296
|
prev.retryCount >= SILENT_END_MAX_RETRIES
|
|
257
297
|
) {
|
|
298
|
+
// Staleness guard (mirrors the represent-guard's #2472 fix,
|
|
299
|
+
// `gateway/represent-guard.ts:shouldSuppressRepresent`): `turnKey` here
|
|
300
|
+
// is `statusKey(chatId, threadId)` — stable across every turn on the
|
|
301
|
+
// same chat/thread, NOT a per-turn nonce (unlike the obligation
|
|
302
|
+
// ledger's `originTurnId`). The whole mechanism depends on a reply
|
|
303
|
+
// ALWAYS clearing this state file via `clearSilentEndState` at the
|
|
304
|
+
// send-site. `clearSilentEndState` is fail-silent by design (state
|
|
305
|
+
// corruption / a write race must never crash the gateway), so if a
|
|
306
|
+
// clear is ever missed, a stale `retryCount >= MAX_RETRIES` record
|
|
307
|
+
// from an OLD, already-answered turn would be misread as "this
|
|
308
|
+
// brand-new dark turn already exhausted its re-prompt budget" —
|
|
309
|
+
// firing the user-facing fallback immediately, without the turn ever
|
|
310
|
+
// going through the Stop-hook re-prompt ladder. Before trusting that
|
|
311
|
+
// reading, verify against real delivery history: has a genuine reply
|
|
312
|
+
// landed on this chat/thread since the stale record was last written?
|
|
313
|
+
// If so, the record is satisfied-but-misdetected — drop it silently
|
|
314
|
+
// and let this dark turn start its OWN fresh retry cycle instead of
|
|
315
|
+
// inheriting someone else's spent budget.
|
|
316
|
+
if (deps?.hasOutboundDeliveredSince?.(args.chatId, prev.timestamp, args.threadId)) {
|
|
317
|
+
emitLog(
|
|
318
|
+
deps,
|
|
319
|
+
`silent-end: stale exhausted record for turnKey=${args.turnKey} ` +
|
|
320
|
+
`(retryCount=${prev.retryCount}) but a reply was delivered since ` +
|
|
321
|
+
`${prev.timestamp} — treating as satisfied-but-misdetected, not exhausted\n`,
|
|
322
|
+
)
|
|
323
|
+
// MUST clear before writing (adversarial review of #2892):
|
|
324
|
+
// writeSilentEndState re-inherits retryCount whenever the on-disk
|
|
325
|
+
// turnKey matches — which it ALWAYS does here, since turnKey is the
|
|
326
|
+
// stable statusKey(chatId, threadId). Writing over the stale record
|
|
327
|
+
// directly would start the "fresh" ladder at retryCount=MAX: the
|
|
328
|
+
// Stop hook would see retryCount >= MAX_RETRIES and never re-prompt,
|
|
329
|
+
// while this call just returned exhausted:false so no fallback fires
|
|
330
|
+
// either — pure silence, strictly worse than the pre-fix behaviour.
|
|
331
|
+
// Clearing first makes the new record genuinely start at retryCount=0.
|
|
332
|
+
clearSilentEndState(args.turnKey, deps)
|
|
333
|
+
writeSilentEndState(args, deps)
|
|
334
|
+
return { exhausted: false }
|
|
335
|
+
}
|
|
258
336
|
clearSilentEndState(args.turnKey, deps)
|
|
259
337
|
emitLog(
|
|
260
338
|
deps,
|