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,363 @@
|
|
|
1
|
+
// Markdown -> typed IR parser for the Telegram HTML render engine.
|
|
2
|
+
//
|
|
3
|
+
// Uses `mdast-util-from-markdown` (micromark under the hood) with the GFM
|
|
4
|
+
// syntax + mdast extensions to obtain an mdast tree, then folds that tree into
|
|
5
|
+
// the IR defined in `ir.ts`. This is Increment 1: parser + IR only. There is
|
|
6
|
+
// no renderer and no chunker yet, and nothing here is wired into the live send
|
|
7
|
+
// path.
|
|
8
|
+
//
|
|
9
|
+
// Design rules honored here:
|
|
10
|
+
// - Every emitted node copies UTF-16 source offsets straight off mdast
|
|
11
|
+
// `position.start.offset` / `position.end.offset`, so
|
|
12
|
+
// `source.slice(node.start, node.end)` round-trips to the source text.
|
|
13
|
+
// - Never lose text. Any mdast node type outside the supported palette
|
|
14
|
+
// degrades to a `plain` inline (or a paragraph wrapping one) carrying the
|
|
15
|
+
// raw source slice, rather than being dropped.
|
|
16
|
+
//
|
|
17
|
+
// Underline vs bold (`__…__` vs `**…**`):
|
|
18
|
+
// Telegram's Bot API 10.1 rich markdown reads a `__…__` double-underscore run
|
|
19
|
+
// as UNDERLINE and a `**…**` run as BOLD. GFM/micromark folds BOTH into one
|
|
20
|
+
// `strong` mdast node with no record of which delimiter was used. This module
|
|
21
|
+
// disambiguates by reading the run's source delimiter off its UTF-16 offsets
|
|
22
|
+
// (`source.slice(start, start+2)`): `__` → `underline`, anything else →
|
|
23
|
+
// `bold`. A single `_…_` / `*…*` run stays `italic` (emphasis) either way.
|
|
24
|
+
//
|
|
25
|
+
// Spoiler + highlight handling (`||…||`, `==…==`):
|
|
26
|
+
// The IR carries `spoiler` and `highlight` nodes but GFM/micromark has no
|
|
27
|
+
// syntax for either — both delimiters fold into ordinary `plain` text. Rather
|
|
28
|
+
// than teach micromark two custom inline extensions, this module recognises
|
|
29
|
+
// them in a POST-PARSE pass over the folded `plain` nodes (`expandPlainNode`),
|
|
30
|
+
// splitting a `||secret||` / `==marked==` run into the matching
|
|
31
|
+
// `SpoilerNode` / `HighlightNode`. Recognition is per-`plain`-node: the
|
|
32
|
+
// delimited content must be a single contiguous plain-text run on one line
|
|
33
|
+
// (formatting INSIDE a spoiler — `||**bold**||` — is left as literal
|
|
34
|
+
// delimiters, since `**bold**` is already its own mdast node). A delimiter
|
|
35
|
+
// that does not form a closed, non-empty, single-line pair stays literal
|
|
36
|
+
// plain text and is re-escaped on render. This faithfully mirrors Telegram's
|
|
37
|
+
// own reading of the delimiters.
|
|
38
|
+
//
|
|
39
|
+
// Blockquote expandable handling:
|
|
40
|
+
// The IR carries `expandable: boolean` for Telegram's expandable blockquote
|
|
41
|
+
// (Bot API 10.1). GFM has no expandable marker; the switchroom render path
|
|
42
|
+
// emits `**> ` on the FIRST line of an expandable quote (`render.ts` /
|
|
43
|
+
// `reference/telegram-formatting-guide.md`). micromark does NOT understand
|
|
44
|
+
// `**> ` as a blockquote — the leading `**` makes the line a paragraph with
|
|
45
|
+
// an unclosed strong-emphasis run — so this module pre-transforms each
|
|
46
|
+
// `**>` marker into a plain ` >` marker of IDENTICAL length (`**` → two
|
|
47
|
+
// spaces) before handing the text to mdast. Length preservation keeps every
|
|
48
|
+
// UTF-16 source offset (and therefore the never-lose-text round-trip
|
|
49
|
+
// invariant against the ORIGINAL source) exactly intact — only the two
|
|
50
|
+
// marker characters differ, and they are never part of quoted content. The
|
|
51
|
+
// set of line-start offsets that carried the marker is threaded into
|
|
52
|
+
// `foldBlock` so the matching blockquote nodes get `expandable: true`.
|
|
53
|
+
|
|
54
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
55
|
+
import { gfm } from "micromark-extension-gfm";
|
|
56
|
+
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
57
|
+
import type {
|
|
58
|
+
Node as MdastNode,
|
|
59
|
+
Parent as MdastParent,
|
|
60
|
+
RootContent,
|
|
61
|
+
PhrasingContent,
|
|
62
|
+
AlignType,
|
|
63
|
+
} from "mdast";
|
|
64
|
+
|
|
65
|
+
import type {
|
|
66
|
+
Block,
|
|
67
|
+
Document,
|
|
68
|
+
Inline,
|
|
69
|
+
ListItem,
|
|
70
|
+
PlainNode,
|
|
71
|
+
Pos,
|
|
72
|
+
TableCell,
|
|
73
|
+
TableRow,
|
|
74
|
+
} from "./ir.js";
|
|
75
|
+
|
|
76
|
+
/** Copy UTF-16 offsets off an mdast node. Falls back to 0-length when a
|
|
77
|
+
* synthesized node lacks a position (from-markdown always sets one, but the
|
|
78
|
+
* mdast types make `position` optional). */
|
|
79
|
+
function pos(node: MdastNode): Pos {
|
|
80
|
+
const p = node.position;
|
|
81
|
+
return {
|
|
82
|
+
start: p?.start?.offset ?? 0,
|
|
83
|
+
end: p?.end?.offset ?? 0,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Raw source text a node spans — used for the never-lose-text fallback. */
|
|
88
|
+
function slice(source: string, node: MdastNode): string {
|
|
89
|
+
const { start, end } = pos(node);
|
|
90
|
+
return source.slice(start, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The Bot API 10.1 expandable-blockquote marker: `**>` at the very start of
|
|
94
|
+
* a line (column 0). This is exactly what the render path emits
|
|
95
|
+
* (`render.ts` writes `**> ` on the first line of an expandable quote; see
|
|
96
|
+
* `reference/telegram-formatting-guide.md`). Matching only at column 0 keeps
|
|
97
|
+
* the length-preserving rewrite (`**` → two spaces) inside CommonMark's
|
|
98
|
+
* 3-space blockquote-indent budget — allowing leading indent here would push
|
|
99
|
+
* the rewritten ` >` past 3 spaces and turn it into an indented code block. */
|
|
100
|
+
const EXPANDABLE_MARKER_RE = /^\*\*>/;
|
|
101
|
+
|
|
102
|
+
/** Pre-transform expandable-blockquote markers so mdast can parse them as
|
|
103
|
+
* ordinary blockquotes, WITHOUT shifting any source offset. Each line that
|
|
104
|
+
* opens with `**>` has its two `*` characters replaced by two spaces
|
|
105
|
+
* (`**>` → ` >`), which micromark reads as a normal (optionally
|
|
106
|
+
* 1–3-space-indented) blockquote line. Returns the rewritten text plus the
|
|
107
|
+
* set of line-start offsets that carried the marker — `foldBlock` uses that
|
|
108
|
+
* set to flip `expandable: true` on the produced blockquote nodes. */
|
|
109
|
+
function markExpandableQuotes(markdown: string): {
|
|
110
|
+
text: string;
|
|
111
|
+
expandableLineStarts: Set<number>;
|
|
112
|
+
} {
|
|
113
|
+
const expandableLineStarts = new Set<number>();
|
|
114
|
+
let out = "";
|
|
115
|
+
let offset = 0;
|
|
116
|
+
// Split keeping the trailing newline on each line so offsets are exact.
|
|
117
|
+
for (const line of markdown.split(/(?<=\n)/)) {
|
|
118
|
+
if (EXPANDABLE_MARKER_RE.test(line)) {
|
|
119
|
+
expandableLineStarts.add(offset);
|
|
120
|
+
// Replace the leading two `*` chars with two spaces; the `>` and
|
|
121
|
+
// everything after it are untouched (`**> x` -> ` > x`), a valid
|
|
122
|
+
// 2-space-indented blockquote that mdast parses normally.
|
|
123
|
+
out += " " + line.slice(2);
|
|
124
|
+
} else {
|
|
125
|
+
out += line;
|
|
126
|
+
}
|
|
127
|
+
offset += line.length;
|
|
128
|
+
}
|
|
129
|
+
return { text: out, expandableLineStarts };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Offset of the start of the line containing `offset` in `source`. */
|
|
133
|
+
function lineStart(source: string, offset: number): number {
|
|
134
|
+
let i = offset;
|
|
135
|
+
while (i > 0 && source[i - 1] !== "\n") i--;
|
|
136
|
+
return i;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** mdast `AlignType` (null | 'left' | 'right' | 'center') passes through
|
|
140
|
+
* unchanged; the IR uses the same union. */
|
|
141
|
+
function foldAlign(a: AlignType | undefined): "left" | "center" | "right" | null {
|
|
142
|
+
return a ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function foldInline(node: PhrasingContent, source: string): Inline {
|
|
146
|
+
switch (node.type) {
|
|
147
|
+
case "text":
|
|
148
|
+
return { type: "plain", text: node.value, ...pos(node) };
|
|
149
|
+
case "strong": {
|
|
150
|
+
// GFM folds both `**…**` (bold) and `__…__` (underline) into one `strong`
|
|
151
|
+
// node. Recover the intended construct from the source delimiter.
|
|
152
|
+
const p = pos(node);
|
|
153
|
+
const underline = source.slice(p.start, p.start + 2) === "__";
|
|
154
|
+
return {
|
|
155
|
+
type: underline ? "underline" : "bold",
|
|
156
|
+
children: foldInlineChildren(node, source),
|
|
157
|
+
...p,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
case "emphasis":
|
|
161
|
+
return { type: "italic", children: foldInlineChildren(node, source), ...pos(node) };
|
|
162
|
+
case "delete":
|
|
163
|
+
return { type: "strike", children: foldInlineChildren(node, source), ...pos(node) };
|
|
164
|
+
case "inlineCode":
|
|
165
|
+
return { type: "code", text: node.value, ...pos(node) };
|
|
166
|
+
case "link":
|
|
167
|
+
return {
|
|
168
|
+
type: "link",
|
|
169
|
+
href: node.url,
|
|
170
|
+
children: foldInlineChildren(node, source),
|
|
171
|
+
...pos(node),
|
|
172
|
+
};
|
|
173
|
+
// Not in the palette (break, image, html, footnoteReference, …): keep the
|
|
174
|
+
// raw source text so no content is lost.
|
|
175
|
+
default:
|
|
176
|
+
return { type: "plain", text: slice(source, node), ...pos(node) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Post-parse inline-marker recognition (spoiler `||…||`, highlight `==…==`)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** The post-parse inline delimiters GFM/micromark doesn't natively model. Each
|
|
185
|
+
* matches a CLOSED, NON-EMPTY, single-line delimited run inside `plain` text.
|
|
186
|
+
* `delim` is the delimiter length (both are 2 chars). */
|
|
187
|
+
const INLINE_MARKER_SPECS: ReadonlyArray<{
|
|
188
|
+
type: "spoiler" | "highlight";
|
|
189
|
+
re: RegExp;
|
|
190
|
+
delim: number;
|
|
191
|
+
}> = [
|
|
192
|
+
{ type: "spoiler", re: /\|\|([^|\n]+)\|\|/, delim: 2 },
|
|
193
|
+
{ type: "highlight", re: /==([^=\n]+)==/, delim: 2 },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
function mkPlain(text: string, start: number, end: number): PlainNode {
|
|
197
|
+
return { type: "plain", text, start, end };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Split a single `plain` node's text into `plain` / `spoiler` / `highlight`
|
|
201
|
+
* nodes by recognising `||…||` and `==…==` runs. Offsets are exact when the
|
|
202
|
+
* node's source slice equals its decoded text (the common no-escape case);
|
|
203
|
+
* otherwise every produced piece inherits the original node's span (still a
|
|
204
|
+
* valid in-bounds offset, just not byte-slice-exact). Recurses so a marker
|
|
205
|
+
* nested in plain text after another marker is also recognised. */
|
|
206
|
+
function expandPlainNode(node: PlainNode, source: string): Inline[] {
|
|
207
|
+
const text = node.text;
|
|
208
|
+
if (text.length === 0) return [node];
|
|
209
|
+
const exact = source.slice(node.start, node.end) === text;
|
|
210
|
+
const abs = (k: number): number => (exact ? node.start + k : node.start);
|
|
211
|
+
const absEnd = (k: number): number => (exact ? node.start + k : node.end);
|
|
212
|
+
|
|
213
|
+
// Earliest marker match across all delimiter kinds.
|
|
214
|
+
let best:
|
|
215
|
+
| { idx: number; full: number; inner: string; type: "spoiler" | "highlight"; delim: number }
|
|
216
|
+
| null = null;
|
|
217
|
+
for (const spec of INLINE_MARKER_SPECS) {
|
|
218
|
+
const m = spec.re.exec(text);
|
|
219
|
+
if (m != null && m[1].length > 0 && (best === null || m.index < best.idx)) {
|
|
220
|
+
best = { idx: m.index, full: m[0].length, inner: m[1], type: spec.type, delim: spec.delim };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (best === null) return [node];
|
|
224
|
+
|
|
225
|
+
const out: Inline[] = [];
|
|
226
|
+
if (best.idx > 0) out.push(mkPlain(text.slice(0, best.idx), abs(0), abs(best.idx)));
|
|
227
|
+
|
|
228
|
+
const innerStart = abs(best.idx + best.delim);
|
|
229
|
+
const innerEnd = absEnd(best.idx + best.delim + best.inner.length);
|
|
230
|
+
const innerPlain = mkPlain(best.inner, innerStart, innerEnd);
|
|
231
|
+
out.push({
|
|
232
|
+
type: best.type,
|
|
233
|
+
children: expandPlainNode(innerPlain, source),
|
|
234
|
+
start: abs(best.idx),
|
|
235
|
+
end: absEnd(best.idx + best.full),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const rest = text.slice(best.idx + best.full);
|
|
239
|
+
if (rest.length > 0) {
|
|
240
|
+
out.push(...expandPlainNode(mkPlain(rest, abs(best.idx + best.full), node.end), source));
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Fold a run of mdast phrasing children into IR inlines, then run the
|
|
246
|
+
* post-parse spoiler/highlight recognition over the produced `plain` nodes. */
|
|
247
|
+
function buildInlines(children: ReadonlyArray<MdastNode>, source: string): Inline[] {
|
|
248
|
+
return children
|
|
249
|
+
.map((c) => foldInline(c as PhrasingContent, source))
|
|
250
|
+
.flatMap((n) => (n.type === "plain" ? expandPlainNode(n, source) : [n]));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function foldInlineChildren(node: MdastParent, source: string): Inline[] {
|
|
254
|
+
return buildInlines(node.children, source);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function foldTableRow(
|
|
258
|
+
row: Extract<RootContent, { type: "tableRow" }>,
|
|
259
|
+
source: string,
|
|
260
|
+
): TableRow {
|
|
261
|
+
const cells: TableCell[] = row.children.map((cell) => ({
|
|
262
|
+
children: buildInlines(cell.children, source),
|
|
263
|
+
...pos(cell),
|
|
264
|
+
}));
|
|
265
|
+
return { cells, ...pos(row) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function foldBlock(
|
|
269
|
+
node: RootContent,
|
|
270
|
+
source: string,
|
|
271
|
+
expandableLineStarts: Set<number>,
|
|
272
|
+
): Block {
|
|
273
|
+
switch (node.type) {
|
|
274
|
+
case "paragraph":
|
|
275
|
+
return { type: "paragraph", children: foldInlineChildren(node, source), ...pos(node) };
|
|
276
|
+
case "heading":
|
|
277
|
+
return {
|
|
278
|
+
type: "heading",
|
|
279
|
+
level: node.depth,
|
|
280
|
+
children: foldInlineChildren(node, source),
|
|
281
|
+
...pos(node),
|
|
282
|
+
};
|
|
283
|
+
case "blockquote": {
|
|
284
|
+
const p = pos(node);
|
|
285
|
+
// A blockquote is expandable when its FIRST line carried the `**>`
|
|
286
|
+
// marker in the original source (recorded by markExpandableQuotes).
|
|
287
|
+
const expandable = expandableLineStarts.has(lineStart(source, p.start));
|
|
288
|
+
return {
|
|
289
|
+
type: "blockquote",
|
|
290
|
+
children: node.children.map((c) => foldBlock(c, source, expandableLineStarts)),
|
|
291
|
+
expandable,
|
|
292
|
+
...p,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
case "code":
|
|
296
|
+
return {
|
|
297
|
+
type: "code-block",
|
|
298
|
+
text: node.value,
|
|
299
|
+
language: node.lang ?? null,
|
|
300
|
+
...pos(node),
|
|
301
|
+
};
|
|
302
|
+
case "list": {
|
|
303
|
+
const items: ListItem[] = node.children.map((li) => ({
|
|
304
|
+
children: li.children.map((c) => foldBlock(c, source, expandableLineStarts)),
|
|
305
|
+
checked: li.checked ?? null,
|
|
306
|
+
// mdast `listItem.spread`: whether this item's block children are
|
|
307
|
+
// separated by a blank line. Tight (false) keeps a paragraph and its
|
|
308
|
+
// nested sub-list adjacent so no blank line is injected on render.
|
|
309
|
+
spread: li.spread ?? false,
|
|
310
|
+
...pos(li),
|
|
311
|
+
}));
|
|
312
|
+
return {
|
|
313
|
+
type: "list",
|
|
314
|
+
ordered: node.ordered ?? false,
|
|
315
|
+
startNumber: node.ordered ? node.start ?? 1 : null,
|
|
316
|
+
// mdast `list.spread`: loose (true) vs tight (false) at the list level.
|
|
317
|
+
spread: node.spread ?? false,
|
|
318
|
+
items,
|
|
319
|
+
...pos(node),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
case "thematicBreak":
|
|
323
|
+
return { type: "thematic-break", ...pos(node) };
|
|
324
|
+
case "table": {
|
|
325
|
+
const rows = node.children;
|
|
326
|
+
const [headerRow, ...bodyRows] = rows;
|
|
327
|
+
return {
|
|
328
|
+
type: "table",
|
|
329
|
+
header: foldTableRow(headerRow, source),
|
|
330
|
+
rows: bodyRows.map((r) => foldTableRow(r, source)),
|
|
331
|
+
align: (node.align ?? []).map(foldAlign),
|
|
332
|
+
...pos(node),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Not in the palette (html, definition, footnoteDefinition, …): degrade to
|
|
336
|
+
// a paragraph carrying the raw source slice so no content is dropped.
|
|
337
|
+
default:
|
|
338
|
+
return {
|
|
339
|
+
type: "paragraph",
|
|
340
|
+
children: [{ type: "plain", text: slice(source, node), ...pos(node) }],
|
|
341
|
+
...pos(node),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Parse a markdown string into the typed IR Document. */
|
|
347
|
+
export function parse(markdown: string): Document {
|
|
348
|
+
// Rewrite expandable-blockquote markers (`**>` → ` >`) so micromark parses
|
|
349
|
+
// them as ordinary blockquotes. The rewrite is length-preserving, so mdast's
|
|
350
|
+
// offsets are valid against BOTH the rewritten and the original text — we
|
|
351
|
+
// parse the rewritten text but fold against the ORIGINAL `markdown`, keeping
|
|
352
|
+
// every source slice byte-identical to the caller's input.
|
|
353
|
+
const { text: rewritten, expandableLineStarts } = markExpandableQuotes(markdown);
|
|
354
|
+
const tree = fromMarkdown(rewritten, {
|
|
355
|
+
extensions: [gfm()],
|
|
356
|
+
mdastExtensions: [gfmFromMarkdown()],
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
blocks: tree.children.map((child) =>
|
|
360
|
+
foldBlock(child, markdown, expandableLineStarts),
|
|
361
|
+
),
|
|
362
|
+
};
|
|
363
|
+
}
|